Implementing Android design specs on Wutson

image - my workspace: mac running zeplin, whiteboard with 4 coloured pens

I’ve been working on an app for a while now with fellow Novodite, Qi Qu. It’s called Wutson and it’s a TV tracking app: a guide to what’s on, a diary to track what you’ve seen, and a reminder for when new episodes air.

There are plenty of other apps like it already available on the Play Store (SeriesAddict, another Series Addict, SeriesGuide, zzz…) so there’s a whole range of ideas upon which we can add and improve.

This post documents how I currently go about implementing one of Qi’s designs for Wutson.


Setting the scope of work

Qi has delivered specs for what the My Shows screen ought to look like (left), which is kind of far away from what it currently looks like (right).

image - target and current state of screen

The My Shows screen presents all the shows that the user has marked as tracked. The specs show three pages that are accessible by swiping or clicking on the tabs:

  • all tracked shows
  • tracked shows that are due to air new episodes
  • tracked shows that have aired new episodes recently

It’s not feasible to implement everything that the spec shows. In this case, I sat with Qi to talk about what couldn’t be done yet, and what maybe should be delayed.

We decided to start by implementing only the first screen, showing all the tracked shows (without any sub-actions). This means we can ignore the ViewPager and tabs for now.

The design also call for accent colors generated from the image – this would mean delaying the display of the image until the palette was generated or have a transition from greyscale text/background to colored text/background. This should be a task on its own as it’ll require more details and more time, so we’ll avoid this too and just use a semi-transparent black background.

Don’t start coding until you know your goal

I am a huge proponent of using dry-erase boards, using them to sketch layouts and TODO lists.

When I know the scope of work, I work best if I can break the task down into small steps because:

  • there are logical and frequent stages at which you can ask for feedback
  • there is a sense of achievement at the completion of each stage, and a growing indication of progress
  • you can focus each subtask and sweat the details, rather than aiming for the stars and reaching the moon
  • there are sensible stages to take breaks without worrying about losing context

Zeplin

Zeplin is a new tool we’ve started to use. Qi can export an artboard from Sketch directly to Zeplin, and I can inspect the elements for dimensions, paddings, styles, etc.:

image: Zeplin example of interactive specs

It doesn’t show everything (yet). Attributes like line spacing are not presented, but Qi can add a note to the artboard for these and similarly, if I have questions about something specific, I can leave a note for her:

image: Asking Qi a question via Zeplin with a marker

Subtasks

Even though Zeplin’s a huge improvement over the flat images with annotations, dimensions, grids and keylines, I still find it a bit distracting.

I prefer to use a reductive reference while I’m working because it keeps me focused on the job at hand. Let’s draw an overview of what we’re hoping to achieve:

image: simplified My Shows screen

So clear. We can see a repeating set of items arranged in a grid so let’s see if we can plan steps towards a possible solution:

  • display each item as a separate View
  • bind all the correct data to each item View
  • set the size/aspect ratio of each item View
  • ensure each layout is structured correctly
  • extract styles, dimensions and colors
  • set correct paddings and margins
  • set correct styles (colors, fonts, text size, backgrounds)
  • review

Display each item as a separate View

I went with a RecyclerView because I’ve been using it recently and it’s the fastest for me to do. You could also use a GridView.

Given that the RecyclerView.Adapter contract enforces the use of the ViewHolder pattern, I opted to inflate my item View inside the ViewHolder: the adapter doesn’t know about the item Views themselves, nor the associated layout inflated to create it – it only knows about the ViewHolder.

private static class TrackedShowsAdapter extends RecyclerView.Adapter<TrackedShowsAdapter.TrackedShowsItemViewHolder> {

    private final LayoutInflater layoutInflater;

    private List<ShowSummary> showSummaries;

    TrackedShowsAdapter(LayoutInflater layoutInflater) {
        this.layoutInflater = layoutInflater;
    }

    void update(List<ShowSummary> showSummaries) {
        this.showSummaries = showSummaries;
        notifyDataSetChanged();
    }

    @Override
    public TrackedShowsItemViewHolder onCreateViewHolder(ViewGroup parent, int viewType) {
        return TrackedShowsItemViewHolder.inflate(layoutInflater, parent);
    }

    @Override
    public void onBindViewHolder(TrackedShowsItemViewHolder holder, int position) {
        holder.bind(showSummaries.get(position));
    }

    @Override
    public int getItemCount() {
        if (showSummaries == null) {
            return 0;
        }
        return showSummaries.size();
    }

    @Override
    public long getItemId(int position) {
        return showSummaries.get(position).getId().hashCode();
    }

    static class TrackedShowsItemViewHolder extends RecyclerView.ViewHolder {

        static TrackedShowsItemViewHolder inflate(LayoutInflater layoutInflater, ViewGroup parent) {
            View view = layoutInflater.inflate(R.layout.view_tracked_shows_item, parent, false);
            return new TrackedShowsItemViewHolder(view);
        }

        public TrackedShowsItemViewHolder(View itemView) {
            super(itemView);
        }

        void bind(ShowSummary show) {
            ((TextView) itemView).setText(show.getName());
            // STOPSHIP: remove this debug thingy
            if (even) {
                itemView.setBackgroundResource(android.R.color.holo_red_light);
            } else {
                itemView.setBackgroundResource(android.R.color.holo_blue_dark);
            }
            even = !even;
        }

        static boolean even;

    }

}

I have this gross bit inside the bind(ShowSummary) method of the ViewHolder, where I set the background color. This is only so we can identify the bounds of each View and will be deleted before this code makes it way into the main branch.


image: recycler view where each item is a textview displaying the show name

Bind all required data to the View

I tend to use custom Views a lot, particularly for the Views inflated by adapters. They don’t have any crazy customisations – they’re mostly compounds of existing Views and ViewGroups, with some additional mutators so I can bind data with more meaningful APIs.

For these tracked Show items, we need to display the name of the show and the poster so we can have two APIs on our custom View – setTitle(String) and setPoster(URI).

public class ShowSummaryView extends FrameLayout {

    private ImageView posterImageView;
    private TextView titleTextView;

    public ShowSummaryView(Context context, AttributeSet attrs) {
        super(context, attrs);
    }

    @Override
    protected void onFinishInflate() {
        View.inflate(getContext(), R.layout.merge_show_summary, this);

        posterImageView = (ImageView) findViewById(R.id.show_summary_image_poster);
        titleTextView = (TextView) findViewById(R.id.show_summary_text_title);
    }

    public void setPoster(URI uri) {
       Glide.load(uri.toString())
           .into(posterImageView);
    }

    public void setTitle(String title) {
        titleTextView.setText(title);
    }

}

layout/merge_show_summary.xml:

<?xml version="1.0" encoding="utf-8"?>
<merge xmlns:android="http://schemas.android.com/apk/res/android"
  android:layout_width="match_parent"
  android:layout_height="wrap_content">

  <ImageView
    android:id="@+id/show_summary_image_poster"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:scaleType="centerCrop"
    android:contentDescription="@null" />

  <TextView
    android:id="@+id/show_summary_text_title"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:layout_gravity="bottom" />

</merge>

Now I can update the ViewHolder to use ShowSummaryView instead of a TextView.

static class TrackedShowsItemViewHolder extends RecyclerView.ViewHolder {

    ...

    void bind(ShowSummary show) {
        ((ShowSummaryView) itemView).setTitle(show.getName());
        ((ShowSummaryView) itemView).setPoster(show.getPosterUri());
    }

}

and the layout too (layout/view_tracked_shows_item.xml):

<?xml version="1.0" encoding="utf-8"?>
<com.ataulm.wutson.view.ShowSummaryView xmlns:android="http://schemas.android.com/apk/res/android"
  android:layout_width="match_parent"
  android:layout_height="wrap_content" />

and it looks…


image: each item shows textview, with cropped show poster as background

…a bit pants. No worries, it’s going according to plan: we have all our data bound, the item view is filled with the image, with the show name on top.

We just need to sort out the height of each item View.

Setting the correct aspect ratio

While we don’t care too much about the height or width specifically (which will change on different devices and in different configurations based on available space), we do care about the aspect ratio of our items.

I grabbed the dimensions from Zeplin:

image: whiteboard sketch showing item view dimensions

Our RecyclerView knows how wide it is, and so it tells child items how much available width they have to grow. It grows vertically based on its children, so it doesn’t know how tall it is; if we specify an explicit height in XML for each item View, then it can enforce this, but we don’t know the height – we can calculate it if we know how wide each item is though, that is, we know the aspect ratio we want.

In ShowSummaryView, we can override onMeasure(int, int) to have finer control over our width and height. onMeasure is triggered by the parent ViewGroup’s call of childView.measure(...) and gives the child View a chance to calculate how its dimensions – in the end, the parent ViewGroup has the final say.


image: harry wormwood telling matilda off

Adding the following onMeasure() to ShowSummaryView, we calculate our desired height from the given width and the ratio we know (214 by 178), then call super.onMeasure(int, int) with the modified height spec. We could call setMeasuredDimension() instead but then we’d also have to take care of telling our children to measure themselves; using super.onMeasure() will leverage the ViewGroup’s existing implementation.

private static final float HEIGHT_BY_WIDTH_RATIO = 214f / 178;
private static final float HALF_PIXEL = 0.5f;

...

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    int width = MeasureSpec.getSize(widthMeasureSpec);
    int desiredHeight = (int) (width * HEIGHT_BY_WIDTH_RATIO + HALF_PIXEL);

    int desiredHeightMeasureSpec = MeasureSpec.makeMeasureSpec(desiredHeight, MeasureSpec.EXACTLY);
    super.onMeasure(widthMeasureSpec, desiredHeightMeasureSpec);
}

which yields:


image: item views are bigger, with text in top left corner

Ensure each layout is structured correctly

This just means making sure that things show up in the right general place in the View.

In our case, we just need the text to be at the bottom of this View. Setting the layout_gravity attribute on the TextView in our merge layout should be enough:

<TextView
  android:id="@+id/show_summary_text_title"
  android:layout_width="match_parent"
  android:layout_height="wrap_content"
  android:layout_gravity="bottom" />


image: item views with text at the bottom

Extract styles, dimensions and colors

Once I’ve sorted my layout file, I’ll extract styles for each View:

<?xml version="1.0" encoding="utf-8"?>
<merge xmlns:android="http://schemas.android.com/apk/res/android"
  android:layout_width="match_parent"
  android:layout_height="wrap_content">

  <ImageView
    android:id="@+id/show_summary_image_poster"
    style="@style/ShowSummary.Poster"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:contentDescription="@null" />

  <TextView
    android:id="@+id/show_summary_text_title"
    style="@style/ShowSummary.Title"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:layout_gravity="bottom" />

</merge>

and set up dimensions for the ones I know need padding:

<style name="ShowSummary.Title">
  <item name="android:minHeight">@dimen/show_summary_title_min_height</item>
  <item name="android:paddingLeft">@dimen/show_summary_title_padding_left</item>
  <item name="android:paddingTop">@dimen/show_summary_title_padding_top</item>
  <item name="android:paddingRight">@dimen/show_summary_title_padding_right</item>
  <item name="android:paddingBottom">@dimen/show_summary_title_padding_bottom</item>
</style>

even if they just start with a value of 0:

<dimen name="show_summary_title_min_height">0dp</dimen>
<dimen name="show_summary_title_padding_left">0dp</dimen>
<dimen name="show_summary_title_padding_top">0dp</dimen>
<dimen name="show_summary_title_padding_right">0dp</dimen>
<dimen name="show_summary_title_padding_bottom">0dp</dimen>

Set correct paddings and margins

With the styles set up, I don’t have to return to the layout again. I can open Zeplin on the side and feed in the correct dimensions:

<dimen name="show_summary_title_min_height">60dp</dimen>
<dimen name="show_summary_title_padding_left">8dp</dimen>
<dimen name="show_summary_title_padding_top">8dp</dimen>
<dimen name="show_summary_title_padding_right">8dp</dimen>
<dimen name="show_summary_title_padding_bottom">8dp</dimen>


image: item views with paddings applied

I added the red background to the title to ensure the paddings were being applied correctly.

Set correct colors, text sizes and fonts

Then we set the correct colors, text sizes and fonts, adding the new resources as we go.

<style name="ShowSummary.Title">
  ...
  <item name="android:background">@color/show_summary_title_background</item>
  <item name="android:gravity">center_vertical</item>
  <item name="android:textColor">@color/show_summary_title_text</item>
  <item name="android:textSize">@dimen/show_summary_title_text</item>
  <item name="textFont">robotoRegular</item>
</style>

The textFont attribute is from a custom TextView (not shown in this post). Even though Roboto Regular is available on the platform, it’s not guaranteed that it’ll be available in every future platform, on every manufacturer’s version, etc., so it’s bundled with the app.


image: itemviews with correct colors, textsize and font

Review

Looks ok! From the designs, I see I’m missing margins between items (but the items should be flush with the screen). The correct way to do this (with RecyclerView) is by overriding getItemOffsets() in RecyclerView.ItemDecoration, and it’s easy enough if you don’t mind gaps between the edge of the screen and the items at the edge.

To do it properly (as far as I could tell) we’d also need to use the SpanSizeLookup from RecyclerView’s GridLayoutManager to ensure we only apply the item offsets on the “inside”.

Alternatively, we could cheat with negative margins:


image: sketch showing desired margins between items

The image on the left shows items with a gap of size 16, and no gap either side. To achieve this, we just need to apply a left and right margin of size 8 on each of our items. And to get rid of the edge gaps, use a left and right margin of negative 8 on the RecyclerView.

It’s not perfect because of the leftover pixels, depending on the screen width and the number of columns.

So, given the choice of doing it imperfectly (booo!) using negative margins or spending time working on a custom ItemDecoration, I went for the third options of replacing the RecyclerView for a GridView.

GridView already has this functionality with horizontalSpacing and verticalSpacing properties. The adapter is also pretty easy, and should take less than 15 minutes to migrate. Here’s one I did earlier:

class TrackedShowsAdapter extends BaseAdapter {

    private final LayoutInflater layoutInflater;

    private List<ShowSummary> showSummaries;

    TrackedShowsAdapter(LayoutInflater layoutInflater) {
        this.layoutInflater = layoutInflater;
    }

    void update(List<ShowSummary> showSummaries) {
        this.showSummaries = showSummaries;
        notifyDataSetChanged();
    }

    @Override
    public int getCount() {
        if (showSummaries == null) {
            return 0;
        }
        return showSummaries.size();
    }

    @Override
    public ShowSummary getItem(int position) {
        return showSummaries.get(position);
    }

    @Override
    public long getItemId(int position) {
        return 0;
    }

    @Override
    public View getView(int position, View convertView, ViewGroup parent) {
        View view = convertView;
        if (view == null) {
            view = layoutInflater.inflate(R.layout.view_tracked_shows_item, parent, false);
        }
        bindView(position, (ShowSummaryView) view);
        return view;
    }

    private void bindView(int position, ShowSummaryView view) {
        ShowSummary show = getItem(position);
        view.setTitle(show.getName());
        view.setPoster(show.getPosterUri());
    }

}

the layout:

...
<GridView
  android:id="@+id/my_shows_list"
  style="@style/MyShows.ShowsList"
  android:layout_width="match_parent"
  android:layout_height="match_parent" />
...

and the styles too:

<style name="MyShows.ShowsList">
  <item name="android:numColumns">@integer/my_shows_span_count</item>
  <item name="android:horizontalSpacing">@dimen/my_shows_item_spacing</item>
  <item name="android:verticalSpacing">@dimen/my_shows_item_spacing</item>
</style>

and we’re done:


image: mockup, before, and after screenies

Further considerations

A few things to always check when creating listview/grid style UIs:

  • scroll position ought to be retained over rotations and resuming the app (after switching to a different task)
  • all the items should be accessible via keyboard input, and should all have a focused state (and pressed state when we add a click action)
  • the entire item View should have a content description, not just the TextView

I haven’t covered these in this post. With regards to saving position, I thought GridView did it automatically (if it’s got an ID, which it does) if you override boolean hasStableIds() to return true and return unique IDs in the adapter, but it didn’t by default. Instead of reading up on it further, I just saved/restored the state in my Activity.

With the other two points, it’s part of an effort to try and get into good habits so that less work is required for apps to be compatible with accessibility guidelines and for apps to just work out of the box on Android TV. I’m hoping to write more about these two things in the near future.

Leave a Reply

Your email address will not be published. Required fields are marked *

To create code blocks or other preformatted text, indent by four spaces:

    This will be displayed in a monospaced font. The first four 
    spaces will be stripped off, but all other whitespace
    will be preserved.
    
    Markdown is turned off in code blocks:
     [This is not a link](http://example.com)

To create not a block, but an inline code span, use backticks:

Here is some inline `code`.

For more help see http://daringfireball.net/projects/markdown/syntax

You may use these HTML tags and attributes: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <s> <strike> <strong>