package com.marshalchen.common.demoofui.recyclerplayground.layout; import android.graphics.PointF; import android.support.v7.widget.LinearSmoothScroller; import android.support.v7.widget.RecyclerView; import android.util.Log; import android.util.SparseArray; import android.view.View; /** * A {@link android.support.v7.widget.RecyclerView.LayoutManager} implementation * that places children in a two-dimensional grid, sized to make the data appear * as square as possible. User scrolling is possible in both horizontal and vertical * directions to view the data set. * * <p>On {@link android.support.v7.widget.RecyclerView.Adapter} data set changes, * the view configures the number of columns used based on the square root * of the item count. As data sets get larger, they will use both more columns and * more rows in the view collection. * * <p>This manager does make some assumptions to simplify the implementation: * <ul> * <li>All child views are assumed to be the same size</li> * <li>The window of visible views is a constant</li> * </ul> */ public class StaticGridLayoutManager extends RecyclerView.LayoutManager { private static final String TAG = StaticGridLayoutManager.class.getSimpleName(); /* Fill Direction Constants */ private static final int DIRECTION_NONE = -1; private static final int DIRECTION_START = 0; private static final int DIRECTION_END = 1; private static final int DIRECTION_UP = 2; private static final int DIRECTION_DOWN = 3; /* First (top-left) position visible at any point */ private int mFirstVisiblePosition; /* Consistent size applied to all child views */ private int mDecoratedChildWidth; private int mDecoratedChildHeight; /* Number of columns that exist in the grid */ private int mTotalColumnCount; /* Metrics for the visible window of our data */ private int mVisibleColumnCount; private int mVisibleRowCount; /* Flag to force current scroll offsets to be ignored on re-layout */ private boolean mForceClearOffsets; /* * This method is your initial call from the framework. You will receive it when you * need to start laying out the initial set of views. This method will not be called * repeatedly, so don't rely on it to continually process changes during user * interaction. * * This method will be called when the data set in the adapter changes, so it can be * used to update a layout based on a new item count. */ @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; } //Make the grid as square as possible, column count is root of the data set mTotalColumnCount = (int) Math.round(Math.sqrt(getItemCount())); if (getChildCount() == 0) { //First or empty layout //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); } //Always update the visible row/column counts 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 cases where shifting to the bottom-right overscrolls the top-left // 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); } @Override public void onAdapterChanged(RecyclerView.Adapter oldAdapter, RecyclerView.Adapter newAdapter) { //Completely scrap the existing layout removeAllViews(); } /* * Rather than continuously checking how many views we can fit * based on scroll offsets, we simplify the math by computing the * visible grid as what will initially fit on screen, plus one. */ private void updateWindowSizing() { mVisibleColumnCount = (getHorizontalSpace() / mDecoratedChildWidth) + 1; if (getHorizontalSpace() % mDecoratedChildWidth > 0) { mVisibleColumnCount++; } //Allow minimum value for small data sets if (mVisibleColumnCount > getTotalColumnCount()) { mVisibleColumnCount = getTotalColumnCount(); } mVisibleRowCount = (getVerticalSpace()/ mDecoratedChildHeight) + 1; if (getVerticalSpace() % mDecoratedChildHeight > 0) { mVisibleRowCount++; } if (mVisibleRowCount > getTotalRowCount()) { mVisibleRowCount = getTotalRowCount(); } } private void fillGrid(int direction, RecyclerView.Recycler recycler) { fillGrid(direction, 0, 0, recycler); } private void fillGrid(int direction, int emptyLeft, int emptyTop, RecyclerView.Recycler recycler) { if (mFirstVisiblePosition < 0) mFirstVisiblePosition = 0; if (mFirstVisiblePosition >= getItemCount()) mFirstVisiblePosition = (getItemCount() - 1); /* * First, we will detach all existing views from the layout. * detachView() is a lightweight operation that we can use to * quickly reorder views without a full add/remove. */ SparseArray<View> viewCache = new SparseArray<View>(getChildCount()); int startLeftOffset = getPaddingLeft() + emptyLeft; int startTopOffset = getPaddingTop() + emptyTop; if (getChildCount() != 0) { final View topView = getChildAt(0); startLeftOffset = getDecoratedLeft(topView); startTopOffset = getDecoratedTop(topView); switch (direction) { case DIRECTION_START: startLeftOffset -= mDecoratedChildWidth; break; case DIRECTION_END: startLeftOffset += mDecoratedChildWidth; break; case DIRECTION_UP: startTopOffset -= mDecoratedChildHeight; break; case DIRECTION_DOWN: startTopOffset += mDecoratedChildHeight; break; } //Cache all views by their existing position, before updating counts for (int i=0; i < getChildCount(); i++) { int position = positionOfIndex(i); final View child = getChildAt(i); viewCache.put(position, child); } //Temporarily detach all views. // Views we still need will be added back at the proper index. for (int i=0; i < viewCache.size(); i++) { detachView(viewCache.valueAt(i)); } } /* * Next, we advance the visible position based on the fill direction. * DIRECTION_NONE doesn't advance the position in any direction. */ switch (direction) { case DIRECTION_START: mFirstVisiblePosition--; break; case DIRECTION_END: mFirstVisiblePosition++; break; case DIRECTION_UP: mFirstVisiblePosition -= getTotalColumnCount(); break; case DIRECTION_DOWN: mFirstVisiblePosition += getTotalColumnCount(); break; } /* * Next, we supply the grid of items that are deemed visible. * If these items were previously there, they will simple be * re-attached. New views that must be created are obtained * from the Recycler and added. */ int leftOffset = startLeftOffset; int topOffset = startTopOffset; for (int i = 0; i < getVisibleChildCount(); i++) { int nextPosition = positionOfIndex(i); if (nextPosition >= getItemCount()) { //Item space beyond the data set, don't attempt to add a view continue; } //Layout this position View view = viewCache.get(nextPosition); if (view == null) { /* * The Recycler will give us either a newly constructed view, * or a recycled view it has on-hand. In either case, the * view will already be fully bound to the data by the * adapter for us. */ view = recycler.getViewForPosition(nextPosition); addView(view); /* * It is prudent to measure/layout each new view we * receive from the Recycler. We don't have to do * this for views we are just re-arranging. */ measureChildWithMargins(view, 0, 0); layoutDecorated(view, leftOffset, topOffset, leftOffset + mDecoratedChildWidth, topOffset + mDecoratedChildHeight); } else { //Re-attach the cached view at its new index attachView(view); viewCache.remove(nextPosition); } if (i % mVisibleColumnCount == (mVisibleColumnCount - 1)) { leftOffset = startLeftOffset; topOffset += mDecoratedChildHeight; //If we wrapped without setting the column count, we've reached it } else { leftOffset += mDecoratedChildWidth; } } /* * Finally, we ask the Recycler to scrap and store any views * that we did not re-attach. These are views that are not currently * necessary because they are no longer visible. */ for (int i=0; i < viewCache.size(); i++) { recycler.recycleView(viewCache.valueAt(i)); } } /* * You must override this method if you would like to support external calls * to shift the view to a given adapter position. In our implementation, this * is the same as doing a fresh layout with the given position as the top-left * (or first visible), so we simply set that value and trigger onLayoutChildren() */ @Override public void scrollToPosition(int position) { if (position >= getItemCount()) { Log.e(TAG, "Cannot scroll to "+position+", item count is "+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(); } /* * You must override this method if you would like to support external calls * to animate a change to a new adapter position. The framework provides a * helper scroller implementation (LinearSmoothScroller), which we leverage * to do the animation calculations. */ @Override public void smoothScrollToPosition(RecyclerView recyclerView, RecyclerView.State state, final int position) { if (position >= getItemCount()) { Log.e(TAG, "Cannot scroll to "+position+", item count is "+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. */ LinearSmoothScroller scroller = new LinearSmoothScroller(recyclerView.getContext()) { /* * 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); } /* * Use this method to tell the RecyclerView if scrolling is even possible * in the horizontal direction. */ @Override public boolean canScrollHorizontally() { //We do allow scrolling return true; } /* * This method describes how far RecyclerView thinks the contents should scroll horizontally. * You are responsible for verifying edge boundaries, and determining if this scroll * event somehow requires that new views be added or old views get recycled. */ @Override public int scrollHorizontallyBy(int dx, RecyclerView.Recycler recycler, RecyclerView.State state) { if (getChildCount() == 0) { return 0; } //Take leftmost measurements from the top-left child final View topView = getChildAt(0); //Take rightmost measurements from the top-right child final View bottomView = getChildAt(mVisibleColumnCount-1); //Optimize the case where the entire data set is too small to scroll int viewSpan = getDecoratedRight(bottomView) - getDecoratedLeft(topView); if (viewSpan <= getHorizontalSpace()) { //We cannot scroll in either direction return 0; } int delta; boolean leftBoundReached = getFirstVisibleColumn() == 0; boolean rightBoundReached = getLastVisibleColumn() >= getTotalColumnCount(); if (dx > 0) { // Contents are scrolling left //Check right bound if (rightBoundReached) { //If we've reached the last column, enforce limits int rightOffset = getHorizontalSpace() - getDecoratedRight(bottomView) + getPaddingRight(); delta = Math.max(-dx, rightOffset); } else { //No limits while the last column isn't visible delta = -dx; } } else { // Contents are scrolling right //Check left bound if (leftBoundReached) { int leftOffset = -getDecoratedLeft(topView) + getPaddingLeft(); delta = Math.min(-dx, leftOffset); } else { delta = -dx; } } offsetChildrenHorizontal(delta); if (dx > 0) { if (getDecoratedRight(topView) < 0 && !rightBoundReached) { fillGrid(DIRECTION_END, recycler); } else if (!rightBoundReached) { fillGrid(DIRECTION_NONE, recycler); } } else { if (getDecoratedLeft(topView) > 0 && !leftBoundReached) { fillGrid(DIRECTION_START, recycler); } else if (!leftBoundReached) { fillGrid(DIRECTION_NONE, recycler); } } /* * Return value determines if a boundary has been reached * (for edge effects and flings). If returned value does not * match original delta (passed in), RecyclerView will draw * an edge effect. */ return -delta; } /* * Use this method to tell the RecyclerView if scrolling is even possible * in the vertical direction. */ @Override public boolean canScrollVertically() { //We do allow scrolling return true; } /* * This method describes how far RecyclerView thinks the contents should scroll vertically. * You are responsible for verifying edge boundaries, and determining if this scroll * event somehow requires that new views be added or old views get recycled. */ @Override public int scrollVerticallyBy(int dy, RecyclerView.Recycler recycler, RecyclerView.State state) { if (getChildCount() == 0) { return 0; } //Take top measurements from the top-left child final View topView = getChildAt(0); //Take bottom measurements from the bottom-right child. final View bottomView = getChildAt(getChildCount()-1); //Optimize the case where the entire data set is too small to scroll int viewSpan = getDecoratedBottom(bottomView) - getDecoratedTop(topView); if (viewSpan <= getVerticalSpace()) { //We cannot scroll in either direction return 0; } int delta; int maxRowCount = getTotalRowCount(); boolean topBoundReached = getFirstVisibleRow() == 0; boolean bottomBoundReached = getLastVisibleRow() >= maxRowCount; if (dy > 0) { // Contents are scrolling up //Check against bottom bound if (bottomBoundReached) { //If we've reached the last row, enforce limits int bottomOffset; if (rowOfIndex(getChildCount() - 1) >= (maxRowCount - 1)) { //We are truly at the bottom, determine how far bottomOffset = getVerticalSpace() - getDecoratedBottom(bottomView) + getPaddingBottom(); } else { /* * Extra space added to account for allowing bottom space in the grid. * This occurs when the overlap in the last row is not large enough to * ensure that at least one element in that row isn't fully recycled. */ bottomOffset = getVerticalSpace() - (getDecoratedBottom(bottomView) + mDecoratedChildHeight) + getPaddingBottom(); } delta = Math.max(-dy, bottomOffset); } else { //No limits while the last row isn't visible delta = -dy; } } else { // Contents are scrolling down //Check against top bound if (topBoundReached) { int topOffset = -getDecoratedTop(topView) + getPaddingTop(); delta = Math.min(-dy, topOffset); } else { delta = -dy; } } offsetChildrenVertical(delta); if (dy > 0) { if (getDecoratedBottom(topView) < 0 && !bottomBoundReached) { fillGrid(DIRECTION_DOWN, recycler); } else if (!bottomBoundReached) { fillGrid(DIRECTION_NONE, recycler); } } else { if (getDecoratedTop(topView) > 0 && !topBoundReached) { fillGrid(DIRECTION_UP, recycler); } else if (!topBoundReached) { fillGrid(DIRECTION_NONE, recycler); } } /* * Return value determines if a boundary has been reached * (for edge effects and flings). If returned value does not * match original delta (passed in), RecyclerView will draw * an edge effect. */ return -delta; } /* * We must override this method to provide the default layout * parameters that each child view will receive when added. */ @Override public RecyclerView.LayoutParams generateDefaultLayoutParams() { return new RecyclerView.LayoutParams( RecyclerView.LayoutParams.WRAP_CONTENT, RecyclerView.LayoutParams.WRAP_CONTENT); } /* * This is a helper method used by RecyclerView to determine * if a specific child view can be returned. */ @Override public View findViewByPosition(int position) { for (int i=0; i < getChildCount(); i++) { if (positionOfIndex(i) == position) { return getChildAt(i); } } return null; } /** Private Helpers and Metrics Accessors */ /* Return the overall column index of this position in the global layout */ private int getGlobalColumnOfPosition(int position) { return position % getTotalColumnCount(); } /* Return the overall row index of this position in the global layout */ private int getGlobalRowOfPosition(int position) { return position / getTotalColumnCount(); } /* * Mapping between child view indices and adapter data * positions helps fill the proper views during scrolling. */ private int positionOfIndex(int childIndex) { int row = childIndex / mVisibleColumnCount; int column = childIndex % mVisibleColumnCount; return mFirstVisiblePosition + (row * getTotalColumnCount()) + column; } private int rowOfIndex(int childIndex) { int position = positionOfIndex(childIndex); return position / getTotalColumnCount(); } private int getFirstVisibleColumn() { return (mFirstVisiblePosition % getTotalColumnCount()); } private int getLastVisibleColumn() { return getFirstVisibleColumn() + mVisibleColumnCount; } private int getFirstVisibleRow() { return (mFirstVisiblePosition / getTotalColumnCount()); } private int getLastVisibleRow() { return getFirstVisibleRow() + mVisibleRowCount; } private int getVisibleChildCount() { return mVisibleColumnCount * mVisibleRowCount; } private int getTotalColumnCount() { return mTotalColumnCount; } private int getTotalRowCount() { int maxRow = getItemCount() / mTotalColumnCount; //Bump the row count if it's not exactly even if (getItemCount() % mTotalColumnCount != 0) { maxRow++; } return maxRow; } private int getHorizontalSpace() { return getWidth() - getPaddingRight() - getPaddingLeft(); } private int getVerticalSpace() { return getHeight() - getPaddingBottom() - getPaddingTop(); } }