A Practical Guide to Android Custom Views
A Practical Guide to Android Custom Views
From a blank
Viewto a reusable, interactive component — everything you need to know to build your own custom views in Android.
Table of Contents
- Why Custom Views?
- The Core Lifecycle Methods
- Step-by-Step: Building a Circular Progress View
- Custom Attributes
- Handling Touch Events
- Performance Best Practices
- Common Pitfalls
- Conclusion
1. Why Custom Views?
The Android SDK ships with a rich set of built-in widgets — TextView, Button, RecyclerView, etc. But sometimes none of them fit your design or interaction needs. That’s where custom views come in.
You should consider writing a custom view when:
- You need a unique visual appearance that can’t be achieved by composing existing views.
- You need custom drawing (shapes, charts, graphs, gauges).
- You need specialized touch handling (gestures, dragging, multi-touch).
- You want a reusable component shared across projects.
Rule of thumb: If you can achieve the result by composing existing views or using a
ShapeableImageView/MaterialCardView, do that first. Reach for a custom view only when composition falls short.
2. The Core Lifecycle Methods
Every custom view revolves around three key overridable methods. Understanding them is non-negotiable.
onMeasure(int widthMeasureSpec, int heightMeasureSpec)
Called when the parent layout asks your view how big it wants to be. The MeasureSpec encodes a size and a mode:
| Mode | Meaning |
|---|---|
EXACTLY |
The parent has decided an exact size for you (e.g., layout_width="100dp" or match_parent in a constrained parent). |
AT_MOST |
You can be as big as you want up to the given size (e.g., wrap_content in a constrained parent). |
UNSPECIFIED |
No constraints — be as big as you want. |
You must call setMeasuredDimension(width, height) before returning.
onSizeChanged(int w, int h, int oldw, int oldh)
Called whenever the view’s size changes. This is the perfect place to cache calculations (center point, radius, scaled bitmap) so you don’t recompute them every frame in onDraw.
onDraw(Canvas canvas)
Where the magic happens. The Canvas is your drawing surface; Paint is your brush. Draw shapes, text, paths, bitmaps here.
Critical:
onDrawis called frequently (every animation frame, every invalidation). Keep it lean. Never allocate objects insideonDraw.
3. Step-by-Step: Building a Circular Progress View
Let’s build a reusable circular progress indicator — the kind you see in loading spinners and progress rings.
3.1 Create the class
class CircularProgressView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {
private var progress = 0f // 0f..1f
private var strokeWidth = 24f.px // dp to px
private var startAngle = -90f // begin at top
private val backgroundPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.STROKE
this.strokeWidth = this@CircularProgressView.strokeWidth
color = Color.parseColor("#E0E0E0")
}
private val progressPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.STROKE
this.strokeWidth = this@CircularProgressView.strokeWidth
color = Color.parseColor("#6200EE")
strokeCap = Paint.Cap.ROUND
}
private val bounds = RectF()
}
A few notes:
@JvmOverloadslets you instantiate the view from Java with fewer arguments.Paint.ANTI_ALIAS_FLAGsmooths jagged edges.strokeCap = Paint.Cap.ROUNDgives the progress arc rounded ends..pxis an extension property:val Float.px get() = this * resources.displayMetrics.density.
3.2 Measure the view
We want a square view by default, but we respect the parent’s constraints:
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
val defaultSize = 200.dp // preferred size in px
val width = resolveSize(defaultSize, widthMeasureSpec)
val height = resolveSize(defaultSize, heightMeasureSpec)
// Force square: pick the smaller dimension
val size = minOf(width, height)
setMeasuredDimension(size, size)
}
resolveSize handles the MeasureSpec mode logic for you — use it instead of reinventing the wheel.
3.3 Cache geometry on size change
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
val inset = strokeWidth / 2
bounds.set(inset, inset, w - inset, h - inset)
}
We inset by half the stroke width so the thick line isn’t clipped at the edges.
3.4 Draw
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
// Background ring
canvas.drawArc(bounds, 0f, 360f, false, backgroundPaint)
// Progress arc
val sweepAngle = 360f * progress
canvas.drawArc(bounds, startAngle, sweepAngle, false, progressPaint)
}
3.5 Expose a public API
fun setProgress(value: Float) {
progress = value.coerceIn(0f, 1f)
invalidate() // trigger a redraw
}
invalidate() schedules a redraw on the next frame. Call it whenever a property that affects drawing changes.
3.6 Use it in XML
<com.yourpackage.CircularProgressView
android:id="@+id/progressView"
android:layout_width="120dp"
android:layout_height="120dp" />
val progressView = findViewById<CircularProgressView>(R.id.progressView)
progressView.setProgress(0.75f)
4. Custom Attributes
Hardcoded colors and sizes are bad practice. Let’s expose them via XML attributes.
4.1 Define attributes in res/values/attrs.xml
<resources>
<declare-styleable name="CircularProgressView">
<attr name="progressColor" format="color" />
<attr name="trackColor" format="color" />
<attr name="strokeWidth" format="dimension" />
<attr name="progress" format="float" />
</declare-styleable>
</resources>
4.2 Read them in the constructor
init {
context.obtainStyledAttributes(attrs, R.styleable.CircularProgressView).use { typedArray ->
progressPaint.color = typedArray.getColor(
R.styleable.CircularProgressView_progressColor,
Color.parseColor("#6200EE")
)
backgroundPaint.color = typedArray.getColor(
R.styleable.CircularProgressView_trackColor,
Color.parseColor("#E0E0E0")
)
strokeWidth = typedArray.getDimension(
R.styleable.CircularProgressView_strokeWidth,
24f.px
)
progress = typedArray.getFloat(
R.styleable.CircularProgressView_progress,
0f
)
}
backgroundPaint.strokeWidth = strokeWidth
progressPaint.strokeWidth = strokeWidth
}
The .use { } extension automatically recycles the TypedArray — always recycle to free native resources.
4.3 Use them in XML
<com.yourpackage.CircularProgressView
android:layout_width="120dp"
android:layout_height="120dp"
app:progressColor="#FF4081"
app:trackColor="#F5F5F5"
app:strokeWidth="8dp"
app:progress="0.6" />
5. Handling Touch Events
To make your view interactive, override onTouchEvent:
override fun onTouchEvent(event: MotionEvent): Boolean {
when (event.action) {
MotionEvent.ACTION_DOWN -> {
// Finger touched the view
return true // we want subsequent events
}
MotionEvent.ACTION_MOVE -> {
val x = event.x
val y = event.y
// Update state based on touch position
updateFromTouch(x, y)
return true
}
MotionEvent.ACTION_UP -> {
// Finger lifted
performClick() // accessibility requirement!
return true
}
}
return super.onTouchEvent(event)
}
override fun performClick(): Boolean {
super.performClick() // handles accessibility
// handle click logic
return true
}
Important: If your view handles
ACTION_UP, you must callperformClick()to preserve accessibility (TalkBack) behavior. Lint will warn you if you forget.
For complex gestures, delegate to a GestureDetector:
private val gestureDetector = GestureDetector(context, object : GestureDetector.SimpleOnGestureListener() {
override fun onScroll(e1: MotionEvent, e2: MotionEvent, distanceX: Float, distanceY: Float): Boolean {
// Handle dragging
return true
}
})
override fun onTouchEvent(event: MotionEvent): Boolean {
return gestureDetector.onTouchEvent(event)
}
6. Performance Best Practices
Custom views can become performance bottlenecks. Follow these rules:
6.1 Never allocate in onDraw
// BAD — allocates every frame
override fun onDraw(canvas: Canvas) {
val paint = Paint() // DON'T
canvas.drawRect(rect, paint)
}
// GOOD — reuse pre-allocated objects
private val paint = Paint() // allocated once
override fun onDraw(canvas: Canvas) {
canvas.drawRect(rect, paint)
}
6.2 Use hardware layers for animations
When animating a complex view, enable a hardware layer to cache the rendered output:
fun startAnimation() {
setLayerType(LAYER_TYPE_HARDWARE, null)
// ... animate ...
// Disable after animation to free GPU memory
setLayerType(LAYER_TYPE_NONE, null)
}
6.3 Avoid invalidate() when postInvalidateOnAnimation() suffices
For smooth 60fps animations, use ValueAnimator and postInvalidateOnAnimation():
fun animateProgress(target: Float) {
val animator = ValueAnimator.ofFloat(progress, target).apply {
duration = 300
interpolator = DecelerateInterpolator()
addUpdateListener {
progress = it.animatedValue as Float
postInvalidateOnAnimation()
}
}
animator.start()
}
6.4 Profile with GPU rendering
Enable Developer Options → Profile GPU Rendering to spot janky frames. If your view’s bars exceed the green line, you have overdraw or expensive draw calls.
7. Common Pitfalls
| Pitfall | Fix |
|---|---|
| View is invisible / zero size | You forgot to call setMeasuredDimension() in onMeasure. |
| Drawing is clipped at edges | Inset your RectF/Path by half the stroke width. |
wrap_content doesn’t work |
Implement proper onMeasure logic respecting AT_MOST mode. |
| Memory leaks | Don’t hold Activity/Fragment references; use WeakReference if needed. |
| Janky scrolling | Allocate Paint/Path once; avoid object creation in onDraw. |
| Accessibility broken | Call performClick() in ACTION_UP; set contentDescription. |
| RTL layout issues | Use canvas.save()/canvas.restore() with layoutDirection checks. |
8. Conclusion
Custom views are one of the most powerful tools in an Android developer’s toolkit. The key takeaways:
- Understand the lifecycle —
onMeasure,onSizeChanged,onDraware your holy trinity. - Respect
MeasureSpec— useresolveSize/resolveSizeAndStateinstead of ignoring constraints. - Keep
onDrawallocation-free — pre-create allPaint,Path, andRectFobjects. - Expose XML attributes — your view should be configurable from layout files.
- Don’t forget accessibility —
performClick(),contentDescription, and touch target sizing matter.
Start simple, measure performance early, and iterate. Once you’re comfortable, explore advanced topics like RenderEffect (API 31+), SurfaceView for high-performance rendering, and Compose’s Canvas for the modern toolkit.
Happy drawing!
Written by [Your Name] · Last updated: 2026-08-22
- 点赞
- 收藏
- 关注作者
评论(0)