Introduction
Many mobile applications ask a user to provide data, and RadioButton
is one of the components that help developers/designers to simplify the onboarding process and how users can fill the information.
In this article, we will explore
- the possibilities of
RadioButton
components - replacing
RadioButton
withIconToggleButton
for replacing "RadioButton" indicator - the possibilities for testing screens with both
RadioButton
andIconToggleButton
components.
This article is a part of Jetpack Compose series:
- Jetpack Compose: Preview
- Jetpack Compose: Layouts
- Jetpack Compose: Theme and Typography
- Jetpack Compose: style Text
- Jetpack Compose: Building Grids
The content of this article is based on the 1.1.1 version of Jetpack Compose.
Overview of RadioButton
I want to start with an overview of the RadioButton
composable function along with a description and examples.
If you are already familiar with it, feel free to skip this section and move to the next one.
The RadioButton
component can be used for creating a single item (selection indicator with label), or multiple items in a group.
Let's explore the signature of the RadioButton
composable function.
@Composable
fun RadioButton(
selected: Boolean,
onClick: (() -> Unit)?,
modifier: Modifier = Modifier,
enabled: Boolean = true,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
colors: RadioButtonColors = RadioButtonDefaults.colors()
)
The "selected" parameter represents the state of the component. The "enabled" parameter indicates if RadioButton
is enabled, and we can interact with this component. The "interactionSource" parameter represents the stream of interactions for the RadioButton
. The "colors" parameter allows us to configure colors for the different states of the RadioButton
components. We will explore the styling of an element in one of the following sections.
The RadioButton
doesn't include a label.
To build a simple RadioButton
with a label we can use the following code.
@Preview
@Composable
fun Preview_SingleRadioButton() {
val selectedValue = remember { mutableStateOf("") }
val label = "Item"
Row(
verticalAlignment = Alignment.CenterVertically
) {
RadioButton(
selected = selectedValue.value == label,
onClick = { selectedValue.value = label }
)
Text(
text = label,
modifier = Modifier.fillMaxWidth()
)
}
}
The code above has a problem with selectable components because only the RadioButton
component is selectable. We can add a selectable
modifier to the Row
component to fix this issue.
Let's explore the signature of the selectable
modifier.
fun Modifier.selectable(
selected: Boolean,
enabled: Boolean = true,
role: Role? = null,
onClick: () -> Unit
)
The selected
parameter represents the state of the component. The enabled
parameter indicates if a selectable area is enabled and we can interact with it. The role
parameter represents the type of UI component. Accessibility service used this to describe the element. We have the following roles: Button
, Checkbox
, Switch
, RadioButton
, Tab
and Image
.
@Preview
@Composable
fun Preview_MultipleRadioButtons() {
val selectedValue = remember { mutableStateOf("") }
val isSelectedItem: (String) -> Boolean = { selectedValue.value == it }
val onChangeState: (String) -> Unit = { selectedValue.value = it }
val items = listOf("Item 1", "Item 2", "Item 3", "Item 4", "Item 5")
Column(Modifier.padding(8.dp)) {
Text(text = "Selected value: ${selectedValue.value.ifEmpty { "NONE" }}")
items.forEach { item ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.selectable(
selected = isSelectedItem(item),
onClick = { onChangeState(item) },
role = Role.RadioButton
).padding(8.dp)
) {
RadioButton(
selected = isSelectedItem(item),
onClick = null
)
Text(
text = item,
modifier = Modifier.fillMaxWidth()
)
}
}
}
}
When we use theonClick = { ... }
parameter in theselectable
modifier we can use theonClick = null
in theRadioButton
component.
Styling
There are not many possibilities for customization of RadioButton
components. We will explore how to use different colors for states of the RadioButton
and how to use different indicators for selected/unselected states (in this case, we will use the IconToggleButton
component).
Color customization
We change colors of the RadioButton
states:
- selected
- unselected
- disabled
The RadioButtonColors
interface allows us to change the "selected" and "unselected" colors of the RadioButton component. However, if we will use the RadioButtonDefaults
object, we can change "selected", "unselected", and "disabled" colors.
The RadioButtonDefaults has the colors()
function that returns a State<Color>
based on the state of the RadioButton. We can see it in the implementation of the DefaultRadioButtonColors
class.
@Stable
private class DefaultRadioButtonColors(
private val selectedColor: Color,
private val unselectedColor: Color,
private val disabledColor: Color
) : RadioButtonColors {
@Composable
override fun radioColor(enabled: Boolean, selected: Boolean): State<Color> {
val target = when {
!enabled -> disabledColor
!selected -> unselectedColor
else -> selectedColor
}
// If not enabled 'snap' to the disabled state, as there should be no animations between
// enabled / disabled.
return if (enabled) {
animateColorAsState(target, tween(durationMillis = RadioAnimationDuration))
} else {
rememberUpdatedState(target)
}
}
}
Let's use custom colors for the RadioButton
elements.
@Preview
@Composable
fun Preview_MultipleRadioButtonsWithCustomColors() {
val selectedValue = remember { mutableStateOf("") }
val textToEnableList = listOf(
"Item 1" to true,
"Item 2" to true,
"Item 3" to true,
"Item 4" to false,
"Item 5" to true
)
val isSelectedItem: (String) -> Boolean = { selectedValue.value == it }
val onChangeState: (String) -> Unit = { selectedValue.value = it }
Column(Modifier.padding(8.dp)) {
Text(text = "Selected value: ${selectedValue.value.ifEmpty { "NONE" }}")
textToEnableList.forEach { textToEnableState ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.selectable(
selected = isSelectedItem(textToEnableState.first),
enabled = textToEnableState.second,
onClick = { onChangeState(textToEnableState.first) }
).padding(8.dp)
) {
RadioButton(
selected = isSelectedItem(textToEnableState.first),
onClick = null,
colors = RadioButtonDefaults.colors(
selectedColor = Color.Green,
unselectedColor = Color.Red,
disabledColor = Color.LightGray
),
enabled = textToEnableState.second
)
Text(
text = textToEnableState.first,
color = when {
isSelectedItem(textToEnableState.first) -> Color.Green
!textToEnableState.second -> Color.LightGray
else -> Color.Red
}
)
}
}
}
}
Custom indicator for selection state
Sometimes we want to use a custom indicator instead of the default one for RadioButton
in our application. Let's imagine we want to customize our screen and have a result as we have on the image below.
Unfortunately, we cannot build it with the RadioButton
component, but we can use IconToggleButton
instead.
@Preview
@Composable
fun Preview_CustomRadioButtonIndicator_WithIconToggleButton() {
MaterialTheme {
val selectedValue = remember { mutableStateOf("") }
val items = listOf("Item 1", "Item 2", "Item 3", "Item 4", "Item 5")
Column(Modifier.padding(8.dp)) {
Text(text = "Selected value: ${selectedValue.value.ifEmpty { "NONE" }}")
items.forEach { item ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.selectable(
selected = (selectedValue.value == item),
onClick = { selectedValue.value = item },
role = Role.RadioButton
).padding(8.dp)
) {
IconToggleButton(
checked = selectedValue.value == item,
onCheckedChange = { selectedValue.value = item },
modifier = Modifier.size(24.dp)
) {
Icon(
painter = painterResource(
if (selectedValue.value == item) {
R.drawable.ic_circle_checked
} else {
R.drawable.ic_circle_outline
}
),
contentDescription = null,
tint = MaterialTheme.colors.primary
)
}
Text(
text = item,
modifier = Modifier.fillMaxWidth()
)
}
}
}
}
}
When we use therole = Role.RadioButton
parameter in theselectable
modifier we can use thecontentDescription = null
in theIconToggleButton
component and accessibility reader will readRow
as aRadioButton
component.
Testing
Let's talk about testing RadioButton
and IconToggleButton
components. Let's explore different possibilities related to UI testing of these components.
Every verification of UI component can contain a few steps (not all of them are needed):
- find an element
- interact with an element
- verify the state of the element
Let's look at possibilities that can be done for every step with pros and cons. We create a test cases for the screen with the question and multiple answers that use RadioButton
/IconToggleButton
components.
Find an element
When we analyze every selectable component of the screen above, we should remember that it has two components: RadioButton
and Text
. If both are placed in a container, like Row
we can add the testTag
modifier or use the role
property in the selectable
modifier to find/interact/verify the component.
Let's compare both of these options.
The "testTag" modifier
The testTag
modifier allows us to find a component in tests by the testTag
value. When we find an element by testTag
, we can access RadioButton
and Text
separately. Here we have multiple options:
- Set similar value to
testTag
modifier for allRow
elements
val items = listOf("Item 1", "Item 2", "Item 3", "Item 4", "Item 5")
...
Column(Modifier.padding(8.dp)) {
items.forEach { item ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.testTag("item")
.selectable(
selected = isItemSelected(item),
onClick = { selectedValue.value = item },
role = Role.RadioButton
)
) { ... }
}
}
In this case, we need to use the onAllNodesWithTag(testTag = "...")
to get all nodes with a similar tag, and later on, we can interact with a specific component by index
of an element.
onAllNodesWithTag("item")[2]
.performClick()
- Set a label to the
testTag
modifier that we use for theText
component inside theRow
component.
val items = listOf("Item 1", "Item 2", "Item 3", "Item 4", "Item 5")
Column(Modifier.padding(8.dp)) {
items.forEach { item ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.testTag(item)
.selectable(
selected = isItemSelected(item),
onClick = { selectedValue.value = item },
role = Role.RadioButton
)
) { ... }
}
}
In this case, we can use the onNodeWithTag(testTag = "...")
and interact with this node. We should remember to always use a similar value for both the testTag
modifier and the Text
component inside of the Row
.
The "role" property in "selectable" modifier
The selectable
modifier configures the component to be selectable. It is usually used as part of a group where only one item should be selected.
This modifier has the role
property, which can be used by accessibility services to describe the element.
inline class Role private constructor(@Suppress("unused") private val value: Int) {
companion object {
val Button = Role(0)
val Checkbox = Role(1)
val Switch = Role(2)
val RadioButton = Role(3)
val Tab = Role(4)
val Image = Role(5)
}
override fun toString() = when (this) {
Button -> "Button"
Checkbox -> "Checkbox"
Switch -> "Switch"
RadioButton -> "RadioButton"
Tab -> "Tab"
Image -> "Image"
else -> "Unknown"
}
}
We don't need to use the testTag
modifier, and we don't need to sync values between the testTag
modifier and the Text
component.
Interaction with a component
We can interact with RadioButton
, IconToggleButton
and Row
(which can include RadioButton
and Text
) using the performClick
action.
onNodeWithText("Item 3")
.performClick()
Verify state of element
The final step is to verify the state of the RadioButton
and IconToggleButton
components. We can do this with with the following SemanticsNodeInteraction
s:
- The
assertIsDisplayed
verifies the node exists - The
assertIsEnabled
/assertIsNotEnabled
verifies the node is enabled/disabled - The
assertIsSelected
/assertIsNotSelected
verifies the node is selected or not selected - The
assertIsSelectable
verifies the node is selectable - The
assertTextContains
verifies the node contains text - The
assertTextEquals
verifies the node contains exact text - The
assertDoesNotExist
verifies the node does not exist
Example
Let's take a look at how we can test both screens. One screen uses RadioButton
and another uses IconToggleButton
. If we use the role = Role.RadioButton
property in the selectable
modifier, we can test both of these screens with a similar test case.
For the sake of simplicity, we will create one test case which includes verification/action:
- The selectable item with text is selected
- Click on a selectable item
- Check which item is selected
We can create a withRole
matcher to avoid code duplication, which find nodes with specific role: Role
.
fun withRole(role: Role): SemanticsMatcher {
return SemanticsMatcher("${SemanticsProperties.Role.name} contains '$role'") {
it.config.getOrNull(SemanticsProperties.Role) == role
}
}
We can replace the "withRole" custom matcher with the "SemanticsMatcher.expectValue(...)" function. In case of checking the "Role" semantics properties this function the usage will be next:
SemanticsMatcher.expectValue(SemanticsProperties.Role, role)
In addition, we can create an extension function for the SemanticsNodeInteractionsProvider
interface, which helps us find a selectable component based on a specific role: Role
and text: String
.
fun SemanticsNodeInteractionsProvider.onNodeWithRoleAndText(
role: Role,
text: String
) = onNode(
withRole(role)
.and(isSelectable())
.and(isEnabled())
.and(hasText(text))
)
The final step is to create a test case that can test both of the following screens.
@Test
fun firstItemSelectedByDefaultUseIconToggleButton_whenThirdItemIsSelected_thenSelectedValueDisplayedCorrectly() {
composeTestRule.apply {
setContent { DemoScreen() }
onNodeWithRoleAndText(Role.RadioButton, "Item 1")
.assertIsSelected()
onNodeWithText("Item 3")
.performClick()
onNodeWithRoleAndText(Role.RadioButton, "Item 3")
.assertIsSelected()
onNodeWithText("Selected value: Item 3")
.assertIsDisplayed()
}
}
You can find the full source code of test cases here.
Summary
The RadioButton
composable function doesn't include a label property. Usually, we need to combine the RadioButton
with the Text
component. However, the RadioButton
has pretty limited customization options, and if we want to change the default RadioButton
indicator, we can use the IconToggleButton
component.
We can have similar test cases for both the RadioButton
and IconToggleButton
components if we use the selectable
modifier with the role = Role.RadioButton
property.
When we use the selectable
modifier with the role = Role.RadioButton
property, we won't have problems with the accessibility because of the role
value used by accessibility services.
Do not hesitate to ping me on Twitter if you have any questions.