DEV Community

Carlos Monzon
Carlos Monzon

Posted on • Updated on

Jetpack compose accessibility best practices

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:

  1. Essentials: Visual elements, touch area, custom selection controls, clickable composable
  2. Semantics: Merge composables, custom actions in list items.
  3. 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, and Surface—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)
    }
}
Enter fullscreen mode Exit fullscreen mode

Visual Result:

Checkbox

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)
    }
}
Enter fullscreen mode Exit fullscreen mode

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)
        )
    }
}
Enter fullscreen mode Exit fullscreen mode

Result:

Clickable box

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)
        )
    }
}
Enter fullscreen mode Exit fullscreen mode

Result:

clickable box
)

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
        )
    }
}
Enter fullscreen mode Exit fullscreen mode

Result:

Clickable icon
Recommended:

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)
        )
    }
}
Enter fullscreen mode Exit fullscreen mode

Result:

Clickable icon accessible

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")
    }
  }
Enter fullscreen mode Exit fullscreen mode

Not merged

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")
    }
  }
Enter fullscreen mode Exit fullscreen mode

Merged

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:

custom actions list item

@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)
            )
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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.

Custom actions no accessibility

Recommended:

  1. Define action labels for row click
  2. Set explicit semantic properties to parent container using CustomActions.
  3. On CustomAccessbilityAction action lambda, invoke proper action and return true/false to indicate if action has been successfully handled.
  4. 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)
            )
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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

Result:

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))
}
Enter fullscreen mode Exit fullscreen mode

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,
    )
}
Enter fullscreen mode Exit fullscreen mode

Talkback behaviour:

  • User can navigate using up/down gesture between headings, all composable are ignore when using headings reading control

Top comments (0)