15.
Accessibility in Jetpack Compose
Written by Denis Buketa
Building accessible experiences is something every developer should strive for. Not only because you’ll make your app available to people with disabilities, but because you’ll also help people who might find them in short-term disability situations where those features might be useful.
Unfortunately, accessibility features are often skipped over when implementing mobile apps. In this chapter, you’ll learn how easy and simple it is to build accessibility features in Jetpack Compose.
Accessibility covers many things: impaired vision, color blindness, impaired hearing, cognitive disabilities, and temporary situations people find themselves in. It’s impossible to cover all those cases in this chapter, but you’ll have a chance to implement the most common use cases step by step.
You’ll learn how to:
- Correctly implement touch target size.
- Add visual element descriptions.
- Add click labels.
- Create custom actions.
- Make it easier to navigate by using headings.
- Merge multiple elements in one semantic unit.
- Lift toggle behavior from switches and checkboxes.
- Define state descriptions.
For testing the code, you’ll use TalkBack — accessibility tools used by people with visual impairments.
Note: If you are not familiar with TalkBack, please refer to the official documentation: https://support.google.com/accessibility/android/answer/6283677?hl=en.
It is important to emphasize that TalkBack might behave differently depending on the device and TalkBack version you have installed. You can check the version in the TalkBack settings and you can also check controls for your version if you go to TalkBack settings and check Customize gestures or Customize menu options. There is also an option that allows you to practice the gestures to get used to them.
If you open Customize gestures screen, you’ll be able to see all the gestures you can do: 1 finger gestures, 1 finger angle, 2 fingers, etc. In this chapter, we use multi-finger gestures for some actions.
If your TalkBack version doesn’t support multi-finger gestures, you should be able to find an alternative within your available options. For example, 1 finger angle Swipe up then right gesture and Tap with 3 fingers have the same result: Open TalkBack menu. We’ll remind you of that for examples where this is important.
Semantic Properties
Before you jump straight into coding, you should understand the basic principles of how accessibility services interpret UI elements. Accessibility services need information about UI elements to understand the UI. Jetpack Compose defines that information using semantic properties.
Some composables can define that information by interpreting their children composables. Other composables might use some modifiers that fill that information. And in some cases, there isn’t enough information, so developers need to fill it using semantic properties.
In this chapter, you won’t go into more details about Compose semantics, but you’ll learn how to use them to manually fill accessibility information.
Note: If you want to learn more about Compose semantics, check out the official documentation: https://developer.android.com/jetpack/compose/semantics.
Implementing Accessible Touch Target Size
To follow along with the code examples, open this chapter’s starter project in Android Studio and select Open an existing project.
Then, navigate to 15-accessibility-in-jetpack-compose/projects and select the starter folder as the project root. Once the project opens, let it build and sync and you’re ready to go! You can see the completed project by skipping ahead to the final project.
Also make sure to clean the app’s storage, before running the project.
For this chapter, we’ve purposely made some components less accessibility friendly. Don’t worry, you’ll make sure that by the end of this chapter, all those components follow accessibility best practices. :]
We’ve also added a new Post screen that you can open by clicking on the first post in the list. This is for learning purposes and by clicking on other posts Post screen won’t be opened.
Before you continue, go to device Settings and turn on the TalkBack service.
Note: If you are not familiar with how to turn on TalkBack service, please refer to official documentation: https://support.google.com/accessibility/android/answer/6007100.
Once you’ve done that, open JetReddit app. You’ll probably see something like this:
TalkBack has focused on the first focusable element on the screen. Swipe right two times and navigate to the Chat icon. Notice how its touch area doesn’t meet the minimal requirements specified by Material Design. Screen elements that the user can interact with should have a width and height of at least 48dp.
Open JetRedditApp.kt and find Icon() in TopAppBar(). Note that its size is set to 24dp. One way to fix its touch area is the following:
Icon(
modifier = Modifier
.clickable {
context.startActivity(
Intent(context, ChatActivity::class.java))
}
.padding(12.dp) // HERE
.size(24.dp),
imageVector = Icons.Filled.MailOutline,
tint = Color.LightGray,
contentDescription = null
)
Here, you added a padding to the icon. With that padding you increased the touch area to 48dp.
Another way you can solve this problem is by replacing the Icon() with IconButton():
IconButton(onClick = {
context.startActivity(
Intent(context, ChatActivity::class.java)
)
}) {
Icon(
Icons.Filled.MailOutline,
tint = Color.LightGray,
contentDescription = null
)
}
Build and run your app. You should see that your Chat icon now meets the minimum requirements for touch area.
By using the Material components, you’ll make sure that your app follows best accessibility practices.
Adding Visual Element Descriptions
In this section, you’ll further improve the Chat icon. Visual elements like Image() and Icon() have contentDescription parameters for which you can pass the text used by accessibility services to describe what that element represents.
Some users might not be able to see or interpret visual elements in your app, and in those cases, you can use the contentDescription to add more information.
When TalkBack focuses on the Chat icon, it communicates to the user the following message: Button. Double tap to activate. If your users couldn’t see this icon, they would not know why the button is there.
In JetRedditApp.kt for the Chat icon, replace the null in contentDescription with a meaningful message;
IconButton(onClick = {
context.startActivity(
Intent(context, ChatActivity::class.java)
)
}) {
Icon(
Icons.Filled.MailOutline,
tint = Color.LightGray,
contentDescription = "Navigate to chat"
)
}
Build and run the app and focus on the Chat icon. TalkBack will now communicate the following: Navigate to chat. Button. Double tap to activate. This is much better and your user will be aware of what will happen if they press that button.
Adding a Click Label
In the previous section, you added a description to the Chat icon that explains what it represents. In this section, you’ll add a description of what happens when the user clicks on it.
You are still working on the same IconButton(), so find it and add a specific modifier to it:
IconButton(
modifier = Modifier.semantics { // HERE
onClick(label = "open Chat", action = null)
},
onClick = {
context.startActivity(
Intent(context, ChatActivity::class.java)
)
}
) {
Icon(
Icons.Filled.MailOutline,
tint = Color.LightGray,
contentDescription = "Navigate to chat"
)
}
Add following imports as well:
import androidx.compose.ui.semantics.onClick
import androidx.compose.ui.semantics.semantics
You added Modifier.semantics(), which allows you to define semantic properties. With it, you specified an action to be performed when the user clicks on an IconButton().
Build and run the app and select the Chat icon.
You should hear TalkBack communicating a following message: Navigate to chat. Button. Double tap to open chat.
You don’t have to always use Modifier.semantics() to set click labels. You can set them when using Modifier.clickable(). The following code snippet shows how you could’ve done it if you haven’t used IconButton():
Icon(
modifier = Modifier
.clickable(
onClickLabel = "open Chat" // HERE
) {
context.startActivity(
Intent(context, ChatActivity::class.java)
)
}
.padding(12.dp)
.size(24.dp),
imageVector = Icons.Filled.MailOutline,
tint = Color.LightGray,
contentDescription = "Navigate to Chat"
)
This would’ve produced the same result.
Now your users will know what this icon represents and what will happen when they click on it.
Implementing Custom Actions
Next, you’ll focus on making your posts accessibility friendly. If you try swiping right to navigate through posts, you’ll notice many actions you must go through before TalkBack focuses on the next post.
In the image above, you can see the many focusable elements on each post.
Now, if you imagine your users trying to navigate through posts quickly, you can guess that it will be a challenging experience. To make that navigation more pleasant, you can group actions your user can perform on each post.
Before grouping those actions, you’ll first make those elements unfocusable by TalkBack.
Open Post.kt and modify two composables, MoreActionsMenu() and PostActions():
@Composable
fun MoreActionsMenu() {
var expanded by remember { mutableStateOf(false) }
Box(modifier = Modifier
.wrapContentSize(Alignment.TopStart)
.clearAndSetSemantics { } // HERE
) {
...
}
}
// Rest of Post.kt content
...
@Composable
fun PostActions(post: PostModel) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp, end = 16.dp)
.clearAndSetSemantics { }, // HERE
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
...
}
}
Next, open JoinButton.kt and modify Text() composable in it:
@Composable
fun JoinButton(onClick: (Boolean) -> Unit = {}) {
...
Box(
...
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = iconAsset,
contentDescription = null,
tint = iconTintColor,
modifier = Modifier
.size(16.dp)
)
Text(
text = "Join",
color = Color.White,
fontSize = 14.sp,
maxLines = 1,
modifier = Modifier
.widthIn(
min = 0.dp,
max = textMaxWidth
)
.clearAndSetSemantics { } // HERE
)
}
}
}
Add the following imports as well:
import androidx.compose.ui.semantics.clearAndSetSemantics
You used Modifier.clearAndSetSemantics() which clears the semantic of all descendants elements (nodes) and allows you to set new semantics. After clearing those semantics, TalkBack won’t focus those elements if you try navigating through posts the same way you did previously.
If you build and run your app, you’ll notice that now only the post element itself is focusable by TalkBack and that by swiping to the right, you can quickly go through posts.
After these changes, your users can navigate more easily, but they no longer have the ability to perform any post actions.
Open Post.kt and modify Card() in Post() accordingly :
@Composable
fun Post(
post: PostModel,
onJoinButtonClick: (Boolean) -> Unit = {},
onPostClicked: () -> Unit = {},
content: @Composable () -> Unit = {}
) {
Card(
shape = MaterialTheme.shapes.large,
onClick = { onPostClicked.invoke() },
modifier = Modifier.semantics { // HERE
customActions = listOf(
CustomAccessibilityAction(
label = "Join",
action = { /* Join / Leave */ true }
),
CustomAccessibilityAction(
label = "Save post",
action = { /* Save post */ true }
),
CustomAccessibilityAction(
label = "Upvote",
action = { /* Upvote */ true }
),
CustomAccessibilityAction(
label = "Downvote",
action = { /* Downvote */ true }
),
CustomAccessibilityAction(
label = "Navigate to comments",
action = { /* Navigate to comments */ true }
),
CustomAccessibilityAction(
label = "Share",
action = { /* Share */ true }
),
CustomAccessibilityAction(
label = "Award",
action = { /* Award */ true }
)
)
}
) {
...
}
}
Add these imports as well:
import androidx.compose.ui.semantics.customActions
import androidx.compose.ui.semantics.CustomAccessibilityAction
Here you again used Modifier.semantic() to define custom actions. Custom actions are defined with CustomAccessibilityAction(), which allows you to specify the label and result of the action.
Build and run your app. When you focus the post, notice the last part of TalkBack message. In our version of TalkBack, after describing the content, it will communicate: Actions available. Use tap with three fingers to view.
It’s suggesting to the user that they can open a TalkBack menu for more actions. Depending on your TalkBack version, you should be able to do that with one of the following gestures: tapping with three fingers on the post, swiping up then right or swiping down then right. One of those gestures should open a dialog as in image below. If this doesn’t work, go to TalkBack settings and find a gesture for opening TalkBack menu.
Now your users can easily navigate through posts while being able to perform all post actions.
Navigating Through Headings
In this section you’ll again work on improving a specific navigation experience in the app. Open the app, focus the first post by tapping on it, and double tap on the screen to open a Post screen. As mentioned at the beginning of this chapter, this was added for learning purposes and it only works for the first post in the list.
This screen contains a lot of text and users with visual impairments could have difficulty navigating through it. If you take a better look at the screen, you can see that it is organized in a couple of sections: title, author, content and comments.
It would be great if there was a way to allow your users to quickly navigate through those sections. :]
Configuring TalkBack Reading Control
TalkBack allows you to navigate by headings. Before you continue updating the code, make sure that you configure the TalkBack to navigate by headings.
In Customize gesture screen in TalkBack options you can find how to change reading control. By default, reading controls include:
- Characters
- Words
- Lines
- Paragraphs
- Headings
- Controls
- Links
- Speech rate
- Language
In the version of TalkBack used here, to change between previous or next reading control you use the following gestures:
- Swipe up then down / Swipe down then up,
- Swipe up with 3 fingers / Swipe down with 3 fingers,
- Swipe left with 3 fingers / Swipe right with 3 fingers.
Navigate between different reading controls until you see Headings:
Note: If you are still having problems configuring reading controls, please refer to official documentation: https://support.google.com/accessibility/android/answer/6006598.
Adding Headings
After you’ve done that, try to swipe down with one finger and notice the TalkBack response: No next heading. That means that there are no headings configured on that screen.
Open PostScreen.kt and modify TitleSection() and SectionDescriptor():
@Composable
private fun TitleSection() {
Text(
text = "Check out this new book about Jetpack Compose from Kodeco!",
color = colors.primaryVariant,
fontSize = 18.sp,
modifier = Modifier
.padding(horizontal = 16.dp)
.semantics { heading() } // HERE
)
}
@Composable
private fun SectionDescriptor(text: String) {
Text(
text = text,
color = Color.Gray,
fontSize = 14.sp,
modifier = Modifier
.padding(horizontal = 16.dp)
.semantics { heading() } // HERE
)
}
Don’t forget to also add following imports:
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
With this, you marked specific screen sections as accessibility headings.
Build and run your app and try again navigating by swiping down or up with one finger.
You’ll notice that now your users can quickly find section of interest and then continue from there.
Custom Merging
You’ve probably noticed that when it comes to accessibility, you try to minimize the number of focus changes while keeping the same context. In this section, you’ll learn how to merge composables in one semantic unit so that your users don’t have to go through each element.
In Post screen, you can notice that when navigating in author section, TalkBack will focus subreddit name element and then author element. Those two elements could be merged to one semantic element.
In AuthorSection(), update the modifier like this:
@Composable
private fun AuthorSection() {
SectionDescriptor(text = "Author")
Row(
modifier = Modifier
.padding(start = 16.dp)
.semantics(mergeDescendants = true) { } // HERE
,
verticalAlignment = Alignment.CenterVertically
) {
...
}
}
With this, you merged all Row() descendants as one logical unit.
If you build and run your app, you’ll notice that TalkBack will read both the subreddit name and author element together.
Lifting Toggle Behavior
In this section, you’ll learn how to lift toggleable state up from the toggle composable itself to its containing composable.
Open Subreddits screen and take a look at it.
Each subreddit element currently has two focusable elements: name and switch. When TalkBack focuses on the switch, it communicates to the user the switch state. The users need to remember the context of how they got to this switch to understand what might happen.
To make it easier for your users to understand that context, you can lift up the toggleable state from the switch.
Open SubredditsScreen.kt and modify Community() composable:
@Composable
fun Community(
text: String,
modifier: Modifier = Modifier,
showToggle: Boolean = false,
onCommunityClicked: () -> Unit = {}
) {
var checked by remember { mutableStateOf(true) }
val defaultRowModifier = modifier
.padding(start = 16.dp, end = 16.dp, top = 16.dp)
.fillMaxWidth()
val rowModifier = if (showToggle) { // HERE
defaultRowModifier
.toggleable(
value = checked,
onValueChange = { checked = it },
role = Role.Switch
)
} else {
defaultRowModifier.clickable { onCommunityClicked.invoke() }
}
Row(
modifier = rowModifier, // HERE
verticalAlignment = Alignment.CenterVertically
) {
...
if (showToggle) {
Switch(
checked = checked,
onCheckedChange = null // HERE
)
}
}
}
With Modifier.toggleable(), you can configure component to be toggleable via input and accessibility events. This now allows your user to double tap the whole element to toggle it.
For this to work, please add the following imports as well:
import androidx.compose.foundation.selection.toggleable
import androidx.compose.ui.semantics.Role
If you build and run your app, you’ll notice that the whole subreddit element gets focusable by TalkBack and you can toggle it by double tapping.
Adding State Descriptions
In the previous section you lifted the toggleable state. You can further improve the user experience by adding a better state description.
Again, expand the modifier you created in the previous section:
@Composable
fun Community(
text: String,
modifier: Modifier = Modifier,
showToggle: Boolean = false,
onCommunityClicked: () -> Unit = {}
) {
var checked by remember { mutableStateOf(true) }
val defaultRowModifier = modifier
.padding(start = 16.dp, end = 16.dp, top = 16.dp)
.fillMaxWidth()
val rowModifier = if (showToggle) {
defaultRowModifier
.toggleable(
value = checked,
onValueChange = { checked = it },
role = Role.Switch
)
.semantics {
stateDescription = if (checked) { // HERE
"Subscribed"
} else {
"Not subscribed"
}
}
} else {
defaultRowModifier.clickable { onCommunityClicked.invoke() }
}
...
}
Add following imports as well:
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.stateDescription
With this, you’ve overridden the default TalkBack’s interpretation of switch state (on/off). When you now focus the subreddit element, TalkBack will first communicate Subscribed or Not subscribed for that subreddit.
Excellent work! You just learned the basic principles of accessibility in Jetpack Compose. Your app can now definitely be used by more users! :]
Key Points
- When implementing screen elements that the user can interact with, think about its size. Their width and height should be at least 48dp.
- You should use the contentDescription to add more information to visual elements for users who might not be able to see or interpret them.
- Adding a click label adds more context about what will happen when the user interacts with the screen elements.
- Custom actions make your app easier to navigate using a screen reader and group common actions for specific screen elements.
- Defining semantic headings allows users to quickly jump between sections on your screens.
- You can use custom merging when screen elements can be grouped into one logical unit.
- By lifting the toggle behavior from some screen elements, you add more context to the toggleable element.
- State descriptions can further add more context to toggleable elements.
Where to Go From Here?
Congratulations, you just completed the Accessibility in Jetpack Compose chapter!
As mentioned at the beginning, in this chapter you covered most common use cases and there is always room to improve the accessibility of your app.
Don’t be afraid to learn more about the subject. Check out the Jetpack Compose course if you want to get a second example of implementing accessibility features using Compose from the ground up.
Wishing you all the best in your continued Jetpack Compose adventures!