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.
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.
onMeasuremust honour theMeasureSpecmode, or the view will overflow or collapse.onDrawmust not allocate. ReusePaint,PathandRectobjects; a per-frame allocation is a GC pause waiting to happen.- Save and restore state with
onSaveInstanceState/onRestoreInstanceStateand aBaseSavedState, 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#
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.
| Rule | Why |
|---|---|
create Paint in the constructor | it is expensive and immutable in practice |
reuse Rect/RectF/Path | allocation pressure during scroll |
avoid String concatenation in draw | creates garbage per frame |
avoid Bitmap decoding in draw | blocks the main thread |
prefer drawPath/drawRect over many small ops | fewer 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
truefromACTION_DOWNto receive the rest of the gesture. - Call
performClick()rather than doing the action inline. It firesOnClickListener, plays the accessibility click sound, and lets TalkBack activate the view. - Always handle
ACTION_CANCEL. A parent that intercepts the gesture sends cancel; leavingisPressedtrue 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:
onSizeChangedis not called once. Any resize — rotation, split screen, a keyboard appearing — triggers it. Anything derived from the size must be recomputed there, not inonMeasure.onSaveInstanceStaterequires an ID. Without one, the framework has nowhere to file the state.- A view can be created before it is attached. Reading
contextin the constructor is fine; readingparentorwindowTokenis 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
onMeasurecallresolveSize(or handle all three modes by hand)? - Does
onDrawallocate anything? - Is
performClick()called, and does the view have a content description? - Is there a
SavedState, and does the view have anandroid: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:
- Can it be composed from existing views in a layout? A
LinearLayoutwith twoTextViews is almost always cheaper to build and maintain than a customView. - Is it a one-off visual? A
Drawableis simpler than aViewwhen there is no input. - 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#
| API | Version | What changed |
|---|---|---|
| 1 | 1.0 |
|
| 3 | 1.5 Cupcake |
|
| 9 | 2.3 Gingerbread |
|
| 11 | 3.0 Honeycomb |
|
| 12 | 3.1 Honeycomb |
|
| 16 | 4.1 Jelly Bean |
|
| 20 | 4.4W KitKat (Wear) |
|
| 21 | 5.0 Lollipop |
|
| 24 | 7.0 Nougat |
|
| 26 | 8.0 Oreo |
|
| 28 | 9 Pie |
|
| 29 | 10 |
|
| 30 | 11 |
|
| 31 | 12 |
|
| 33 | 13 Tiramisu |
|
| 36 | 16 Baklava |
|
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#
- View.javaonMeasure, onDraw, onTouchEvent and the saved-state contract
- ViewGroup.javahow a parent measures and lays out children
- ViewConfiguration.javathe touch slop and timing constants
- AccessibilityNodeInfo.javathe semantics a custom view must publish
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.
onDrawcan 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#
adb shell dumpsys activity top | grep -A 30 "View Hierarchy"
adb shell setprop debug.hwui.overdraw show
Check yourself#
Check yourself
What is the difference between `invalidate()` and `requestLayout()`?
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.
- docsCreate custom viewsdeveloper.android.comaccessed 2026-10-27↩
- docsAccessibility on Androiddeveloper.android.comaccessed 2026-10-27↩
- docsCreate a view layoutdeveloper.android.comaccessed 2026-10-27↩
- docsHandle input eventsdeveloper.android.comaccessed 2026-10-27↩
- docsRendering | App qualitydeveloper.android.comaccessed 2026-10-27↩