Design systems on Android · Series · Part 4 of 6

State and accessibility in Android components: text field, chip and alert

Khairul Alam·October 2026·~8 min read
AccessibilitySaved stateCustom viewsTalkBack

A button is stateless enough to be forgiving. Inputs, toggles and messages are not: they hold what the user typed, they must survive rotation and process death, and they have to make sense to someone who cannot see them. This part opens the text field, chip and alert in the example library and shows the Android details that decide whether such components feel solid or fragile.

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 4 in 60 seconds
  • A compound view that owns child views must save its own state, or two instances on one screen will clobber each other.
  • Superclass constructors call overridable methods early. Guard them with a ready flag.
  • A text field is accessible when it has a visible label, an error with text and icon, and a live region.
  • Selection and error are never colour alone: a chip shows a check mark, an error shows an icon and a prefix.
  • Motion respects the system's animation setting through one function, MdsMotion.isReduced.

The text field is three views in a trench coat

MdsTextField looks like one control but is a vertical LinearLayout with a label, a rounded box containing an EditText, and a line of helper or error text. Composition keeps each piece simple. The cost is that you now own behaviour a single view would give you for free.

MdsTextField (LinearLayout) Label (always visible) Input boxborder: normal / focus / error / disabled Support linehelper text, or error: icon + "Error: ..." (live region)
The border state is derived from three facts: enabled, has error, has focus. One function, updateBox(), applies them.
functional/molecules/MdsTextField.kt (excerpt)
private fun updateBox() {
    val c = context
    val thin = c.mdsPx(R.dimen.mds_stroke_thin)
    val thick = c.mdsPx(R.dimen.mds_stroke_thick)
    val (width, strokeColor) = when {
        !isEnabled -> thin to c.mdsColor(R.attr.mdsColorOutline)
        !errorText.isNullOrEmpty() -> thick to c.mdsColor(R.attr.mdsColorDanger)
        focused -> thick to c.mdsColor(R.attr.mdsColorPrimary)
        else -> thin to c.mdsColor(R.attr.mdsColorOutlineStrong)
    }
    ...
}

The order of the when branches is the priority order: disabled wins over error, and error wins over focus. A field that is both focused and in error should look like an error.

Saving state: why two fields collided

Android saves the state of views by their id. When a view group has children with the same id as another instance of the same group, their saved states collide in one shared array. This is the classic custom-view bug: two MdsTextFields on a screen swap or lose their text after a rotation, because each contains an EditText with the same id.

The fix is to stop the framework from saving the children individually, and save the whole component's state yourself:

functional/molecules/MdsTextField.kt (excerpt)
// Children share generated ids across instances, so save our own state
// instead of the default per-child state.
override fun dispatchSaveInstanceState(container: SparseArray<Parcelable>) {
    dispatchFreezeSelfOnly(container)
}

override fun dispatchRestoreInstanceState(container: SparseArray<Parcelable>) {
    dispatchThawSelfOnly(container)
}

override fun onSaveInstanceState(): Parcelable {
    val state = SavedState(super.onSaveInstanceState())
    state.text = editText.text?.toString()
    state.error = errorText?.toString()
    return state
}

dispatchFreezeSelfOnly means "save me, not my children". The component then writes what matters (the text and the current error) into a small BaseSavedState subclass with a manual CREATOR. It is a few dozen lines, and it is the difference between a demo and a component you can put on a form.

Constructors that call you before you exist

Here is a trap that is easy to hit in Kotlin. The View constructor reads XML attributes. For some of them it calls overridable methods. If your class overrides one, the call arrives before your own properties have been initialised, and a non-null field is, at that moment, null.

functional/molecules/MdsTextField.kt (excerpt)
private var ready = false      // a primitive boolean defaults to false in the JVM, even this early

override fun setEnabled(enabled: Boolean) {
    super.setEnabled(enabled)
    if (ready) {
        editText.isEnabled = enabled
        updateBox()
    }
}

init {
    ...
    ready = true
    editText.isEnabled = isEnabled
    updateBox()
    updateSupportText()
}

The guard keeps the early call harmless, and the end of init catches up by applying the current state once. MdsChip uses the same pattern, because AppCompatCheckBox may call setChecked while it is still being constructed.

An honest note about android:enabled

In the sample, a field declared with android:enabled="false" was rendering as enabled. Rather than chase which part of the construction order lost the flag, the component now declares android:enabled in its own styleable and applies it explicitly at the end of init. That made the behaviour deterministic and it is verified in the running sample, but I did not isolate the framework reason, so treat the explanation as incomplete.

Making it usable without sight

Three small choices do most of the accessibility work for a text field.

1. A label that is always there

Placeholders disappear when you type, so they cannot be the label. The field has a real label view, linked to the input with labelFor, so a screen reader announces "Email, edit box" when focus lands:

labelView.labelFor = View.generateViewId().also { editText.id = it }

2. Errors that are more than red

When an error is set, the support line shows the message with an icon and a localisable prefix ("Error: %1$s"), in the danger colour. Someone who cannot distinguish red from the neutral grey still gets the icon and the word.

3. A live region

The support line is marked as a polite live region, so when validation fails after a tap, assistive technology announces the new text without stealing focus:

ViewCompat.setAccessibilityLiveRegion(
    supportView, ViewCompat.ACCESSIBILITY_LIVE_REGION_POLITE,
)

The alert component applies the same idea to the whole message, and its dismiss button is a full 48dp target with a content description from a string resource, so it can be translated.

What I have and have not verified

I checked the visual states in the running sample (light, dark, error, disabled) and the structure through the view hierarchy. I did not run a complete TalkBack pass. The accessibility choices above follow the platform guidance, but always test with TalkBack on a device before you rely on them.

The chip: selection you can see without colour

A chip is a checkbox wearing a pill. MdsChip extends AppCompatCheckBox (so toggling, state, accessibility role and keyboard behaviour are inherited), removes the box with buttonDrawable = null, and draws its own background from state lists keyed on state_checked. When checked it shows a check icon as a start compound drawable, so selection is not colour alone. It uses the same 36dp-drawn, 48dp-touchable inset as the button.

val checked = intArrayOf(android.R.attr.state_checked)
val fill = ColorStateList(
    arrayOf(checked, intArrayOf()),
    intArrayOf(c.mdsColor(R.attr.mdsColorPrimaryContainer), c.mdsColor(R.attr.mdsColorSurface)),
)

Motion that can be switched off

The skeleton placeholder shimmers while content loads. Some people turn animations off, for comfort or battery, and the system exposes that as a global scale of zero. One tiny object centralises the check so every animated component asks the same question:

perceptual/MdsMotion.kt
public object MdsMotion {
    public fun isReduced(context: Context): Boolean =
        Settings.Global.getFloat(
            context.contentResolver,
            Settings.Global.ANIMATOR_DURATION_SCALE, 1f,
        ) == 0f
}

The skeleton still draws its grey shape when motion is reduced. It simply does not animate. The shimmer also starts and stops with visibility (onVisibilityAggregated) and detach, so it is not running on a screen the user cannot see.

A bug in the sample, not the library

One more state story, from the gallery app. The dark-mode switch wrote the user's choice to preferences from its checked-change listener. After a configuration change, Android restored the switch's saved state, which flipped it to a stale value and fired the listener, which overwrote the preference. The app then showed light mode even though the system was dark.

Two fixes, both in MainActivity: only react to user-driven changes, and do not let Android restore a control whose state is derived from somewhere else.

binding.switchDark.isSaveEnabled = false
binding.switchDark.setOnCheckedChangeListener { button, checked ->
    if (!button.isPressed) return@setOnCheckedChangeListener // ignore programmatic changes
    ...
}

The lesson for design-system authors: be careful with listeners that fire on programmatic changes, and document whether your components do.

Try it yourself

  1. Put two MdsTextFields in a layout, type different text, and rotate the device. Both keep their text.
  2. Temporarily remove the two dispatch*SelfOnly overrides and repeat. Watch what happens.
  3. Turn on TalkBack, focus the email field in the sign-in page, submit an empty form and listen to the announcement.
  4. Turn off animations in developer options and open the Molecules and Atoms tabs. The skeleton stays still.
TermIn plain words
Compound viewA view made of other views, behaving as one control.
Saved instance stateData Android keeps so a screen can be rebuilt after rotation or process death.
Live regionA view whose changes screen readers announce automatically.
labelForLinks a label to the field it describes.
Content descriptionThe text a screen reader reads for a non-text control.
Reduced motionThe user setting that asks apps to cut or stop animation.
← Back to all writing