Custom Views

Every View you've used so far in this track — TextView, Button, RecyclerView, Material components — was written by someone else and handed to you ready to use. Most of the time that's exactly right: composing existing Views inside a layout is simpler, better tested, and easier for the next developer to read. But every so often you need a visual or interactive result that no combination of built-in Views can produce, and that's when you write your own custom View by subclassing View directly and drawing it yourself.

A Case Where a Custom View Actually Earns Its Place

TrailLog tags every trail with a difficulty — Easy, Moderate, or Hard — and wants to show it as a small rounded, colored badge with the difficulty text centered inside. You could fake this with a TextView plus a shape drawable background, and for a static badge that's honestly fine. But once you want the badge's color to react to data at draw time, support crisp custom corner radii independent of density buckets, or later add a small icon drawn manually next to the text, a real custom View — TrailDifficultyBadge — gives you full control over exactly what gets drawn, pixel by pixel.

Subclassing View and Overriding onDraw()

A custom View that only draws (no child Views, no touch handling of its own) is built by extending View and overriding onDraw(canvas: Canvas). The Canvas is your drawing surface, and a Paint object controls color, style, and text size for whatever you draw on it:

TrailDifficultyBadge.kt — drawing a rounded badge

class TrailDifficultyBadge @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null
) : View(context, attrs) {

    var badgeText: String = "Moderate"
    var badgeColor: Int = Color.parseColor("#FF9800")

    private val backgroundPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
        style = Paint.Style.FILL
    }

    private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
        color = Color.WHITE
        textSize = 36f
        textAlign = Paint.Align.CENTER
    }

    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)

        backgroundPaint.color = badgeColor
        val cornerRadius = height / 2f
        canvas.drawRoundRect(
            0f, 0f, width.toFloat(), height.toFloat(),
            cornerRadius, cornerRadius,
            backgroundPaint
        )

        val textY = (height / 2f) - ((textPaint.descent() + textPaint.ascent()) / 2f)
        canvas.drawText(badgeText, width / 2f, textY, textPaint)
    }
}

drawRoundRect() paints the pill-shaped background using half the View's own height as the corner radius, so it's always perfectly rounded regardless of size. drawText() then centers the label on top, with a small vertical-centering calculation using the Paint's ascent and descent so the text doesn't look visually low inside the badge.

Letting XML Set the Badge's Color: Custom Attributes

Right now, badgeColor can only be changed from Kotlin code. A more natural fit for a View used in layouts is letting someone set it directly from XML, the same way you'd write android:textColor="#000" on a TextView. That means declaring a custom attribute in res/values/attrs.xml:

res/values/attrs.xml

<resources>
    <declare-styleable name="TrailDifficultyBadge">
        <attr name="badgeColor" format="color" />
        <attr name="badgeText" format="string" />
    </declare-styleable>
</resources>

Then read those attributes back inside the View's constructor with context.obtainStyledAttributes(), and — critically — recycle() the resulting TypedArray once you're done with it:

Reading the custom attributes

init {
    context.obtainStyledAttributes(attrs, R.styleable.TrailDifficultyBadge).apply {
        try {
            badgeColor = getColor(R.styleable.TrailDifficultyBadge_badgeColor, badgeColor)
            badgeText = getString(R.styleable.TrailDifficultyBadge_badgeText) ?: badgeText
        } finally {
            recycle()
        }
    }
}

Now the badge's color and text can be set straight from a layout file, just like any other View property:

Using it in a layout

<com.traillog.ui.TrailDifficultyBadge
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="wrap_content"
    android:layout_height="32dp"
    app:badgeColor="#4CAF50"
    app:badgeText="Easy" />

The app: namespace (bound to res-auto) is what lets custom attributes live alongside standard android: attributes on the same tag.

onMeasure(): Controlling Your Own Sizing

The layout above hardcodes a fixed 32dp height. If you want TrailDifficultyBadge to size itself sensibly when someone writes android:layout_height="wrap_content" instead, you override onMeasure() and call setMeasuredDimension() with the size your View actually wants:

override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
    val desiredHeight = (textPaint.textSize * 1.8f).toInt()
    val resolvedHeight = resolveSize(desiredHeight, heightMeasureSpec)
    val resolvedWidth = resolveSize(
        (textPaint.measureText(badgeText) + 64).toInt(),
        widthMeasureSpec
    )
    setMeasuredDimension(resolvedWidth, resolvedHeight)
}

resolveSize() is the standard helper that reconciles what your View would like to be with whatever constraint the parent layout actually imposed (an exact size, a maximum, or no constraint at all) — you almost always want to go through it rather than returning your desired size unconditionally.

You'll Rarely Need More Than a Handful of These

It's worth saying plainly: most apps, TrailLog included, need very few custom Views over their entire lifetime. Reach for one only when composing existing Views genuinely can't achieve the visual or interaction you're after — a badge with fully custom drawing, a graph, a specialized gesture surface. For anything a LinearLayout, a shape drawable, or a Material component can already do, prefer composing those; a custom View is more code to write, test, and maintain than a layout file.

Note: This is correct, real Kotlin/Android code, but this website's own project doesn't run a live Android Studio build — nothing on this page actually renders the badge. Paste it into a real TrailLog project to see it drawn.
Next up: A static badge is nice, but a badge that fades or slides into place feels alive. Animations & Transitions covers exactly that.