Jetpack compose has support to add accessibility feature into composables with few amount of code. This article provides guidance on best practices that we can apply on our app to improve screen's accessibility.
People with impaired vision, color blindness, impaired hearing, impaired dexterity, cognitive disabilities, and many other disabilities use Android devices to complete tasks in their day-to-day lives. When you develop apps with accessibility in mind, you make the user experience better, particularly for users with these and other accessibility needs. Source
This article also provides a demo application that implements recommendations described in this article.
GitHub - carlosmonzon/ComposeAccessibilityApp: Compose app that shows compose accessibility feature
Topics:
- Essentials: Visual elements, touch area, custom selection controls, clickable composable
- Semantics: Merge composables, custom actions in list items.
- Headings
Essentials
Touch target sizes
Any on-screen element that someone can click, touch, or interact with should be large enough for reliable interaction. When sizing these elements, make sure to set the minimum size to 48dp to correctly follow the Material Design Accessibility Guidelines.
When dealing with touchable elements, material compose elements already conform to Material Design Accessibility Guidelines. (Material components—like
Checkbox
,RadioButton
,Switch
,Slider
, andSurface
—set this minimum size internally, but only when the component can receive user actions.) Source
// No need to add padding to Checkbox element because it is a Material Design Element
@Composable
private fun FunctionalCheckbox() {
var checked by remember { mutableStateOf(false) }
Row(modifier = Modifier.fillMaxWidth()) {
RowDescription(
"Functional Checkbox,\npadding is added automatically",
Modifier
.weight(1f)
.padding(start = 16.dp, end = 16.dp)
)
Checkbox(checked = checked, onCheckedChange = {
checked = !checked
})
}
}
// When passing null to the onCheckedChange Material Checkbox will disable padding automatically
@Composable
private fun DecorativeCheckbox() {
Row(modifier = Modifier.fillMaxWidth()) {
RowDescription(
"Decorative Checkbox,\npadding is disabled automatically",
Modifier
.weight(1f)
.padding(start = 16.dp, end = 16.dp)
)
Checkbox(checked = true, onCheckedChange = null)
}
}
Visual Result:
Custom selection control
When implementing a custom selection control, lift the clickable behaviour to the parent container and set the clickable to null to the child control.
@Composable
private fun AcsRowCheckBox(checked: MutableState<Boolean>) {
Row(
// 1. parent handles the toggleable click behavior
Modifier
.toggleable(
value = checked.value,
// 2. Accessibility semantics
role = Role.Checkbox,
onValueChange = { checked.value = !checked.value }
)
.padding(16.dp)
.fillMaxWidth()
) {
Text("Option", Modifier.weight(1f))
// 3. Pass null to the child control and handle click event on parent
Checkbox(checked = checked.value, onCheckedChange = null)
}
}
Custom clickable view
Compose automatically increases the touch target size but depending on the use case we should aim to define a minimum size of the composable to prevent overlap between composable touch areas
When the size of a clickable composable is smaller than the minimum touch target size, Compose still increases the touch target size. It does so by expanding the touch target size outside of the boundaries of the composable. Source
Default:
@Composable
private fun NonAcsCustomClickableBox(checked: MutableState<Boolean>) {
Box(
Modifier
.size(80.dp)
.background(if (checked.value) Color.DarkGray else Color.LightGray)
) {
Box(
Modifier
.align(Alignment.Center)
.clickable { checked.value = !checked.value }
.background(Color.Black)
.size(4.dp)
)
}
}
Result:
Recommended:
Using sizeIn we can define the default size for given composable
@Composable
private fun AcsCustomClickableBox(checked: Boolean, onClick: () -> Unit) {
Box(
Modifier
.size(80.dp)
.background(if (checked) Color.DarkGray else Color.LightGray)
) {
val stateLabel =
stringResource(if (checked) R.string.cd_enabled_state_custom_clickable_box else R.string.cd_disable_custom_clickable_box)
val clickLabel =
stringResource(if (checked) R.string.cd_disable_custom_clickable_box else R.string.cd_enable_custom_clickable_box)
Box(
Modifier
.align(Alignment.Center)
// 1. explicit semantics properties, describe the current state of CustomClickableBox
.semantics {
stateDescription = stateLabel
}
.clickable(
onClick = onClick,
// 2. Role
role = Role.Checkbox,
// 3. accessibility click label
onClickLabel = clickLabel
)
.background(Color.Black)
// 4. sizeIn modifier set the minimum size for the inner box
.sizeIn(minWidth = 48.dp, minHeight = 48.dp)
)
}
}
Result:
Actions on Icon or Image
It is very common in apps to have icons or images to let the user know that there is an action when is clicked.
The following example shows 2 different icons with actions.
The first one (default) it is simply showing the Delete icon but it is not following any recommended minimum target size.
The second one adds extra padding to conform to the minimum size requirement.
@Composable
private fun NonAcsDeleteButton(onDelete: () -> Unit) {
SectionRow(
horizontalArrangement = Arrangement.spacedBy(48.dp)
) {
// default
Icon(
imageVector = Icons.Default.Delete,
modifier = Modifier.clickable(onClick = onDelete),
contentDescription = null
)
// adding padding and define size to helps us to meet the requirements of 48dp touch area
Icon(
imageVector = Icons.Default.Delete,
modifier = Modifier
.clickable(onClick = onDelete)
.padding(12.dp)
.size(24.dp),
contentDescription = null
)
}
}
Result:
Rather than defining the Icon clickable area ourself, we can use IconButton which has an minimum touch target size following accessibility guidelines.
Also, it is important to define a contentDescription parameter to describe the visual element to the user. contentDescription must be localised since is going to be communicated to the user.
Based on the icon alone, the Android framework can’t figure out how to describe it to a visually impaired user. The Android framework needs an additional textual description of the icon.
The
contentDescription
parameter is used to describe a visual element. You should use a localized string, as this will be communicated to the user. Source
Action Icon after applying recommendations:
@Composable
private fun AcsDeleteButton(onDelete: () -> Unit) {
IconButton(onClick = onDelete) {
Icon(
imageVector = Icons.Default.Delete,
contentDescription = stringResource(R.string.cd_delete)
)
}
}
Result:
Essentials summary
Use case | Do | Do Not |
---|---|---|
Custom selection control (Switch, Checkbox, RadioButton, Slider) | - Lift clickable behaviour to parent container | |
- Use modifier that best fit the composable use case (Modifer.clickable, Modifier.toggable, etc) | ||
- Describe composable state by using Modifier.semantics | ||
- Define role that best describes the composable. ie: Role.Checkbox, Role.Switch | ||
Custom Clickable composables | - Make sure of minimum touch area, add clickable label and role | |
Clickable Icons | - Wrap Icon using IconButton which ensure minimum touch area. | - Do not set null or empty content description |
- If composable holds a state (selected/unselected) use semantics to describe the state. |
Semantics
Every single composable is recognised as an independent accessibility element unless it is list item or it has a clickable modifier (Compose will merge it automatically)
Not merged:
Row {
Image(
imageVector = Icons.Filled.AccountCircle,
contentDescription = null // decorative
)
Column {
Text(metadata.author.name)
Text("${metadata.date} • ${metadata.readTimeMinutes} min read")
}
}
Recommended:
// Merge elements below for accessibility purposes
Row(modifier = Modifier.semantics(mergeDescendants = true) {}) {
Image(
imageVector = Icons.Filled.AccountCircle,
contentDescription = null // decorative
)
Column {
Text(metadata.author.name)
Text("${metadata.date} • ${metadata.readTimeMinutes} min read")
}
}
Custom actions - List items
When dealing with list items that have actions, talkback screen reader will select the whole item first and then will focus on the action element(s)
In long list it will become repetitive to deal with for talkback users. We can leverage semantics to provide custom actions to the the whole item instead.
Given this row item with a favourite action:
@Composable
private fun NonAcsPostMetadataItem(data: PostMetadata, onToggleFavourite: () -> Unit) {
val favouriteIcon =
if (data.isFavourite) Icons.Default.Favorite else Icons.Default.FavoriteBorder
Row(
modifier = Modifier
.fillMaxWidth()
.clickable {},
verticalAlignment = Alignment.CenterVertically
) {
Row(
modifier = Modifier
.weight(1f)
) {
PostMetaData(data)
}
IconButton(onClick = onToggleFavourite) {
Icon(
imageVector = favouriteIcon,
contentDescription = stringResource(R.string.favourite)
)
}
}
}
Talkback behaviour:
- Talkback will first focus the whole list item and the user has to swipe right to continue the screen read.
- User is not aware until Talkback selects the Icon for the trigger the action
- Talkback list navigation will be harder if list item has multiple actions.
Recommended:
- Define action labels for row click
- Set explicit semantic properties to parent container using CustomActions.
- On CustomAccessbilityAction action lambda, invoke proper action and return true/false to indicate if action has been successfully handled.
- Disable semantic properties on list item children composables.
@Composable
private fun AcsPostMetadataItem(data: PostMetadata, onToggleFavourite: () -> Unit) {
// 1. define action labels
val actionLabel = stringResource(
if (data.isFavourite) R.string.unfavourite else R.string.favourite
)
val favouriteIcon =
if (data.isFavourite) Icons.Default.Favorite else Icons.Default.FavoriteBorder
Row(
modifier = Modifier
.fillMaxWidth()
.clickable(
onClick = {},
onClickLabel = stringResource(id = R.string.cd_read_article)
)
.semantics {
// 2. Set explicit semantic properties using CustomAccessibilityAction
customActions = listOf(
CustomAccessibilityAction(label = actionLabel, action = {
onToggleFavourite()
// 3. Return true/false if accessibility action was handled
true
})
)
},
verticalAlignment = Alignment.CenterVertically
) {
Row(
modifier = Modifier
.weight(1f)
) {
PostMetaData(data)
}
IconButton(onClick = onToggleFavourite,
// 4. disable semantics properties.
// Talkback will not interact with this node
modifier = Modifier.clearAndSetSemantics { }) {
Icon(
imageVector = favouriteIcon,
contentDescription = stringResource(R.string.favourite)
)
}
}
}
Talkback behaviour:
- Talkback will focus the whole list item and it will automatically read if the item has actions available to the user.
- If user wants to know more about the actions, user has to perform the proper accessibility gesture.
- After proper gesture, talkback menu is shown. User can select the actions menu.
- Custom actions setup will appear accordingly
Semantics summary
Use case | Do | Do not |
---|---|---|
Composable with multiple children | Group child composables to control accessibility granularity | |
List item with actions | Use customActions when list item multiple actions available to improve screen reader navigation |
Headings
Sometimes apps have to show multiple content in the same screen. Letting the screen reader know which composable are headings will help users to navigate through heavy content.
Tip: Enable Heading reading control to enable this functionality in talkback:
- > Enable Talkback
- > Swipe up and down in the same gesture until Headings reading control is selected.
Let's explore how Talkback will behave if our composables are not using headings.
@Composable
fun NonACSHeader(
text: String,
modifier: Modifier = Modifier,
style: TextStyle = MaterialTheme.typography.h4,
) {
Text(
text = text,
modifier = modifier
.padding(start = 16.dp, end = 16.dp),
style = style,
)
}
@Composable
private fun ColumnScope.NonAcsHeadings(metadata: PostMetadata) {
val loremIpsum = stringResource(id = R.string.lorem_ipsum)
NonACSHeader("Headings")
NonAcsPostMetadata(metadata = metadata)
NonACSection("Section 1")
RowDescription(text = loremIpsum, modifier = Modifier.padding(start = 16.dp, end = 16.dp))
NonACSection("Section 2")
RowDescription(text = loremIpsum, modifier = Modifier.padding(start = 16.dp, end = 16.dp))
}
Talkback behaviour:
- Navigation through headings not found
- User has to navigate element by element
Recommended:
For all headings text in your screen use the heading semantics property
@Composable
fun Header(
text: String,
modifier: Modifier = Modifier,
style: TextStyle = MaterialTheme.typography.h4,
) {
Text(
text = text,
modifier = modifier
.padding(start = 16.dp, end = 16.dp)
.semantics {
heading()
},
style = style,
)
}
Talkback behaviour:
- User can navigate using up/down gesture between headings, all composable are ignore when using headings reading control
Top comments (0)