7.
Operable — Navigating the Screen
Written by Tori Gonda
If you’ve worked through this book chapter by chapter, then you’ve learned a lot about what comprises a perceivable app. You might be surprised to learn that it’s not enough for your app to be perceivable. It also needs to be operable, which is defined by the WCAG as:
2. Operable: User interface components and navigation must be operable.
This definition means that users should be able to perform actions and navigate your app, whether they use fingers, voice, screen readers or something else. Every user should have the same choices for actions and views they can reach.
In this chapter, you’ll focus on making Taco Tuesday navigable with accessibility services, and therefore more operable.
Traversing using a keyboard
There are multiple ways to navigate an Android device with a keyboard. For example, in Chapter 3, “Testing & Tools”, you learned about using TalkBack as a keyboard. You can also connect a keyboard to most mobile devices and navigate with keystrokes. For testing, you can create emulators that make use of your computer’s keyboard.
To navigate with a keyboard, you use the Tab and Arrow keys. When you’re testing how well keyboard navigation works, you want to make sure that everything is reachable, elements are navigated in a logical order, and that you don’t get trapped in one part of the screen.
WCAG’s guideline for keyboards is straightforward:
Guideline 2.1 Keyboard Accessible: Make all functionality available from a keyboard.
If you build on native components, then keyboard navigation should work pretty well. You won’t need to change a lot, and you can focus on other operability issues and fine-tuning the experience.
Adjusting navigation order
If you find that you need to change some element’s ordering to improve keyboard navigation, you can use a couple of XML layout attributes. Here’s the first:
android:nextFocusForward="@+id/editText1"
You use nextFocusForward to instruct the view where to go next when the user presses Tab or next. It says that when you’re on this view and navigate to the next view, the view with the ID editText1 will be next.
For arrow key navigation, you need these:
android:nextFocusUp="@+id/editText1"
android:nextFocusDown="@+id/editText2"
android:nextFocusLeft="@+id/editText3"
android:nextFocusRight="@+id/editText4"
These attributes tell the view where to take you when you press a directional arrow key.
Note: To learn more about keyboard navigation on Android, go to https://developer.android.com/training/keyboard-input/navigation.
Navigating your app
This chapter focuses on how to allow users who use accessibility tools to navigate your app. WCAG’s guideline is logical but broad:
Guideline 2.4 Navigable: Provide ways to help users navigate, find content, and determine where they are.
There’s a lot to unpack for this guideline, and this chapter can only cover so much, so you’re going to dive right into the first use case: people who use screen readers to discover content and determine where they are.
Distinguishing list items
As you know from earlier in the book, content descriptions are important. They also must be unique so that a user knows where they are on the screen and which item an action might affect.
Lists often need special attention to ensure operability.
Build and run. Go to your saved recipes and scan the screen with the Accessibility Scanner. You should get this suggestion back: “Item descriptions: Multiple items have the same description.”
When someone uses a screen reader, it should be clear which elements go with which items. For example, a checkbox description, such as Made recipe, should be attributed to the correct recipe.
Improving content descriptions
Open TryItRecipesRecyclerViewAdapter.kt. In ViewHolder, find bind(). Here is where you’ll add all your content descriptions.
You probably noticed that three views in the list items are ambiguous:
- The Made it checkbox
- The view details button
- The nacho-based rating display
When you use a screen reader and reach one of these elements, it’s not always clear to which recipe it pertains. A simple fix is to add the recipe name to the content description.
For now, you’ll just improve the checkbox and the view details button, and you’ll save the third view for “Chapter 10, Robust”.
In bind(), add the following:
binding.itemRecipeMade.contentDescription =
itemView.context.getString(
R.string.try_it_description_made_recipe, recipe.name)
binding.itemRecipeDetails.contentDescription =
itemView.context.getString(
R.string.try_it_description_details_recipe, recipe.name)
Now, when you reach a recipe you’ve marked as made in the recipe list, it will say “Made <recipe name>” instead of “Made it”. Similarly, when you reach the view details button, the screen reader says, “<recipe name> details.”
Build and run.
Run the Accessibility Scanner on this view to confirm you resolved the suggestions. Then, turn on TalkBack and observe the output when you reach these views.
Keeping list item focus
Focus items have similar requirements. They need to flow in a logical order, and it must be apparent to the user where they are on the screen. It’s also acceptable, if not advisable, to skip duplicated content. Here’s the WCAG success criterion for focus ordering:
Success Criterion 2.4.3 Focus Order: If a web page can be navigated sequentially and the navigation sequences affect meaning or operation, focusable components receive focus in an order that preserves meaning and operability.
Level A
Taco Tuesday conveniently has a related bug: when you mark a recipe as made on the list view using TalkBack, the list will lose focus. This behavior could confuse or frustrate users when they are transported to another part of the screen.
Try it for yourself:
- Make sure you have a couple of recipes saved.
- Turn on TalkBack, and try to mark a recipe from the list view as made.
- See how you jump away?
The jump happens when you change the data that’s backing the view. You need to address this issue when you allow users to perform actions on list items that change the underlying data.
A known bug in RecyclerView item animator, which exists at the time of writing, is this cause for this behavior. The best way to resolve it is a workaround.
Note: If you’re interested, you can follow along with this bug report at https://issuetracker.google.com/issues/37088814.
Resolving the list item focus bug
First, disable the item animator:
- Open TryItRecipesFragment.kt.
- In
onCreateView()find thewith(binding.root)that configures theRecyclerView. - In that block, add the following line:
itemAnimator = null
This line sets the item animator to null. If you’re using adapter.notifyItem*() when changing items, this is all you need to do.
Yes, this solution has a downside. Alternatively, you could make animations optional so people who want animations can have them, and people using a screen reader can skip them.
Taco Tuesday submits the full list when it changes, so there is another step: You need to use stable IDs.
Open TryItRecipesRecyclerViewAdapter.kt. Add the following init block to TryItRecipesRecyclerViewAdapter.
init {
setHasStableIds(true)
}
This init states that the RecyclerView uses stable IDs. Since you’re saying this, you also need to define those IDs.
Override the following method in the same class:
override fun getItemId(position: Int): Long {
return getItem(position).id
}
This block uses the id of the recipe as the stable ID.
Build and run. Use TalkBack again. When you toggle the checkbox, the focus should stay on that item.
Now it’s much easier for users running on TalkBack to mark recipes as made.
Managing links
Links are common in apps, especially when displaying user-generated content. Because of this, there are criteria for addressing links. Here’s one of them:
Success Criterion 2.4.4 Link Purpose (In Context): The purpose of each link can be determined from the link text alone or from the link text together with its programmatically determined link context, except where the purpose of the link would be ambiguous to users in general.
Level A
This rule applies to links that are in line with other content.
When you make the link text understandable, you make it easier for the user to decide if they should click it. You also allow people using screen readers to view all the links on a page and intentionally navigate to them.
There are many ways to contextualize a link:
- Add context right before or in the link text itself.
- Include the title of the link.
- Include a description of the contents.
- Specify the purpose of the link.
Exploring links in Taco Tuesday
Take a look at a detailed view for a recipe. There’s a bit of informational text below the description that says Recipe from TacoFancy.
Here, TacoFancy is a hard-coded link. The link text is the title of the page, so the link’s purpose is fairly straightforward.
But consider how messy links will get when people add user-generated content. It’s much harder to control what you can’t foresee.
Experience this issue for yourself:
- Find a recipe with a description containing links.
- Turn on TalkBack.
- Select that view.
- Then either swipe up and to the right, or use press Alt + Shift + Space to open the local context menu.
- Select Links to see the full list of links.
It’s not always clear where you’ll navigate to when you open these links.
You don’t need to edit the user’s content. It’s OK to display it as-is, but you can also make improvements.
Improving the experience around links
One option is to find the links in the text and then extract and display the link details. You see this in many apps. For example, Twitter shows link previews.
Another option is to use a custom span to inform the accessibility services about extra link data. With this option, you tell the services to read the full URL. Although it’s not the best experience for Taco Tuesday, you’ll learn about TtsSpan, which will be useful in other cases.
TtsSpan is a Span that can provide metadata to text-to-speech engines such as TalkBack. You can use it to improve the experience when these engines encounter text containing dates, times, money, websites and more.
For example, when you use it for the date “02/02/2020”, it will read “Sunday the second of February 2020” instead of “02 slash 02 slash 2020”.
Implementing TtsSpan
You’ll use TtsSpan as a custom Span for your markdown links.
Open RecipeDetailFragment.kt and find getMarkwon(), the code that lets you customize the configuration for the markdown parser, Markwon, via plugins.
You’ll see the below plugin that modifies the link text color — delete it:
.usePlugin(object : AbstractMarkwonPlugin() {
override fun configureTheme(builder: MarkwonTheme.Builder) {
builder.linkColor(ContextCompat.getColor(requireContext(),
R.color.colorPrimary))
}
})
Now you can replace it with a new plugin, which will still modify link color.
Add a new, empty plugin by adding this code where you deleted the previous plugin:
.usePlugin(object : AbstractMarkwonPlugin() {
override fun configureSpansFactory(
builder: MarkwonSpansFactory.Builder
) {
super.configureSpansFactory(
builder.setFactory(Link::class.java,
object : LinkSpanFactory() {
override fun getSpans(
configuration: MarkwonConfiguration,
props: RenderProps
): Any? {
}
})
)
}
})
This boilerplate code creates the AbstractMarkwonPlugin that Markwon will use while parsing your markdown. In that anonymous class, you are overriding configureSpansFactory() to set a custom Span factory for Link items.
Right now, it won’t compile because you need to return something.
An example of an acceptable return type is a list of Span items. You’ll return your TtsSpan, a LinkSpan, so the system knows to treat it as a link, and a ForegroundColorSpan to make sure you’re matching your theme.
You need the link information before you build a Span.
Add this to the top of getSpans():
val href = CoreProps.LINK_DESTINATION.require(props)
val uri = Uri.parse(href)
These lines get the href of the link and parse it into a Uri, so you get the different parts of the link.
Next, add the following to the bottom of getSpans():
return arrayOf<Any>(
)
This provides a place to return Spans.
Next, add the LinkSpan and the ForegroundColorSpan to the list to preserve the existing behavior:
LinkSpan(configuration.theme(), href,
configuration.linkResolver()),
ForegroundColorSpan(ContextCompat.getColor(requireContext(),
R.color.colorPrimary)),
Here you add a LinkSpan for link behavior. Because this is a Markwon Span, you pass along the existing Markwon metadata. You also add a ForegroundColorSpan to make sure the link uses your primary color.
Finally, the moment you’ve been waiting for: add your TtsSpan to the bottom of the list and build:
TtsSpan.ElectronicBuilder()
.setPort(uri.port)
.setDomain(uri.host)
.setPath(uri.path)
.setQueryString(uri.query)
.build()
Each type that TtsSpan supports has a builder, and you use a builder to define each part of the link. Specifically, you use the ElectronicBuilder to set the port, domain, path, and query.
Note: You’ll see an error when you add this because
TtsSpanis only available for Android Lollipop and higher. It’s safe to add a@RequiresApi(Build.VERSION_CODES.LOLLIPOP)to the Taco Tuesday app. Use your discretion to decide what to do in your own apps.
Build and run with TalkBack on. Go to the details screen for a recipe. Swipe up and to the right to show the context menu.
When you select links, TalkBack should read the entire URL rather than just the text displayed to sighted users.
Handling gestures
Support for gestures gives an app a layer of polish — allowing your users to swipe or pinch to perform actions can bring delight. Unfortunately, you can create accessibility issues when a gesture is the only way to perform a particular action.
Success Criterion 2.5.1 Pointer Gestures: All functionality that uses multipoint or path-based gestures for operation can be operated with a single pointer without a path-based gesture, unless a multipoint or path-based gesture is essential.
Level A
Simply put, this criterion specifies that any action that you can perform using multiple fingers or a drag of a finger must also have a single-tap based alternative.
You might have noticed a related issue in Taco Tuesday’s recipe list: You can swipe to discard a recipe, but swiping is the only way to discard a recipe.
You’ll implement two different options to further improve the app’s accessibility.
Adding long press to discard
The first option is a long press. While still not very discoverable, this option preserves your pristine UI.
Open TryItRecipesRecyclerViewAdapter.kt, and find bind() in the ViewHolder. Add the following long-click listener to the method:
// 1
binding.itemRecipeTitle.setOnLongClickListener {
// 2
MaterialAlertDialogBuilder(it.context)
.setTitle(R.string.try_it_discard_confirm_title)
.setMessage(it.context.getString(
R.string.try_it_discard_confirm_message, recipe.name))
// 3
.setPositiveButton(
R.string.try_it_discard_confirm_discard) { _, _ ->
onDiscardRecipe(recipe)
}
// 4
.setNegativeButton(
R.string.try_it_discard_confirm_cancel) { _, _ -> }
.show()
true
}
What you’re doing here is:
- Setting up a long-click listener for the recipe title.
- Showing a confirmation dialog when the user long clicks.
- Discarding the recipe upon confirmation.
- Nothing if the user cancels.
Build and run. Long press on the title of a saved recipe to see the confirmation and delete a recipe.
Describing actions
Now attempt the same action with TalkBack turned on. You’ll hear “Double-tap and hold to long-press”.
That’s not very descriptive. In addition to views, your descriptions can provide insights into available actions. Accessibility delegates are one tool that you can use to create better descriptions.
Create a new file named DeleteRecipeAccessibilityDelegate.kt to create your delegate. Add the following empty class to the file:
class DeleteRecipeAccessibilityDelegate(
private val recipeName: String
) : AccessibilityDelegateCompat() {
}
Here you create DeleteRecipeAccessibilityDelegate, which inherits from AccessibilityDelegateCompat. By overriding methods on this delegate, you provide your action metadata.
You’re also passing in a recipe name as a constructor parameter, so you can include that name in the description.
Next, override onInitializeAccessibilityNodeInfo() with the following body:
override fun onInitializeAccessibilityNodeInfo(
host: View,
info: AccessibilityNodeInfoCompat
) {
// 1
super.onInitializeAccessibilityNodeInfo(host, info)
// 2
val longClick =
AccessibilityNodeInfoCompat.AccessibilityActionCompat(
AccessibilityNodeInfo.ACTION_LONG_CLICK,
host.context.getString(
R.string.try_it_description_discard_recipe,
recipeName))
// 3
info.addAction(longClick)
}
Here you:
- Call the super method.
- Create an
AccessibilityActionCompatto specify that you’re defining a long click and description of the action. - Add the
AccessibilityActionCompatto theAccessibilityNodeInfoCompat.
Finally, you need to set this accessibility delegate on the list item title.
Open TryItRecipesRecyclerViewAdapter.kt and find bind() where you added the long click listener. Add this to bind():
ViewCompat.setAccessibilityDelegate(binding.itemRecipeTitle,
DeleteRecipeAccessibilityDelegate(recipe.name))
This gives the recipe name to the accessibility delegate and sets it on the title.
Build and run. Use TalkBack again and notice that you now hear “Double-tap and hold to discard <recipe name>.”
Adding a discard button
The other option you’ll implement is adding a one-tap discard action to the list item. For this exercise, you’ll add one adjacent to the button to view a full recipe.
Open item_try_it_recipe.xml. Right above the view with the ID item_recipe_details, add the following view:
<ImageButton
android:id="@+id/item_recipe_discard"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:iconGravity="end"
android:contentDescription="@string/shared_discard"
android:src="@drawable/ic_baseline_thumb_down_24"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/item_recipe_details"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/item_recipe_rating"
/>
With this, you add a thumbs-down icon to the list item that you can tap to discard the recipe.
To finish your view’s layout, you’ll constrain the button to view the recipe that is next to it.
Add this attribute to the view with the ID item_recipe_details:
app:layout_constraintStart_toEndOf="@id/item_recipe_discard"
Remove this constraint from the same view:
app:layout_constraintStart_toStartOf="parent"
This constrains the icon to view the details, so it sits next to the discard icon.
Finally, you need to set a click listener for the new icon.
Return to the bind() method in TryItRecipesRecyclerViewAdapter.kt, and add the following click listener:
binding.itemRecipeDiscard.setOnClickListener {
onDiscardRecipe(recipe)
}
This will discard the recipe when you tap the button. Build and run to ensure the new option to discard a recipe works.
There is one more minor issue to address, which you’ll do during a challenge at the end of this chapter. This icon needs the same content description treatment that you gave the view icon so that you don’t have multiple items labeled as Discard.
Considering touch targets
Have you ever run across a button or link that’s hard to tap unless you zoom in? This is an example of a touch target issue. WCAG says this about touch targets:
Success Criterion 2.5.5 Target Size: The size of the target for pointer inputs is at least 44 by 44 CSS pixels…
Level AAA
Simply said: Tappable views need to be big enough. Android guidelines recommend at least 48dp by 48dp.
This rule, like many others, comes with some exceptions. This chapter will cover two from the list.
- Equivalent: The target is available through an equivalent link or control on the same page that is at least 44 by 44 CSS pixels.
If there’s another view on the screen that is sufficiently large and performs the same action, it’s OK if for the other to be too small.
- Inline: The target is in a sentence or block of text.
Even though touch targets for links are generally smaller than 48dp by 48dp, you don’t need to make them bigger to meet requirements when embedded in text.
There are a couple of places where Taco Tuesday is noncompliant. Run the Accessibility Scanner on both the discover view and the list of recipes to find them.
The try it and discard icons on the discover screen are too small, as are the view and discard icons on the list items.
Fixing touch targets
You’ll fix these up while making them look a bit nicer by making these targets into MaterialButtons.
Open fragment_discover.xml and find the views with the IDs discover_button_discard and discover_button_try.
Starting with the view with the ID discover_button_discard, change ImageView to be com.google.android.material.button.MaterialButton.
Delete this attribute:
android:src="@drawable/ic_baseline_thumb_down_24"
And add these:
style="@style/Widget.MaterialComponents.Button.TextButton"
android:layout_margin="@dimen/space_normal"
android:text="No thanks"
app:icon="@drawable/ic_baseline_thumb_down_24"
app:iconPadding="@dimen/drawable_padding"
The view should now look like this:
<com.google.android.material.button.MaterialButton
android:id="@+id/discover_button_discard"
style="@style/Widget.MaterialComponents.Button.TextButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="@dimen/space_normal"
android:text="No thanks"
app:icon="@drawable/ic_baseline_thumb_down_24"
app:iconPadding="@dimen/drawable_padding"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@id/discover_button_try"
app:layout_constraintHorizontal_chainStyle="packed"
app:layout_constraintStart_toStartOf="parent" />
These changes add numerous benefits and make your app more navigable:
- The buttons now meet requirements.
- There’s a little extra space around the button, making the touch targets easier to tap and more distant from their neighbors.
- There’s now text to make the tapping action’s purpose more clear.
Add the equivalent changes to the view with the ID discover_button_try. Your result should look like this:
<com.google.android.material.button.MaterialButton
android:id="@+id/discover_button_try"
style="@style/Widget.MaterialComponents.Button.TextButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="@dimen/space_normal"
android:text="Save for later"
app:icon="@drawable/ic_baseline_thumb_up_24"
app:iconPadding="@dimen/drawable_padding"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/discover_button_discard"
/>
Now you have all these benefits, plus some nice style improvements, on both buttons.
Build and run to see the changes.
You’ll repeat the same for the list items’ icons.
Open item_try_it_recipe.xml, and find the views with IDs item_recipe_discard and item_recipe_details. Make the same changes you did above so that they look like this:
<com.google.android.material.button.MaterialButton
android:id="@+id/item_recipe_discard"
style="@style/Widget.MaterialComponents.Button.TextButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:iconGravity="end"
android:contentDescription="@string/shared_discard"
android:textColor="?colorOnPrimary"
app:icon="@drawable/ic_baseline_thumb_down_24"
app:iconTint="?colorOnPrimary"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/item_recipe_details"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/item_recipe_rating"
/>
<com.google.android.material.button.MaterialButton
android:id="@+id/item_recipe_details"
style="@style/Widget.MaterialComponents.Button.TextButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:contentDescription="@string/shared_details"
android:textColor="?colorOnPrimary"
app:icon="@drawable/ic_baseline_view_24"
app:iconTint="?colorOnPrimary"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/item_recipe_discard"
app:layout_constraintTop_toBottomOf="@id/item_recipe_rating"
/>
Now your recipe list items have buttons that meet WCAG guidelines for touch target size!
Build and run to see the changes.
Run the Accessibility Scanner to confirm all touch target suggestions are addressed.
Note: Another way to make views adhere to the minimum touch target size is to set a minimum height and width on the view or increase the padding. In some designs, where these simple fixes don’t work, you can use a
TouchDelegate.
TouchDelegateis a helper class that lets your set the touch area for a view to be different than the view’s bounds. You create it with aRectfor the bounds you want to be clickable, then you assign it to the view.
Targeting links
While inline links are exempt from touch target size guidance, there’s a specific case for following them to make a link more clickable: anytime you have a sentence with a single link. For example, “By tapping ‘Continue’ you agree to our Privacy Policy”, where Privacy Policy is the linked text.
In this scenario, you can make the full text view tappable to increase the touch target size.
Apply this to the “Recipe from TacoFancy” text on the details screen.
First, remove the explicit link from the String:
- Open strings.xml.
- Replace the value of the
Stringnamedrecipe_detail_recipe_from_tacofancywith the following:
Recipe from TacoFancy
This removes the anchor tag from the String.
Next, open RecipeDetailFragment.kt and find showRecipeDetails(). Add the following click listener to this method:
recipeDetailCreditText.setOnClickListener {
startActivity(Intent(
Intent.ACTION_VIEW,
Uri.parse("https://github.com/sinker/tacofancy")))
}
Now you have a click listener that opens the TacoFancy URL when you tap the view.
Finally, you’ll remove the LinkMovementMethod from the view since you’re no longer using an inline link. You can decide if you want to apply a Span to keep the link styling.
In the same showRecipeDetails() method, remove the following line:
recipeDetailCreditText.movementMethod =
LinkMovementMethod.getInstance()
This removes the explicit movementMethod — you no longer need it.
Build and run to ensure that tapping anywhere on the text opens the link in a browser.
Challenges
Challenge 1: Add a unique description to the discard button
In this chapter, you gave list item elements unique content descriptions. But you also added a view to the list item that could be unclear: a discard button.
For this challenge, you’ll add a unique content description to this button, following the pattern you did before. A String resource has been created to give you a starting point: try_it_description_discard_recipe.
Try to implement this yourself, then check your results against the chapter materials’ challenge solution.
Key points
- Operability is a crucial part of achieving accessibility.
- Accessibility tools help you identify operability issues within an app’s navigation.
- All elements of a given view must be reachable via a keyboard interface.
- Content descriptions for different list items should be unique so that the user can differentiate between similar items.
- Focus ordering should flow in a logical pattern.
- When performing operations on list items, make sure a screen reader can keep focus on the correct element.
- Make link text clear and descriptive, and use
TtsSpanwhen applicable. - Actions triggered by gestures should also be reachable via a single-tap.
- Touch targets should be at least 48dp by 48dp.