Hello!
Here you are! You have finally decided to take a look at what it takes to make your android application accessible for people with visual impairment. First, that's great news ! You'll see that it's a rewarding path that you are about to take.
Today I'm going to share with you a list of best practices to make your app accessible specifically for people who are blind. I will show you how to do that with Jetpack Compose.
How does Talkback work ?
Talkback is the screen reader on android used by blind or low vision users.
Here are several gestures that you can perform anywhere on the screen, that allows you to navigate through each elements:
➡️: swipe to the right to go to next element
⬅️: swipe to the left to go to previous element
↕️: swipe up and down on the screen or swipe down and up, will open a menu. In this menu you can configure swipe up / swipe down gesture, for example: Navigation between headers, speech rate, read letter by letter (useful to read a password for example).
⬇️ or ⬆️: swipe up or down allows you to perform an action that you configured with the previous gesture.
👆: single tap on an element on the screen will give the focus to this element.
👆👆: double tap to activate an element for example a button.
When an element is focused, Talkback will give user informations about it. For example:
- its content
- its value: for example for an Edit text.
- Its type: "Button" "Edit box", "Tab", "List: 9 of 11 items".
- Its state: "Selected" for a tab for example.
- The action you can perform with it: "activate", "toggle", etc...
Best practices list
1. Add content description to images
Let's take for example a shopping app. In the following screen, we have an image showing a black t-shirt. If we do nothing, blind users would not know what this element is unless they perform 8 swipes to the right, to reach its label "T-shirt with cut-outs tied". So we should add a description to this image. (and all the other images on this screen)
Here is how to do that with Jetpack Compose.
Image( painter = rememberCoilPainter(product.url),
contentDescription = product.contentDescription)
The good news with Jetpack Compose is that contentDescription is mandatory!
2. Hide decorative images
When an element is purely decorative and doesn't bring any additional information to the user (for example a background, an image to illustrate a text that doesn't add any meaningful information to it), you can hide it so that Talkback will not give the focus on this element.
Image( painter = painterResource(R.drawable.background),
contentDescription = null)
3. Improve element’s description
In some situations the way that Talkback will read the element content will not be user friendly. Specifically in situations when we don't say something out-loud the same way that it's written.
For example: the daily rate for a car rental: $16/day
We will never say out loud: "Dollar sixteen slash day", but if we do nothing in our app, Talkback will. That's not a very nice user experience.
By overriding the text attribute of our Text composable using the Modifier.semantics property, we are able to improve the information read by Talkback.
@Composable
fun DailyRate(rate: Int){
Text(text = “$rate/day”,
modifier = Modifier.semantics {
text = AnnotatedString(“$rate dollars per day”)
}
)
}
4. Group elements together
In some situations, it can really improve the user experience to group element together. Imagine a screen with lots of elements on it. Imagine now that you have to go through every single element by swiping right. It's exhausting.
For example on this pet sitter profile screen. We can group together information like "no pets, home, car owner" to be focused and read all together. We can perform that by setting mergeDescendants
to true
in the Modifier.semantics
properties of the main row.
Row (modifier = Modifier.semantics(mergeDescendants = true){}){
Column {
Icon(painter = painterResource(R.drawable.ic_pets),
contentDescription = null)
Text(text = stringResource(R.string.no_pet))
}
Column {
Icon(painter = painterResource(R.drawable.ic_car),
contentDescription = null)
Text(text = stringResource(R.string.car_owner))
}
Column(/*...*/)
}
5. Change reading order
Sometimes, it makes sense to influence the reading order of the element on the screen to improve the user experience and sometimes avoid having a feature completely inaccessible because the element is unreachable.
For example every time you design something on the z-index, let's say with a floating action button like the "Compose" button in gmail. If you do nothing, the focus to this button will be given after going through all the elements of the emails list behind (if the list is not infinite...).
There is no way to fix that with Jetpack Compose yet 😔... Here is an issue about that: A11y - Need a way to change manually focus order. Please comment or star the issue if you also think that it's important for the users.
To fix that without Jetpack Compose, you can use android:accessibilityTraversalBefore
and android:accessibilityTraversalAfter
properties to specify the element before and after the FloatingActionButton
.
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/fabButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:src="@drawable/ic_pencil"
android:contentDescription="@string/create_new_email"
android:layout_gravity="bottom|right"
android:accessibilityTraversalBefore="@id/mailList"
android:accessibilityTraversalAfter="@id/profilePicture"
/>
6. Facilitate navigation with headings
On the web, screen reader users are used to navigate quickly between headings (<h1>
, <h2>
, <h3>
...). It gives them a quick overview of the content and help them to be more efficient.
Starting from Talkback 9.1, it's now also possible to do that in android apps, as long as the developer specifies it.
A good example to set up headings is when you have sections in a list. Like here in this settings screen:
To specify that sections of that list are headings, we have to add heading()
inside the Modifier.semantics
property.
@Composable
private fun Section(text: String) {
Text(text = text,
modifier = Modifier.semantics { heading() }
)
}
7. Disable element and describe element state
Last situation I want to illustrate is the following: you have a list of items, and a switch button on each row of the list.
If we do nothing Talkback will behave on each row like this:
- Focus the entire row and say "Profile button"
- Focus on the switch button: "On - Switch - Double tap to toggle"
This can be exhausting to go through a long list of configurable rows if Talkback stops twice per row.
The original composable will probably looks like this:
var checked by remember { mutableStateOf(true) }
Row{
Text(text = "Profile details")
Switch(checked = checked,
onCheckedChange = { checked = !checked }
)
}
A better user experience, could be something like this on each row :
- Focus the entire row and say "Profile button - ON - Double tap to toggle"
We will do that in two step :
- Disable the focus on the Switch button
- Add toggle behaviour to the entire row
Let's go.
1) Disable the focus on the Switch button
We can do that by adding the Modifier.clearAndSetSemantics
with nothing inside. This way talkback will ignore it.
var checked by remember { mutableStateOf(true) }
Row{
Text(text = "Profile details")
Switch(checked = checked,
onCheckedChange = { checked = !checked },
modifier = Modifier.clearAndSetSemantics { }
)
}
Although using clearAndSetSemantics{}
is the usual way to ask Talkback to ignore an element, keep in mind that in general when semantics properties can be changed by setting another default parameter of a composable, that should be preferred over using the lower level semantics modifier. In this situation, we can for example set null
to onCheckChange
callback, and the switch button will be ignored by Talkback too.
var checked by remember { mutableStateOf(true) }
Row{
Text(text = "Profile details")
Switch(checked = checked,
onCheckedChange = null
)
}
2) Add toggle behaviour to the entire row
We add a toggleable
Modifier to the row and perform in it the same action than on the switch.
We add in the semantics
Modifier the stateDescription
properties to configure what Talkback should say regarding the switch state.
var checked by remember { mutableStateOf(true) }
Row(modifier = Modifier
.toggleable(checked) { checked = !checked }
.semantics { stateDescription =
if(checked) "ON" else "OFF" }
){
Text(text = "Profile details")
Switch(checked = checked,
onCheckedChange = null
)
}
That's it
That's it for my list of best practices to make your app usable for blind users.
You can also check the Compose documentation for accessibility here : Jetpack Compose - Accessibility Documentation
Of course accessibility is not only about blind people, so I really encourage you to learn more about that. Here is where you can start : Mozilla website - what is accessibility.
I hope you enjoyed this article. Don't hesitate to reach out to me on Twitter if you have any questions : @FannyDemey
Top comments (4)
Great article, thanks!
FYI the google issue tracker link in the "Change reading order" section is broken (copy/pasting the URL in the text works though).
Thanks for noticing ! I'll fix that 🙂
Great article. Thanks for sharing.
Thanks for this really interesting feedback !