The following is the first few sections of a chapter from The Busy Coder's Guide to Android Development, plus headings for the remaining major sections, to give you an idea about the content of the chapter.


Advanced RecyclerView

RecyclerView is the “Swiss army knife” of Android selection widgets. You can use it for a wide range of scenarios, well beyond what classic AdapterView widgets — like ListView or GridView — could handle.

In this chapter, we will “go outside the (AdapterView) box” and explore some advanced uses of RecyclerView.

Prerequisites

Understanding this chapter requires that you have read the preceding chapter, on RecyclerView.

RecyclerView as Pager

ViewPager has been used for horizontally-swiped page-at-a-time user interfaces since its debut in 2011.

However, ViewPager is not that flexible:

And so on.

However, as it turns out, RecyclerView can be readily adapted to serve as a ViewPager replacement. Instead of a PagerAdapter, you use an ordinary RecyclerView.Adapter, where your pages are simple views. RecyclerView itself is far more flexible than is ViewPager, giving you a stronger foundation for more advanced paging scenarios.

Using RecyclerViewPager

The original solution for using RecyclerView as a ViewPager replacement came in the form of a third-party library, com.github.lsjwzh.RecyclerViewPager. This library offers a RecyclerViewPager subclass of RecyclerView that offers the page-at-a-time swiping metaphor.

The RecyclerViewPager/PlainRVP sample project illustrates its use. This is another rendition of the 10-EditText-widgets pager that was used in the chapter on ViewPager, swapping in RecyclerViewPager for the ViewPager.

Adding the Dependency

The com.github.lsjwzh.RecyclerViewPager library is not on JCenter or Maven Central. Instead, it is on jitpack.io, an artifact repository that builds artifacts directly from GitHub source repositories. So, to use this library, we need to add jitpack.io as a repository, in addition to adding the RecyclerViewPager library itself:

apply plugin: 'com.android.application'

repositories {
  maven { url "https://jitpack.io" }
}

dependencies {
    implementation 'com.android.support:recyclerview-v7:25.1.0'
    implementation 'com.github.lsjwzh.RecyclerViewPager:lib:v1.1.2'
}

android {
    compileSdkVersion 25
    buildToolsVersion '26.0.2'

    defaultConfig {
        minSdkVersion 15
        targetSdkVersion 25
    }
}

Using the Widget

In the equivalent ViewPager sample app, the main.xml layout resource held the ViewPager. In this sample, it holds the RecyclerViewPager (or, more accurately, the com.lsjwzh.widget.recyclerviewpager.RecyclerViewPager, since the class name needs to be fully-qualified since it is coming from a library):

<?xml version="1.0" encoding="utf-8"?>
<com.lsjwzh.widget.recyclerviewpager.RecyclerViewPager android:id="@+id/pager"
  xmlns:android="http://schemas.android.com/apk/res/android"
  xmlns:app="http://schemas.android.com/apk/res-auto"
  android:layout_width="match_parent"
  android:layout_height="match_parent"
  android:clipToPadding="false"
  android:layout_margin="@dimen/pager_padding"
  app:rvp_singlePageFling="true"
  app:rvp_triggerOffset="0.1" />

The app:rvp_singlePageFling indicates that we want to limit the user to switch one page at a time, rather than a long fling gesture resulting in moving through many pages at once. The app:rvp_triggerOffset attribute is undocumented but appears to control how much of a swipe gesture is necessary to trigger a page change.

Populating the Pages

With ViewPager, you supply the pages via a PagerAdapter, typically a FragmentPagerAdapter or a FragmentStatePagerAdapter. With RecyclerViewPager, you supply the pages via a RecyclerView.Adapter, just as you would with any other RecyclerView.

So, in onCreate() of the MainActivity, we get the RecyclerViewPager, hand it a horizontal LinearLayoutManager, create a PageAdapter, and attach that PageAdapter to the RecyclerViewPager:

  @Override
  public void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.main);

    RecyclerViewPager pager=(RecyclerViewPager)findViewById(R.id.pager);

    pager.setLayoutManager(new LinearLayoutManager(this,
      LinearLayoutManager.HORIZONTAL, false));
    adapter=new PageAdapter(pager, getLayoutInflater());
    pager.setAdapter(adapter);
  }

By using a horizontal LinearLayoutManager, the RecyclerViewPager will behave akin to a regular ViewPager, with navigation occurring via horizontal swipes. Want a vertical ViewPager? Replace the horizontal LinearLayoutManager with a vertical one, and you are set.

Our PageAdapter is a RecyclerView.Adapter, for a RecyclerView.ViewHolder named PageController. The basic setup for PageAdapter is not that different than any other RecyclerView.Adapter:

class PageAdapter extends RecyclerView.Adapter<PageController> {
  private static final String STATE_BUFFERS="buffers";
  private static final int PAGE_COUNT=10;
  private final RecyclerViewPager pager;
  private final LayoutInflater inflater;
  private ArrayList<String> buffers=new ArrayList<>();

  PageAdapter(RecyclerViewPager pager, LayoutInflater inflater) {
    this.pager=pager;
    this.inflater=inflater;

    for (int i=0;i<10;i++) {
      buffers.add("");
    }
  }

  @Override
  public PageController onCreateViewHolder(ViewGroup parent, int viewType) {
    return(new PageController(inflater.inflate(R.layout.editor, parent, false)));
  }

  @Override
  public void onBindViewHolder(PageController holder, int position) {
    holder.setText(buffers.get(position));
  }

  @Override
  public int getItemCount() {
    return(PAGE_COUNT);
  }

In this case, our model data is an ArrayList of String objects, representing the text that the user enters into each page’s EditText. PAGE_COUNT caps the number of editors (and pages) at 10, and so we initialize 10 buffers in the PageAdapter constructor.

The layout used for the pages — inflated by onCreateViewHolder() – is just a full-page multi-line EditText widget:

<EditText xmlns:android="http://schemas.android.com/apk/res/android"
  android:id="@+id/editor"
  android:layout_width="match_parent"
  android:layout_height="match_parent"
  android:inputType="textMultiLine"
  android:gravity="left|top"
  />

PageController is a fairly basic RecyclerView.ViewHolder, wrapping our EditText and offering a getter and setter for manipulating the text in the editor:

package com.commonsware.android.rvp;

import android.support.v7.widget.RecyclerView;
import android.view.View;
import android.widget.EditText;

class PageController extends RecyclerView.ViewHolder {
  private final EditText editor;

  PageController(View itemView) {
    super(itemView);

    editor=(EditText)itemView.findViewById(R.id.editor);
  }

  void setText(String text) {
    editor.setText(text);
    editor.setHint(editor.getContext().getString(R.string.hint,
      getAdapterPosition()+1));
  }

  String getText() {
    return(editor.getText().toString());
  }
}

In case the buffer is empty (as it is at the outset), we also set the hint of the EditText to be the current page’s index, adding one to adjust the range to start at 1 rather than 0. The hint text itself is in a string resource, with a %d placeholder for the page number:

<?xml version="1.0" encoding="utf-8"?>
<resources>
  <string name="app_name">RVP Demo</string>
  <string name="hint">Editor #%d</string>

</resources>

We use the N-parameter getString() method to not only retrieve the hint string resource but run it through String.format() to populate the placeholders, in this case using getAdapterPosition() to determine our page number.

Dealing with Recycling

RecyclerView wants to recycle its items. That is in contrast to how the stock PagerAdapter implementation work:

If our pages were read-only, we would not have to worry about recycling. This is how many RecyclerView implementations work — they just focus on binding the right data into the right RecyclerView.ViewHolder at the right time, based on calls to onBindViewHolder in the RecyclerView.Adapter.

However, when the RecyclerView items are interactive, we need to make sure that we hold onto the changed data, rather than having it be overwritten when we bind fresh data into the recycled item’s views.

In PageAdapter, we handle this by overriding onViewDetachedFromWindow(), which is called when the views of a PageController are no longer part of our activity’s window. Typically, this will occur as part of scrolling. In our case, we use this opportunity to grab the current contents of that EditText widget and update our buffers data model to match:

  @Override
  public void onViewDetachedFromWindow(PageController holder) {
    super.onViewDetachedFromWindow(holder);

    buffers.set(holder.getAdapterPosition(), holder.getText());
  }

Alternatively, you could aim to deal with this more in “real time”, such as by using a TextWatcher to update the model as the user types. That adds a fair bit of overhead, though.

Dealing with Configuration Changes

We need to make sure that we do not lose what the user types into the pages when we undergo a configuration change. Since our model is a simple ArrayList of String objects, we can use the saved instance state Bundle to hold onto the in-flight information.

A RecyclerView.Adapter does not have its own onSaveInstanceState() method, but we can add one, then call it from MainActivity:

  @Override
  protected void onSaveInstanceState(Bundle state) {
    super.onSaveInstanceState(state);

    Bundle adapterState=new Bundle();

    adapter.onSaveInstanceState(adapterState);
    state.putBundle(STATE_ADAPTER, adapterState);
  }

Here, MainActivity provides a fresh Bundle to the adapter. This way, values that the adapter wishes to save in the instance state will not collide with anything else the activity would want to save in the instance state, due to accidental key collisions. In this case, this may well be superfluous, but it is a worthwhile practice.

The challenge in our PageAdapter is that buffers only has text from those PageController objects that have been recycled. That will not include the currently-visible page or possibly some adjacent pages.

So, we iterate over all pages and call findViewHolderForAdapterPosition() on the RecyclerView itself. This will return null for any positions for which no PageController is presently allocated, or the PageController for the position for those positions that are actively being used. For those latter ones, we update the buffers to reflect whatever is in the EditText widgets, saving that into the instance state Bundle:

  void onSaveInstanceState(Bundle state) {
    for (int i=0;i<PAGE_COUNT;i++) {
      PageController holder=
        (PageController)pager.findViewHolderForAdapterPosition(i);

      if (holder!=null) {
        buffers.set(i, holder.getText());
      }
    }

    state.putStringArrayList(STATE_BUFFERS, buffers);
  }

MainActivity has a corresponding onRestoreInstanceState() method:

  @Override
  protected void onRestoreInstanceState(Bundle state) {
    super.onRestoreInstanceState(state);

    adapter.onRestoreInstanceState(state.getBundle(STATE_ADAPTER));
  }

That delegates the work to the onRestoreInstanceState() method on the PageAdapter:

  void onRestoreInstanceState(Bundle state) {
    buffers=state.getStringArrayList(STATE_BUFFERS);
  }

This sets up our buffers for use in populating pages again.

Using SnapHelper

RecyclerViewPager was first released in 2014. Since then, RecyclerView and its supporting classes have evolved. Now, you can get much of the functionality of RecyclerViewPager with an ordinary RecyclerView, with the assistance of SnapHelper. As Lisa Wray profiled in a droidcon NYC 2016 presentation, SnapHelper is a utility class that forces swipe gestures to “snap” to certain locations or boundaries. And, there is a PagerSnapHelper that, in conjunction with properly-configured RecyclerView and items, gives you ViewPager-like behavior.

The RecyclerViewPager/PlainSnap sample project is a clone of the PlainRVP sample, except that the RecyclerViewPager is replaced by a RecyclerView and a PagerSnapHelper.

There are three requirements of PagerSnapHelper. Two are tied to the layouts: both the RecyclerView and its items need to have match_parent for android:layout_width and android:layout_height. That was how PlainRVP was set up already, though PlainSnap swaps in a RecyclerView for the RecyclerViewPager in main.xml:

<?xml version="1.0" encoding="utf-8"?>
<android.support.v7.widget.RecyclerView android:id="@+id/pager"
  xmlns:android="http://schemas.android.com/apk/res/android"
  android:layout_width="match_parent"
  android:layout_height="match_parent"
  android:layout_margin="@dimen/pager_padding"
  android:clipToPadding="false" />

The other requirement is that we create an instance of PagerSnapHelper and call attachToRecyclerView() on it, supplying our RecyclerView. This is handled in an updated MainActivity:

package com.commonsware.android.rvp;

import android.app.Activity;
import android.os.Bundle;
import android.support.v7.widget.LinearLayoutManager;
import android.support.v7.widget.PagerSnapHelper;
import android.support.v7.widget.RecyclerView;
import android.support.v7.widget.SnapHelper;

public class MainActivity extends Activity {
  private static final String STATE_ADAPTER="adapter";
  private final SnapHelper snapperCarr=new PagerSnapHelper();
  private PageAdapter adapter;

  @Override
  public void onCreate(Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.main);

    RecyclerView pager=(RecyclerView)findViewById(R.id.pager);

    pager.setLayoutManager(new LinearLayoutManager(this,
      LinearLayoutManager.HORIZONTAL, false));
    snapperCarr.attachToRecyclerView(pager);

    adapter=new PageAdapter(pager, getLayoutInflater());
    pager.setAdapter(adapter);
  }

  @Override
  protected void onSaveInstanceState(Bundle state) {
    super.onSaveInstanceState(state);

    Bundle adapterState=new Bundle();

    adapter.onSaveInstanceState(adapterState);
    state.putBundle(STATE_ADAPTER, adapterState);
  }

  @Override
  protected void onRestoreInstanceState(Bundle state) {
    super.onRestoreInstanceState(state);

    adapter.onRestoreInstanceState(state.getBundle(STATE_ADAPTER));
  }
}

We hold onto the PagerSnapHelper in a field, to ensure that it will not be garbage-collected unexpectedly. Probably the PagerSnapHelper has sufficient connections to the RecyclerView to ensure that it will stay around as long as its associated RecyclerView does, but that is not apparent from the API or the documentation.

Beyond that, we configure the RecyclerView much as we had configured the RecyclerViewPager, and our PageAdapter and PageController are largely unaffected by the UI switch. In the end, we wind up once again with page-at-a-time horizontal swiping, though this time we can skip the third-party library.

Adding Tabs

Many times, with a pager-style interface, we want an indicator to help the user understand where they are within the range of pages offered by the pager. One of the more popular indicator styles is tabs, as those also provide an alternative navigation option, with the user tapping on tabs to switch to particular pages.

For adding tabs to a RecyclerView-powered pager, you need a tab implementation that is not tied inextricably to ViewPager, the way PagerTabStrip is. At the same time, you need one that is not tied inextricably to some other particular UI setup, the way that FragmentTabHost is. Instead, you need tabs that “stick to their knitting” and focus solely on handling the tab UI, giving you the hooks necessary to update your UI based on tab changes, and to update the tabs based on other UI changes.

TabLayout, from the Design Support library, is one such tab implementation. While it offers hooks into ViewPager, those are optional. You have two main options for using TabLayout:

  1. Literally use the version from the Design Support library, which will require you to use appcompat-v7. This works back to API Level 7, as does RecyclerView itself.
  2. Use the TabLayout from the CWAC-CrossPort library, which is the official TabLayout code, with all references to appcompat-v7 replaced by references to Theme.Material and related native items. However, this limits this cross-ported TabLayout to API Level 21 and higher (Android 5.0).

The RecyclerViewPager/TabSnap sample project is a clone of the PlainSnap sample, with tabs added, via the TabLayout from CWAC-CrossPort. As a result, we need the CWAC-CrossPort dependency and need to raise our minSdkVersion to 21:

apply plugin: 'com.android.application'

repositories {
  maven {
    url "https://s3.amazonaws.com/repo.commonsware.com"
  }
}

dependencies {
    implementation 'com.android.support:recyclerview-v7:25.1.0'
    implementation 'com.commonsware.cwac:crossport:0.0.2'
}

android {
    compileSdkVersion 25
    buildToolsVersion '26.0.2'

    defaultConfig {
        minSdkVersion 21
        targetSdkVersion 25
        applicationId 'com.commonsware.cwac.rvp.tabsnap'
    }
}

The tabs themselves can then go above the RecyclerView, in a vertical LinearLayout:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
  android:layout_width="match_parent"
  android:layout_height="match_parent"
  xmlns:app="http://schemas.android.com/apk/res-auto"
  android:orientation="vertical">

  <com.commonsware.cwac.crossport.design.widget.TabLayout
    android:id="@+id/tabs"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    app:tabMode="scrollable"/>

  <android.support.v7.widget.RecyclerView
    android:id="@+id/pager"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:layout_margin="@dimen/pager_padding"
    android:clipToPadding="false" />
</LinearLayout>

Next, we need to set up the tab contents in onCreate() of MainActivity. To do that, we get our hands on the TabLayout using findViewById(), then iterate through the items in the PageAdapter to set up tabs for each:

    final TabLayout tabs=(TabLayout)findViewById(R.id.tabs);

    for (int i=0;i<adapter.getItemCount();i++) {
      tabs.addTab(tabs.newTab().setText(adapter.getTabText(this, i)));
    }

We ask the PageAdapter for the text to show in the tab, via a getTabText() method:

  String getTabText(Context ctxt, int position) {
    return(PageController.getTitle(ctxt, position));
  }

That, in turn, delegates to a static version of the getTitle() method on PageController, to fill in the string resource template with the proper page number:

  static String getTitle(Context ctxt, int position) {
    return(ctxt.getString(R.string.hint, position+1));
  }

We now need to add code in onCreate() of MainActivity to tie the navigation together:

This is handled by event listeners:

    tabs.addOnTabSelectedListener(new TabLayout.OnTabSelectedListener() {
      @Override
      public void onTabSelected(TabLayout.Tab tab) {
        pager.smoothScrollToPosition(tab.getPosition());
      }

      @Override
      public void onTabUnselected(TabLayout.Tab tab) {
        // unused
      }

      @Override
      public void onTabReselected(TabLayout.Tab tab) {
        // unused
      }
    });

    pager.setOnScrollListener(new RecyclerView.OnScrollListener() {
      @Override
      public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
        int tab=layoutManager.findFirstCompletelyVisibleItemPosition();

        if (tab>=0 && tab<tabs.getTabCount()) {
          tabs.getTabAt(tab).select();
        }
      }
    });

When a tab is selected, our anonymous TabLayout.OnTabSelectedListener implementation will get control in onTabSelected(). There, we tell the RecyclerView to scroll to show a particular position, tied to the position of the selected tab.

Similarly, when the user scrolls the pager, we need to update the tabs to show the new selection. To do that, we take advantage of the findFirstCompletelyVisibleItemPosition() method on LinearLayoutManager. As the (lengthy) method name suggests, this returns the position of the first item that is completely visible within the pager. This might return –1, if we are in the middle of a swipe, as no item may be completely visible at that point. But, once we get a plausible value, we tell the TabLayout to select that tab.

RecyclerViewPager has a more sophisticated algorithm for integrating with the official Design Support library implementation of TabLayout. RecyclerViewPager calculates the position of the tab highlight, based upon the current swipe position, and updates that. This provides visual feedback within the tabs while the swipe is going on. The approach shown in the sample app has the effect of only updating the tabs once the swipe is completed.

Declaring a LayoutManager in the Layout

The preview of this section was whisked away by a shark-infested tornado.

Transcript Mode

The preview of this section was fed to a gremlin, after midnight.