Accessibility in Jetpack Compose

1. Introduction

In this codelab you will learn how to use Jetpack Compose to improve your app's accessibility. We will walk through several common use cases and improve a sample app step by step. We will cover touch target sizes, content descriptions, click labels, and more.

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.

During this codelab, we will use TalkBack to manually test our code changes. TalkBack is an accessibility service primarily used by people with visual impairments. Make sure to also test any changes to your code with other accessibility services, for example Switch Access.

TalkBack focus rectangle moving through the home screen of Jetnews. The text that TalkBack announces is shown at the bottom of the screen.

TalkBack in action in the Jetnews app.

What you'll learn

In this codelab, you will learn:

  • How to cater to users with impaired dexterity by increasing touch target sizes.
  • What semantics properties are and how you change them.
  • How to provide information to composables to make them more accessible.

What you'll need

What you'll build

In this codelab we will improve the accessibility of a news-reading app. We will start with an app that misses vital accessibility features and apply what we learn to make our app more usable for people with accessibility needs.

2. Getting set up

In this step, you will download the code for this which comprises a simple news-reader app.

What you will need

Get the code

The code for this codelab can be found in the codelab-android-compose Github repository. To clone it, run:

$ git clone https://github.com/android/codelab-android-compose

Alternatively, you can download two zip files:

Check out the sample app

The code you just downloaded contains code for all Compose codelabs available. To complete this codelab, open the AccessibilityCodelab project inside Android Studio.

We recommend that you start with the code in the main branch and follow the codelab step-by-step at your own pace.

Set up TalkBack

During this Codelab, we will use TalkBack to check our changes. When you use a physical device for testing, follow these instructions to turn TalkBack on. Emulators don't come with TalkBack installed by default. Choose an emulator that includes the Play Store, and download the Android Accessibility Suite.

3. 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.

If these controls are sized dynamically, or resize based on the size of their content, consider using the sizeIn modifier to set a lower bound on their dimensions.

Some Material components set these sizes for you. For example, the Button composable has its MinHeight set to 36dp, and uses 8dp vertical padding. This adds up to the required 48dp height.

When we open our sample app and run TalkBack, we will notice that the cross icon in the post cards has a very small touch target. We'd like this touch target to be at least 48dp.

Here's a screenshot with our original app on the left, versus our improved solution on the right.

Comparison of a list item showing a small outline of cross icon on the left, large outline on the right.

Let's look at the implementation and check the size of this composable. Open PostCards.kt and look for the PostCardHistory composable. As you can see, the implementation sets the size of the overflow menu icon to 24dp:

@Composable
fun PostCardHistory(post: Post, navigateToArticle: (String) -> Unit) {
   // ...

   Row(
       // ...
   ) {
       // ...
       CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurfaceVariant) {
           Icon(
               imageVector = Icons.Default.Close,
               contentDescription = stringResource(R.string.cd_show_fewer),
               modifier = Modifier
                   .clickable { openDialog = true }
                   .size(24.dp)
           )
       }
   }
   // ...
}

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

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

In our use case, there is an easier way to make sure the touch target is at least 48dp. We can make use of the Material component IconButton that will handle this for us:

@Composable
fun PostCardHistory(post: Post, navigateToArticle: (String) -> Unit) {
   // ...
   Row(
       // ...
   ) {
       // ...
       CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurfaceVariant) {
           IconButton(onClick = { openDialog = true }) {
               Icon(
                   imageVector = Icons.Default.Close,
                   contentDescription = stringResource(R.string.cd_show_fewer)
               )
           }
       }
   }
   // ...
}

Going through the screen with TalkBack now correctly shows a touch target area of 48dp. In addition, the IconButton also adds a ripple indication, which shows the user that the element is clickable.

4. Click labels

Clickable elements in your app by default don't provide any information on what clicking that element will do. Therefore, accessibility services like TalkBack will use a very generic default description.

To provide the best experience for users with accessibility needs, we can provide a specific description that explains what will happen when the user clicks this element.

In the Jetnews app, users can click on the various post cards to read the full post. By default this will read out the content of the clickable element, followed by the text "Double tap to activate". Instead, we'd like to be more specific and use "Double tap to read article". This is what the original version looks like, compared to our ideal solution:

Two screen recordings with TalkBack enabled, tapping a post in a vertical list and a post in a horizontal carousel.

Changing the click label of a composable. Before (on the left) vs after (on the right).

The clickable modifier includes a parameter that allows you to directly set this click label.

Let's take another look at the PostCardHistory implementation:

@Composable
fun PostCardHistory(
   // ...
) {
   Row(
       Modifier.clickable { navigateToArticle(post.id) }
   ) {
       // ...
   }
}

As you can see, this implementation uses the clickable modifier. To set a click label, we can set the onClickLabel parameter:

@Composable
fun PostCardHistory(
   // ...
) {
   Row(
       Modifier.clickable(
               // R.string.action_read_article = "read article"
               onClickLabel = stringResource(R.string.action_read_article)
           ) {
               navigateToArticle(post.id)
           }
   ) {
       // ...
   }
}

TalkBack now correctly announces "Double tap to read article".

The other post cards in our home screen have the same generic click label. Let's take a look at the implementation of the PostCardPopular composable and update its click label:

@Composable
fun PostCardPopular(
   // ...
) {
   Card(
       shape = MaterialTheme.shapes.medium,
       modifier = modifier.size(280.dp, 240.dp),
       onClick = { navigateToArticle(post.id) }
   ) {
       // ...
   }
}

This composable uses the Card composable internally, which does not allow you to directly set the click label. Instead, you can use the semantics modifier to set the click label:

@Composable
fun PostCardPopular(
   post: Post,
   navigateToArticle: (String) -> Unit,
   modifier: Modifier = Modifier
) {
   val readArticleLabel = stringResource(id = R.string.action_read_article)
   Card(
       shape = MaterialTheme.shapes.medium,
       modifier = modifier
          .size(280.dp, 240.dp)
          .semantics { onClick(label = readArticleLabel, action = null) },
       onClick = { navigateToArticle(post.id) }
   ) {
       // ...
   }
}

5. Custom actions

Many apps show some sort of list, where each item in the list contains one or more actions. When using a screen reader, navigating such a list can become tedious, as the same action would be focused over and over again.

Instead, we can add custom accessibility actions to a composable. This way the actions that relate to the same list item can be grouped together.

In the Jetnews app, we're showing a list of articles that the user can read. Each list item includes an action to indicate that the user wants to see less of this topic. In this section we'll move this action to a custom accessibility action, so navigating through the list gets easier.

On the left you can see the default situation, where each cross icon is focusable. On the right, you can see the solution, where the action is included in the custom actions in TalkBack:

Two screen recordings with TalkBack enabled. Screen on the left shows how the cross icon on the post item is selectable. Double tapping opens a dialog. Screen on the right shows using a three-tap gesture to open a custom Actions menu. Tapping action 'Show fewer of this' opens the same dialog.

Adding a custom action to a post item. Before (on the left) vs after (on the right).

Let's open PostCards.kt and look at the implementation of the PostCardHistory composable. Note the clickable properties of both the Row and IconButton, using Modifier.clickable and onClick:

@Composable
fun PostCardHistory(post: Post, navigateToArticle: (String) -> Unit) {
   // ...
   Row(
       Modifier.clickable(
           onClickLabel = stringResource(R.string.action_read_article)
       ) {
           navigateToArticle(post.id)
       }
   ) {
       // ...
       CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurfaceVariant) {
           IconButton(onClick = { openDialog = true }) {
               Icon(
                   imageVector = Icons.Default.Close,
                   contentDescription = stringResource(R.string.cd_show_fewer)
               )
           }
       }
   }
   // ...
}

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:

@Composable
fun PostCardHistory(post: Post, navigateToArticle: (String) -> Unit) {
   // ...
   Row(
       Modifier.clickable(
           onClickLabel = stringResource(R.string.action_read_article)
       ) {
           navigateToArticle(post.id)
       }
   ) {
       // ...
       CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurfaceVariant) {
            IconButton(
                modifier = Modifier.clearAndSetSemantics { },
                onClick = { openDialog = true }
            ) {
                Icon(
                    imageVector = Icons.Default.Close,
                    contentDescription = stringResource(R.string.cd_show_fewer)
                )
            }
       }
   }
   // ...
}

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(LocalContentColor provides MaterialTheme.colorScheme.onSurfaceVariant) {
            IconButton(
                modifier = Modifier.clearAndSetSemantics { },
                onClick = { openDialog = true }
            ) {
                Icon(
                    imageVector = Icons.Default.Close,
                    contentDescription = showFewerLabel
                )
            }
       }
   }
   // ...
}

Now we can use the custom action popup in TalkBack to apply the action. This becomes more and more relevant as the number of actions inside a list item increases.

6. Visual element descriptions

Not every user of your app will be able to see or interpret visual elements that show in the app, like icons and illustrations. There's also no way for accessibility services to make sense of visual elements based on their pixels alone. This makes it necessary for you as a developer to pass more information on the visual elements in your app to the accessibility services.

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.

In our app, the article screen is missing some content descriptions. Let's run the app and select the top article to navigate to the article screen.

Two screen recordings with TalkBack enabled, tapping the back button in the article screen. Left calls out 'Button—double tap to activate'. Right calls out 'Navigate up—double tap to activate'.

Adding a visual content description. Before (on the left) vs after (on the right).

When we don't provide any information, the top left navigation icon will simply announce "Button, double tap to activate". This does not tell the user anything about the action that will be taken when they activate that button. Let's open ArticleScreen.kt:

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

Add a meaningful content description to the Icon:

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

Another visual element in this article is the header image. In our case, this image is purely decorative, it doesn't show anything that we need to convey to the user. Therefore, the content description is set to null and the element is skipped when we use an accessibility service.

The last visual element in our screen is the profile picture. In this case we're using a generic avatar, so adding a content description here is not necessary. When we would use the actual profile picture of this author, we could ask them to provide a fitting content description for it.

7. Headings

When a screen contains a lot of text, like our article screen, it is quite hard for users with visual difficulties to quickly find the section they're looking for. To help with that, we can indicate which parts of the text are headings. Users can then navigate quickly through these different headings by swiping up or down.

By default, no composables are marked as headings, so there will be no navigation possible. We would like our article screen to provide heading by heading navigation:

Two screen recordings with TalkBack enabled, using swipe down to navigate through headings. Left screen reads out 'No next heading'. Right screen cycles through the headings and reads each of them out loud.

Adding headings. Before (on the left) vs after (on the right).

The headings in our article are defined in PostContent.kt. Let's open that file and scroll to the Paragraph composable:

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

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)
               )
           }
           // ...
       }
   }
}

8. 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.

Let's check our article screen. Most of the elements get the right level of focus. But the metadata of the article is currently read aloud as several separate items. It can be improved by merging that into one focusable entity:

Two screen recordings with TalkBack enabled. Left sreen shows separate green TalkBack rectangles for Author and Metadata fields. Right screen shows one rectangle around both fields and reads the concatenated content.

Merging composables. Before (on the left) vs after (on the right).

Let's open PostContent.kt and check the PostMetadata composable:

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

           CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurfaceVariant) {
               Text(
                   // ..
               )
           }
       }
   }
}

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(LocalContentColor provides MaterialTheme.colorScheme.onSurfaceVariant) {
               Text(
                   // ..
               )
           }
       }
   }
}

9. 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:

Two screen recordings with TalkBack enabled, showing the interests screen with a list of selectable topics. On the left screen, TalkBack separately selects each Checkbox. On the right screen, TalkBack selects the whole row.

Working with checkboxes. Before (on the left) vs after (on the right).

Let's open InterestsScreen.kt and look at the implementation of the TopicItem composable:

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

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)
       )
   }
}

10. 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:

Two screen recordings with TalkBack enabled, tapping a topic in the interests screen. Left screen announces 'Not ticked', while right screen announces 'Not subscribed'.

Adding state descriptions. Before (on the left) vs after (on the right).

We can continue with the TopicItem composable that we adapted in the last step:

@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)
       )
   }
}

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)
       )
   }
}

11. Congratulations!

Congratulations, you've successfully completed this codelab and learned more about accessibility in Compose. You learned about touch targets, visual element descriptions, and state descriptions. You added click labels, headings, custom actions. You know how to add custom merging, and how to work with switches and checkboxes. Applying these learnings to your apps will greatly improve their accessibility!

Check out the other codelabs on the Compose pathway. And other code samples, including Jetnews.

Documentation

For more information and guidance about these topics, check out the following documentation: