Building a RecyclerView LayoutManager – Part 2

This article is Part 2 in our series. Here are links to Part 1 and Part 3 as well.

In the last post, we walked through the core functionality necessary for building a RecyclerView LayoutManager. In this post, we are going to add support for a few additional features that the average adapter-based view is expected to have.

A reminder that the entire sample application can be found here on GitHub.

Supporting Item Decorations

RecyclerView has a really neat feature in which an RecyclerView.ItemDecoration instance can be supplied to do custom drawing alongside the child view content, as well as provide insets (margins) that will apply to the child views without the need for modifying layout parameters. The latter places a constraint on how the children should be laid out that the LayoutManager implementation must support.

The RecyclerPlayground repository uses a few different decorators in the examples to illustrate how they are implemented.

LayoutManager gives us helper methods to account for decorations so we don’t have to think about them:

  • To get the left edge of a child view, use getDecoratedLeft() instead of child.getLeft()

  • To get the top edge of a child view, use getDecoratedTop() instead of child.getTop()

  • To get the right edge of a child view, use getDecoratedRight() instead of child.getRight()

  • To get the bottom edge of a child view, use getDecoratedBottom() instead of child.getBottom()

  • Use measureChild() or measureChildWithMargins() instead of child.measure() to measure new views coming from the Recycler.

  • Use layoutDecorated() instead of child.layout() to lay out new views coming from the Recycler.

  • Use getDecoratedMeasuredWidth() or getDecoratedMeasuredHeight() instead of child.getMeasuredWidth() or child.getMeasuredHeight() to get the measurements of a child view.

As long as you take into account using the proper methods for getting view properties and measurements, RecyclerView will handle dealing with decorations so you don’t have to.

Data Set Changes

When the attached RecyclerView.Adapter triggers an update via notifyDataSetChanged(), the LayoutManager will be responsible for updating the layout in the view. In this case, onLayoutChildren() will be called again. To support this we need to make some adjustments to our sample to make the distinction between a fresh layout and a layout change due to an adapter update. Below is the fully fleshed out method from the FixedGridLayoutManager:

@Override
public void onLayoutChildren(RecyclerView.Recycler recycler,
                             RecyclerView.State state) {
    //We have nothing to show for an empty data set but clear any existing views
    if (getItemCount() == 0) {
        detachAndScrapAttachedViews(recycler);
        return;
    }

    //...on empty layout, update child size measurements
    if (getChildCount() == 0) {
        //Scrap measure one child
        View scrap = recycler.getViewForPosition(0);
        addView(scrap);
        measureChildWithMargins(scrap, 0, 0);

        /*
         * We make some assumptions in this code based on every child
         * view being the same size (i.e. a uniform grid). This allows
         * us to compute the following values up front because they
         * won't change.
         */
        mDecoratedChildWidth = getDecoratedMeasuredWidth(scrap);
        mDecoratedChildHeight = getDecoratedMeasuredHeight(scrap);

        detachAndScrapView(scrap, recycler);
    }

    updateWindowSizing();

    int childLeft;
    int childTop;
    if (getChildCount() == 0) { //First or empty layout
        /*
         * Reset the visible and scroll positions
         */
        mFirstVisiblePosition = 0;
        childLeft = childTop = 0;
    } else if (getVisibleChildCount() > getItemCount()) {
        //Data set is too small to scroll fully, just reset position
        mFirstVisiblePosition = 0;
        childLeft = childTop = 0;
    } else { //Adapter data set changes
        /*
         * Keep the existing initial position, and save off
         * the current scrolled offset.
         */
        final View topChild = getChildAt(0);
        if (mForceClearOffsets) {
            childLeft = childTop = 0;
            mForceClearOffsets = false;
        } else {
            childLeft = getDecoratedLeft(topChild);
            childTop = getDecoratedTop(topChild);
        }

        /*
         * Adjust the visible position if out of bounds in the
         * new layout. This occurs when the new item count in an adapter
         * is much smaller than it was before, and you are scrolled to
         * a location where no items would exist.
         */
        int lastVisiblePosition = positionOfIndex(getVisibleChildCount() - 1);
        if (lastVisiblePosition >= getItemCount()) {
            lastVisiblePosition = (getItemCount() - 1);
            int lastColumn = mVisibleColumnCount - 1;
            int lastRow = mVisibleRowCount - 1;

            //Adjust to align the last position in the bottom-right
            mFirstVisiblePosition = Math.max(lastVisiblePosition
                    - lastColumn - (lastRow * getTotalColumnCount()), 0);

            childLeft = getHorizontalSpace()
                    - (mDecoratedChildWidth * mVisibleColumnCount);
            childTop = getVerticalSpace()
                    - (mDecoratedChildHeight * mVisibleRowCount);

            //Correct overscroll when shifting to the bottom-right
            // This happens on data sets too small to scroll in a direction.
            if (getFirstVisibleRow() == 0) {
                childTop = Math.min(childTop, 0);
            }
            if (getFirstVisibleColumn() == 0) {
                childLeft = Math.min(childLeft, 0);
            }
        }
    }

    //Clear all attached views into the recycle bin
    detachAndScrapAttachedViews(recycler);

    //Fill the grid for the initial layout of views
    fillGrid(DIRECTION_NONE, childLeft, childTop, recycler);
}

Our implementation determines if this is a new layout or an update based on whether we have child views attached already. In the case of an update, the first visible position (i.e. the top-left view, which we track continuously) and the current scrolled x/y offset give us enough information to do a new fillGrid() while preserving that the same item position remain in the top-left.

There are a few special cases we handle as well.

  • When the new data set is too small to scroll, the layout is reset with position 0 in the top-left.

  • If the new data set is smaller, and preserving the current position would cause the layout to be scrolled beyond the allowed boundary (on the right and/or bottom). Here we adjust the first position so the layout aligns with the bottom-right of the grid.

onAdapterChanged()

This method provides you an additional opportunity to reset the layout in the event that the entire adapter is swapped out (i.e. setAdapter() is invoked again on the view). In this event, it’s safer to assume that the views returned will be completely different than from the previous adapter. Therefore, our example simply removes all current views (without recycling them):

@Override
public void onAdapterChanged(RecyclerView.Adapter oldAdapter,
                             RecyclerView.Adapter newAdapter) {
    //Completely scrap the existing layout
    removeAllViews();
}

The view removal will trigger a new layout pass, and when onLayoutChildren() is called again, our code can perform a fresh layout since there are no longer any child views attached.

Scroll to Position

Another important feature you will likely want from your LayoutManager is the ability to tell the view to scroll to a specific position. This can be done with or without animation, and there is a callback for each.

scrollToPosition()

This method is invoked from the RecyclerView when the layout should immediately update with the given position as the first visible item. In a vertical list, the element would be placed at the top; in a horizontal list, it would generally be on the left. In our grid, the “selected” position will be placed at the top-left of the view.

@Override
public void scrollToPosition(int position) {
    if (position >= getItemCount()) {
        Log.e(TAG, "Cannot scroll to "+position+", item count "+getItemCount());
        return;
    }

    //Ignore current scroll offset, snap to top-left
    mForceClearOffsets = true;
    //Set requested position as first visible
    mFirstVisiblePosition = position;
    //Trigger a new view layout
    requestLayout();
}

With a proper implementation of onLayoutChildren(), this can be as simple as updating the target position and triggering a new fill.

smoothScrollToPosition()

In the case where the selection should be animated, we need to take a slightly different approach. The contract of this method is for the LayoutManager to construct an instance of a RecyclerView.SmoothScroller, and begin the animation by invoking startSmoothScroll() before the method returns.

RecyclerView.SmoothScroller is an abstract class with an API that consists of four required methods:

  • onStart(): Triggered when the scroller animation begins.

  • onStop(): Triggered when the scroller animation ends.

  • onSeekTargetStep(): Invoked incrementally as the scroller searches for the target view. The implementation is responsible for reading the provided dx/dy and updating how far the view should actually scroll in both directions. — A RecyclerView.SmoothScroller.Action instance is passed to this method. Notify the view how it should animate the next increment by passing a new dx, dy, duration, and Interpolator to the action’s update() method.

    The framework will warn you if you are taking too long to animate (i.e. your increments are too small); try to tune your animation steps to match a standard animation duration from the framework.
  • onTargetFound(): Called only once, after a view for the target position has been attached. This is one final chance to animate the target view to its exact position. — Internally, this uses findViewByPosition() from the LayoutManager to determine when the view is attached. If your LayoutManager is efficient about mapping views to positions, override this method to improve performance. The default implementation iterates over all child views…all the time.

You can provide your own scroller implementation if you really want to fine-tune your scrolling animations. We have chosen to use the framework’s LinearSmoothScroller instead, which implements the callback work for us. We only need to implement a single method, computeScrollVectorForPosition(), to tell the scroller the initial direction and approximate distance it needs to travel to get from its current location to the target location.

@Override
public void smoothScrollToPosition(RecyclerView recyclerView,
                                   RecyclerView.State state,
                                   final int position) {
    if (position >= getItemCount()) {
        Log.e(TAG, "Cannot scroll to "+position+", item count "+getItemCount());
        return;
    }

    /*
     * LinearSmoothScroller's default behavior is to scroll the contents until
     * the child is fully visible. It will snap to the top-left or bottom-right
     * of the parent depending on whether the direction of travel was positive
     * or negative.
     */
    final Context context = recyclerView.getContext();
    LinearSmoothScroller scroller = new LinearSmoothScroller(context) {
        /*
         * LinearSmoothScroller, at a minimum, just need to know the vector
         * (x/y distance) to travel in order to get from the current positioning
         * to the target.
         */
        @Override
        public PointF computeScrollVectorForPosition(int targetPosition) {
            final int rowOffset = getGlobalRowOfPosition(targetPosition)
                    - getGlobalRowOfPosition(mFirstVisiblePosition);
            final int columnOffset = getGlobalColumnOfPosition(targetPosition)
                    - getGlobalColumnOfPosition(mFirstVisiblePosition);

            return new PointF(columnOffset * mDecoratedChildWidth,
                              rowOffset * mDecoratedChildHeight);
        }
    };
    scroller.setTargetPosition(position);
    startSmoothScroll(scroller);
}

This implementation, similar to the existing behavior of ListView, will stop scrolling as soon as the view becomes fully visible; whether that be on the left, top, right, or bottom of the RecyclerView.

Now What?

You mean that wasn’t enough? Things are starting to look pretty good! In fact, for many the implementation could be considered complete. But we’re going to go just one step further.
In the next, and final post of this series, we will look at supporting animations for data set changes in your LayoutManager.

 

Dave

Dave Smith is an embedded software developer based in Denver, CO and head geek Wireless Designs, LLC. He has been focused on the Android platform since 2009. If you would like to hear more from Dave, you can follow him on Twitter @devunwired. You can also find him on Google+.

 
  • Android Developer

    I have a small question regarding scrolling:
    Does the new RecyclerView support scrolling itself by Y pixels, and not just to an item within itself?

  • Yes, RecyclerView has a smoothScrollBy(dx, dy) method as well. Any LayoutManager will support this without additional work because it calls on the same incremental scrolling methods implemented to support user scrolling.

  • Android Developer

    I see. so it’s like on ListView, but does it also have a duration, or a way to scroll by Y pixels immediately ?
    Also, do you know of any way to use RecyclerView on pre-L versions of Android?

  • There are no duration contols, but just like any view scrollBy() still exists as well for adjusting the view without an animation. The scrollTo() method is not supported since RecyclerView (or any adapter view) doesn’t have a concept of an absolute x/y scroll position…everything is relative to the adapter positions.

    There is a note in the GitHub repo describing how to get RecyclerView working on pre-L for testing. Currently, the preview SDK license prevents you from shipping any code that is a part of the preview until “L” releases.

  • Android Developer

    Sure scrollBy and scrollTo works on it?

    Also, about Github, that’s too bad. I hoped that since it’s working well (I assume) and since it’s open source (and by Google), it should have been possible. Surely it will be available when it’s ready…

  • Alexandre Doubov

    Hey Dave,

    Thanks for sharing this.

    After the official release, flinging the grid around the boundaries stops the RecyclerView’s scrolling in both directions upon hitting an edge on either axis. One of the workarounds is to return the original value of dx/dy unaltered, but it messes up the edge Glows.

    Going through the source for RecyclerView, my guess is that the problematic lines are the following:

    if (ViewCompat.getOverScrollMode(this) != ViewCompat.OVER_SCROLL_NEVER) {
    considerReleasingGlowsOnScroll(x, y);
    pullGlows(overscrollX, overscrollY);
    }

    if (!awakenScrollBars()) {
    invalidate();
    }

    It doesn’t look like we can override that functionality though. Do you see any other solutions?

    Thanks,
    Alex

  • This looks to be an implementation bug in the latest release. The scrolling implementation for a fling is no longer the same as that for a drag. I will try to gather more information, but my position at this point is that this should be reported to Google.

  • Yarian Gomez

    Hey Dave, Great series of posts. Was curious. If you read the two bullets you wrote after: “There are a few special cases we handle as well.” What is the difference between the two cases?

  • Yarian –

    The first point means that the data set is small enough for all the views to fit on screen…no scrolling is allowed in any direction.

    The second is a bit more complicated. Since the logic attempts to keep the same item position in the top-left corner, a significant enough reduction in data set might cause the grid to shrink to the point where laying out the remaining tiles with the same position in the top-left would overscroll the grid to have whitespace on the right and/or bottom. The correction for this is to offset the grid to be exactly aligned with the right and/or bottom of the new grid at the expense of maintaining the top-left position.

  • Yarian Gomez

    Thanks for the quick response. I was struggling for the second part. Could you please you look at attached image and see if I am understanding it correctly? http://i.imgur.com/fGQpTkW.png

  • If I’m reading your image correctly, yes. When the data shrinks (columns 1 -> 2), the data is overscrolled too far up (column 2), so we have to adjust the scroll to look like column 3 instead.

  • Yarian Gomez

    Great. Thanks a lot for your help.

  • Antonis Filippidis

    Hello Dave, Thanks for these series of posts in RecyclerView. I am trying to implement your FixedGridLayoutManager with a static column at the beginning with no luck. Any pointers? I instead tried with a vertical LinearLayoutManager list at the beginning of the layout and synchronized scrolling but it feels like a hack.

  • oakpip

    how to smooth scroll with offset ?? like linearLayoutManager.scrollToPositionWithOffset(0, 150);

    I want to smooth scroll position with offset.

  • Chris Margonis

    Hey Dave, great article series. I based a similar implementation on your code. Thanks 🙂

  • Android Developer

    Nice post….but i am getting one error…getDecoratedMeasuredWidth returns 0 for me…i dont know how to fix this….please help me

  • Sairam Rachapudi

    Hi Dave Please help me in below code. I wrote a sample layoutmanager and it is not showing up any views.

    @Override
    public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
    if(getItemCount()>0) {
    for (int i = 0; i =getHeight()){
    break;
    }
    }
    }
    }