Design systems on Android · Series · Part 3 of 6

Building a themeable Android component: MdsButton, line by line

Khairul Alam·October 2026·~8 min read
Custom viewsKotlinAndroidAccessibility

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:

Part 3 in 60 seconds
  • 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:

designsystem/src/main/res/values/mds_attrs.xml
<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:

functional/atoms/MdsButton.kt
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:

functional/atoms/MdsButton.kt
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:

functional/atoms/MdsButton.kt (excerpt)
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:

functional/atoms/MdsButton.kt (excerpt)
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)
Save Touch area: 48dpthe dashed box Drawn: 36dpthe filled shape Inset: 6dpabove and below
The dashed box is what a finger can hit. The filled box is what you see. Sizes shown are not to scale.
The trap: padding vanishes

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:

perceptual/Theming.kt
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:

functional/atoms/MdsButton.kt (excerpt)
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 trap: custom drawables ignore tint

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:

functional/atoms/MdsButton.kt (excerpt)
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

  1. Open the Atoms tab in the sample. Press and hold each button and watch the ripple stay inside the corners.
  2. In MdsButton.kt, swap the order of background = and setPadding(...). Rebuild and compare the "Small" button.
  3. Add a sixth variant, "Link", by following docs/adding-a-component.md.
TermIn plain words
defStyleAttrA theme attribute that names the default style for a view.
declare-styleableThe list of custom XML attributes a view understands.
ColorStateListA colour that depends on state: pressed, disabled, checked.
RippleDrawableThe touch wave; a mask clips it to the shape.
InsetDrawableWraps a drawable with transparent margins around it.
Touch targetThe area that responds to a tap, which can be larger than what is drawn.
State descriptionText that screen readers announce for a control's current state.
← Back to all writing