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.