Modular and Dynamic UI for Android Phones and Tablets

A short Android developer's guide on how to make responsive UI that makes both phone and tablet users happy. This is the companion blog post for my 10 minute presentation of the same title. There are code samples in the Example section of this post.

You can see the actual presentation here!

I want to highlight two things in this post: Modularity and Responsiveness. I think apps that keep both phone and tablet users in mind should have modular views/code and look good regardless of screen size.

Modular

Some views should be thought of individual modules: they should be able to stand on their own, or complement other views. Example: You're building a screen that is somewhat similar in functionality for both phone and tablet, but the tablet version has an extra view.

You can do two things 1) create an activity that works for phone, another for tablet, or better 2) create fragments for these two views, use the same 'modules' for tablet version.

This way, you can avoid copy-pasting the same code for your views.

Responsive

A responsive app means the app can present itself well regardless of orientation or screen size. As a programmer, you don't want to create different activities/fragments/views for different screen sizes. Instead, you want to write the same code and have Android do all the work for you.

Why It Makes Sense to Use Modular/Dynamic UI

Let's look at the similarities and differences between phones and tablets.

People typically use phones in portrait mode. Portrait is somewhat limited in screen real estate, but it works well for lists.

Tablets gain a lot more screen space when used in landscape mode. A tablet in landscape showing the same layout as if it were in portrait mode looks awkward. There's too much underutilized space! We could use that space to show more relevant information.

Phone users use one hand while navigating using their thumb, with the "sweet spots" located near the lower right corner of the screen. Tablet users vary. Sometimes they use two hands, one hand to hold the tablet, the other (forefinger) to navigate. Maybe they use one hand with the tablet laid flat on the table or docked at an angle. This tells us that locations of buttons, etc. on the phone may not work as well as on a tablet.

Phones and tablets share the same interaction paradigm. Users can swipe, tap, long press, etc. This is awesome, because this enables us to create a familiar experience for both users. We don't have to "teach" them how to use a tablet.

tl;dr - Users can perform the same actions, but you can show more data with a tablet in landscape mode. You might need to reposition stuff to help the user navigate the app better.

So.. now we're motivated to write a responsive app that works well on both phone and tablet! How do we do this on Android?

Android resources can be specified using qualifiers. Android loads the corresponding .xml resource based on declared qualifiers. Qualifiers are simple to get into, but could get quite complicated depending on usage.

To simplify this post, let's stick to using the smallest width qualifier (specifically, 600dp). This ensures that we will use a different layout whenever Android detects that there is at least 600dp available for either length or width of the screen.

Wait, why not check if the device is used horizontally (landscape mode)?

We care about showing the user more data / repositioning views when there is enough screen the app could use. Using land (landscape) as a qualifier only checks if the device is used horizontally. What if the device is actually 4:3, and landscape mode doesn't have as much screen space as we thought?

Consider the gif below. This is a tablet in landscape mode. If we used land as the qualifier for the "bigger" layout, the app would have been only displayed in the "bigger" layout. Instead, we let Android use the "smaller" layout if necessary by using the w600dp-land qualifier.

Multi-Window Demow600dp-land lets Android choose the bigger layout when there is at least 600dp width and if the device is in landscape. The bigger layout is commonly referred to as the Master-Detail Pattern.

Example

Let's look at this example from one of the open source projects I follow, Standard Notes. It has a generic user interface, making it easy for us to pick it apart.

portrait

Here's a regular RecyclerView with a Floating Action Button (FAB). Let's call this screen the NotesListFragment. Upon clicking the FAB, it takes the user to the CreateNotesFragment screen. Let's refer to the "meat" of the window (white area w/ text below the toolbar) as R.id.main_content.

portrait

You can see there's nothing crazy here. I am simply using FragmentTransaction to add whatever is on R.id.main_content with our new CreateNotesFragment. I also used addToBackStack() so that when the user hits the back button, the user is brought back to the NotesListFragment.

supportFragmentManager
   .beginTransaction()
   .add(R.id.note_list_container, noteFragment, TAG_NOTE_FRAGMENT)
   .addToBackStack(null)
   .commit()

Let's call the Activity handling these Fragments as MainActivity, and it's corresponding layout activity_main.xml. activity_main.xml contains:

<Drawer layout + toolbar stuff..>
    <include layout="@layout/main_container" />
</closing tags>

And under layout/main_container.xml:

<FrameLayout
    android:id="@+id/note_list_container"
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    app:layout_behavior="@string/appbar_scrolling_view_behavior"
    android:layout_width="match_parent"
    android:layout_height="match_parent" />

So far, so good!

What about tablet/large landscape layouts? We can use the Master-Detail pattern for this!

w600dp-land

I created a new layout under the w600dp-land folder. Recall earlier that Android will load this layout when the w600dp-land criteria is met. All this new layout contains is:

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:orientation="horizontal"
    android:showDividers="middle"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <FrameLayout
        android:id="@+id/note_list_container"
        app:layout_behavior="@string/appbar_scrolling_view_behavior"
        android:layout_weight="1"
        android:layout_width="0dp"
        android:layout_height="match_parent" />

    <FrameLayout
        android:id="@+id/note_container"
        android:layout_marginTop="?android:attr/actionBarSize"
        android:layout_weight="3"
        android:layout_width="0dp"
        android:layout_height="match_parent" />

</LinearLayout>

Again, nothing too crazy here. I am adding the NotesListFragment to R.id.note_list_container, and replacing CreateNotesFragment to R.id.note_container. (This is a simplified example. In the app I am doing a few checks for savedInstanceState, etc)

supportFragmentManager
    .beginTransaction()
    .add(R.id.note_list_container, noteListFragment, TAG_NOTE_LIST_FRAGMENT)
    .add(R.id.note_container, noteFragment, TAG_NOTE_FRAGMENT)
    .commit()

Phew! That was a lot. However, there's more :D

In the screenshots, the FAB in landscape disappears and turns into a MenuItem, with the text "New Note". Thankfully, qualifiers work on menu layouts as well! In this example, the NotesListFragment layout handles the FAB.

Let's refer to the NotesListFragment regular menu layout as menu_list.xml. This "menu" layout is left empty for our purposes.

but in menu-w600dp-land..

<menu xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto">

    <item
        android:id="@+id/new_note"
        android:title="@string/new_note"
        android:orderInCategory="1"
        app:showAsAction="ifRoom" />
</menu>

We're adding a new menu item with the id new_note whenever the qualifier w600dp-land is met.

How will NotesListFragment know when New Note is clicked? There's quite a bit of boilerplate, but it's relatively short. We want to tell MainActivity that NotesListFragment has its own menu items, and we want to display it. NotesListFragment will also handle click events for New Notes.

Excuse my kotlin.

override fun onCreateView(-stuff-): View? {
    val view = inflater.inflate(R.layout.frag_note_list, container, false)
    setHasOptionsMenu(true)  // tell `MainActivity` to display this fragment's menu
    return view
}

override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
    inflater.inflate(R.menu.note_list, menu) // inflate this menu layout
    super.onCreateOptionsMenu(menu, inflater)
}

override fun onOptionsItemSelected(item: MenuItem?): Boolean {
    when (item?.itemId) {
         R.id.new_note -> startNewNote(selectedTagId) // handle click events for "New Notes'
    }
    return super.onOptionsItemSelected(item)
}

That's it for this example! There's more you can do to create dynamic layouts, but I can tackle that on a different post. I briefly pointed out some of these in my slides.

Sources

tags: code