Zygote
UI toolkitsintermediate

Custom views

Writing a View is the smallest useful unit of Android UI work: measure, draw, handle input, save state. It is also the best way to learn what the framework does for you everywhere else.

11 minAPI 1–37 · 17interactivequizverified 2026-10-27

Every good custom view answers four questions: how big am I, what do I draw, what happens when I am touched, and what must I remember across a configuration change.1 Miss one and the view works in the demo and fails in the app.

TL;DR#

  • Subclass for a single visual element; subclass when you need to position children.
  • onMeasure must honour the MeasureSpec mode, or the view will overflow or collapse.
  • onDraw must not allocate. Reuse Paint, Path and Rect objects; a per-frame allocation is a GC pause waiting to happen.
  • Save and restore state with onSaveInstanceState/onRestoreInstanceState and a BaseSavedState, or the view resets on every rotation.1
  • Accessibility is part of the view: set a content description, or the element is invisible to TalkBack.

Mental model#

Building a custom view is like commissioning a bespoke piece of furniture.

You specify the dimensions it can adapt to (measure), the finish (draw), how it responds to being touched (input), and what settings must be preserved if it is moved to another room (saved state). You also produce a label for the inventory system (accessibility), or nobody can find it.

Where the analogy breaks#

Furniture is built once. A view is rebuilt constantly: it is measured on every layout pass, redrawn on every invalidation, and reconstructed from scratch after a rotation. The "specification" is not a one-off document; it is code that runs many times per second.

The big picture#

Loading diagram…
Text description of this diagram

A walkthrough of the measure, layout and draw pipeline. Invalidate or requestLayout sets dirty flags; Choreographer schedules the traversal for the next vsync; measure runs top-down with MeasureSpec constraints; layout assigns bounds; draw records display-list commands; and sync hands them to RenderThread. For a custom view, the highlighted stages are measure — where onMeasure must honour the MeasureSpec mode — and draw, where onDraw records commands without allocating. The frame-budget bar shows why allocation inside onDraw costs a frame.

The two stages that a custom view owns are measure and draw. Everything between them — scheduling, traversal, sync — is the framework's job, and interfering with it is how custom views become jank sources.

Deep dive#

A complete custom view#

class RatingBarView @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0,
) : View(context, attrs, defStyleAttr) {
 
    private val starPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
        style = Paint.Style.FILL
    }
    private val outlinePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
        style = Paint.Style.STROKE
        strokeWidth = 2.dp
    }
    private val starPath = Path()
    private val bounds = RectF()
 
    var rating: Int = 0
        set(value) {
            val clamped = value.coerceIn(0, maxRating)
            if (field == clamped) return
            field = clamped
            contentDescription = context.getString(R.string.rating_value, clamped)
            invalidate()
        }
 
    var maxRating: Int = 5
        set(value) {
            field = value
            requestLayout()
        }
 
    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        val desired = (maxRating * starSize + (maxRating - 1) * gap).toInt()
        val w = resolveSize(desired, widthMeasureSpec)
        val h = resolveSize(starSize.toInt(), heightMeasureSpec)
        setMeasuredDimension(w, h)
    }
 
    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)
        // No allocation: all objects were created in the constructor.
        for (i in 0 until maxRating) {
            val left = i * (starSize + gap)
            bounds.set(left, 0f, left + starSize, starSize)
            starPath.reset()
            starPath.addStar(bounds)
            canvas.drawPath(starPath, if (i < rating) starPaint else outlinePaint)
        }
    }
 
    override fun onSaveInstanceState(): Parcelable =
        Bundle().apply {
            putParcelable(KEY_SUPER, super.onSaveInstanceState())
            putInt(KEY_RATING, rating)
        }
 
    override fun onRestoreInstanceState(state: Parcelable?) {
        if (state is Bundle) {
            super.onRestoreInstanceState(state.getParcelable(KEY_SUPER))
            rating = state.getInt(KEY_RATING)
        } else {
            super.onRestoreInstanceState(state)
        }
    }
}

Four things in that class are doing the real work, and each corresponds to one of the questions at the top.

Measure correctly#

The single most common custom-view bug is ignoring the measure mode:

// Wrong: overflows an AT_MOST parent.
override fun onMeasure(w: Int, h: Int) {
    setMeasuredDimension(desiredWidth, desiredHeight)
}
 
// Right: resolve against the constraint.
override fun onMeasure(w: Int, h: Int) {
    setMeasuredDimension(
        resolveSize(desiredWidth, w),
        resolveSize(desiredHeight, h),
    )
}

resolveSize returns the desired size when the mode is UNSPECIFIED, the constraint when it is EXACTLY, and the smaller of the two when it is AT_MOST. That is almost always what you want.

If the view has a natural aspect ratio, compute it and use MeasureSpec arithmetic rather than guessing:

override fun onMeasure(widthSpec: Int, heightSpec: Int) {
    val width = resolveSize(suggestedMinimumWidth, widthSpec)
    val height = (width / aspectRatio).toInt()
    setMeasuredDimension(width, resolveSize(height, heightSpec))
}

Draw without allocating#

onDraw runs inside the frame's deadline. Every Paint(), RectF() or String.format in it is a potential GC pause and a certain amount of CPU.

RuleWhy
create Paint in the constructorit is expensive and immutable in practice
reuse Rect/RectF/Pathallocation pressure during scroll
avoid String concatenation in drawcreates garbage per frame
avoid Bitmap decoding in drawblocks the main thread
prefer drawPath/drawRect over many small opsfewer display-list entries

For text, StaticLayout (pre-computed) is much cheaper than repeated canvas.drawText with layout logic in onDraw.

Handle input#

override fun onTouchEvent(event: MotionEvent): Boolean {
    when (event.actionMasked) {
        MotionEvent.ACTION_DOWN -> {
            isPressed = true
            return true                    // claim the gesture
        }
        MotionEvent.ACTION_MOVE -> {
            // Optional: update a hover/preview state.
            return true
        }
        MotionEvent.ACTION_UP -> {
            isPressed = false
            performClick()                 // accessibility and click listeners
            return true
        }
        MotionEvent.ACTION_CANCEL -> {
            isPressed = false
            return true
        }
    }
    return super.onTouchEvent(event)
}
 
override fun performClick(): Boolean {
    super.performClick()
    if (rating < maxRating) rating += 1
    return true
}

Three things worth noting:

  • Return true from ACTION_DOWN to receive the rest of the gesture.
  • Call performClick() rather than doing the action inline. It fires OnClickListener, plays the accessibility click sound, and lets TalkBack activate the view.
  • Always handle ACTION_CANCEL. A parent that intercepts the gesture sends cancel; leaving isPressed true produces a view stuck in a pressed state.

If the view needs to distinguish a tap from a drag, use ViewConfiguration.get(context).scaledTouchSlop rather than a hard-coded pixel threshold — it respects the user's accessibility settings.

Save and restore state#

Views are recreated on rotation. Without saved state, a custom view silently resets. The Bundle approach above works, but the more idiomatic form is a BaseSavedState subclass:

private class SavedState : BaseSavedState {
    var rating: Int = 0
 
    constructor(superState: Parcelable?) : super(superState)
    private constructor(source: Parcel) : super(source) {
        rating = source.readInt()
    }
 
    override fun writeToParcel(out: Parcel, flags: Int) {
        super.writeToParcel(out, flags)
        out.writeInt(rating)
    }
 
    companion object {
        @JvmField
        val CREATOR = object : Parcelable.Creator<SavedState> {
            override fun createFromParcel(source: Parcel) = SavedState(source)
            override fun newArray(size: Int) = arrayOfNulls<SavedState>(size)
        }
    }
}

A View also needs android:id for its state to be saved at all — the framework stores view state keyed by ID. A custom view without an ID restores nothing, which is a subtle and common failure.

Accessibility is not optional#

A custom-drawn view is invisible to accessibility services unless you describe it:

override fun onInitializeAccessibilityNodeInfo(info: AccessibilityNodeInfo) {
    super.onInitializeAccessibilityNodeInfo(info)
    info.className = RatingBarView::class.java.name
    info.contentDescription = context.getString(R.string.rating_value, rating)
    info.isScrollable = false
}

For richer semantics, expose actions:

info.addAction(
    AccessibilityNodeInfo.AccessibilityAction(
        AccessibilityNodeInfo.ACTION_CLICK,
        context.getString(R.string.increase_rating),
    )
)

The View lifecycle, and why it matters for a custom view#

A View has a lifecycle of its own, and a custom view that ignores it acquires bugs that look like layout bugs:

override fun onAttachedToWindow() {
    super.onAttachedToWindow()
    // The view now has a window, a display and a real configuration.
    registerSensorListener()
}
 
override fun onDetachedFromWindow() {
    unregisterSensorListener()
    super.onDetachedFromWindow()
}

onAttachedToWindow is the first moment the view knows which display it is on, which matters on foldables and in multi-display setups. onDetachedFromWindow is the only reliable place to unregister a listener — a view removed from the tree does not get onDestroy, and it can be re-attached later.

Three lifecycle traps worth naming:

  • onSizeChanged is not called once. Any resize — rotation, split screen, a keyboard appearing — triggers it. Anything derived from the size must be recomputed there, not in onMeasure.
  • onSaveInstanceState requires an ID. Without one, the framework has nowhere to file the state.
  • A view can be created before it is attached. Reading context in the constructor is fine; reading parent or windowToken is not.

Measurement, text and the font scale#

A custom view that draws text inherits the user's font scale, and a layout that assumed a fixed height breaks at 200%. The measurement must account for it:

override fun onMeasure(widthSpec: Int, heightSpec: Int) {
    val textHeight = paint.fontMetrics.let { it.descent - it.ascent }
    val desiredHeight = (textHeight + paddingTop + paddingBottom).toInt()
    setMeasuredDimension(
        resolveSize(desiredWidth, widthSpec),
        resolveSize(desiredHeight, heightSpec),
    )
}

That is the difference between a view that scales with accessibility settings and one that clips. It is also a reminder that onMeasure is the place to compute anything the layout depends on, and onDraw is the place to use it — not the reverse.

Styleable attributes#

A reusable custom view needs to be configurable from XML, which means a declare-styleable:

<resources>
    <declare-styleable name="RatingBarView">
        <attr name="maxRating" format="integer" />
        <attr name="starSize" format="dimension" />
        <attr name="starColor" format="color" />
    </declare-styleable>
</resources>
private val maxRatingValue = context.obtainStyledAttributes(
    attrs, R.styleable.RatingBarView, defStyleAttr, 0,
).use { ta -> ta.getInt(R.styleable.RatingBarView_maxRating, 5) }

Three rules: always pass defStyleAttr through so a theme can style the view; always recycle the TypedArray (the use extension does it); and give every attribute a default, because a view that crashes when the attribute is omitted is a view nobody can use in a layout preview.

A checklist before you ship one#

Six questions, and a "no" on any of them means the view is not ready:

  • Does onMeasure call resolveSize (or handle all three modes by hand)?
  • Does onDraw allocate anything?
  • Is performClick() called, and does the view have a content description?
  • Is there a SavedState, and does the view have an android:id?
  • Are listeners unregistered in onDetachedFromWindow?
  • Does it render correctly at 200% font scale and in a 320 dp wide window?

That list is short because the failure modes are narrow. A custom view that passes all six is usually a good one; a view that fails one of them is a bug report waiting for a device it was not tested on.

When not to write a custom view#

The View system already has most of what you need. Before writing one:

  1. Can it be composed from existing views in a layout? A LinearLayout with two TextViews is almost always cheaper to build and maintain than a custom View.
  2. Is it a one-off visual? A Drawable is simpler than a View when there is no input.
  3. Does it need to be fast in a list? Then the recycling contract matters more than the drawing code.

A custom view earns its place when it has input semantics of its own — a rating control, a signature pad, a chart with touch inspection — or when the drawing is genuinely cheaper as one view than as a tree.

How it evolved#

Interface changes16 releases
APIVersionWhat changed
11.0
  • Pull-down notification shade
  • Home screen with widgets
31.5 Cupcake
  • Home screen widgets and folders
92.3 Gingerbread
  • New UI with dark theme and green accents
113.0 Honeycomb
  • The Holo design language arrives
123.1 Honeycomb
  • Resizable widgets
164.1 Jelly Bean
  • Project Butter: vsync-driven, smooth UI
  • Expandable notifications
204.4W KitKat (Wear)
  • Voice-driven card UI
215.0 Lollipop
  • Material Design
  • Notifications on the lock screen
247.0 Nougat
  • Quick Settings tiles
  • Direct Reply in notifications
268.0 Oreo
  • Notification channels
289 Pie
  • Dark theme groundwork
2910
  • System-wide dark theme
3011
  • Conversation notifications and bubbles
  • Media controls in Quick Settings
3112
  • Material You dynamic colour
3313 Tiramisu
  • Themed app icons
  • Runtime notification permission
3616 Baklava
  • Material 3 Expressive
  • Live Updates notifications

The custom-view API has been stable since API 1. The change is in what surrounds it: hardware acceleration changed what Canvas operations are cheap, and Compose introduced a different way to build the same control without a View at all.

Under the hood#

Curiosity corner#

  • A view without an ID saves no state. The framework keys saved view state by ID, so the same custom view in two places needs two IDs.
  • onDraw can be called with a null-size canvas during measurement previews. Guard against zero dimensions.
  • Hardware layers (setLayerType) are a trap. They can help a complex animation and hurt everything else by forcing an offscreen buffer.

Try it#

Inspect a view hierarchy

adb shell dumpsys activity top | grep -A 30 "View Hierarchy"

Check for overdraw on a device

adb shell setprop debug.hwui.overdraw show

Check yourself#

Check yourself

1 / 12

What is the difference between `invalidate()` and `requestLayout()`?

0 answered · 0 correct

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. docsAccessibility on Androiddeveloper.android.comaccessed 2026-10-27↩
  3. docsCreate a view layoutdeveloper.android.comaccessed 2026-10-27↩
  4. docsHandle input eventsdeveloper.android.comaccessed 2026-10-27↩
  5. docsRendering | App qualitydeveloper.android.comaccessed 2026-10-27↩