Phase 2 · Building Interactive UIsModule 20~50 min read

Checkbox, Switch, RadioButton, Slider & Chips

Match each control to a selection model, hoist values, make entire labeled rows accessible, and calculate slider steps correctly.

Match the control to the decision

A checkbox, switch, radio button, slider, and filter chip can all change state. They communicate different kinds of decisions. Choose the right one, then give it a clear label, one state owner, and predictable input behavior.

Prerequisites: state hoisting, event callbacks, Row and Column. You will assemble a study-preferences screen, make whole labeled rows accessible, and calculate discrete slider steps correctly. The demonstration changes local UI state; it does not schedule notifications, download files, or filter a database.

Start with the question being asked

ControlDecisionGood example
CheckboxAn independent selection, often one of severalInclude downloaded lessons
SwitchAn on/off settingEnable study reminders
RadioButtonExactly one choice within a groupDaily or weekly reminders
SliderA value along an ordered numeric rangeSession length in minutes
FilterChipA compact selected/unselected content filterOnly unfinished lessons

Ask whether several choices may coexist. Choosing Daily should replace Weekly, so radio buttons fit. Download over Wi-Fi only and Include completed can both be selected, so independent checkboxes fit. A switch generally expresses a setting whose current state matters, rather than membership in a batch of selected items.

Keep one source of truth

SelectionContract.kt
// Focused excerpt inside a composable:
var checked by rememberSaveable { mutableStateOf(false) }
Checkbox(
    checked = checked,
    onCheckedChange = { proposedValue -> checked = proposedValue },
)
// checked describes the current UI; the callback requests its next value.
// Hoist both values when a parent also needs to read or change the choice.
Focused excerpt. The complete screen below lists the required imports.

Selection controls display state; they do not decide where it lives. A reusable preference row should accept its current value and an event callback. The screen or ViewModel decides how that event updates state and whether a change must be persisted.

Prefer assigning the value supplied by onCheckedChange over manually guessing the next value. For an explicit toggle event such as a chip click, computing !selected is appropriate. Avoid duplicating the same preference in a local child Boolean and a parent Boolean: the two can drift apart after reset or restoration.

Make the label part of one accessible action

A tiny checkbox beside a label is unnecessarily hard to tap if only the box responds. Give the Row a toggleable modifier with the appropriate Role, and pass onCheckedChange = null to its visual Checkbox or Switch. The row then owns the interaction and exposes a coherent label and state.

One owner for one action

Do not add a clickable Text and another independent control handler to the same preference row. Let the semantic row handle the event, while the child control reflects its checked state.

Use a minimum height rather than a fixed height. The 48 dp minimum preserves a practical target while allowing the row to grow for large or translated labels. Place padding after the interaction modifier when you want that padded area to participate in the row's action.

Represent a radio group with one selected value

Store frequency = "Daily", not unrelated Booleans named dailySelected and weeklySelected. Comparing each option to that one value guarantees at most one visual selection. In a production model, an enum or stable identifier is usually a better stored value than a translated label.

Add selectableGroup() to the group container. Each Row uses selectable(selected, role = Role.RadioButton, onClick), and its RadioButton has onClick = null. This lets assistive technology understand the options as a group and treats the label and radio graphic as one choice.

Calculate slider stops instead of guessing

A continuous slider uses steps = 0. For a discrete slider, steps counts the interior stops; the two endpoints are additional choices. Seven steps in the 5–45 range therefore gives nine selectable values, spaced five minutes apart.

SliderStops.kt
// Inside a composable; minutes is hoisted Float state:
Slider(
    value = minutes,
    onValueChange = onMinutesChange,
    valueRange = 5f..45f,
    steps = 7,
    onValueChangeFinished = { onCommitMinutes(minutes) },
)
// Seven INTERIOR stops + two endpoints = nine possible values.
// Interval = (45 - 5) / (7 + 1) = 5 minutes.
// Values: 5, 10, 15, 20, 25, 30, 35, 40, 45.
Focused hoisted-state example: onMinutesChange updates local display state; onCommitMinutes is an optional caller callback.

onValueChange can run repeatedly as someone drags. Update the visible value promptly. If persisting every movement would be expensive, commit after interaction through onValueChangeFinished; it receives no value argument, so read the latest state. Keep the value inside valueRange when loading external data.

Show units near the slider and provide a useful accessible label. Prefer direct numeric entry or a stepper when exact large numbers matter more than relative position; a slider is not a universal replacement for numeric input.

Use selected state to explain a filter

FilterChip accepts selected, onClick, and a label slot. Its selected appearance reports an active filter. Decide explicitly whether multiple chips may coexist or whether your product permits only one; the composable does not enforce that policy for you.

In this lesson, Only unfinished lessons is a Boolean filter and can be turned on or off. A real catalog would derive its visible list from that state. Do not merely recolor a chip while leaving the data unchanged; users need the selection to have an observable effect.

Build a study-preferences screen

Create StudyPreferences.kt in your app's package, retain the package declaration, and paste the following imports and composable. Call StudyPreferences() inside your generated Activity's existing theme in setContent. It uses the standard Compose and Material 3 dependencies.

StudyPreferences.kt
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.foundation.selection.toggleable
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Checkbox
import androidx.compose.material3.FilterChip
import androidx.compose.material3.RadioButton
import androidx.compose.material3.Slider
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.stateDescription
import androidx.compose.ui.unit.dp
import kotlin.math.roundToInt

@Composable
fun StudyPreferences(modifier: Modifier = Modifier) {
    var reminders by rememberSaveable { mutableStateOf(true) }
    var wifiOnly by rememberSaveable { mutableStateOf(false) }
    var frequency by rememberSaveable { mutableStateOf("Daily") }
    var minutes by rememberSaveable { mutableStateOf(25f) }
    var unfinishedOnly by rememberSaveable { mutableStateOf(false) }

    Column(
        modifier = modifier.verticalScroll(rememberScrollState()).padding(16.dp),
        verticalArrangement = Arrangement.spacedBy(12.dp),
    ) {
        Text("Study preferences")
        Row(
            modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp)
                .toggleable(
                    value = reminders,
                    role = Role.Switch,
                    onValueChange = { reminders = it },
                ).padding(horizontal = 8.dp),
            verticalAlignment = Alignment.CenterVertically,
        ) {
            Text("Study reminders", Modifier.weight(1f))
            Switch(checked = reminders, onCheckedChange = null)
        }
        Row(
            modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp)
                .toggleable(
                    value = wifiOnly,
                    role = Role.Checkbox,
                    onValueChange = { wifiOnly = it },
                ).padding(horizontal = 8.dp),
            verticalAlignment = Alignment.CenterVertically,
        ) {
            Text("Download over Wi-Fi only", Modifier.weight(1f))
            Checkbox(checked = wifiOnly, onCheckedChange = null)
        }
        Text("Reminder frequency")
        Column(Modifier.selectableGroup()) {
            listOf("Daily", "Weekly").forEach { option ->
                Row(
                    modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp)
                        .selectable(
                            selected = frequency == option,
                            role = Role.RadioButton,
                            onClick = { frequency = option },
                        ).padding(horizontal = 8.dp),
                    verticalAlignment = Alignment.CenterVertically,
                ) {
                    RadioButton(selected = frequency == option, onClick = null)
                    Text(option, Modifier.padding(start = 12.dp))
                }
            }
        }
        Text("Session length: ${minutes.roundToInt()} minutes")
        Slider(
            value = minutes,
            onValueChange = { minutes = it },
            valueRange = 5f..45f,
            steps = 7,
            modifier = Modifier.semantics {
                contentDescription = "Session length"
                stateDescription = "${minutes.roundToInt()} minutes"
            },
        )
        FilterChip(
            selected = unfinishedOnly,
            onClick = { unfinishedOnly = !unfinishedOnly },
            label = { Text("Only unfinished lessons") },
        )
        Text(if (unfinishedOnly) "Filter: unfinished" else "Filter: all lessons")
        Text("Demo settings only; no reminders or downloads are scheduled.")
    }
}
Complete local UI demonstration. Move English text to string resources before localizing; store stable identifiers instead of translated radio labels.

Check the screen in order: tap each label as well as its control; choose Weekly and confirm Daily clears; move the slider to both endpoints; toggle the chip and read its summary. Rotate and confirm the small saveable preferences restore. Close and relaunch to distinguish UI restoration from durable storage: persistent settings belong in a repository such as DataStore, introduced later.

Check semantics as carefully as appearance

SymptomDiagnosisBetter approach
Two radio options remain selectedEach option owns an unrelated BooleanUse one selected option identifier.
The label cannot be tappedOnly the child control handles inputLift the interaction to one labeled row.
A value flips back or an action repeatsParent and child handlers both change stateUse one handler and a null child callback.
The slider has more values than expectedEndpoints were counted as interior stepsTotal positions equal steps + 2.
Disabled graphics still have active rowsOnly the child control was disabledSet enabled on the semantic parent as well.

With TalkBack, confirm each row announces its label, role, and selection state. Navigate with a keyboard, change slider values without dragging, and enlarge system text. Do not use color as the only indicator of selection; retain the control's standard state visuals and semantics.

Practice: disable a dependent choice without losing it

Make reminder frequency unavailable while Study reminders is off. Keep the existing frequency so switching reminders back on restores the person's choice. Provide a sentence explaining why the choice is temporarily unavailable.

  1. Select Weekly, turn reminders off, and try both the radio graphic and its label.
  2. Neither should change selection; accessibility should report that the option is disabled.
  3. Turn reminders back on: Weekly remains selected and can be changed to Daily.
Compare the worked changes
StudyPreferences-dependent-controls.kt
// Inside the worked example's radio-option Row:
modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp)
    .selectable(
        selected = frequency == option,
        enabled = reminders,
        role = Role.RadioButton,
        onClick = { frequency = option },
    ).padding(horizontal = 8.dp)

// In the same Row, also reflect availability visually:
RadioButton(
    selected = frequency == option,
    onClick = null,
    enabled = reminders,
)
// Keep frequency unchanged when reminders turn off.
// Add contextual guidance after the group:
if (!reminders) Text("Turn on reminders to change their frequency.")
Targeted edits inside the complete StudyPreferences example. Availability belongs to both the interactive row and the visual control.

Review & quick check

Key takeaways

  • Choose controls according to independent, exclusive, Boolean, or numeric decisions.
  • Hoist a shared preference once and report change events to its owner.
  • For an interactive labeled row, use one semantic parent and null child callbacks.
  • A radio group needs one selected value and appropriate group semantics.
  • Discrete slider positions equal steps plus two endpoints; expensive commits can follow interaction completion.

Quick check

1. A radio group offers Daily, Weekly, and Monthly. Which state model prevents contradictory selections?

2. A Slider ranges from 0 to 100 with steps = 3. Which values can it select?

3. A Row uses toggleable and contains a Checkbox for the same choice. What should the Checkbox callback be?

Official references