The Google+ Android team have been focusing a lot recently on improving TalkBack support. In this post, I'll explain one of my favourite features, custom actions, and how you can implement it.

There's a Google+ Accessibility community which the team uses to collect feedback from the users and there's been a strong positive reaction to the improvements in the stream view.

I've written before about how you can optimise lists (with multi-action list items) for TalkBack, specifically advocating for action dialogs (instead of inline actions) to facilitate faster navigation between list items.

In the screenshots above, inline actions are disabled for TalkBack users - only the whole card is focusable (green outline). Tapping on the card as a whole displays the dialog containing all the actions, making it a lot quicker to navigate through a long list of cards for vision impaired users.

Google+ has taken this one step further with the use of custom accessibility actions:

The same actions shown in the dialog are available in this view, but for some users, a radial menu will be quicker to use than a linear list of actions.

While the dialog is available on all API versions, custom accessibility actions are only available on Android Lollipop and above.

Let's do it!

Don't worry, we're not going to create the whole app. I'm just going to throw my hat into the ring by creating a simple View to display a Tweet.

We should present the author and the Tweet content; other information (like the date, time, location, etc.) can be displayed in a detail screen:

public class Tweet { public String getAuthor() {...} public String getText() {...} }

The View should also include affordances for user actions:

public interface TweetActionListener { void onClick(Tweet tweet); void onClickReplyTo(Tweet tweet); void onClickRetweet(Tweet tweet); void onClickLike(Tweet tweet); }

Extending a ViewGroup to add our own logic means we can hide the fact that the View will have essentially three different sets of affordances (inline actions, action dialog, and custom accessibility actions). You can alternatively put this logic in your presentation layer.

To start with, this is our goal:

It shows a Tweet View with the author, Tweet content and three Buttons to "reply", "retweet" and "like". Clicking anywhere (except the Buttons) should open the detail screen.

The first thing I do is create the skeleton of the custom View:

public class TweetSummaryView extends LinearLayout { private TextView authorTextView; private TextView contentTextView; private View replyButton; private View retweetButton; private View likeButton; public TweetSummaryView(Context context, AttributeSet attrs) { super(context, attrs); setOrientation(VERTICAL); setBackgroundResource(android.R.color.holo_red_light); View.inflate(getContext(), R.layout.merge_tweet_summary, this); } @Override protected void onFinishInflate() { super.onFinishInflate(); // TODO: use `findViewById` to find the widgets and assign to fields } public void display(Tweet tweet) { // TODO: bind Tweet data to the TextViews } }

Next, I add this View’s internal layout, res/layout/merge_tweet_summary.xml :

<?xml version="1.0" encoding="utf-8"?> <merge xmlns:android="http://schemas.android.com/apk/res/android"> <TextView android:id="@+id/tweet_summary_text_author" android:layout_width="match_parent" android:layout_height="wrap_content" android:layout_marginLeft="4dp" android:textColor="@android:color/white" /> <!--not shown: another TextView for the tweet text--> <!--not shown: a horizontal LinearLayout with three buttons--> </merge>

We find and assign the Views:

@Override protected void onFinishInflate() { super.onFinishInflate(); authorTextView = (TextView) findViewById(R.id.tweet_summary_text_author); // ... finding and assigning the others }

and bind data to them:

public void display(Tweet tweet) { authorTextView.setText(tweet.getAuthor()); contentTextView.setText(tweet.getText()); }

Finally, let’s add the callbacks:

public void display(final Tweet tweet, final TweetActionListener tweetActions) { ... replyButton.setOnClickListener(new OnClickListener() { @Override public void onClick(View v) { tweetActions.onClickReplyTo(tweet); } }); // ... same for the other buttons setOnClickListener(new OnClickListener() { @Override public void onClick(View v) { tweetActions.onClick(tweet); } }); } }

Ta-da! We have everything:

and our requirements are met:

both the author and Tweet is readable

the user can reply, retweet or like the Tweet

the user can click on the Tweet to open a detail screen

Considering TalkBack

Testing it with TalkBack, a screenreader for Android, it still meets our requirements! TalkBack will stop on actionable elements to read them aloud - that covers all of our buttons - and because the whole view is clickable, the author and Tweet content is read aloud too.

We can do better though - navigating through an infinite timeline of these Tweet Views would be awful because you'd have to perform four gestures to get to the next one.

Our plan is to present the actions via a dialog, instead of inline, so let's disable the buttons first. We can do this by marking the buttons' ViewGroup as not important:

<LinearLayout android:importantForAccessibility="noHideDescendants" ...

which tells TalkBack to ignore that ViewGroup and its children.

Adding an actions dialog

Now we need to repurpose the click listener on Tweet View (if TalkBack is enabled!) to show the dialog.

A guide for detecting whether TalkBack is enabled is given in a previous post.

public void display(final Tweet tweet, final TweetActionListener actions) { ... setOnClickListener(new OnClickListener() { @Override public void onClick(View v) { if (spokenFeedbackIsEnabled()) { showActionsDialog(tweet, tweetActions); } else { actions.onClick(tweet); } } }); }

I use a standard AlertDialog for the actions dialog:

private void showActionsDialog(Tweet tweet, TweetActionListener tweetActions) { CharSequence[] labels = getLabelsFrom(tweetActions); DialogInterface.OnClickListener dialogItemClickListener = dialogItemClickListenerFrom(tweet, tweetActions); new AlertDialog.Builder(getContext()) .setTitle("Tweet options") .setItems(labels, dialogItemClickListener) .create() .show(); }

Adding custom accessibility actions

Adding custom actions is simple - you can either override two methods of the View, or you can set your own AccessibilityDelegate on the View and implement two methods in that.

I went for the second option; with a bit of work, the AccessibilityDelegate class can be made reusable between Views.

public void display(final Tweet tweet, final TweetActionListener actions) { ... ViewCompat.setAccessibilityDelegate(this, new TweetAccessibilityDelegateCompat(tweet, tweetActions)); } private static class TweetAccessibilityDelegateCompat extends AccessibilityDelegateCompat { private final Tweet tweet; private final TweetActionListener tweetActions; TweetAccessibilityDelegateCompat(Tweet tweet, TweetActionListener tweetActions) { this.tweet = tweet; this.tweetActions = tweetActions; } @Override public void onInitializeAccessibilityNodeInfo(View host, AccessibilityNodeInfoCompat info) { super.onInitializeAccessibilityNodeInfo(host, info); info.addAction(new AccessibilityNodeInfoCompat.AccessibilityActionCompat(R.id.action_reply, "Reply")); // ... for each action } @Override public boolean performAccessibilityAction(View host, int action, Bundle args) { switch (action) { case R.id.action_reply: tweetActions.onClickReplyTo(tweet); return true; // ... other actions default: return super.performAccessibilityAction(host, action, args); } } }

And that does it. Any questions, shoot them over, otherwise get cracking!