In this series of posts we’ll talk about Android’s LayoutManager. We’ll explain it’s role and functionality and talk about how we used it to improve a core function of one of our apps.

This first part will talk about the LayoutManager’s concepts and how they are used to display a simple list of items in a RecyclerView.

The AD.nl app

Earlier this year, we released a completely new version of the AD.nl app. This update rebuilt the existing app from the ground up and featured a new design with a focus on large photos and a streamlined navigation. The app was also brought up to date with the modern Android standards such as runtime permissions and multi-window support.

The new version has a very visual news feed with multiple types of “tiles” to show the news items in many different forms. There’s a couple of special tiles such as a horizontal carousel (ViewPager) with the most important news or a grouped collection of related articles, but most of the feed consists of smaller tiles that can be shown in 3 different variants.

Variants

These 3 variants can then be displayed in 3 different sizes (normal, wide and tall) to form 4 possible compositions:

  • UNO: A single full width tile.
  • DUO: Two half width tiles placed side by side.
  • TRIO LEFT: A double height tile on the left with two vertically stacked tiles beside it.
  • TRIO RIGHT: Same as TRIO LEFT, but with the tall tile on the right side.

Groups

The problem

The newsfeed in AD is built using a RecyclerView with a variety of different view types.

Due to a combination of time pressure, changing requirements and lack of experience with layout managers, we implemented this feed with a standard LinearLayoutManager. This worked, but had a couple of big drawbacks.

Using a LinearLayoutManager meant that every item had to be a full width rectangular shape. This is fine for the UNO (and special) tiles, but forced us to place the DUO and TRIO groups into a single item in the RecyclerView. This works, but because every item in those groups can be one of 3 variants (STANDARD, PICTURE, TEXT) you quickly start running into problems. A DUO item with 2 STANDARD tiles for example is not the same as a DUO with 1 STANDARD and 1 PICTURE tile.

At this point we saw two options:

  • Create a unique RecyclerView view type for every DUO and TRIO combination.
  • Create 3 layouts (DUO, TRIO LEFT, TRIO RIGHT) and dynamically modify their views based on the news items they had to display.

Option 1 was simply not feasable. The DUO alone would have resulted in 9 different view types and the TRIO would have added 54 more. This meant only option 2 remained.

However, this approach goes against the ViewHolder pattern. You want to create your view once and then reuse that view by simply changing its content (like text and images).
Our app had us change view visibilities, layout weights and more to force the layout into the desired appearance.

This code worked but proved to be very hard to maintain. Seemingly simple changes required a lot of effort to implement in this feed structure.
We all knew we were doing this the wrong way and one of our top priorities after the release was fixing this implementation.

The solution

We realised the only way to do this properly would be by writing our own LayoutManager. This would allow us to split up those large chunks into individual news tiles leading to much more flexible and maintainable code.

If it weren’t for the large TRIO tiles spanning two rows, a simple GridLayoutManager would have sufficed. Its SpanSizeLookup can be used to specify the number of columns occupied by each item in the grid.

Creating a custom layout manager can be a lot of work and you can go really crazy with them. Because we had a clear vision of what we needed to achieve, we could focus our efforts into building a LayoutManager that would be tailored to our specific usecase. Features that would not be used could be left out, which would greatly reduce the required effort and complexity.

The LayoutManager

Let’s quickly go over the LayoutManager’s function before we dive into the code.

A RecyclerView is made up out of several components that each fulfil a specific role. Each one of these can be swapped out to customise the RecyclerView’s appearance or behaviour.

  • Adapter. Holds the list of objects that need to be displayed in the list. For every position in the feed, the adapter provides the ViewHolder and makes sure the correct data is bound to it. The ViewHolder is an important part of the RecyclerView. It holds an item’s view and the data that has been bound to it (text, images,…). ViewHolders get removed once they go offscreen and get reused for new items. They keep the view (which only needs to be inflated once) and simply replace the data with that of the new item.
  • LayoutManager. Responsible for actually measuring and placing the views on screen and making them scroll. This class also puts the “Recycle” in RecyclerView by determining which views have gone offscreen and can therefore be removed from the RecyclerView.
  • ItemAnimator. Takes care of the animations that are played when items are added, removed or moved. The default version uses fade in/out effects for most of these animations.
  • ItemDecoration. Can add margins between the items of the RecyclerView. Can also draw around these items (to add dividers between the items for example).

The adapter only knows about the ViewHolders that will be placed at a specific index, it does not know or care about the screen position or size of that view.
Similarly, the LayoutManager does not care about the content of the View(Holder). It is only responsible for calculating the size and position of that view. The LayoutManager will ask the adapter for the next view (through the Recycler), give it the correct size and place it at the correct location on screen.

A LayoutManager has access to the Recycler which manages the views. When a new view needs to be added, the LayoutManager will ask the Recycler to retrieve the correct view for that position. views that have gone offscreen are placed back into the Recycler. This class abstracts away the creation and binding of ViewHolders. The LayoutManager simply requests the next view and the Recycler makes sure the correct one is provided.

Google provides 3 default LayoutManagers: LinearLayoutManager (a horizontal or vertical list), GridLayoutManager (same, but supports mulltiple columns or rows) and StaggeredGridLayoutManager (a more dynamic version of the GridLayoutManager that will place differently sized views where it finds room for them).

If those 3 are not enough for what you are trying to achieve, a custom LayoutManager might be what you are looking for.

Creating a basic LinearLayoutManager

To get started with our very own LayoutManager we looked at this Google LayoutManager sample as a concise and straightforward implementation of a custom LayoutManager. This sample gives you a very basic LinearLayoutManager that clearly explains the concepts used in a LayoutManager.

First we’ll go over the code in this sample to figure out how exactly a LayoutManager does its thing. In the next part we will take a look at the changes we made to this code to arrive at the AD.nl LayoutManager.

Getting started

A custom LayoutManager extends from RecyclerView.LayoutManager. This abstract class forces you to implement just one method: generateDefaultLayoutParams(). This method will supply the default layout parameters for the child views in your RecyclerView.

@Override
public RecyclerView.LayoutParams generateDefaultLayoutParams() {
	return new RecyclerView.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT);
}

It’s important to note that RecyclerView.LayoutManager also contains the following code:

public void scrollToPosition(int position) {
    if (DEBUG) {
        Log.e(TAG, "You MUST implement scrollToPosition. It will soon become abstract");
    }
}

It is therefore strongly recommended to also implement this method in your custom LayoutManager. We will add this method in the next part.

Laying out the initial set of items

With our LayoutManager class created, it’s time to display some items.

The first call your LayoutManager will receive will be to its onLayoutChildren() method. This method is used to lay out the initial set of child views.

The sample app does layout this with the following code:

@Override
public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
    final int parentBottom = getHeight() - getPaddingBottom();
    final View oldTopView = getChildCount() > 0 ? getChildAt(0) : null;
    int oldTop = getPaddingTop();
    if (oldTopView != null) {
        oldTop = oldTopView();
    }

    detachAndScrapAttachedViews(recycler);

    int top = oldTop;
    int bottom;
    final int left = getPaddingLeft();
    final int right = getWidth() - getPaddingRight();
    final int count = state.getItemCount();
    for (int i = 0; mFirstPosition + i < count && top < parentBottom; i++, top = bottom) {
        View v = recycler.getViewForPosition(mFirstPosition + i);
        addView(v, i);
        measureChildWithMargins(v, 0, 0);
        bottom = top + getDecoratedMeasuredHeight(v);
        layoutDecorated(v, left, top, right, bottom);
    }
}

First, the left, top, right and bottom are calculated to determine the area that needs to be filled with views. Generally this will be the size of the screen (accounting for paddings around the RecyclerView).

The code also checks for existing views already attached to the RecyclerView and updates the top value if there are any. This is important because onLayoutChildren() can actually be called for a variety of reasons. Some of the events that would trigger this method are:

  • Initial layout of the first set of child views.
  • A child view is removed or added.
  • notifyDataSetChanged() gets called on the adapter.
  • A child view goes through a layout pass and triggers a new layout in its parent (the RecyclerView and connected LayoutManager).
  • scrollToPosition() gets called and triggers a new layout starting from the desired position. More on that later.

By using the already attached views to calculate the top of the fill area, we make sure the items will be laid out in their same position during this new layout pass. If we were to reset the top to 0 every time the feed would “jump” and views would suddenly be in a (slightly) different position.

After the bounds and top value are determined any attached views are removed from the RecyclerView and passed to the Recycler. A new layout is started and child views are added to the RecyclerView until one of the following conditions has been met:

  • The entire area has been filled with views.
  • All available items have been added. The screen will be only partially filled with views and some empty space will be visible.

This LayoutManager continuously keeps track of the first item position that is visible on screen. This makes it possible to quickly find the position for any subsequent views that need to be added.

Scrolling

Running this code would give you a screen filled with items, but it wouldn’t actually do much. We have some more work to do if we want our content to scroll.

First you need to indicate which scroll direction you will support by overriding canScrollVertically() or canScrollHorizontally() (or both!) and returning true. In our case we just want to scroll up and down.

@Override
public boolean canScrollVertically() {
    return true;
}

There’s only one other method to implement to get scrolling behaviour and that’s scrollVerticallyBy().
This method is split into two parts because the logic for scrolling up and scrolling down differs slightly.

@Override
public int scrollVerticallyBy(int dy, RecyclerView.Recycler recycler, RecyclerView.State state) {
    if (getChildCount() == 0) {
        return 0;
    }
    int scrolled = 0;
    final int left = getPaddingLeft();
    final int right = getWidth() - getPaddingRight();
    if (dy < 0) {
        while (scrolled > dy) {
            final View topView = getChildAt(0);
            final int hangingTop = Math.max(-getDecoratedTop(topView), 0);
            final int scrollBy = Math.min(scrolled - dy, hangingTop);
            scrolled -= scrollBy;
            offsetChildrenVertical(scrollBy);
            if (mFirstPosition > 0 && scrolled > dy) {
                mFirstPosition--;
                View v = recycler.getViewForPosition(mFirstPosition);
                addView(v, 0);
                measureChildWithMargins(v, 0, 0);
                final int bottom = getDecoratedTop(topView);
                final int top = bottom - getDecoratedMeasuredHeight(v);
                layoutDecorated(v, left, top, right, bottom);
            } else {
                break;
            }
        }
    } else if (dy > 0) {
        final int parentHeight = getHeight();
        while (scrolled < dy) {
            final View bottomView = getChildAt(getChildCount() - 1);
            final int hangingBottom =
                    Math.max(getDecoratedBottom(bottomView) - parentHeight, 0);
            final int scrollBy = -Math.min(dy - scrolled, hangingBottom);
            scrolled -= scrollBy;
            offsetChildrenVertical(scrollBy);
            if (scrolled < dy && state.getItemCount() > mFirstPosition + getChildCount()) {
                View v = recycler.getViewForPosition(mFirstPosition + getChildCount());
                final int top = getDecoratedBottom(getChildAt(getChildCount() - 1));
                addView(v);
                measureChildWithMargins(v, 0, 0);
                final int bottom = top + getDecoratedMeasuredHeight(v);
                layoutDecorated(v, left, top, right, bottom);
            } else {
                break;
            }
        }
    }
    recycleViewsOutOfBounds(recycler);
    return scrolled;
}

The most important parameter sent to this method is dy indicating the distance of the scroll or fling action.

Views will be moved the desired distance and any empty space (between the last view and the edge of the RecyclerView) created by this movement will be filled with new views.

imagnad Scrolling up creates empty space below the last child that is filled with a new view.

If no more views are available to be added it might not be possible to scroll the full dy distance. scrollVerticallyBy notifies the RecyclerView of this by returning the distance that was actually scrolled. The RecyclerView will detect the discrepancy between the passed in and returned values and interpret this as the list having reached the edge of the content. An edge glow effect will be drawn to signal this to the user.

This is also one of the places where the firstPosition value is used to very quickly retrieve the position of the next view that will be added. Scrolling up and adding a new view at the top of the list requires decrementing this value. Incrementing this value is not done here (because adding a view at the bottom does not necessarily mean a view was removed at the top), but in recycleViewsOutOfBounds(). That method gets called at the very end of scrollVerticallyBy() to remove any views that have gone off screen because of the processed scroll event.

Recycling views

It wouldn’t be much of a RecyclerView without recycling and that’s what this final method does. In short, it loops over all child views, finds the first and last (at least partially) visible views and removes all views that fall outside of that range.

This method also increments the mFirstPosition value, but only if a view was removed because of scrolling down. Scrolling up updates this value in the scrollVerticallyBy() method.

public void recycleViewsOutOfBounds(RecyclerView.Recycler recycler) {
    final int childCount = getChildCount();
    final int parentWidth = getWidth();
    final int parentHeight = getHeight();
    boolean foundFirst = false;
    int first = 0;
    int last = 0;
    for (int i = 0; i < childCount; i++) {
        final View v = getChildAt(i);
        if (v.hasFocus() || (getDecoratedRight(v) >= 0 &&
                getDecoratedLeft(v) <= parentWidth &&
                getDecoratedBottom(v) >= 0 &&
                getDecoratedTop(v) <= parentHeight)) {
            if (!foundFirst) {
                first = i;
                foundFirst = true;
            }
            last = i;
        }
    }
    for (int i = childCount - 1; i > last; i--) {
        removeAndRecycleViewAt(i, recycler);
    }
    for (int i = first - 1; i >= 0; i--) {
        removeAndRecycleViewAt(i, recycler);
    }
    if (getChildCount() == 0) {
        mFirstPosition = 0;
    } else {
        mFirstPosition += first;
    }
}

Conclusion

And that’s all there is to this basic vertical LinearLayoutManager. It has very limited functionality, but clearly explains the concepts that make a LayoutManager work:

  • It measures and lays out just enough views to fill the screen.
  • It enables scrolling by moving all child views up or down the correct amount of pixels.
  • It adds new views when a scroll event would cause empty space.
  • It removes child views again as soon as they go off screen.

In the next part we’ll go into more detail about the changes we made to this sample to create our AD.nl LayoutManager.