A complete, beginner-friendly tutorial on Android Canvas API.
Build a fully working analog clock from absolute scratch — one step at a time.
No third-party libraries. No XML drawables. Pure Canvas, Paint, and Path.
⭐ If this tutorial helped you understand Canvas — please star this repo. It helps others find it. ⭐
- What a Custom View is and how Android renders it
- The Canvas coordinate system and how to think about drawing
- Every important Canvas draw method — with short examples
- Paint and all its properties — what each one does
- How
onMeasureworks — negotiating size with Android - Canvas transformations —
save,restore,translate,rotate - How to draw complex shapes with
Path - How to animate a View without any library
- How to properly manage View lifecycle
├── README.md ← you are here — full tutorial
└── CanvasPlayground.kt ← final complete clock code
Every UI element in Android — Button, TextView, ImageView — is a View. It's just a rectangular area on screen that knows how to draw itself.
When you create a Custom View, you subclass View and take ownership of three things:
| Method | Responsibility |
|---|---|
onMeasure |
How big should I be? |
onDraw |
What do I look like? |
onTouchEvent |
How do I respond to touch? |
The skeleton of every custom view looks like this:
class ClockView(context: Context, attrs: AttributeSet?) : View(context, attrs) {
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
// everything you draw goes here
}
}Never call onDraw() yourself.
Android owns the rendering loop. You call invalidate() which signals Android that the view needs to be redrawn — Android then calls onDraw() at the right time.
// Wrong ❌
onDraw(canvas)
// Correct ✅
invalidate()Before touching a single draw method, you must understand how Canvas thinks about space. It is not like school math coordinates.
(0,0) ──────────────────────── X increases →
│
│
│
↓
Y increases downward
- Top-left =
(0, 0) - Top-right =
(width, 0) - Bottom-left =
(0, height) - Bottom-right =
(width, height) - Center =
(width / 2f, height / 2f)
Always use the view's width and height properties so your view works on every screen size.
// Wrong ❌ — breaks on different screen sizes
canvas.drawCircle(300f, 300f, 200f, paint)
// Correct ✅ — works on every device
canvas.drawCircle(width / 2f, height / 2f, radius, paint)Canvas says what to draw and where. Paint says how it looks. You always need both.
Never create Paint objects inside
onDraw.
onDraw runs up to 60 times per second. Creating a Paint() object inside it allocates memory on every frame — causing garbage collection and dropped frames.
// Wrong ❌ — creates a new Paint object 60 times per second
override fun onDraw(canvas: Canvas) {
val paint = Paint() // GC pressure every frame
}
// Correct ✅ — created once as a class field, reused forever
private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.RED
strokeWidth = 8f
}// Smooth edges on curves and diagonals — always set this
paint.isAntiAlias = true
// FILL = solid shape, STROKE = outline only, FILL_AND_STROKE = both
paint.style = Paint.Style.STROKE
// Thickness of lines — only matters when style is STROKE
paint.strokeWidth = 8f
// Color — use Color constants or hex strings
paint.color = Color.RED
paint.color = Color.parseColor("#C9A84C")
// Transparency — 0 = invisible, 255 = fully opaque
paint.alpha = 128
// For drawing text
paint.textSize = 42f
paint.textAlign = Paint.Align.CENTER // LEFT, CENTER, RIGHT
// Shape of line endings
paint.strokeCap = Paint.Cap.ROUND // rounded
paint.strokeCap = Paint.Cap.BUTT // flat, no extension
paint.strokeCap = Paint.Cap.SQUARE // flat, extends slightlyHere is every draw method you will use regularly, with short examples.
canvas.drawColor(Color.BLACK)
canvas.drawColor(Color.parseColor("#0D0D1A"))Use this as the first call in onDraw to set the background color.
// One dot
canvas.drawPoint(100f, 200f, paint)
// Many dots efficiently — pairs of (x, y)
val points = floatArrayOf(100f, 200f, 300f, 400f, 500f, 600f)
canvas.drawPoints(points, paint)paint.strokeWidth controls how large each dot appears.
// One line from (x1,y1) to (x2,y2)
canvas.drawLine(0f, 0f, 100f, 100f, paint)
// Many lines efficiently — groups of 4 floats (x1, y1, x2, y2)
val lines = floatArrayOf(
0f, 0f, 100f, 100f, // line 1
200f, 0f, 300f, 100f // line 2
)
canvas.drawLines(lines, paint)drawLines batches multiple lines into one native call — more efficient than a loop of drawLine.
// Sharp corners
canvas.drawRect(left, top, right, bottom, paint)
// Rounded corners
canvas.drawRoundRect(left, top, right, bottom, cornerRadiusX, cornerRadiusY, paint)With Paint.Style.FILL — solid rectangle. With Paint.Style.STROKE — just the border.
canvas.drawCircle(
centerX, // x coordinate of center
centerY, // y coordinate of center
radius, // radius in pixels
paint
)This is one of the most important methods — used for progress rings, BMI arcs, gauges.
canvas.drawArc(
left, top, right, bottom, // bounding box of the full circle
startAngle, // 0° = 3 o'clock position. Goes clockwise.
sweepAngle, // how many degrees to draw
useCenter, // true = pie slice, false = arc only
paint
)Example — a 75% progress arc starting from 12 o'clock:
canvas.drawArc(
100f, 100f, 300f, 300f,
-90f, // start at 12 o'clock (subtract 90 from default 3 o'clock)
270f, // 75% of 360 degrees
false, // arc only, no lines to center
paint
)canvas.drawText("Hello", x, y, paint)
⚠️ Important:yis the text baseline, not the top edge. This trips everyone up.
paint.textAlign controls what x means:
LEFT— x is the left edge of the textCENTER— x is the horizontal center of the textRIGHT— x is the right edge of the text
For triangles, arrows, clock hands — any shape that doesn't fit the above methods. Covered in depth in Part 7.
| Method | Draws |
|---|---|
drawColor |
Solid background fill |
drawPoint / drawPoints |
Dots |
drawLine / drawLines |
Straight lines |
drawRect |
Rectangle |
drawRoundRect |
Rounded rectangle |
drawCircle |
Circle |
drawArc |
Arc or pie slice |
drawText |
Text string |
drawBitmap |
Image |
drawPath |
Any custom shape |
onMeasure is where Android negotiates size with your view. It calls this method with two parameters that each contain a mode and a size packed into one integer.
val widthMode = MeasureSpec.getMode(widthMeasureSpec)
val widthSize = MeasureSpec.getSize(widthMeasureSpec)| Mode | Triggered by | Meaning |
|---|---|---|
EXACTLY |
match_parent or fixed dp |
You must be exactly this size |
AT_MOST |
wrap_content |
You can be up to this size |
UNSPECIFIED |
Inside ScrollView |
Take whatever you need |
Skipping this crashes your app. It's how you tell Android the final size you've decided on.
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
val desiredSize = (radius * 2 + 20f).toInt()
val w = when (MeasureSpec.getMode(widthMeasureSpec)) {
MeasureSpec.EXACTLY -> MeasureSpec.getSize(widthMeasureSpec)
MeasureSpec.AT_MOST -> minOf(desiredSize, MeasureSpec.getSize(widthMeasureSpec))
else -> desiredSize
}
setMeasuredDimension(w, w) // square clock — same for both dimensions
}resolveSize(desired, measureSpec) is a built-in helper that does this mode logic for you:
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
val size = resolveSize((radius * 2 + 20f).toInt(), widthMeasureSpec)
setMeasuredDimension(size, size)
}This is the most powerful concept in Canvas drawing. Instead of calculating rotated coordinates manually with sin and cos — you rotate the canvas itself, then draw simple shapes.
Think of it like a physical drawing board. You tilt the board first, then draw a straight line — the result appears rotated on screen.
canvas.translate(540f, 960f)
// Now (0, 0) refers to the point (540, 960) on screen
// All subsequent draw calls are relative to this new origincanvas.rotate(45f)
// The coordinate system is now rotated 45° clockwise
// A line drawn "upward" now points upper-rightcanvas.scale(2f, 2f)
// Everything drawn after this is twice as largecanvas.translate(540f, 960f) // move origin to center
canvas.rotate(45f) // rotate that center
canvas.drawLine(0f, 0f, 0f, -200f, paint)
// Result: a line starting at (540, 960) pointing upper-right at 45°Every transformation affects everything drawn after it. Wrap transformations in save() / restore() to isolate them.
canvas.save() // 📸 snapshot current state
canvas.rotate(45f)
canvas.drawLine(...) // drawn at 45°
canvas.restore() // ↩️ undo the rotation
canvas.drawCircle(...) // drawn normally — unaffected by the rotation aboveThink of
save()as opening a bracket{andrestore()as closing it}. Every save must have a matching restore.
canvas.save() returns an integer — the stack depth. You can jump back to any depth:
val checkpoint = canvas.save()
// ... many nested save/restore calls ...
canvas.restoreToCount(checkpoint) // jump directly back to this stateUseful as a safety net in onDraw:
override fun onDraw(canvas: Canvas) {
val checkpoint = canvas.save()
try {
// all your drawing
} finally {
canvas.restoreToCount(checkpoint) // always cleans up, even on exceptions
}
}Path is how you draw anything that isn't a standard shape — triangles, clock hands, arrows, custom outlines.
Think of Path like a pen on paper:
val path = Path()
path.moveTo(x, y) // pick up pen, place it here. No line drawn.
path.lineTo(x, y) // draw a straight line to this point
path.close() // draw a line back to the first moveTo — closes the shapeprivate val trianglePath = Path() // declared outside onDraw
// inside onDraw:
trianglePath.reset() // clear previous frame's shape
trianglePath.moveTo(200f, 0f) // top point
trianglePath.lineTo(400f, 400f) // bottom right
trianglePath.lineTo(0f, 400f) // bottom left
trianglePath.close() // line back to top
canvas.drawPath(trianglePath, paint)
⚠️ Same rule as Paint — never createPath()insideonDraw. Declare it as a class field and callreset()to reuse it.
// Quadratic bezier — one control point
path.quadTo(controlX, controlY, endX, endY)
// Cubic bezier — two control points
path.cubicTo(cx1, cy1, cx2, cy2, endX, endY)Used for smooth wave animations, speech bubbles, smooth progress curves.
Now we build the clock incrementally. Each step compiles and runs — you can see the result at every stage.
What we're building: A dark circle on a dark background — the clock face.
New concepts introduced: onMeasure, drawColor, drawCircle, Paint with FILL style.
class CanvasPlayground @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null
) : View(context, attrs) {
private val radius = 300f
// Paint for the clock face — filled dark navy circle
private val clockFacePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.parseColor("#14142B")
style = Paint.Style.FILL // solid fill, not outline
}
// Paint for the gold border ring
private val borderPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.parseColor("#C9A84C")
style = Paint.Style.STROKE // outline only
strokeWidth = 20f
strokeCap = Paint.Cap.ROUND
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
// resolveSize respects EXACTLY and AT_MOST modes automatically
val size = resolveSize((radius * 2 + 20f).toInt(), widthMeasureSpec)
setMeasuredDimension(size, size) // square view
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
val cx = width / 2f
val cy = height / 2f
// Step 1: Fill background
canvas.drawColor(Color.parseColor("#0D0D1A"))
// Step 2: Draw filled clock face circle
canvas.drawCircle(cx, cy, radius, clockFacePaint)
// Step 3: Draw gold border ring on top
canvas.drawCircle(cx, cy, radius, borderPaint)
}
}Why draw border after face? Canvas draws in order — later calls sit on top of earlier ones. The border must be drawn after the face so it frames it cleanly.
Result: A dark navy circle with a gold border on a deep navy background.
What we're building: 12 gold tick marks at the hour positions around the clock.
New concepts introduced: translate, rotate, save/restore in a loop.
// Add this Paint as a class field
private val tickPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.parseColor("#C9A84C") // gold
strokeWidth = 8f
style = Paint.Style.STROKE
strokeCap = Paint.Cap.ROUND
}
private val tickLength = 40fAdd this inside onDraw, after drawing the face and before drawing the border:
canvas.save()
canvas.translate(cx, cy) // move origin to clock center — all ticks now relative to center
for (i in 0..11) {
canvas.save()
canvas.rotate(i * 30f) // rotate to this hour's position (360/12 = 30° per hour)
// Draw a line from near the edge inward
// Negative Y = upward from center (remember: Y decreases going up)
canvas.drawLine(
0f, -(radius - 5f), // start: just inside the border
0f, -(radius - tickLength), // end: 40px inward
tickPaint
)
canvas.restore() // undo this tick's rotation before next iteration
}
canvas.restore() // undo the translateWhy save/restore inside the loop? Each iteration rotates the canvas. Without restore, rotation accumulates — tick 2 would be at 60°, tick 3 at 90°, etc. restore resets to the translated state (not fully clean state) before each rotation.
Why negative Y for the tick start? After translate(cx, cy), the origin is at center. Moving upward means decreasing Y. -(radius - 5f) places us near the top of the clock.
Result: 12 evenly spaced gold tick marks around the clock edge.
What we're building: The numbers 1–12 at each hour position, always upright regardless of rotation.
New concepts introduced: drawText, undoing rotation for text, textAlign.
// Add this Paint as a class field
private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.parseColor("#E8DFC8") // warm cream
textSize = 42f
textAlign = Paint.Align.CENTER // x coordinate = horizontal center of text
}Extend the tick loop to also draw the label:
for (i in 0..11) {
canvas.save()
canvas.rotate(i * 30f)
// Draw tick mark
canvas.drawLine(0f, -(radius - 5f), 0f, -(radius - tickLength), tickPaint)
// Move origin to where we want the label
canvas.translate(0f, -(radius - tickLength - 40f))
// Undo the rotation so text appears upright
// Without this, "3" at the 3 o'clock position would be rotated 90°
canvas.rotate(-i * 30f)
canvas.drawText(if (i == 0) "12" else i.toString(), 0f, 0f, textPaint)
canvas.restore()
}Why canvas.rotate(-i * 30f) before drawing text?
After rotating for the tick position, the coordinate system is tilted. If you draw text now, the number itself would appear rotated. Applying the inverse rotation (-i * 30f) brings the coordinate system back to upright — so every number reads normally regardless of where it sits on the clock.
Why translate before the counter-rotation?
translate moves the origin to the label position while the coordinate system is still rotated — so "upward" in the rotated system is exactly the right spot. Then we counter-rotate for upright text. The order matters.
Result: Numbers 1–12 correctly positioned and all perfectly upright.
What we're building: Hour, minute, and second hands — each a tapered trapezoid shape.
New concepts introduced: Path, moveTo, lineTo, close, Calendar for current time.
The hand shape is a trapezoid — wider at the base (pivot), narrower at the tip:
── ← handWidthTop (narrow tip)
/ \
/ \
──────── ← handWidthBottom (wide base at center)
// Class fields
private val handPath = Path() // reused every frame — never created inside onDraw
private val innerDialRadius = 10f
private val secondLength = 190f
private val minuteLength = 170f
private val hourLength = 140f
private val handWidthBottom = innerDialRadius - 2f // wide base
private val handWidthTop = 5f // narrow tip
private val handWidthBottomSecond = innerDialRadius - 6f
private val handWidthTopSecond = 2f
private val secondPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.parseColor("#E63946") // vivid red
style = Paint.Style.FILL
}
private val minHourPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.parseColor("#E8DFC8") // warm cream
style = Paint.Style.FILL
}
// Gold filled center pin — drawn last so it sits above all hands
private val innerDPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.parseColor("#C9A84C")
style = Paint.Style.FILL
}Add a private helper function:
private fun drawHand(
canvas: Canvas,
handWidthTop: Float,
handWidthBottom: Float,
handLength: Float,
paint: Paint,
angle: Float = 0f
) {
canvas.save()
canvas.rotate(angle) // rotate to this hand's current time position
handPath.reset()
// Build trapezoid shape centered at origin
// Base: from (-handWidthBottom, 0) to (handWidthBottom, 0) — horizontal line at center
// Tip: from (-handWidthTop, -handLength) to (handWidthTop, -handLength) — narrow end pointing up
handPath.moveTo(-handWidthBottom, 0f)
handPath.lineTo(handWidthBottom, 0f)
handPath.lineTo(handWidthTop, -handLength)
handPath.lineTo(-handWidthTop, -handLength)
handPath.close() // connects back to (-handWidthBottom, 0)
canvas.drawPath(handPath, paint)
canvas.restore()
}Get the current time and draw the hands inside onDraw:
val calendar = Calendar.getInstance()
val second = calendar.get(Calendar.SECOND)
val minute = calendar.get(Calendar.MINUTE)
val hour = calendar.get(Calendar.HOUR) // 12-hour format
// Angles — each unit maps to degrees
// Second: 60 seconds = 360° → 1 second = 6°
val secondAngle = second * 6f
// Smooth minute hand — also advances slightly as seconds pass
// 60 minutes = 360° → 1 minute = 6° | each second = 6/60 = 0.1°
val minuteAngle = minute * 6f + second * 0.1f
// Smooth hour hand — also advances as minutes pass
// 12 hours = 360° → 1 hour = 30° | each minute = 30/60 = 0.5°
val hourAngle = hour * 30f + minute * 0.5f
// Inside the canvas.save()/translate(cx,cy) block:
drawHand(canvas, handWidthTopSecond, handWidthBottomSecond, secondLength, secondPaint, secondAngle)
drawHand(canvas, handWidthTop, handWidthBottom, minuteLength, minHourPaint, minuteAngle)
drawHand(canvas, handWidthTop, handWidthBottom, hourLength, minHourPaint, hourAngle)Draw the center pin last — after canvas.restore() from the translate block, back to screen coordinates:
canvas.drawCircle(cx, cy, innerDialRadius, innerDPaint)Why draw center pin last? It must sit visually above all three hands. Drawing order = visual stack order.
Result: All three clock hands correctly positioned showing the current time.
What we're building: The clock updates every second automatically.
New concepts introduced: postDelayed, named Runnable, onDetachedFromWindow.
// Class field — named Runnable, not a lambda
// Must be named so removeCallbacks can find it by reference
private val ticker = Runnable { invalidate() }At the end of onDraw:
removeCallbacks(ticker) // cancel any pending callback first
postDelayed(ticker, 1000L) // schedule next redraw in 1 secondAnd always clean up when the view is destroyed:
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
removeCallbacks(ticker) // stop the loop — prevent memory leaks
}Why a named Runnable instead of a lambda?
removeCallbacks(runnable) matches by reference. A lambda { invalidate() } creates a new object each time — removeCallbacks can never find and cancel it. The named field ticker always refers to the same object, so cancellation works correctly.
Why removeCallbacks before postDelayed inside onDraw?
If onDraw is called multiple times rapidly (e.g. during a layout change), multiple postDelayed calls would stack up, causing the clock to update more than once per second. Cancelling first ensures only one callback is ever pending at a time.
Result: A fully ticking analog clock.
The complete, production-quality file is in CanvasPlayground.kt in this repository.
You now understand every concept used in this clock. Here are six challenges to deepen your knowledge — each builds on the last.
Real clocks have 60 minute ticks — 4 smaller ticks between each major hour tick.
What to do:
- Change the hour loop from
0..11to0..59 - Every 5th tick (
i % 5 == 0) → draw a long tick with a number label (existing behavior) - All other ticks → draw a shorter, thinner tick with no label
Hint: You'll need a second tick paint with smaller strokeWidth and shorter tick length. The rotation per tick becomes i * 6f (360° / 60 ticks).
The second hand currently jumps once per second. Make it sweep smoothly like a luxury watch.
What to do:
- Extract milliseconds:
val ms = calendar.get(Calendar.MILLISECOND) - Factor them into
secondAngle:val secondAngle = second * 6f + ms * 0.006f - Change
postDelayed(ticker, 1000L)topostDelayed(ticker, 16L)for ~60fps updates
Think about: What is 0.006f and where does it come from?
Draw a small "AM" or "PM" text label inside the clock face, below center.
What to do:
- Use
Calendar.HOUR_OF_DAY(0–23) to determine AM or PM - Draw the text at approximately
(0f, radius * 0.45f)relative to the translated center - Use a smaller
textSizeand a muted color so it doesn't compete with the hands
Hint: This draw call goes inside the canvas.save()/translate(cx, cy) block.
Add a subtle drop shadow to each clock hand to create depth.
What to do:
- Use
paint.setShadowLayer(blurRadius, dx, dy, shadowColor)on each hand paint - Enable hardware acceleration in your Activity:
window.addFlags(WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED)
Important: setShadowLayer only works with hardware acceleration enabled — without it, shadows are silently ignored.
Make the clock configurable directly from XML without touching Kotlin code.
What to do:
- Create
res/values/attrs.xmlwith attributes:clockBorderColor,clockFaceColor,secondHandColor,showNumbers - Read them in the constructor using
context.obtainStyledAttributes - Always call
ta.recycle()in afinallyblock
Target usage:
<com.example.CanvasPlayground
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:clockBorderColor="#FF0000"
app:secondHandColor="#00FF00"
app:showNumbers="false" />Replace the straight trapezoid hands with smooth curved hands using Path.quadTo.
What to do:
- Instead of 4 straight
lineTocalls indrawHand, usequadTofor the sides - The control point should be slightly offset from the center line to create a gentle outward curve
- The tip of the hand should taper to a fine point
Hint: path.quadTo(controlX, controlY, endX, endY) — experiment with the control point offset.
| Concept | Rule |
|---|---|
| Paint | Always declare outside onDraw — never inside |
| Path | Always declare outside onDraw — call reset() to reuse |
| Redrawing | Call invalidate(), never onDraw() directly |
| Size | Use width / height, never hardcoded pixels |
| Transformations | Every save() must have a matching restore() |
| Lifecycle | Always cancel callbacks in onDetachedFromWindow |
| Object pools | Use VelocityTracker.obtain() / recycle() pattern for pooled objects |
Once you're comfortable with this clock, these are the natural next steps:
clipRectandclipPath— restrict drawing to specific regions (used in before/after image sliders)PorterDuff modes— advanced compositing for masking and blendingValueAnimatorwith Canvas — smooth property-based animationsRenderEffect(API 31+) — GPU-accelerated blur and visual effectsJetpack Compose Canvas— the Compose equivalent usingDrawScope
⭐ If this tutorial helped you — star this repo. It takes one second and helps other developers find it. ⭐
Built with ❤️ using Android Canvas API — zero libraries.
