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
| Control | Decision | Good example |
|---|---|---|
| Checkbox | An independent selection, often one of several | Include downloaded lessons |
| Switch | An on/off setting | Enable study reminders |
| RadioButton | Exactly one choice within a group | Daily or weekly reminders |
| Slider | A value along an ordered numeric range | Session length in minutes |
| FilterChip | A compact selected/unselected content filter | Only 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
// 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.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
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.
// 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.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.
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.")
}
}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
| Symptom | Diagnosis | Better approach |
|---|---|---|
| Two radio options remain selected | Each option owns an unrelated Boolean | Use one selected option identifier. |
| The label cannot be tapped | Only the child control handles input | Lift the interaction to one labeled row. |
| A value flips back or an action repeats | Parent and child handlers both change state | Use one handler and a null child callback. |
| The slider has more values than expected | Endpoints were counted as interior steps | Total positions equal steps + 2. |
| Disabled graphics still have active rows | Only the child control was disabled | Set 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.
- Select Weekly, turn reminders off, and try both the radio graphic and its label.
- Neither should change selection; accessibility should report that the option is disabled.
- Turn reminders back on: Weekly remains selected and can be changed to Daily.
Compare the worked changes
// 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.")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?