Accessibility is the practice of making your application usable by as many people as possible.
It is tempting to think of accessibility as being about a subset of the total interactions with your software. This is a reductive view, for two reasons:
- “We are all just temporarily abled”: in the span of time, no faculty or sense that functions today is guaranteed to function tomorrow. Vison, hearing, mobility, focus, touch — all human senses can be obstructed by temporary (tired? drunk? in a hurry?) or permanent (genetics, accident, age) causes. As explained in the linked article: we aren’t just designing accessible products and websites for a subgroup of people that have permanent sensory disabilites — though that is a just goal — we are also designing these software for our current and future selves.
- “Accessible design is universal design”: accessible software is usable software for everyone, including those without disabilites. “Situational limitations” like device size, outdoor brightness, network speed, etc. are all impediments to using software that hasn’t undertaken the necessary technical and architectural steps to be accessible. Even in the best of circumstances, software can impose usability obstacles for everyone if accessibility is not a part of the design (for e.g.: are the buttons too small? is the font too small? is there text overflow? is the color contrast blinding? does “Tab” not cycle interactive elements in the expected order? and so on!)
You may be familiar with accessibility concepts already: they are enshrined in Canadian and Quebecois law, they are foundational principles of the world wide web and open source software, and they are general beyond software in architecture, engineering, and public life.
This lecture won’t cover all of those subjects, though they are worth your time and study. Rather, we turn to the focus of this course: how do we make accessible Android applications in Jetpack Compose?
These notes have been adapted from Accessibility in Compose and Build Accessible Apps
Apps written in Compose should support accessibility for users with different needs and for use in different circumstances. The following sections describe the main knowledge needed to make this happen in Jetpack Compose.
-
Best Practises that Android developers follow are similar to principles found on other platforms, relating to button size, typography, color contrast, etc. The Material theme incorporates many of these best practises automatically, but these can be customized and its important to know what effect changes can have to component accessibility.
-
Responsive Layouts are layouts that can adapt to changing device orientations and sizes. This concept is especially important if you are interested in adapting your app to multiple platforms. For our course, it mainly concerns being able to handled both portrait and landscape orientations in a mobile device.
-
Accessibility services for the Android Framework come in two varieties:
- tools like Accessibility Scanner used by developers to debug accessibility properties
- tools like Talkback and Switch Access that transform what’s shown on screen to a more fitting format for a user with a specific need.
Like in Web development (see Semantic HTML), the components of Android User Interfaces carry semantic information — that is, most elements in a user interface have meaning that is separate from their appearance.
Compose uses semantic properties in the Modifier
object to pass information about @Composables
to accessibility services. Semantic properties provide information about UI elements that are displayed to the user.
Most built-in composables like Text
and Button
, as well as Material3 elements, fill these semantic properties with information inferred from the composable and its children. Some modifiers like toggleable
and clickable
will also set certain semantic properties. However, sometimes the framework needs more information to understand how to describe a UI element to the user.
The best practises below describes various situations in which you may need to explicitly add extra information to a @Composable
so it can be correctly described to the Android framework.
For each set of text within your app, the color contrast—or difference in perceived brightness between the color of the text and the color of the background behind the text— needs to be above a specific threshold. The exact threshold depends on the text’s font size and whether the text appears in bold:
- If the text is smaller than 18pt, or if the text is bold and smaller than 14pt, set the color contrast ratio to at least 4.5:1.
- For all other text, set the color contrast ratio to at least 3:1.
The following image shows two examples of text-to-background color contrast:
Figure. Lower than recommended (left) and sufficient (right) color contrast.
To check the text-to-background color contrast in your app, use an online color contrast checker or the Accessibility Scanner app.
To assist users with color vision deficiencies, use cues other than color to distinguish UI elements within your app’s screens. These techniques can include using different shapes or sizes, providing text or visual patterns, or adding audio- or touch-based (haptic) feedback to mark the elements’ differences.
The figure below shows two versions of an activity. One version uses only color to distinguish between two possible actions in a workflow. The other version uses the best practice of including shapes and text in addition to color to highlight the differences between the two options:
Figure. Examples of creating UI elements using color only (left) and using color, shapes, and text (right).
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.
Material components—like Checkbox
, RadioButton
, Switch
, Slider
, and Surface
—set this minimum size internally, so this is usually taken care of for you. See setting minimum target sizes for more detail and examples.
You can use a click label to add semantic meaning to the click behavior of a composable. Click labels describe what happens when the user interacts with the composable. Accessibility services use click labels to help describe the app to users with specific needs.
Set the click label by passing a parameter in the modifier.clickable
:
@Composableprivate fun ArticleListItem(openArticle: () -> Unit) { Row( Modifier.clickable( onClickLabel = stringResource(R.string.action_read_article), onClick = openArticle ) ) { // .. }}
@Composableprivate fun ArticleListItem(openArticle: () -> Unit) { Row( Modifier.clickable( onClickLabel = stringResource(R.string.action_read_article), onClick = openArticle ) ) { // .. }}
Alternatively, if you don’t have access to the clickable modifier, you can set the click label in modifier.semantics
:
@Composableprivate fun LowLevelClickLabel(openArticle: () -> Boolean) { val readArticleLabel = stringResource(R.string.action_read_article) Canvas( Modifier.semantics { onClick(label = readArticleLabel, action = openArticle) } ) { // .. }}
@Composableprivate fun LowLevelClickLabel(openArticle: () -> Boolean) { val readArticleLabel = stringResource(R.string.action_read_article) Canvas( Modifier.semantics { onClick(label = readArticleLabel, action = openArticle) } ) { // .. }}
When you define an Image
or Icon
composable, there is no automatic way for the Android framework to understand what is being displayed. You need to pass a textual description of the visual element.
Imagine a screen where the user can share the current page with friends. This screen contains a clickable share icon:
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.
@Composableprivate fun ShareButton(onClick: () -> Unit) { IconButton(onClick = onClick) { Icon( imageVector = Icons.Filled.Share, contentDescription = stringResource(R.string.label_share) ) }}
@Composableprivate fun ShareButton(onClick: () -> Unit) { IconButton(onClick = onClick) { Icon( imageVector = Icons.Filled.Share, contentDescription = stringResource(R.string.label_share) ) }}
Some visual elements are purely decorative and you might not want to communicate them to the user. When you set the contentDescription parameter to null, you indicate to the Android framework that this element does not have associated actions or state.
@Composableprivate fun PostImage(post: Post, modifier: Modifier = Modifier) { val image = post.imageThumb ?: painterResource(R.drawable.placeholder_1_1)
Image( painter = image, // Specify that this image has no semantic meaning contentDescription = null, modifier = modifier .size(40.dp, 40.dp) .clip(MaterialTheme.shapes.small) )}
@Composableprivate fun PostImage(post: Post, modifier: Modifier = Modifier) { val image = post.imageThumb ?: painterResource(R.drawable.placeholder_1_1)
Image( painter = image, // Specify that this image has no semantic meaning contentDescription = null, modifier = modifier .size(40.dp, 40.dp) .clip(MaterialTheme.shapes.small) )}
It is up to you to decide whether a given visual element needs a contentDescription
. Ask yourself if the element conveys information that the user will need to perform their task. If not, it’s better to leave the description out.
Accessibility services like Talkback and Switch Access allow users to move focus across elements on the screen. It is important that elements are focused at the right granularity. If every single low-level composable in your screen is focused independently, a user will have to interact a lot to move across the screen. If elements are merged too aggressively, users might not understand which elements belong together.
When you apply a clickable
modifier to a composable, Compose will automatically merge all elements it contains. This also holds for ListItem
; elements within a list item will be merged and accessibility services will view them as one element.
It is possible to have a set of composables that form a logical group, but that group is not clickable or part of a list item. You’d still want accessibility services to view them as one element. For example, imagine a composable that shows a user’s avatar, their name, and some extra information:
You can tell Compose to merge these elements by using the mergeDescendants
parameter in the semantics modifier. This way, accessibility services will select only the merged element, and all semantics properties of the descendants are merged.
@Composableprivate fun PostMetadata(metadata: Metadata) { // 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") } }}
@Composableprivate fun PostMetadata(metadata: Metadata) { // 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") } }}
Accessibility services will now focus on the whole container at once, merging their contents:
A composable can define a stateDescription
for semantics which is used by the Android framework to read out the state that the composable is in. For example, a toggleable
composable can be in either a “Checked” or an “Unchecked” state. In some cases, you might want to override the default state description labels that are used by Compose. You can do so by explicitly specifying the state description labels before defining a composable as toggleable
:
@Composableprivate fun TopicItem(itemTitle: String, selected: Boolean, onToggle: () -> Unit) { val stateSubscribed = stringResource(R.string.subscribed) val stateNotSubscribed = stringResource(R.string.not_subscribed) Row( modifier = Modifier .semantics { // Set any explicit semantic properties stateDescription = if (selected) stateSubscribed else stateNotSubscribed } .toggleable( value = selected, onValueChange = { onToggle() } ) ) { /* ... */ }}
@Composableprivate fun TopicItem(itemTitle: String, selected: Boolean, onToggle: () -> Unit) { val stateSubscribed = stringResource(R.string.subscribed) val stateNotSubscribed = stringResource(R.string.not_subscribed) Row( modifier = Modifier .semantics { // Set any explicit semantic properties stateDescription = if (selected) stateSubscribed else stateNotSubscribed } .toggleable( value = selected, onValueChange = { onToggle() } ) ) { /* ... */ }}
Adapted from https://developer.android.com/jetpack/compose/layouts/adaptive
The UI for your app should be responsive to account for different screen sizes, orientations and form factors. An responsive layout changes based on the screen space available to it. These changes range from simple layout adjustments to fill up space, to changing layouts completely to make use of additional room.
As a declarative UI toolkit, Jetpack Compose is well suited for designing and implementing layouts that adjust themselves to render content differently across a variety of sizes. This section contains some guidelines for how you can use Compose to make your UI responsive.
When using Compose to lay out an entire application, app-level and screen-level composables occupy all of the space your app is given to render. At this level in your design, it might make sense to change the overall layout of a screen to take advantage of larger screens.
You can use the actual portion of the screen that is allocated to your app using the the Material3 WindowSizeClass
library. These size classes refer to the overall window of your app, so use these classes for layout decisions that affect your overall screen layout.
You can pass the current window size class down as state that @Composables
can use, as in the example below:
import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApiimport androidx.compose.material3.windowsizeclass.WindowHeightSizeClassimport androidx.compose.material3.windowsizeclass.WindowSizeClassimport androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
class MainActivity : ComponentActivity() { @OptIn(ExperimentalMaterial3WindowSizeClassApi::class) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState)
setContent { // calculateWindowSizeClass converts the current window size to a WindowHeightSizeClass val windowSizeClass = calculateWindowSizeClass(this) MyApp(windowSizeClass) } }}@Composablefun MyApp(windowSizeClass: WindowSizeClass) { // Perform logic on the size class to decide whether to show // the top app bar. // Below: showTopAppBar is "true" when WindowHeighSizeClass is NOT Compact, "false" otherwise val showTopAppBar = windowSizeClass.heightSizeClass != WindowHeightSizeClass.Compact
// MyScreen composeable (screen-level) knows nothing about window sizes // performs logic based on a Boolean flag. MyScreen( showTopAppBar = showTopAppBar, /* ... */ )}
import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApiimport androidx.compose.material3.windowsizeclass.WindowHeightSizeClassimport androidx.compose.material3.windowsizeclass.WindowSizeClassimport androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
class MainActivity : ComponentActivity() { @OptIn(ExperimentalMaterial3WindowSizeClassApi::class) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState)
setContent { // calculateWindowSizeClass converts the current window size to a WindowHeightSizeClass val windowSizeClass = calculateWindowSizeClass(this) MyApp(windowSizeClass) } }}@Composablefun MyApp(windowSizeClass: WindowSizeClass) { // Perform logic on the size class to decide whether to show // the top app bar. // Below: showTopAppBar is "true" when WindowHeighSizeClass is NOT Compact, "false" otherwise val showTopAppBar = windowSizeClass.heightSizeClass != WindowHeightSizeClass.Compact
// MyScreen composeable (screen-level) knows nothing about window sizes // performs logic based on a Boolean flag. MyScreen( showTopAppBar = showTopAppBar, /* ... */ )}
Note: creating user interfaces for tablets and desktops is beyond the scope of this course, so you can usually assume that the “Compact” width is what applies to your device.
Note: the “Compact” height class is useful for detecting landscape mode in phones.
This approach confines screen size logic to a single location, instead of scattering it across your app in many places that need to be kept in sync.
This single location produces state, which can be explicitly passed down to other composables just like you would for any other app state. Explicitly passing state simplifies individual composables, since they will just be normal composable functions that take the size class or specified configuration along with other data.
To see how to use WindowManager
in a real Android application, check out the JetNews sample compose app.
- The Accessibility scanner is somewhat like the Developer Console of web browsers like Firefox and Google Chrome — it calculates a series of accessibilty properties (contrast, typography, button size) on the client device of your application. Since it is on the client device, you’ll need to install it on your emulated device in Android Studio, see the section below for further instructions. You’ll get the hang of using this in one of the codelabs of Assignment 4.
- The Layout inspector allows you to see semantic label information and view the hiearchy between
@Composables
in your application. The Layout inspector is included in Android Studio and does not need to be installed on your emulated device.
These services are a bit beyond the scope of this course — they are tools that users with disabilities with install on their Android Devices to help them navigate applications on their phone. One of the optional codelabs in Assignment 4 gives you practise using these tools.
There are two choices:
- Use Google Play with the emulated device to find and install the app
- Download the .apk for the app you require, place it in the
build
folder of the emulated device, and start the device as normal.
I’m having trouble finding a straightforward tutorial for either. Stay tuned.
You can see and use example components written by the Android Development team here: Jetpack Compose snippets
There are many useful snippets here, but in particular for Accessibility, you can check out the following folders: