Design systems on Android · Series · Part 3 of 6
Building a themeable Android component: MdsButton, line by line
Part 2 gave us tokens. This part spends them. We build one component, the button, in plain Views and read every important line of the real MdsButton: how it takes its defaults from the theme, how variants and sizes are modelled, how touch feedback and loading work, and the two quiet Android traps that make custom views look subtly wrong.
Working code. Everything in this post exists and runs in the reference repository: github.com/liconrepo/MobileDesignSystem. Clone it and follow along; the docs are also published at liconrepo.github.io/MobileDesignSystem.
Files for this part:
- A themeable view takes a default style from a theme attribute (
mdsButtonStyle), not a fixed style. - Variants and sizes are enums in XML and in Kotlin. One
refresh()rebuilds the look from tokens. - Visual height and touch target are separate: a 36dp button still gets a 48dp hit area, using an
InsetDrawable. - Set the background before padding when using an
InsetDrawable, or your padding silently disappears. - Custom
Drawables ignore tint. Give a spinner its colour directly.
The public shape of the button
Before any drawing code, decide what consumers see. A button needs an importance level (variant), a density (size) and a busy state. In XML that reads like this:
<io.github.liconrepo.mds.functional.atoms.MdsButton
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Save"
app:mdsButtonVariant="primary"
app:mdsButtonSize="large" />
<!-- or use a named style -->
<io.github.liconrepo.mds.functional.atoms.MdsButton
style="@style/Mds.Widget.Button.Destructive"
android:text="Delete account" />
And in Kotlin:
button.variant = MdsButton.Variant.Secondary
button.isLoading = true
Declaring the attributes is the first piece of work. Enums keep the XML readable and typo-proof:
<declare-styleable name="MdsButton">
<attr name="mdsButtonVariant" format="enum">
<enum name="primary" value="0" />
<enum name="secondary" value="1" />
<enum name="outline" value="2" />
<enum name="ghost" value="3" />
<enum name="destructive" value="4" />
</attr>
<attr name="mdsButtonSize" format="enum"> ... </attr>
<attr name="mdsButtonLoading" format="boolean" />
</declare-styleable>Defaults from the theme
Every Android view constructor takes a defStyleAttr: a theme attribute whose value is a style to use when the layout does not say otherwise. Most custom views leave it as 0. A design system should point it at its own attribute:
public class MdsButton @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = R.attr.mdsButtonStyle,
) : AppCompatButton(context, attrs, defStyleAttr) {And Theme.Mds sets mdsButtonStyle to @style/Mds.Widget.Button. The result is a hook that costs nothing today and pays off later: an app can restyle every button without touching a layout, by pointing the same attribute at its own style.
<style name="Theme.MyApp" parent="Theme.Mds">
<item name="mdsButtonStyle">@style/MyApp.Button</item>
</style>
Extending AppCompatButton, rather than View, keeps text rendering, fonts, accessibility and compound drawables working for free.
One source of truth: refresh()
The button has three inputs that change its look: variant, size and loading. A common mistake is to react to each change with a small patch. That works until two changes interact. A simpler design is one function that rebuilds everything from the current inputs:
public var variant: Variant = Variant.Primary
set(value) {
if (field != value) { field = value; refresh() }
}
public var size: Size = Size.Medium
set(value) {
if (field != value) { field = value; refresh() }
}
public var isLoading: Boolean = false
set(value) {
if (field != value) { field = value; refresh() }
}It is slightly wasteful (a few drawables are rebuilt), but a button changes state rarely, and the code can be read top to bottom with no hidden ordering rules. Inside refresh(), a when picks colours for each variant, and every colour comes from a theme attribute:
when (variant) {
Variant.Primary -> {
fill = stateColors(
c.mdsColor(R.attr.mdsColorPrimary),
disabledFill,
c.mdsColor(R.attr.mdsColorPrimaryPressed),
)
foreground = c.mdsColor(R.attr.mdsColorOnPrimary)
rippleBase = foreground
}
Variant.Secondary -> {
fill = stateColors(c.mdsColor(R.attr.mdsColorPrimaryContainer), disabledFill)
foreground = c.mdsColor(R.attr.mdsColorOnPrimaryContainer)
rippleBase = foreground
}
...
}stateColors(normal, disabled, pressed) is a four-line helper that builds a ColorStateList for the three states every control has. Because the fill and text colours are state lists, the disabled look is automatic: you never write if (!isEnabled).
Visual size versus touch size
Material guidance, and the Android accessibility documentation, recommend touch targets of at least 48dp. But a 36dp-tall "small" button is a real and common design. The two can both be true if the drawn shape is smaller than the tappable area. The trick is to wrap the shape in an InsetDrawable that adds transparent space above and below:
val visualHeight = c.mdsPx(heightRes) // 36, 44 or 52dp
val touchHeight = maxOf(visualHeight, c.mdsPx(R.dimen.mds_touch_target)) // at least 48dp
val inset = (touchHeight - visualHeight) / 2
minimumHeight = touchHeight
val shape = roundedRect(radius, fill, strokeWidth, stroke)
val ripple = rippleOf(rippleBase.withAlpha(0.16f), radius, shape)
background = InsetDrawable(ripple, 0, inset, 0, inset)
// Set padding after the background: an InsetDrawable reports its own padding and would override ours.
setPadding(c.dp(hPadDp), inset, c.dp(hPadDp), inset)The first version of the button set setPadding(...) and then assigned the background. The buttons looked correct when they stretched across the screen, but a wrap-content "Small" button had its label touching the edges. The reason: setBackground asks the drawable for its padding, and an InsetDrawable always reports one (its inset), which overwrites whatever you set before. Setting the background first and the padding second fixes it. The same ordering is used in MdsChip.
Touch feedback that respects the corners
A RippleDrawable without a mask draws its wave across the whole rectangle, which pokes out of rounded corners. The helper gives it a mask that has the same corner radius as the shape:
internal fun rippleOf(@ColorInt color: Int, radius: Float, content: Drawable? = null): RippleDrawable {
val mask = GradientDrawable().apply {
shape = GradientDrawable.RECTANGLE
cornerRadius = radius
setColor(Color.BLACK)
}
return RippleDrawable(ColorStateList.valueOf(color), content, mask)
}The ripple colour is the foreground colour at 16 percent opacity, so it is visible on a filled button (white on blue) and on an outline button (dark on white) alike.
The loading state
Loading must do three things: show progress, stop duplicate taps, and tell assistive technology. The button does all three, and keeps the label visible so context is not lost:
override fun performClick(): Boolean = if (isLoading) false else super.performClick()
...
ViewCompat.setStateDescription(
this,
if (isLoading) c.getString(R.string.mds_state_loading) else null,
)Overriding performClick blocks every route to a click: touch, keyboard and accessibility actions. The spinner is a small custom Drawable that implements Animatable, with a ValueAnimator that rotates a 270-degree arc. The button starts it when attached to a window and stops it when detached, so it never animates off screen.
The base Drawable.setTintList does nothing. A vector drawable honours it because it overrides it; your own class does not unless you write that code. The spinner therefore receives its colour in the constructor (MdsSpinnerDrawable(foreground, size, stroke)) rather than being tinted by the TextView.
Initialisation order
One more detail that bites in Kotlin: the view's superclass constructor runs before your property initialisers. If it calls something you override, your fields are not ready yet. In the button the order is therefore deliberate: read any icon set in XML first, then read the styled attributes, then apply the one-off settings, and call refresh() last:
init {
restingIcon = compoundDrawablesRelative[0] // capture before loading can replace it
context.withStyledAttributes(attrs, R.styleable.MdsButton, defStyleAttr, 0) {
variant = Variant.entries[getInt(R.styleable.MdsButton_mdsButtonVariant, 0)]
size = Size.entries[getInt(R.styleable.MdsButton_mdsButtonSize, 1)]
isLoading = getBoolean(R.styleable.MdsButton_mdsButtonLoading, false)
}
isAllCaps = false
stateListAnimator = null
elevation = 0f
refresh()
}Capturing restingIcon first matters: if the button starts in the loading state, the compound drawable has already been replaced by the spinner by the time you ask for it.
Try it yourself
- Open the Atoms tab in the sample. Press and hold each button and watch the ripple stay inside the corners.
- In
MdsButton.kt, swap the order ofbackground =andsetPadding(...). Rebuild and compare the "Small" button. - Add a sixth variant, "Link", by following
docs/adding-a-component.md.
| Term | In plain words |
|---|---|
| defStyleAttr | A theme attribute that names the default style for a view. |
| declare-styleable | The list of custom XML attributes a view understands. |
| ColorStateList | A colour that depends on state: pressed, disabled, checked. |
| RippleDrawable | The touch wave; a mask clips it to the shape. |
| InsetDrawable | Wraps a drawable with transparent margins around it. |
| Touch target | The area that responds to a tap, which can be larger than what is drawn. |
| State description | Text that screen readers announce for a control's current state. |