The Complete Methodology of Custom Views
What Problem Does a Custom View Solve?
System-provided Views (TextView, ImageView, etc.) are generic UI components; they mathematically cannot satisfy every bespoke visual requirement in complex business scenarios. Authentic, high-fidelity data charts, arched progress bars, rolling dice animations, or specialized geometric avatars—these all necessitate Custom Views.
There are two fatal misconceptions regarding Custom View engineering:
- "As long as it draws correctly, it's fine," completely ignoring
Measure/Layoutconstraints. - Recklessly instantiating objects within
onDraw, triggering catastrophic Garbage Collection (GC) stutters.
Mastering Custom Views demands answering the three fundamental questions the OS asks every UI component: How big are you? Where do you sit? How do you paint yourself?
Measure: How Much Space Do You Actually Consume?
What is a MeasureSpec?
A Parent View does not simply dictate a raw pixel count to a Child View and say "you are exactly this big." Instead, it transmits a payload combining a "Suggestion + Constraint Mode"—this is the MeasureSpec.
A MeasureSpec is a highly optimized 32-bit int: the highest 2 bits encode the operational mode, while the lower 30 bits encode the size.
// Unpacking the payload
val mode = MeasureSpec.getMode(measureSpec)
val size = MeasureSpec.getSize(measureSpec)
// Packing the payload
val spec = MeasureSpec.makeMeasureSpec(200, MeasureSpec.EXACTLY)
The strict semantics of the three modes:
| Mode | Binary | Semantics | Mapped Layout Parameter |
|---|---|---|---|
EXACTLY |
00 |
The Parent dictates a strict, non-negotiable size. | match_parent or hardcoded dp. |
AT_MOST |
10 |
The Child must not exceed this physical limit. | wrap_content. |
UNSPECIFIED |
01 |
The Parent imposes zero constraints (e.g., inside a ScrollView). |
Exceedingly rare. |
The Standard onMeasure Template
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
// 1. Calculate the intrinsic dimensions required by the payload itself (ignoring external constraints)
val desiredWidth = CONTENT_WIDTH + paddingLeft + paddingRight
val desiredHeight = CONTENT_HEIGHT + paddingTop + paddingBottom
// 2. Resolve the final dimensions by crossing desires with constraints
val measuredWidth = resolveSize(desiredWidth, widthMeasureSpec)
val measuredHeight = resolveSize(desiredHeight, heightMeasureSpec)
// 3. Mandatory invocation: You MUST call setMeasuredDimension, otherwise an IllegalStateException is thrown
setMeasuredDimension(measuredWidth, measuredHeight)
}
resolveSize(size, spec) is a system utility method that executes the following logic:
fun resolveSize(size: Int, measureSpec: Int): Int {
val specMode = MeasureSpec.getMode(measureSpec)
val specSize = MeasureSpec.getSize(measureSpec)
return when (specMode) {
MeasureSpec.EXACTLY -> specSize // Mandate: Obey the Parent
MeasureSpec.AT_MOST -> minOf(size, specSize) // Limit: Do not exceed the Parent
else -> size // Infinite: Take what you intrinsically need
}
}
Canonical Engineering Failure: Directly invoking super.onMeasure() without handling wrap_content. For most Custom Views inheriting directly from View, the default base implementation violently treats wrap_content as match_parent, causing the View to cannibalize the entire parent container.
Layout: Where Do You Sit?
A standard Custom View (inheriting from View, not ViewGroup) does NOT need to override onLayout. The parent ViewGroup mathematically calculates its position utilizing the onMeasure results and LayoutParams.
When onLayout is Mandatory: When you inherit from ViewGroup and must orchestrate the physical positioning of Child Views.
// Abstracted Custom FlowLayout
class FlowLayout(ctx: Context, attrs: AttributeSet?) : ViewGroup(ctx, attrs) {
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
// 1. Command all subordinates to measure themselves
measureChildren(widthMeasureSpec, heightMeasureSpec)
// 2. Compute own dimensions based on the cumulative size of children
var totalHeight = 0; var lineWidth = 0
val maxWidth = MeasureSpec.getSize(widthMeasureSpec)
var lineHeight = 0
for (child in nonGoneChildren()) {
if (lineWidth + child.measuredWidth > maxWidth) {
totalHeight += lineHeight
lineWidth = 0; lineHeight = 0
}
lineWidth += child.measuredWidth
lineHeight = maxOf(lineHeight, child.measuredHeight)
}
totalHeight += lineHeight
setMeasuredDimension(
resolveSize(maxWidth, widthMeasureSpec),
resolveSize(totalHeight, heightMeasureSpec)
)
}
override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
var x = 0; var y = 0; var lineHeight = 0
val maxWidth = width
for (child in nonGoneChildren()) {
if (x + child.measuredWidth > maxWidth) {
x = 0; y += lineHeight; lineHeight = 0
}
// CORE EXECUTION: Use layout() to hardcode the four vertex vectors of the child
child.layout(x, y, x + child.measuredWidth, y + child.measuredHeight)
x += child.measuredWidth
lineHeight = maxOf(lineHeight, child.measuredHeight)
}
}
}
Draw: How Do You Paint Yourself?
The Division of Labor: Canvas vs. Paint
Canvas represents the "Drawing Board" (dictating what geometry to draw and where to draw it). Paint represents the "Stylus" (dictating color, stroke thickness, antialiasing, and style).
// Paint MUST be pre-allocated during initialization.
// Never instantiate inside onDraw() (prevents GC thrashing).
private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.BLUE
style = Paint.Style.FILL // FILL = Solid, STROKE = Outline, FILL_AND_STROKE = Both
strokeWidth = 4f
textSize = 48f
}
override fun onDraw(canvas: Canvas) {
// Render a Circle
canvas.drawCircle(cx, cy, radius, paint)
// Render a Rectangle
canvas.drawRect(left, top, right, bottom, paint)
// Render an Arc (startAngle, sweepAngle, useCenter: draws a wedge if true)
canvas.drawArc(rectF, startAngle, sweepAngle, false, paint)
// Render Text
canvas.drawText("Hello", x, y, paint)
// Render a Custom Path (The most powerful vector graphic tool)
val path = Path()
path.moveTo(0f, 0f)
path.lineTo(100f, 100f)
path.quadTo(150f, 50f, 200f, 100f) // Quadratic Bezier Curve
canvas.drawPath(path, paint)
}
Canvas Coordinate Transformations
Canvas natively supports affine transformations: translation, rotation, and scaling. These transformations are highly destructive and cumulative:
override fun onDraw(canvas: Canvas) {
// save() locks the current transformation matrix state
canvas.save()
canvas.translate(cx, cy) // Shift the absolute origin (0,0) to the circle's center
canvas.rotate(angle) // Rotate the entire coordinate system
// All subsequent operations are now mapped relative to the newly translated center
canvas.drawLine(-armLength, 0f, armLength, 0f, paint)
// restore() violently reverts the matrix back to the save() state, preventing pollution
canvas.restore()
}
save()/restore() pairs are the architectural safety nets when engineering complex visual topologies, guaranteeing that one component's matrix rotation doesn't warp the entire UI.
The Rasterization Sequence
When overriding onDraw, recognize the strict sequential execution order enforced by the system:
Execution Sequence of View Drawing:
1. drawBackground() ← Renders Background (Controlled by the OS)
2. onDraw() ← Renders Custom Payload (Sits ABOVE background, BELOW foreground)
3. dispatchDraw() ← Renders Child Views (Crucial for ViewGroups)
4. onDrawForeground() ← Renders Foreground (Scrollbars, Foreground drawables)
Custom Attributes: Exposing XML Configuration
Hardcoding UI metrics directly into a Kotlin class is a severe anti-pattern. By exporting custom attributes, you bridge your View to XML layouts, granting it the identical flexibility of native Android widgets.
Phase 1: Declare the attributes inside res/values/attrs.xml:
<declare-styleable name="ArcProgressView">
<attr name="arcColor" format="color" />
<attr name="arcWidth" format="dimension" />
<attr name="progress" format="float" />
<attr name="maxProgress" format="float" />
</declare-styleable>
Phase 2: Intercept and ingest the attributes within the constructor:
class ArcProgressView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {
private var arcColor = Color.BLUE
private var arcWidth = 8f
private var progress = 0f
init {
// Read the TypedArray payload from XML
context.obtainStyledAttributes(attrs, R.styleable.ArcProgressView).use { ta ->
arcColor = ta.getColor(R.styleable.ArcProgressView_arcColor, Color.BLUE)
arcWidth = ta.getDimension(R.styleable.ArcProgressView_arcWidth, 8f)
progress = ta.getFloat(R.styleable.ArcProgressView_progress, 0f)
}
// The .use{} extension function autonomously executes .recycle(), sealing memory leaks.
}
}
Phase 3: Deploy in the UI Layout:
<com.example.ArcProgressView
android:layout_width="200dp"
android:layout_height="200dp"
app:arcColor="#FF5722"
app:arcWidth="12dp"
app:progress="0.75" />
Animation: Breathing Life into Custom Views
Property Animation Driven Re-Drawing
The standard paradigm: Deploy a ValueAnimator to mathematically mutate a custom property, and explicitly invoke invalidate() within the update callback to force a re-rasterization.
private var sweepAngle = 0f
fun animateTo(targetAngle: Float) {
ValueAnimator.ofFloat(sweepAngle, targetAngle).apply {
duration = 600
interpolator = DecelerateInterpolator()
addUpdateListener { animator ->
sweepAngle = animator.animatedValue as Float
invalidate() // Forces onDraw() execution
}
}.start()
}
override fun onDraw(canvas: Canvas) {
canvas.drawArc(rectF, -90f, sweepAngle, false, paint)
}
Native ObjectAnimator Integration
To permit an ObjectAnimator to reflectively mutate a View property (like "progress"), you must expose standard setter/getter interfaces:
var progress: Float = 0f
set(value) {
field = value.coerceIn(0f, 1f)
invalidate() // Injecting the redraw trigger directly into the setter
}
// The external invocation now becomes radically simpler:
ObjectAnimator.ofFloat(view, "progress", 0f, 1f).apply {
duration = 800
start()
}
Performance Doctrines: The Three onDraw Prohibitions
onDraw can detonate up to 120 times per second. This is the most performance-hostile block in the Android SDK:
Never Allocate Objects: Executing new Paint() or new Path() inside onDraw floods the heap, triggering aggressive Garbage Collection (Memory Churn) and guaranteeing dropped frames (Jank). All rendering dependencies MUST be pre-allocated as class members.
// ❌ CATASTROPHIC
override fun onDraw(canvas: Canvas) {
val paint = Paint() // Object allocation per frame!
paint.color = Color.RED
canvas.drawCircle(cx, cy, r, paint)
}
// ✓ ARCHITECTURALLY SOUND
private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = Color.RED }
override fun onDraw(canvas: Canvas) {
canvas.drawCircle(cx, cy, r, paint)
}
Never Execute Heavy Calculus: Pre-compute layouts, path topologies, and text boundaries inside onSizeChanged() or within property setters.
Never Blindly Invoke invalidate(): Only trigger a redraw if the visual state has actually mutated. (e.g., If the color is set to Red, and someone sets it to Red again, intercept it and abort the invalidate()).
Production-Ready Verification Checklist
Before a Custom View is cleared for production integration, it must pass this architectural audit:
- Does
onMeasureexplicitly intercept and calculate constraints forwrap_content? - Is
onDrawabsolutely purged of allnewobject allocations? - Are
paddingLeft/Right/Top/Bottommathematically accounted for during dimension calculations and drawing? - Are custom XML attributes defined (
attrs.xml) and safely extracted (with.recycle()or.use{})? - Can it be cleanly animated? (Setters inherently trigger
invalidate()). - If interactive, does it override
onTouchEventand providecontentDescriptionfor accessibility? - Does it aggressively terminate all running animators inside
onDetachedFromWindow()to prevent memory leaks? - Does it gracefully handle orientation changes? (Never hardcode raw pixels; convert
dptopxprogrammatically).
Hardcoded metric conversion snippet:
private val Float.dp: Float
get() = this * resources.displayMetrics.density