Custom Views let you draw arbitrary graphics with Canvas, handle touch events precisely, and achieve visual effects that standard widgets can't provide. The main challenge is keeping rendering fast — every frame budget is 16ms at 60fps.
Custom View Anatomy
class SpeedometerView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {
// Step 1: Define paints ONCE — never create Paint objects in onDraw
private val arcPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.STROKE
strokeWidth = 20f
strokeCap = Paint.Cap.ROUND
color = Color.parseColor("#2196F3")
}
private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
textSize = 48f
color = Color.WHITE
textAlign = Paint.Align.CENTER
}
private val arcRect = RectF()
// Step 2: Compute layout-dependent values in onSizeChanged — not onDraw
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
val padding = 40f
arcRect.set(padding, padding, w - padding, h - padding)
}
// Step 3: Draw only — no allocations, no calculations
var speedPercent: Float = 0f
set(value) {
field = value.coerceIn(0f, 1f)
invalidate() // ← triggers onDraw on next frame
}
override fun onDraw(canvas: Canvas) {
// Background arc
arcPaint.color = Color.parseColor("#37474F")
canvas.drawArc(arcRect, 135f, 270f, false, arcPaint)
// Speed arc
arcPaint.color = Color.parseColor("#2196F3")
canvas.drawArc(arcRect, 135f, 270f * speedPercent, false, arcPaint)
// Speed text
val cx = width / 2f
val cy = height / 2f
canvas.drawText("${(speedPercent * 100).toInt()} km/h", cx, cy, textPaint)
}
}
Avoiding Overdraw
Overdraw = drawing pixels that are later covered by other pixels. Inspect with Developer Options > Debug GPU Overdraw.
// ❌ Drawn by parent — skip the background if parent already fills it
override fun onDraw(canvas: Canvas) {
canvas.drawRect(0f, 0f, width.toFloat(), height.toFloat(), backgroundPaint) // overdraw!
canvas.drawCircle(...)
}
// ✅ Skip the background rect if View's background drawable handles it
class MyView : View(...) {
init {
setWillNotDraw(false) // ensure onDraw is called even for subclasses
}
}
Hardware Layers
Hardware layers accelerate repeated animations by caching the view on the GPU:
// Useful when animating a view that draws slowly
view.animate()
.alpha(0f)
.withLayer() // promotes to hardware layer during animation
.start()
// Or manually
view.setLayerType(View.LAYER_TYPE_HARDWARE, null) // set before animation
// ... animate ...
view.setLayerType(View.LAYER_TYPE_NONE, null) // clear after animation
Warning: hardware layers use GPU memory. Don't leave them permanently on static views.
Custom ViewGroup: Measuring and Layout
class FlowLayout @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null
) : ViewGroup(context, attrs) {
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
val maxWidth = MeasureSpec.getSize(widthMeasureSpec)
var currentRowWidth = 0
var currentRowHeight = 0
var totalHeight = 0
for (i in 0 until childCount) {
val child = getChildAt(i)
measureChild(child, widthMeasureSpec, heightMeasureSpec)
if (currentRowWidth + child.measuredWidth > maxWidth) {
totalHeight += currentRowHeight // wrap to next row
currentRowWidth = 0
currentRowHeight = 0
}
currentRowWidth += child.measuredWidth
currentRowHeight = maxOf(currentRowHeight, child.measuredHeight)
}
totalHeight += currentRowHeight
setMeasuredDimension(maxWidth, totalHeight)
}
override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
var x = 0; var y = 0; var rowHeight = 0
for (i in 0 until childCount) {
val child = getChildAt(i)
if (x + child.measuredWidth > width) { y += rowHeight; x = 0; rowHeight = 0 }
child.layout(x, y, x + child.measuredWidth, y + child.measuredHeight)
x += child.measuredWidth
rowHeight = maxOf(rowHeight, child.measuredHeight)
}
}
}
Path and Clip Operations
override fun onDraw(canvas: Canvas) {
// Clip to a rounded rect — cheap GPU operation
val clipPath = Path().apply {
addRoundRect(0f, 0f, width.toFloat(), height.toFloat(), 24f, 24f, Path.Direction.CW)
}
canvas.clipPath(clipPath)
// Draw background image (now clipped to rounded rect)
canvas.drawBitmap(bitmap, 0f, 0f, null)
}
Key Performance Rules
| Rule | Why |
|---|---|
Never allocate in onDraw | GC pauses → dropped frames |
Compute dimensions in onSizeChanged | onDraw is called every frame; onSizeChanged only when dimensions change |
invalidate() only when state changes | Unnecessary invalidations waste GPU time |
invalidate(Rect) for partial updates | Narrow the dirty region when only part of the view changes |
| Hardware layer during animation | Avoids re-drawing complex views on every frame |
Avoid clipPath on older APIs | clipPath disabled hardware acceleration below API 18 |