Zygote
Builder trackintermediate

I03 — Custom view gauge

Draw a gauge by hand: measure, layout, draw, invalidate. A custom view is the shortest path to understanding why a frame costs what it costs, because you write the code that produces it.

3 minAPI 21–37 · 17verified 2026-10-27

A custom view is where the View system stops being a framework you use and becomes code you wrote. The three passes — measure, layout, draw — are not abstractions when you are the one implementing them.

What you are building#

A gauge view that draws an arc, a needle and a value, and animates the needle when the value changes. It has a custom attribute for the maximum value, handles padding, and requests a redraw only when the value actually changes.

Steps#

  1. Create the view class

    Extend View and add the three constructors, including the one that takes an AttributeSet so the view can be inflated from XML.1

    You should seeThe class compiles and can be placed in a layout.
  2. The skeleton is three overrides:

    class GaugeView @JvmOverloads constructor(
        context: Context,
        attrs: AttributeSet? = null,
    ) : View(context, attrs) {
     
        override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
            val size = resolveSize(desiredSize(), widthMeasureSpec)
            setMeasuredDimension(size, size)
        }
     
        override fun onDraw(canvas: Canvas) {
            super.onDraw(canvas)
            // arc, needle, value
        }
    }
  3. Read the custom attribute

    Declare a custom attribute in attrs.xml, read it in the constructor, and give it a default. This is the mechanism every library uses for configuration.

    You should seeThe maximum value comes from the layout.
  4. Measure

    Override onMeasure, compute the desired size from the content plus padding, and call setMeasuredDimension with a value reconciled against the measure spec. A view that ignores the spec breaks its parent's layout.

    You should seeThe view has a sensible size under wrap_content and match_parent.
  5. Draw

    Override onDraw, create the Paint objects once (not per frame), and draw the arc and needle. Then draw the value as text, centred in the available space.

    You should seeAn arc and a needle appear.
  6. Handle padding

    Compute the drawing area from the padded bounds rather than the full view bounds. This is the detail that separates a view that works in one layout from one that works anywhere.

    You should seeThe drawing respects padding.
  7. Animate the value

    Use a ValueAnimator and call setValue on each update. In setValue, call invalidate() and return early if the value has not changed: an unnecessary invalidation is an unnecessary frame.

    You should seeThe needle moves smoothly.
  8. Count the draws

    Log in onDraw and confirm that it runs once per frame. If it runs several times, something else is invalidating the view; a common cause is a layout pass triggered inside onDraw.

    You should seeOne draw per animation frame, not more.
  9. Compare with Compose

    Build a Compose version using Canvas and an animated state. The phases are analogous: composition replaces measure, but the draw is still a draw. The comparison makes the View system's phases concrete.2

    You should seeThe same gauge, declaratively.
Watch the frames
adb shell dumpsys gfxinfo com.example.gauge reset
adb shell dumpsys gfxinfo com.example.gauge
adb shell dumpsys gfxinfo com.example.gauge framestats | head -30
Example output
Illustrative output — counters depend on your device.

Verification#

Rotate the device while the gauge is visible. The view should redraw correctly at the new size, with the padding respected and the value preserved. If it is clipped or off-centre, the measure pass or the padded bounds are wrong, and that is the lesson.

What you learned#

  • The three passes are explicit in a custom view: measure decides size, layout decides position, draw produces the pixels.
  • invalidate schedules, it does not draw. The frame boundary is the Choreographer's vsync.
  • Allocation in onDraw costs frames. Paint objects and shaders are created once.
  • Padding and measure specs are the difference between a demo and a reusable view.

Read The View system for the tree model, and The life of a frame for where onDraw sits.

Extend it#

  1. Add a second needle and an overlapping arc, and watch the draw cost rise without the frame count changing.
  2. Add a saveLayer and measure the difference; layers are a common hidden cost.
  3. Make the gauge accessible by exposing the value through onInitializeAccessibilityNodeInfo.

Sources#

Sources

Every version number, API level, class name and date on this page is traced to one of these. Pages are re-verified against them; last_verified above says when.

  1. docsCreate custom viewsdeveloper.android.comaccessed 2026-10-27↩
  2. docsCompose phasesdeveloper.android.comaccessed 2026-10-27↩
  3. docsCreate a view layoutdeveloper.android.comaccessed 2026-10-27↩
  4. docsRendering | App qualitydeveloper.android.comaccessed 2026-10-27↩
  5. docsCompose performancedeveloper.android.comaccessed 2026-10-27↩