In this second part of the RecyclerView LayoutManager blog we’ll talk about the changes we made to Google’s basic LinearLayoutManager to arrive at the LayoutManager used in the AD.nl app.

Part one of this series can be found here.
A sample project using this layout manager can be found here.

The AD.nl LayoutManager

Functionality

After comparing the result of the basic LayoutManager sample with our own AD feed we came up with a list of required functionality that was still missing:

  • Views should be laid out in 2 columns. These views should be able to span 1 or 2 columns or 1 or 2 rows. It’s that last part that forced us to create our own LayoutManager. If all views were 1 row high a GridLayoutManager with SpanSizeLookup would have sufficed. This SpanSizeLookup allows you to specify the number of columns occupied by each item.
  • Ability to have the LayoutManager calculate a specific size for a view. The sample simply measures the view’s XML layout and uses those measurements to lay out the view.
    Our apps often use specific ratios to calculate the height of an item based on the available width. This same idea was also required here to measure some of the RecyclerView items.
  • Support for item decorations. A lot of our apps use ItemDecorations to add margins between the RecyclerView items and it was important that we would also be able to use these with our new LayoutManager.
  • Ability to retrieve the currently visible items. Mostly for analytics purposes and lazy loading of pages of items.
  • Option to (smoothly) scroll to a specific position in the feed.
  • Support for removing items. The feed contains optional cards that can be dismissed by the user.

Added concepts

Some new ideas were added to the sample to support our 2 column LayoutManager.

LayoutInfoLookup

We took some inspiration from the GridLayoutManager’s SpanSizeLookup system and created a LayoutInfoLookup interface to retrieve the required info for each item in the RecyclerView. This interface gets implemented in the adapter, which uses the item’s position to return the info that is required to position it.

/**
 * The number of rows occupied by this item.
 * The large item in a trio will have 2, the rest 1.
 */
SpanCount getRowSpan(final int position);

/**
 * The number of columns occupied by this item.
 */
SpanCount getColumnSpan(final int position);

/**
 * Whether to let this view determine its own size or to give it a specific size in the LayoutManager.
 *
 * @return true if the view's XML will determine the size. False if the size will be determined by the LayoutManager.
 */
boolean useViewSize(final int position);

/**
 * Set whether the item should be placed in the left or right column.
 */
LayoutGravity getGravity(final int position);

useViewSize() was something we added because some items in the feed would have a dynamic height depending on their content while others would get a fixed height calculated by the LayoutManager. This method allows the adapter to indicate which measuring method should be used for a view.

Top and bottom positions

Adding a new view to the RecyclerView requires knowing the exact X/Y coordinates of where this new view should be placed. The sample app, being a simple LinearLayout, can look at the first or last child view to determine where the next view should go. If the user scrolls down, simply retrieve the Y value of the bottom of the last child and add the new view below that. For scrolling up a new view can be added above the current first child view.

In our case this wasn’t quite as simple. Because our views are laid out in two columns it is possible that the new view should be added to the right of instead of below the current last child.
What we did to get around this was to store the top and bottom values for both the left and right column. If the next view has to go at the bottom of the left column we only have to use the stored bottomLeft value and add our new view below that.

After a new view has been added the bottom or top values are updated so they can be used for the next view. These values are also updated when scrolling and every time a view is recycled in the recycleViewsOutOfBounds() method.

Implementation

onLayoutChildren

This method still follows the same ideas as the sample app. The changes here were necessary because we are now working with 2 columns instead of the 1 column in the sample.

// Check if this is the initial layout or if there are already child views attached.
final View oldTopView = getChildAt(0);
if (oldTopView == null)
{
    // Clean initial layout. Use the default start values.
    topLeft = topRight = bottomLeft = bottomRight = getPaddingTop();
}
else
{
    // onLayoutChildren can also be called for situations other than the initial layout:
    // a child view requested a new layout, notifyDataSetChanged was called on the adapter, scrollToPosition was used,....
    // We record the top/bottom values here so we can detach all child views and then lay them out again in the same position.
    topLeft = topRight = bottomLeft = bottomRight = getDecoratedTop(oldTopView);
}

Just like in the sample app we make the distinction between a fresh layout and a layout where the RecyclerView already contains child views.

Adding of views has also been modified to continue until both columns have been filled:

// Keep adding views until we run out of items or until the visible area has been filled with views.
for (int i = 0; firstPosition + i < count && (bottomRight < parentBottom || bottomLeft < parentBottom); i++)
{
    final int currentPosition = firstPosition + i;

    addViewForPosition(recycler, currentPosition, true);
}

scrollVerticallyBy

scrollVerticallyBy() has been changed in a similar manner. Instead of simply taking the bottom of the last child view when scrolling down, we compare the two columns and take the smallest value. This position is then used to check if the scroll event created a gap that needs to be filled with a new view.

// Compare the bottom views in each column. Use the one that is going to cause gaps first.
final int top = Math.min(bottomLeft, bottomRight);

imagnad
Scrolling up creates a gap that is immediately filled with a new view

We also have to update these top and bottom values when scrolling the content up or down.

offsetChildrenVertical(scrollBy);

// Update the top and bottom values for the new view positions
topLeft += scrollBy;
topRight += scrollBy;
bottomLeft += scrollBy;
bottomRight += scrollBy;

Adding new views

Adding a new view (either for scrolling up, down or in the onLayoutChildren() method) first uses the addViewForPosition() convenience method.

The LayoutInfoLookup is used here to determine the type of view that needs to be created and added for this position. A ratio is passed in to calculate the height of the new View based on the available width. The scrollingDown boolean is required because adding a view is slightly different depending on the scroll direction.

private void addViewForPosition(final RecyclerView.Recycler recycler, int position, final boolean scrollingDown)
{
    if (layoutInfoLookup.getRowSpan(position) == TWO)
    {
        // 1 column x 2 rows
        addHalfWidthView(recycler, index, ratioTall, scrollingDown);
    }
    else if (layoutInfoLookup.getColumnSpan(position) == TWO)
    {
        // 2 columns x 1 row
        addFullWidthView(recycler, position, scrollingDown);
    }
    else
    {
        // 1 column by 1 row
        addHalfWidthView(recycler, index, ratioStandard, scrollingDown);
    }
}

An example of one of these methods (and its usage of the scrollingDown boolean) is addHalfWidthView().

private void addHalfWidthView(final RecyclerView.Recycler recycler, final int index, final float ratio, final boolean scrollingDown)
{
    // Calculate view size and position

    // A half width view will be laid out in either the left or right column.
    final boolean isLeft = layoutInfoLookup.getGravity(index) == LayoutGravity.LEFT;

    final int tileHeight = (int) (getWidth() / 2 / ratio);

    int left, top, right, bottom;

    if (scrollingDown)
    {
        top = isLeft ? bottomLeft : bottomRight;
        bottom = top + tileHeight;
    }
    else
    {
        bottom = isLeft ? topLeft : topRight;
        top = bottom - tileHeight;
    }

    int middle = (getRecyclerViewLeft() + getRecyclerViewRight()) / 2;

    left = isLeft ? getRecyclerViewLeft() : middle;
    right = isLeft ? middle : getRecyclerViewRight();



    // Add View to RecyclerView

    measureAndAddViewAtIndex(recycler, index, scrollingDown ? getChildCount() : 0, left, top, right, bottom, right - left);



    // Update top and bottom values

    if (scrollingDown)
    {
        if (layoutInfoLookup.getGravity(index) == LayoutGravity.LEFT)
        {
            bottomLeft = bottom;
        }
        else
        {
            bottomRight = bottom;
        }
    }
    else
    {
        if (layoutInfoLookup.getGravity(index) == LayoutGravity.LEFT)
        {
            topLeft = top;
        }
        else
        {
            topRight = top;
        }
    }
}

This method is made up out of three parts:

  • Calculate the view’s size and position. By using the view’s gravity and ratio its size and position can be determined. Adding a view requires its left, top, right and bottom position.
  • Add the view. This calculated position info is passed to the measureAndAddViewAtIndex() method to measure the view and add it to the RecyclerView.
  • Update top and bottom values. Once a new view has been added the top or bottom value(s) need to be updated so they can be used for subsequent views.

measureAndAddViewAtIndex() is a helper method that gets used every time a view needs to be added.

private void measureAndAddViewAtIndex(final RecyclerView.Recycler recycler, final int adapterIndex, final int index, final int left, final int top, final int right, final int bottom, final int occupiedWidth)
{
    final View view = recycler.getViewForPosition(adapterIndex);

    addView(view, index);
    measureChildWithMarginsAndDesiredHeight(view, bottom - top, occupiedWidth);
    layoutDecorated(view, left, top, right, bottom);
}

Modified measure code

The standard LayoutManager uses the measureChildWithMargins() method to calculate a view’s size. Because we use specific ratios and calculate our own size we created a modified version of this method: measureChildWithMarginsAndDesiredHeight().
The code in this method is largely copied from the default implementation. The main difference is the option to specify your own height instead of determining the size by measuring the view.

public void measureChildWithMarginsAndDesiredHeight(View child, int desiredHeight, int widthUsed)
{
    final RecyclerView.LayoutParams lp = (RecyclerView.LayoutParams) child.getLayoutParams();

    // The measureChildWithMargins method uses a private method to get the item decorations, we solve it like this:
    final Rect decorations = new Rect();
    calculateItemDecorationsForChild(child, decorations);
    occupiedWidth += decorations.left + decorations.right;
    final int heightUsed = decorations.top + decorations.bottom;


    final int widthSpec = getChildMeasureSpec(getWidth(),
            getWidthMode(),
            getPaddingLeft() + getPaddingRight() + lp.leftMargin + lp.rightMargin + occupiedWidth,
            lp.width,
            canScrollHorizontally());
    // The standard measureChildWithMargins method uses the height from the view's LayoutParams, but we want to be able to supply our own, calculated height.
    final int heightSpec = getChildMeasureSpec(getHeight(),
            getHeightMode(),
            getPaddingTop() + getPaddingBottom() + lp.topMargin + lp.bottomMargin + heightUsed,
            desiredHeight - heightUsed,
            canScrollVertically());

    child.measure(widthSpec, heightSpec);
}

Recycling views

The recycleViewsOutOfBounds() method is almost identical to the one of the sample code. The only change is this addition at the very end to update the top or bottom position values if a view was removed.

if (removedBottom)
{
    updateColumnValues(false);
}
else if (removedTop)
{
    updateColumnValues(true);
}

Updating top and bottom values

Recycling a view means the currently stored top or bottom position will become incorrect. To fix this updateColumnValues() gets called every time a view is removed. It will go over the currently attached child views to find the new first and last view for each column and store their top or bottom position value.

private void updateColumnValues(final boolean updateTopViews)
{
    boolean foundLeft = false;
    boolean foundRight = false;

    final int startIndex = updateTopValues ? 0 : getChildCount() - 1;
    final int endIndex = updateTopValues ? getChildCount() - 1 : 0;
    final int step = updateTopValues ? 1 : -1;

    for (int i = startIndex; i != endIndex; i += step)
    {
        final View checkingView = getChildAt(i);

        if (layoutInfoLookup.getColumnSpan(getPosition(checkingView)) == TWO)
        {
            if (updateTopValues)
            {
                topLeft = topRight = getDecoratedTop(checkingView);
            }
            else
            {
                bottomLeft = bottomRight = getDecoratedBottom(checkingView);
            }

            break;
        }
        else
        {
            if (layoutInfoLookup.getGravity(getPosition(checkingView)) == LayoutGravity.LEFT)
            {
                if (updateTopValues)
                {
                    topLeft = getDecoratedTop(checkingView);
                }
                else
                {
                    bottomLeft = getDecoratedBottom(checkingView);
                }

                foundLeft = true;
            }
            else
            {
                if (updateTopValues)
                {
                    topRight = getDecoratedTop(checkingView);
                }
                else
                {
                    bottomRight = getDecoratedBottom(checkingView);
                }

                foundRight = true;
            }

            if (foundRight && foundLeft)
            {
                break;
            }
        }
    }
}

Above changes were fairly minor but were enough to provide us with the 2-column behaviour we were looking for.

In the next (and final) part of this series we’ll look at some extra features we added.