10 steps to create a custom LayoutManager

Danylo Volokh
Android Development by Danylo :)
12 min readDec 19, 2015

--

This article is based on my github project LondonEyeLayoutManager, recently published in Android Weekly. For the sake of simplicity code snippets here might be different from the code in repository.

What we normally do in order to have this kind of functionality that ListView provides? We have to:

  1. Know how to lay out views on the screen.
  2. Handle touch events, measure scroll velocity and direction.
  3. Move views on the screen when scroll is happening.
  4. Implement views recycling.

With a new RecyclerView & LayoutManager few of these point are handled for us:

1. We don’t have to handle touch events, measure scroll velocity and direction.

LayoutManager provides very convenient API for that:

@Override
public int scrollVerticallyBy(
int dy,
RecyclerView.Recycler recycler,
RecyclerView.State state) {
return 0; // actual scrolled distance@Override
public int scrollHorizontallyBy(
int dx,
Recycler recycler,
State state) {
return 0; // actual scrolled distance

This API has a drawback : we only get vertical or horizontal scroll. If we need to know if user, for example scrolls diagonally we should calculate it by ourselves. These methods gives us a value by which we should move views on the screen. We should return the actual distance (in pixels) by which we moved our views.

For example:

scrollVerticallyBy(dy, recycler, state) was called with dy = 25;

But to the end of a screen we have left 20 pixels. So we move a views by -20px and return -20. You should notice that we are returning a value with an opposite sign.

LayoutManger will “understand” that if we returned less than was scrolled, it should stop sending us these scroll events, we already reached the end. Demo:

It also means that if we return “0” the list will not be scrolled at all.

2. We don’t have to handle recycling.

If we need a view on position we just call the appropriate method from the Recycler.

View newView = recycler.getViewForPosition(position);....recycler.recycleView(viewToRecycle);

This is what we have to implement:

  1. Layout views on the screen.
  2. Move views on the screen when scroll is happening.

Let’s start implementing our LayoutManager.

Goal: Create a LayoutManager that will layout our views on the circular trajectory.

Requirements:

  1. Layout in first quadrant. (Y axis in Android is points to the opposite direction than in Cartesian coordinate system).
  2. Views center should keep it’s center on the circle.

Here is how we are going to do that :

10 steps to implement a LayoutManager.

As I mentioned earlier there is two things that we need to handle: layout views and handle scrolling.

5 steps to handle layout of views:

  1. Get view by position.
  2. Add view to the RecyclerView.
  3. Get view location on the screen.
  4. Layout this view.
  5. Increment view position.

Run it in the loop until we layout a view that will be partially visible. It will be indicator that we’re done.

5 steps to handle scrolling:

  1. Calculate views offset by received scroll value (dx, dy).
  2. Calculate new position of a view using received offset.
  3. Change view location.
  4. Recycle views that become invisible when they were moved.
  5. Add views to empty space created by moved views if needed.

Perform these operation on each call of scrollVerticallyBy.

And of course these 10 operation are very abstract. We have to do a lot of additional job in order to make it work :) Sorry

Creating the circle

In order to lay out and move views on the circular trajectory we have to create a set of predefined points which will be the center of views. Having this will give us very useful functionality:

When scroll is happening we don’t have to calculate the point on the circle to which we need move the view. We just get index of point that is center of view and increase this index by received scroll offset, a point on the position of increased index will be new center of view.

Points should be located with pixel-pixel precision, that’s why we cannot use “the circle equation” nor the sine/cosine to create points. We will use Mid point circle algorithm to create points but modified a bit. (From now on I will describe the implementation assuming that reader knows how Mid point algorithm works)

Here is an original algorithm copy-pasted from Wikipedia:

void DrawCircle(int x0, int y0, int radius)
{
int x = radius;
int y = 0;
int decisionOver2 = 1 - x; // Decision criterion divided by 2 evaluated at x=r, y=0

while( y <= x )
{
DrawPixel( x + x0, y + y0); // Octant 1
DrawPixel( y + x0, x + y0); // Octant 2
DrawPixel(-x + x0, y + y0); // Octant 4
DrawPixel(-y + x0, x + y0); // Octant 3
DrawPixel(-x + x0, -y + y0); // Octant 5
DrawPixel(-y + x0, -x + y0); // Octant 6
DrawPixel( x + x0, -y + y0); // Octant 8
DrawPixel( y + x0, -x + y0); // Octant 7
y++;
if (decisionOver2<=0)
{
decisionOver2 += 2 * y + 1; // Change in decision criterion for y -> y+1
}
else
{
x--;
decisionOver2 += 2 * (y - x) + 1; // Change for y -> y+1, x -> x-1
}
}
}

This algorithm is creating all 8 octants in parallel. It means that created views in the list will be in following order:

(x1, y1) — 1st Octant (Black)

(x2, y2) — 2nd Octant (Blue)

(x3, y3) — 3rd Octant (Dark Grey)

(x4, y4) — 4th Octant (Cyan)

(x5, y5) — 5th Octant (Green)

(x6, y6) — 6th Octant (Pink)

(x7, y7) — 7th Octant (Yellow)

(x8, y8) — 8th Octant (Red)

And here is the problem: if the center of a view is on first point (x1, y1) and received offset from scrollVerticallyBy(int dy, Recycler recycler) is dy=3 we should move our view by 3 points which means move to point (x4, y4). And point (x4, y4) is in 4th octant. But it should be just moved by few pixels.

To have the list in consecutive order to easily get next or previous point on the circle. So the algorithm has to be modified:

  1. Create first octant points using Mid point algorithm.
  2. Mirror points on 2nd octant using points from 1st octant. (after this action we have a points of 1st quadrant).
  3. MIrror points on the 2nd quadrant using points from 1st quadrant(after this action we have a points of 1st semicircle).
  4. Mirror points on the 2nd semicircle using points from 1st semicircle.

And right now the points are created consecutively:

(x1, y1) — 1st Octant (Pink)

(x2, y2) — 2nd Octant (Pink)

(x3, y3) — 3rd Octant (Pink)

(x4, y4) — 4th Octant (Pink)

(x5, y5) — 5th Octant (Pink)

(x6, y6) — 6th Octant (Pink)

(x7, y7) — 7th Octant (Pink)

(x8, y8) — 8th Octant (Pink)

And if while scrolling we get dy=3 then our view will be moved correctly.

Here is the sample application: https://github.com/danylovolokh/MidPointCircleExplained

The same code is used in LondonEyeLayoutManager. We have an abstraction called CircleMirrorHelper that gives the API to perform points mirroring.

public interface CircleMirrorHelper {void mirror_2nd_Octant(
Map<Integer, Point> circleIndexPoint,
Map<Point, Integer> circlePointIndex
);
void mirror_2nd_Quadrant(
Map<Integer, Point> circleIndexPoint,
Map<Point, Integer> circlePointIndex
);
void mirror_2nd_Semicircle(
Map<Integer, Point> circleIndexPoint,
Map<Point, Integer> circlePointIndex
);
}

And there is a concrete implementation FirstQuadrantCircleMirrorHelper that “knows” how to mirror points in our “concrete” first quadrant.

public class FirstQuadrantCircleMirrorHelper implements CircleMirrorHelper {// relevant code here}

You may notice a strange signature of methods. Points are added into two maps. It is done to easily perform following operation:

When scroll is happening we get the center point of a view and use it as a key to get an index of this point. We increase(or decrease, depends on the scroll direction) the index by the received value from scrollVerticallyBy(dy, recycler, state) and use this index as a key to get a new point that will be center of a view.

It would look a lot simpler if it would be List<Point> but it was done for the sake of performance. It is faster to get “index by point” when we have a Map of them.

Layouting the views.

To get quadrant specific stuff there is an abstraction called QuadrantHelper.

/** This is generic interface for quadrant related          
* functionality.
*
* To lay out in each quadrant you should implement quadrant-
* specific classes :
* {@link FirstQuadrantHelper}
*/
public interface QuadrantHelper {

Point findNextViewCenter(ViewData previousViewData, int nextViewHalfViewWidth, int nextViewHalfViewHeight);
int getViewCenterPointIndex(Point point); Point getViewCenterPoint(int newCenterPointIndex); int getNewCenterPointIndex(int newCalculatedIndex);

Point findPreviousViewCenter(ViewData nextViewData, int previousViewHalfViewHeight);
boolean isLastLayoutedView(int recyclerHeight, View view);

int checkBoundsReached(int recyclerViewHeight, int dy,
View firstView, View lastView, boolean isFirstItemReached, boolean isLastItemReached); int getOffset(int recyclerViewHeight, View lastView);
}

And there is a concrete implementation FirstQuadrantHelper.

public class FirstQuadrantHelper implements QuadrantHelper {// code here :)}

LayoutManager forces us to implement only one method

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

But we need to override a few more, the most important is onLayoutChildren:

private Layouter mLayouter;@Override
public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
// some code here...// It will be our stop flag
boolean isLastLayoutedView;
do{
// 1. get view by position
View view = recycler.getViewForPosition(mLastVisiblePosition);
// 2. add view to the recycler view
addView(view);
// 3. get view location on the screen.
// 4. layout this view
// Points 3 and 4 are performed by "Layouter"

viewData = mLayouter.layoutNextView(view, viewData);
isLastLayoutedView = mLayouter.isLastLaidOutView(view);// 5. increment view position
mLastVisiblePosition++;
// we do this until we laid out last visible view
} while (!isLastLayoutedView && mLastVisiblePosition < itemCount);
}

Layouter used in this code snippet is an entity that uses QuadrantHelper to get some information about the views location in concrete quadrant (FirstQuadrantHelper in our case) and provide following API to LayoutManager:

// This method is using a data from previous view in order to 
// layout the next view with pixel precision to the previous
public ViewData layoutNextView(View view, ViewData previousViewData);// This method is using a data from next view in order to
// layout the previous view with pixel precision to the
// previous
public ViewData layoutViewPreviousView(View view, ViewData previousViewData);// This method checks if view is the last visible view on the screenpublic boolean isLastLaidOutView(View view);

Let’s explain layoutNextView.

public ViewData layoutNextView(View view, ViewData previousViewData) {int halfViewHeight;
int halfViewWidth;
// measure view and get a half of it's height & width
// QuadrantHelper uses it to find next view center
Point viewCenter = mQuadrantHelper.findNextViewCenter(previousViewData, halfViewHeight, halfViewWidth);// Layouter uses callback to LayoutManager to perform layoutint left, top, right, bottom;
// calculate values using viewCenter
// layout view using method layoutDecorated from LayoutManager
mLayoutManagerCallback.layoutDecorated(view, left, top, right, bottom);
return viewData of just laid out view.}

layoutNextView takes previousViewData as a parameter. On the first start previousViewData is:

ViewData viewData = new ViewData(0, 0, 0, 0,
mQuadrantHelper.getViewCenterPoint(0)
);

After we implement onLayoutChildren we have views layouted on the screen, but without scrolling, recycling and other stuff for which we need RecyclerView.

Handle scrolling

To do this we have to override scrollVerticallyBy and/or scrollHorizontallyBy and also return “true” from canScrollVertically and/or canScrollHorizontally.

In our case we only handle vertical scroll.

@Override
public boolean canScrollVertically() {
return true;
}
@Override
public int scrollVerticallyBy(int dy, RecyclerView.Recycler recycler, RecyclerView.State state){
int childCount = getChildCount();
if (childCount == 0) {
// we cannot scroll when there is no views
return 0;
}
return mScroller.scrollVerticallyBy(dy, recycler);
}

We have generic interface IScrollHandler and two implementations : PixelPerfectScrollHandler and NaturalScrollHandler. Each of these has their advantages and drawbacks.

Scroll handler also uses QuadrantHelper to get data specific for concrete quadrant.

public interface IScrollHandler {int scrollVerticallyBy(int dy, RecyclerView.Recycler recycler);}

On the first look scrolling looks pretty simple : you just get the dy and move every view by this value, but this is not the case.

NaturalScrollHandler

Why “Natural” ? Because when views are scroller it looks very natural. Distance between center of views is kept.

Using this scroll handler views each view will be moved by the same distance (dy) on the circle which looks great when views has distance between them and they are square shaped:

But when there are no gaps between views they will overlap each other or visual distance between them will be getting bigger.

Here is the code:

In this scroller we can omit first point : Calculate views offset by received scroll value (dx, dy), because our offset is dy.

public class NaturalScrollHandler //..public int scrollVerticallyBy(int dy, RecyclerView.Recycler recycler){for (int indexOfView = 0; indexOfView <    mCallback.getChildCount(); indexOfView++) {    View view = mCallback.getChildAt(indexOfView); 
// Points 1, 2, 4 here
scrollSingleViewVerticallyBy(view, delta);
}
// after scrolling perform recycling
// Points 4,5 here
performRecycling();}/**
* This method calculates new position of single view and
* returns new center point of the view
*/
protected Point scrollSingleViewVerticallyBy(View view, int indexOffset) {
// find view center using view properties
int viewCenterX = view.getRight() - view.getWidth() / 2;
int viewCenterY = view.getTop() + view.getHeight() / 2;
//this object will be updated many times during search of view
SCROLL_HELPER_POINT.update(viewCenterX, viewCenterY);
int centerPointIndex = mQuadrantHelper.getViewCenterPointIndex(SCROLL_HELPER_POINT);// increase the point by indexOffset
int newCenterPointIndex = mQuadrantHelper.getNewCenterPointIndex(centerPointIndex + indexOffset);
// after increasing index, find point by index
// 2. Calculate new position of a view using received offset.
Point newCenterPoint = mQuadrantHelper.getViewCenterPoint(newCenterPointIndex);// calculate dy and dx
int dx = newCenterPoint.getX() - viewCenterX;
int dy = newCenterPoint.getY() - viewCenterY;
// 3. Change view location.
view.offsetTopAndBottom(dy);
view.offsetLeftAndRight(dx);
return newCenterPoint;}

Method performRecycling is also responsible for filling the gap created by moved views.

/**
* This method recycles views:
* If views was scrolled down then it recycles top if needed
* and add views from the bottom
* If views was scrolled up then it recycles bottom if needed
* and add views from the top
*
@param delta - indicator of scroll direction
*/

private void performRecycling(int delta, View firstView, View lastView, RecyclerView.Recycler recycler) {

if (delta < 0) {
/** Scroll down*/
// 4. Recycle views that become invisible when they were
// moved.
recycleTopIfNeeded(firstView, recycler);

// 5. Add views to empty space created by moved views if
// needed.
addToBottomIfNeeded(lastView, recycler);
} else {
/** Scroll up*/
// 4. Recycle views that become invisible when they were
// moved.
recycleBottomIfNeeded(lastView, recycler);
// 5. Add views to empty space created by moved views if
// needed.
addTopIfNeeded(firstView, recycler);
}
}

Because of inability of NaturalScrollHandler to be used with non-square views I’ve decided to implement another one.

PixelPerfectScrollHandler

PixelPerfectScrollHandler was designed to follow two rules while scrolling.

This scroll handler keeps view in touch when scrolling. 1. Views center is on the circle 2. Views edges are always in touch with each other. Sometimes these requirements are making views “jump” when scroll: If “view B” is below “view A” and views are scrolled down we can reach a point in which “view B” cannot longer stay below “view A” and keep it’s center on the circle so, in this case “view B” jumps to the side in order to stay in touch with “view A” side by side and keep it’s center on the circle. The logic: 1. Scroll first view by received offset. 2. Calculate position of other views relatively to first view.

Here is a demo of how the “jump” looks like.

public class PixelPerfectScrollHandler//...//..public int scrollVerticallyBy(int dy, RecyclerView.Recycler recycler){// 1. Calculate views offset by received scroll value dy.
int delta;
// calculate delta. Look if we reached bottom or top of the
// list If yes then return actual distance on which we have
// moved views
// 2.Calculate new position of a view using received offset.Point newCenterPoint = mQuadrantHelper.getNewCenterPoint(/*some ars here*/)int yOffset;
int xOffset;
// calcute yOffeset and xOffset using new center point of this view// 3. Change view location.
// calling these methods will cause view to change it position
view.offsetTopAndBottom(yOffset);
view.offsetLeftAndRight(xOffset);
// Using position of first view now we have to find position of other
// views. Because it's a pixel perfect scroller we have to find a
// location which will be right below previous view or to the left of
// previous view and still keep it's center on the circle.
// There is a lot of code here, so please check
// {@link PixelPerectScrollHandler#scrollSingleView} for the reference

After we done moving views we have to recycle views that were hidden and fill a gap that was created by moved views. We call performRecycling, exatly like in the NaturalScrollHandler.

And that’s it

Of course there is a lot of thing to do :

  1. Animations support.
  2. Handle inPrelayout
  3. Save/Restore instance state
  4. Handle data set changes

And also there are bugs in the project. This is basically a PoC and not a full tested and polished library. So everyone are welcome to contribute.

Summary

I really understand that showing some snippets of code probably isn’t enough to fully explain of how to implement custom LayoutManager, but I hope it’s helps someone if they would like (or need) to implement something similar.

Cheers :)

--

--