ViewPager2 under the Hood

Today, Google released the first alpha of ViewPager2. I had been looking at the code as it was developed in the Android X repository for a while now, and wanted to write a bit about how it works.

Motivations

ViewPager2 builds on top of the versatile RecyclerView to make ViewPager2 a worthy successor to ViewPager.

Per the release notes, there are 3 major features that ViewPager2 adds:

  1. RTL support - ViewPager’s never natively supported RTL. In order to support it, you’d either have to manually reverse the pages yourself, or use a library that does it for you.
  2. Vertical orientation - while you could always use a RecyclerView or the like to achieve this, you don’t get the snapping behavior out of the box without implementing it yourself.
  3. notifyDataSetChanged now works - the old ViewPager’s notifyDataSetChanged didn’t recreate the page (a common workaround was to override getItemPosition to return POSITION_NONE).

I’d argue that a 4th major feature is the ability to inter-op adapters with RecyclerViews. The replacement for PagerAdapter is now RecyclerView.Adapter, which means that any RecyclerView adapter can just be used as is within ViewPager2. It also opens the door to using the more granular notifyDataSetChanged type functions (notifyItemInserted, notifyItemChanged, etc) directly or via DiffUtil.

A Quick Sample

Before diving in to how ViewPager2 is implemented, below is a really quick example of how to use it with views. For more detailed samples, please see the the official ViewPager2 Demos.

import android.graphics.Color
import android.os.Bundle
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.app.AppCompatActivity
import androidx.recyclerview.widget.RecyclerView
import androidx.viewpager2.widget.ViewPager2

class MainActivity : AppCompatActivity() {
  private val data = intArrayOf(Color.BLUE, Color.RED, Color.GRAY, Color.GREEN, Color.YELLOW)

  override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.main)
    val viewPager = findViewById<ViewPager2>(R.id.viewpager)
    // optionally, for vertical orientation
    // viewPager.orientation = ViewPager2.ORIENTATION_VERTICAL
    viewPager.adapter = object : RecyclerView.Adapter<ItemHolder>() {
      override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ItemHolder {
        val view = View(parent.context)
        // limitation: ViewPager2 pages need to fill the page for now
        view.layoutParams = ViewGroup.LayoutParams(
            ViewGroup.LayoutParams.MATCH_PARENT,
            ViewGroup.LayoutParams.MATCH_PARENT)
        return ItemHolder(view)
      }

      override fun getItemCount(): Int = data.size

      override fun onBindViewHolder(holder: ItemHolder, position: Int) {
        holder.itemView.setBackgroundColor(data[position])
      }
    }
  }

  class ItemHolder(view: View) : RecyclerView.ViewHolder(view)
}

main.xml

<?xml version="1.0" encoding="utf-8"?>
<androidx.viewpager2.widget.ViewPager2
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:id="@+id/viewpager"
    android:layout_width="match_parent"
    android:layout_height="match_parent"/>

ViewPager2 can also be used with fragments, using the FragmentStateAdapter. It has 2 required methods - one for the number of items, and one for getting the item.

For more examples, see the official ViewPager2 Demos that are part of AndroidX.

Under the Hood

In order to get an overview of how ViewPager2 is built, let’s start by going back in history to the very first commit for it in AndroidX. The initial version of ViewPager2 was only 175 lines long and serves to give us a good idea of how it is built and how it works.

ViewPager2’s Initial Commit

View Construction

ViewPager2 is a ViewGroup. All of its constructors call an initialize method, which creates a RecyclerView, sets its layout manager to a LinearLayoutManager, and attaches the RecyclerView to itself. It also creates and attaches a PageSnapHelper (this is what supports page snapping).

Measure and Layout

As we’d imagine, there’s an implementation of onMeasure to measure the view. This first asks its internal RecyclerView to measure itself. It adjusts the width and height to add any padding set on the ViewPager2, and uses the maximum between the calculated width/height and the suggested minimum width and height (these suggestions come either from the background (if one is set, and in this case, one isn’t set), or from android:minWidth and android:minHeight if they are defined).

There’s also an onLayout, which lays out the internal RecyclerView while respecting padding (though a note in the source says that this may potentially be delegated to the RecyclerView itself one day to avoid page transition bugs) and setting a gravity of start and top.

Other

There are 2 remaining functions in the initial ViewPager2 implementation that remain in the current master version - onViewAdded, which is called when a view is added to the ViewGroup - this currently throws an exception (with a TODO to add support for decor views).

Finally, there is a setAdapter - this just sets the adapter on the RecyclerView. (In the old initial commit, this wrapped the Adapter with one that ensured that the width and height of the child are MATCH_PARENT. Today, this enforcement is in an enforceChildFillListener method, which it does via a RecyclerView.OnChildAttachStateChangeListener).

(Note - for completion’s sake, the initial commit also had a method to addOnScrollListener, which was just handed down to the RecyclerView).

ViewPager2 Today

Today, ViewPager2 supports many of ViewPager’s APIs that weren’t supported in the initial commit. The vast majority of the new code in today’s ViewPager2 is for adding support for:

Saving and Restoring state

In onSaveInstanceState, the ViewPager2 saves:

A method called onDispatchRestoreInstanceState is what ultimately calls restoreInstanceState during restoration. This method gets the state as a Parcelable from its parent and reads the old RecyclerView’s id. It then replaces the mapping for the RecyclerView’s data from its old id to its current id (since the id is generated by the constructor). This allows the new RecyclerView to restore using the old one’s state.

restoreInstanceState restores the saved values variables back. If the ViewPager2 died while it was scrolling, it temporarily nulls out the OnPageChangeCallback to avoid propagating events. It then posts a message to set the callback back, and calls a scrollToPosition on the RecyclerView to go back to the restored item position. The removing and re-adding of the callback is done to avoid sending out a scroll event when this happens.

Translating RecyclerView scrolls to ViewPager2 events

Like its predecessor, ViewPager2 provides support for getting and setting the current page. How does this work, especially when RecyclerView has no such method for getCurrentItem or setCurrentItem?

RecyclerView allows us to set an RecyclerView.OnScrollListener. This interface has 2 methods:

// newState is one of RecyclerView's constants for:
// SCROLL_STATE_IDLE, SCROLL_STATE_DRAGGING, or SCROLL_STATE_SETTLING
void onScrollStateChanged(RecyclerView recyclerView, int newState);
void onScrolled(RecyclerView recyclerView, int dx, int dy);

ViewPager2 has a helper class called ScrollEventAdapter, which maps calls from RecyclerView.OnScrollListener to calls to OnPageChangeCallback (the ViewPager2 equivalent of ViewPager.OnPageChangeListener). OnPageChangeCallback has 3 methods:

public void onPageScrolled(int position,
                           float positionOffset,
                           @Px int positionOffsetPixels);
public void onPageSelected(int position);
// state is one of ViewPager2's constants for:
// SCROLL_STATE_IDLE, SCROLL_STATE_DRAGGING, or SCROLL_STATE_SETTLING
public void onPageScrollStateChanged(@ScrollState int state)

The ScrollEventAdapter class maintains 3 values in an internal class called ScrollEventValues

  1. position (the adapter position of the first visible item in the RecyclerView - gotten using LayoutManager’s findFirstVisibleItemPosition)
  2. the offset in pixels (this is just “how much of this view is off the screen?”)
  3. the offset as a percentage (dividing the offset by the width for horizontal pagers and by the height for vertical ones).

With a method to calculate the ScrollEventValues, the class can then map the RecyclerView scrolled events into the corresponding ViewPager2 page events.

This serves 2 main purposes:

  1. to maintain the currentItem (for getCurrentItem and setCurrentItem) by means of listening to this class internally, and
  2. to dispatch these events to listeners who call registerOnPageChangeCallback. This also allows PageTransformer to work.

Scrolling (and smooth scrolling) are implemented by calling scrollToPosition or smoothScrollToPosition on the RecyclerView (for longer jumps, it first scrolls to a nearby position and then smooth scrolls to the actual desired position).

Page Transformations

The ViewPager2 allows for transforming pages when they scroll by passing in a PageTransformer through the setPageTransformer method. This method is fired on every onPageScrolled event. The PageTransformer gets called with 2 parameters - the View, and the position - the position is a value of where between -1 and 1 relative to the current page position. 0 means this is the current page. 1 means the page is one entire page to the right, and -1 means the page is one entire page to the left.

So if we scroll from page 1 to page 2 (in an LTR, horizontal ViewPager2), the PageTransformer for page 1 is fired for decreasing values between 0 and -1, and page 2 is fired for increasing values between 1 and 0. If we go the other way (from page 2 back to page 1), page 1 has its transformer fire for values between -1 and 0, and page 2 has its transformer fired for values between 0 and 1.

Here’s a simple example of fading the outgoing page out and the incoming view in:

    val transformer = ViewPager2.PageTransformer { page, position ->
      page.apply {
        page.alpha = 0.25f + (1 - Math.abs(position))
      }
    }

    viewPager.setPageTransformer(transformer)

Here’s a demo of this in action (animated gif):

A quick note about RTL

The RTL support for ViewPager2 comes directly from the existing LinearLayoutManager used by a good portion of RecyclerViews. Like LinearLayout, LinearLayoutManager will draw the views backwards if the RTL flag is set.

Summary

ViewPager2 is a nice replacement for ViewPager built on top of RecyclerView. In addition to the support for RTL, the fact that the adapters are RecyclerView.Adapters is amazing - not just for the fact that it’s easy to inter-op with RecyclerViews, but for the ability to use specific methods like notifyItemChanged, notifyItemInserted, etc directly. DiffUtil can also be used with ViewPager2.

There are some limitations mentioned on the release page, but hopefully these will be resolved as time goes on (especially as this is just the first alpha).

As for features, I hope that one day, ViewPager2 will support more of RecyclerView’s functionality, like ItemDecorations (useful for drawing gaps between pages, for example).

comments powered by Disqus