Android Jetpack compose accessibility cheatsheet

1. Touch target size

Any on-screen element that someone can click, touch, or otherwise interact with should be large enough for reliable interaction. You should make sure these elements have a width and height of at least 48dp.

To increase the touch target size of this Icon, we can add padding:

@Composable
fun PostCardHistory(post: Post, navigateToArticle: (String) -> Unit) {
   // ...
   Row(
       // ...
   ) {
       // ...
       CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) {
           Icon(
               imageVector = Icons.Default.Close,
               contentDescription = stringResource(R.string.cd_show_fewer),
               modifier = Modifier
                   .clickable { openDialog = true }
                   .padding(12.dp)
                   .size(24.dp)
           )
       }
   }
   // ...
}

2. Click labels

Composables like Surface and Card, and the clickable modifier include a parameter where we can directly set this click label. This composable uses the Card composable internally, which has an overload that allows you to pass the click label:

@OptIn(ExperimentalMaterialApi::class)
@Composable
fun PostCardPopular(
   post: Post,
   navigateToArticle: (String) -> Unit,
   modifier: Modifier = Modifier
) {
   Card(
       shape = MaterialTheme.shapes.medium,
       modifier = modifier.size(280.dp, 240.dp),
       onClick = { navigateToArticle(post.id) },
       onClickLabel = stringResource(id = R.string.action_read_article)
   ) {
       // ...
   }
}

3. Custom actions

By default, both the Row and the IconButton composable are clickable and as a result will be focused by TalkBack. This happens for each item in our list, which means a lot of swiping while navigating the list. We rather want the action related to the IconButton to be included as a custom action on the list item. We can tell Accessibility Services not to interact with this Icon by using the clearAndSetSemantics modifier:

However, by removing the semantics of the IconButton, there is now no way to execute the action anymore. We can add the action to the list item instead by adding a custom action in the semantics modifier:

@Composable
fun PostCardHistory(post: Post, navigateToArticle: (String) -> Unit) {
   // ...
   val showFewerLabel = stringResource(R.string.cd_show_fewer)
   Row(
        Modifier
            .clickable(
                onClickLabel = stringResource(R.string.action_read_article)
            ) {
                navigateToArticle(post.id)
            }
            .semantics {
                customActions = listOf(
                    CustomAccessibilityAction(
                        label = showFewerLabel,
                        // action returns boolean to indicate success
                        action = { openDialog = true; true }
                    )
                )
            }
   ) {
       // ...
       CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) {
            IconButton(
                modifier = Modifier.clearAndSetSemantics { },
                onClick = { openDialog = true }
            ) {
                Icon(
                    imageVector = Icons.Default.Close,
                    contentDescription = stringResource(R.string.cd_show_fewer)
                )
            }
       }
   }
   // ...
}

4. Visual element descriptions

Visual composables like Image and Icon include a parameter contentDescription. Here you pass a localized description of that visual element, or null if the element is purely decorative.

@Composable
fun ArticleScreen(
   // ...
) {
   // ...
   Scaffold(
       topBar = {
           InsetAwareTopAppBar(
               title = {
                   // ...
               },
               navigationIcon = {
                   IconButton(onClick = onBack) {
                       Icon(
                           imageVector = Icons.Filled.ArrowBack,
                           contentDescription = stringResource(
                               R.string.cd_navigate_up
                           )
                       )
                   }
               }
           )
       }
   ) { 
       // ...
   }
}

5. Headings

Here, the Header is defined as a simple Text composable. We can set the heading semantics property to indicate that this composable is a heading.

@Composable
private fun Paragraph(paragraph: Paragraph) {
   // ...
   Box(modifier = Modifier.padding(bottom = trailingPadding)) {
       when (paragraph.type) {
           // ...
           ParagraphType.Header -> {
               Text(
                   modifier = Modifier.padding(4.dp)
                     .semantics { heading() },
                   text = annotatedString,
                   style = textStyle.merge(paragraphStyle)
               )
           }
           // ...
       }
   }
}

6. Custom merging

As we’ve seen in the previous steps, accessibility services like TalkBack navigate a screen element by element. By default, each low level composable in Jetpack Compose that sets at least one semantics property receives focus. So for example, a Text composable sets the text semantics property and thus receives focus.

However, having too many focusable elements on screen can lead to confusion as the user navigates them one by one. Instead, composables can be merged together using the semantics modifier with its mergeDescendants property.

We can tell the top level row to merge its descendants, which will lead to the behavior we want:

@Composable
private fun PostMetadata(metadata: Metadata) {
   // ...
   Row(Modifier.semantics(mergeDescendants = true) {}) {
       Image(
           // ...
       )
       Spacer(Modifier.width(8.dp))
       Column {
           Text(
               // ...
           )

           CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) {
               Text(
                   // ..
               )
           }
       }
   }
}

7. Switches and checkboxes

Toggleable elements like Switch and Checkbox read out loud their checked state as they are selected by TalkBack. Without context it can be hard to understand what these toggleable elements refer to though. We can include context for a toggleable element by lifting the toggleable state up, so a user can toggle the Switch or Checkbox by either pressing the composable itself, or the label that describes it.

We can see an example of this in our Interests screen. You can navigate there by opening the navigation drawer from the Home screen. On the Interests screen we have a list of topics that a user can subscribe to. By default, the checkboxes on this screen are focused separately from their labels, which makes it hard to understand their context. We’d prefer the whole Row to be toggleable.

As you can see here, the Checkbox has an onCheckedChange callback which handles toggling the element. We can lift this callback to the level of the whole Row:

@Composable
private fun TopicItem(itemTitle: String, selected: Boolean, onToggle: () -> Unit) {
   // ...
   Row(
       modifier = Modifier
           .toggleable(
               value = selected,
               onValueChange = { _ -> onToggle() },
               role = Role.Checkbox
           )
           .padding(horizontal = 16.dp, vertical = 8.dp)
   ) {
       // ...
       Checkbox(
           checked = selected,
           onCheckedChange = null,
           modifier = Modifier.align(Alignment.CenterVertically)
       )
   }
}

8. State descriptions

In the previous step, we lifted the toggle behavior from a Checkbox to the parent Row. We can improve the accessibility of this element even more by adding a custom description for the state of the composable.

By default, our Checkbox status is read as either “Ticked” or “Not ticked”. We can replace this description with our own custom description:

We can add our custom state descriptions using the stateDescription property inside the semantics modifier:

@Composable
private fun TopicItem(itemTitle: String, selected: Boolean, onToggle: () -> Unit) {
   // ...
   val stateNotSubscribed = stringResource(R.string.state_not_subscribed)
   val stateSubscribed = stringResource(R.string.state_subscribed)
   Row(
       modifier = Modifier
           .semantics {
               stateDescription = if (selected) {
                   stateSubscribed
               } else {
                   stateNotSubscribed
               }
           }
           .toggleable(
               value = selected,
               onValueChange = { _ -> onToggle() },
               role = Role.Checkbox
           )
           .padding(horizontal = 16.dp, vertical = 8.dp)
   ) {
       // ...
       Checkbox(
           checked = selected,
           onCheckedChange = null,
           modifier = Modifier.align(Alignment.CenterVertically)
       )
   }
}

Reference:
https://github.com/googlecodelabs/android-compose-codelabs/tree/main/AccessibilityCodelab
https://developer.android.com/jetpack/compose/accessibility

Search within Codexpedia

Custom Search

Search the entire web

Custom Search