Designing alternative interfaces: a Tweet view with custom actions

Ataul is a Google Developer Expert for Android focusing on inclusive design for mobile platforms and loves facilitating the production of universally useable apps. Also, he watches a lot of movies.

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.

google plus showing talkback focus around a post google plus showing dialog with actions available for a post

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:

google plus showing custom talkback actions available for a post

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!

Yet another Twitter client

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:

Tweet summary view showing author and text, with three buttons underneath: “reply”, “retweet” and “like”

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:

animated GIF showing a toasted message to indicate code works for clicking on the buttons and clicking on the view as a whole

and our requirements are met:

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.

animated GIF showing interactions with tweet view with talkback turned on. visually, the currently focused element has a green outline.

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);
            }
        }
    });
}

animated gif showing an options dialog appear when the tweet view is clicked. there are four options in the dialog corresponding to the previous four actions

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);
        }
    }

}

animated gif showing the four custom tweet actions as part of TalkBack's local context menu

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

About Novoda

We plan, design, and develop the world’s most desirable Android products. Our team’s expertise helps brands like Sony, Motorola, Tesco, Channel4, BBC, and News Corp build fully customized Android devices or simply make their mobile experiences the best on the market. Since 2008, our full in-house teams work from London, Liverpool, Berlin, Barcelona, and NYC.

Let’s get in contact