From 800ee419e84fd25adbeacc7ef7abe230a86663ea Mon Sep 17 00:00:00 2001 From: Lucas Lima Date: Thu, 23 Apr 2020 10:43:10 -0300 Subject: [PATCH] Add custom layout manager --- .../antimine/level/view/LevelFragment.kt | 39 +- .../level/widget/FixedGridLayoutManager.java | 918 ++++++++++++++++++ .../main/res/layout-watch/fragment_level.xml | 45 +- common/src/main/res/layout/fragment_level.xml | 33 +- 4 files changed, 959 insertions(+), 76 deletions(-) create mode 100644 app/src/main/java/dev/lucasnlm/antimine/level/widget/FixedGridLayoutManager.java diff --git a/app/src/main/java/dev/lucasnlm/antimine/level/view/LevelFragment.kt b/app/src/main/java/dev/lucasnlm/antimine/level/view/LevelFragment.kt index c054b9bb..e9db4f2e 100644 --- a/app/src/main/java/dev/lucasnlm/antimine/level/view/LevelFragment.kt +++ b/app/src/main/java/dev/lucasnlm/antimine/level/view/LevelFragment.kt @@ -6,10 +6,8 @@ import android.view.View import android.view.ViewGroup import androidx.lifecycle.Observer import androidx.lifecycle.ViewModelProviders -import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.RecyclerView import dev.lucasnlm.antimine.common.R -import dev.lucasnlm.antimine.common.level.view.UnlockedHorizontalScrollView import dagger.android.support.DaggerFragment import dev.lucasnlm.antimine.common.level.models.Difficulty import dev.lucasnlm.antimine.common.level.models.Event @@ -17,6 +15,7 @@ import dev.lucasnlm.antimine.common.level.view.AreaAdapter import dev.lucasnlm.antimine.common.level.view.SpaceItemDecoration import dev.lucasnlm.antimine.common.level.viewmodel.GameViewModel import dev.lucasnlm.antimine.common.level.viewmodel.GameViewModelFactory +import dev.lucasnlm.antimine.level.widget.FixedGridLayoutManager import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch @@ -29,7 +28,6 @@ open class LevelFragment : DaggerFragment() { private lateinit var viewModel: GameViewModel private lateinit var recyclerGrid: RecyclerView - private lateinit var bidirectionalScroll: UnlockedHorizontalScrollView private lateinit var areaAdapter: AreaAdapter override fun onCreateView( @@ -60,30 +58,22 @@ open class LevelFragment : DaggerFragment() { super.onViewCreated(view, savedInstanceState) recyclerGrid = view.findViewById(R.id.recyclerGrid) - recyclerGrid.apply { - setHasFixedSize(true) - isNestedScrollingEnabled = false - addItemDecoration(SpaceItemDecoration(R.dimen.field_padding)) - adapter = areaAdapter - alpha = 0.0f - } - - bidirectionalScroll = view.findViewById(R.id.bidirectionalScroll) - bidirectionalScroll.setTarget(recyclerGrid) GlobalScope.launch { val levelSetup = viewModel.onCreate(handleNewGameDeeplink()) - val width = levelSetup.width - withContext(Dispatchers.Main) { - recyclerGrid.layoutManager = - GridLayoutManager(activity, width, RecyclerView.VERTICAL, false) + recyclerGrid.apply { + setHasFixedSize(true) + isNestedScrollingEnabled = false + addItemDecoration(SpaceItemDecoration(R.dimen.field_padding)) + layoutManager = FixedGridLayoutManager().apply { + setTotalColumnCount(levelSetup.width) + } + adapter = areaAdapter + alpha = 0.0f - view.post { - recyclerGrid.scrollBy(0, recyclerGrid.height / 2) - bidirectionalScroll.scrollBy(recyclerGrid.width / 4, 0) - recyclerGrid.animate().apply { + animate().apply { alpha(1.0f) duration = 1000 }.start() @@ -97,8 +87,9 @@ open class LevelFragment : DaggerFragment() { }) levelSetup.observe(viewLifecycleOwner, Observer { - recyclerGrid.layoutManager = - GridLayoutManager(activity, it.width, RecyclerView.VERTICAL, false) + recyclerGrid.layoutManager = FixedGridLayoutManager().apply { + setTotalColumnCount(it.width) + } }) fieldRefresh.observe(viewLifecycleOwner, Observer { @@ -114,7 +105,7 @@ open class LevelFragment : DaggerFragment() { Event.Running, Event.ResumeGame, Event.StartNewGame -> areaAdapter.setClickEnabled(true) - else -> {} + else -> { } } }) } diff --git a/app/src/main/java/dev/lucasnlm/antimine/level/widget/FixedGridLayoutManager.java b/app/src/main/java/dev/lucasnlm/antimine/level/widget/FixedGridLayoutManager.java new file mode 100644 index 00000000..1f39f62c --- /dev/null +++ b/app/src/main/java/dev/lucasnlm/antimine/level/widget/FixedGridLayoutManager.java @@ -0,0 +1,918 @@ +package dev.lucasnlm.antimine.level.widget; + +import android.content.Context; +import android.graphics.PointF; +import android.util.AttributeSet; +import android.util.Log; +import android.util.SparseArray; +import android.util.SparseIntArray; +import android.view.View; +import android.view.ViewGroup; + +import androidx.recyclerview.widget.LinearSmoothScroller; +import androidx.recyclerview.widget.RecyclerView; + +import java.util.HashSet; +import java.util.List; + +public class FixedGridLayoutManager extends RecyclerView.LayoutManager { + + private static final String TAG = FixedGridLayoutManager.class.getSimpleName(); + + private static final int DEFAULT_COUNT = 1; + + /* View Removal Constants */ + private static final int REMOVE_VISIBLE = 0; + private static final int REMOVE_INVISIBLE = 1; + + /* 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 = DEFAULT_COUNT; + /* Metrics for the visible window of our data */ + private int mVisibleColumnCount; + private int mVisibleRowCount; + + /* Used for tracking off-screen change events */ + private int mFirstChangedPosition; + private int mChangedPositionCount; + + /** + * Set the number of columns the layout manager will use. This will + * trigger a layout update. + * @param count Number of columns. + */ + public void setTotalColumnCount(int count) { + mTotalColumnCount = count; + requestLayout(); + } + + /* + * You must return true from this method if you want your + * LayoutManager to support anything beyond "simple" item + * animations. Enabling this causes onLayoutChildren() to + * be called twice on each animated change; once for a + * pre-layout, and again for the real layout. + */ + @Override + public boolean supportsPredictiveItemAnimations() { + return true; + } + + /* + * Called by RecyclerView when a view removal is triggered. This is called + * before onLayoutChildren() in pre-layout if the views removed are not visible. We + * use it in this case to inform pre-layout that a removal took place. + * + * This method is still called if the views removed were visible, but it will + * happen AFTER pre-layout. + */ + @Override + public void onItemsRemoved(RecyclerView recyclerView, int positionStart, int itemCount) { + mFirstChangedPosition = positionStart; + mChangedPositionCount = itemCount; + } + + /* + * 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. + * + * If predictive animations are enabled, you will see this called twice. First, with + * state.isPreLayout() returning true to lay out children in their initial conditions. + * Then again to lay out children in their final locations. + */ + @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; + } + if (getChildCount() == 0 && state.isPreLayout()) { + //Nothing to do during prelayout when empty + return; + } + + //Clear change tracking state when a real layout occurs + if (!state.isPreLayout()) { + mFirstChangedPosition = mChangedPositionCount = 0; + } + + 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(); + + SparseIntArray removedCache = null; + /* + * During pre-layout, we need to take note of any views that are + * being removed in order to handle predictive animations + */ + if (state.isPreLayout()) { + removedCache = new SparseIntArray(getChildCount()); + for (int i=0; i < getChildCount(); i++) { + final View view = getChildAt(i); + LayoutParams lp = (LayoutParams) view.getLayoutParams(); + + if (lp.isItemRemoved()) { + //Track these view removals as visible + removedCache.put(lp.getViewLayoutPosition(), REMOVE_VISIBLE); + } + } + + //Track view removals that happened out of bounds (i.e. off-screen) + if (removedCache.size() == 0 && mChangedPositionCount > 0) { + for (int i = mFirstChangedPosition; i < (mFirstChangedPosition + mChangedPositionCount); i++) { + removedCache.put(i, REMOVE_INVISIBLE); + } + } + } + + + int childLeft; + int childTop; + if (getChildCount() == 0) { //First or empty layout + //Reset the visible and scroll positions + mFirstVisiblePosition = 0; + childLeft = getPaddingLeft(); + childTop = getPaddingTop(); + } else if (!state.isPreLayout() + && getVisibleChildCount() >= state.getItemCount()) { + //Data set is too small to scroll fully, just reset position + mFirstVisiblePosition = 0; + childLeft = getPaddingLeft(); + childTop = getPaddingTop(); + } else { //Adapter data set changes + /* + * Keep the existing initial position, and save off + * the current scrolled offset. + */ + final View topChild = getChildAt(0); + childLeft = getDecoratedLeft(topChild); + childTop = getDecoratedTop(topChild); + + /* + * When data set is too small to scroll vertically, adjust vertical offset + * and shift position to the first row, preserving current column + */ + if (!state.isPreLayout() && getVerticalSpace() > (getTotalRowCount() * mDecoratedChildHeight)) { + mFirstVisiblePosition = mFirstVisiblePosition % getTotalColumnCount(); + childTop = getPaddingTop(); + + //If the shift overscrolls the column max, back it off + if ((mFirstVisiblePosition + mVisibleColumnCount) > state.getItemCount()) { + mFirstVisiblePosition = Math.max(state.getItemCount() - mVisibleColumnCount, 0); + childLeft = getPaddingLeft(); + } + } + + /* + * 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 maxFirstRow = getTotalRowCount() - (mVisibleRowCount-1); + int maxFirstCol = getTotalColumnCount() - (mVisibleColumnCount-1); + boolean isOutOfRowBounds = getFirstVisibleRow() > maxFirstRow; + boolean isOutOfColBounds = getFirstVisibleColumn() > maxFirstCol; + if (isOutOfRowBounds || isOutOfColBounds) { + int firstRow; + if (isOutOfRowBounds) { + firstRow = maxFirstRow; + } else { + firstRow = getFirstVisibleRow(); + } + int firstCol; + if (isOutOfColBounds) { + firstCol = maxFirstCol; + } else { + firstCol = getFirstVisibleColumn(); + } + mFirstVisiblePosition = firstRow * getTotalColumnCount() + firstCol; + + 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, getPaddingTop()); + } + if (getFirstVisibleColumn() == 0) { + childLeft = Math.min(childLeft, getPaddingLeft()); + } + } + } + + //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, state, removedCache); + + //Evaluate any disappearing views that may exist + if (!state.isPreLayout() && !recycler.getScrapList().isEmpty()) { + final List scrapList = recycler.getScrapList(); + final HashSet disappearingViews = new HashSet(scrapList.size()); + + for (RecyclerView.ViewHolder holder : scrapList) { + final View child = holder.itemView; + final LayoutParams lp = (LayoutParams) child.getLayoutParams(); + if (!lp.isItemRemoved()) { + disappearingViews.add(child); + } + } + + for (View child : disappearingViews) { + layoutDisappearingView(child); + } + } + } + + @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, RecyclerView.State state) { + fillGrid(direction, 0, 0, recycler, state, null); + } + + private void fillGrid(int direction, int emptyLeft, int emptyTop, + RecyclerView.Recycler recycler, + RecyclerView.State state, + SparseIntArray removedPositions) { + 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 viewCache = new SparseArray(getChildCount()); + int startLeftOffset = emptyLeft; + int startTopOffset = 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 simply 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); + + /* + * When a removal happens out of bounds, the pre-layout positions of items + * after the removal are shifted to their final positions ahead of schedule. + * We have to track off-screen removals and shift those positions back + * so we can properly lay out all current (and appearing) views in their + * initial locations. + */ + int offsetPositionDelta = 0; + if (state.isPreLayout()) { + int offsetPosition = nextPosition; + + for (int offset = 0; offset < removedPositions.size(); offset++) { + //Look for off-screen removals that are less-than this + if (removedPositions.valueAt(offset) == REMOVE_INVISIBLE + && removedPositions.keyAt(offset) < nextPosition) { + //Offset position to match + offsetPosition--; + } + } + offsetPositionDelta = nextPosition - offsetPosition; + nextPosition = offsetPosition; + } + + if (nextPosition < 0 || nextPosition >= state.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); + + /* + * Update the new view's metadata, but only when this is a real + * layout pass. + */ + if (!state.isPreLayout()) { + LayoutParams lp = (LayoutParams) view.getLayoutParams(); + lp.row = getGlobalRowOfPosition(nextPosition); + lp.column = getGlobalColumnOfPosition(nextPosition); + } + + /* + * 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; + + //During pre-layout, on each column end, apply any additional appearing views + if (state.isPreLayout()) { + layoutAppearingViews(recycler, view, nextPosition, removedPositions.size(), offsetPositionDelta); + } + } 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++) { + final View removingView = viewCache.valueAt(i); + recycler.recycleView(removingView); + } + } + + /* + * 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; + } + + //Set requested position as first visible + mFirstVisiblePosition = position; + //Toss all existing views away + removeAllViews(); + //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, state); + } else if (!rightBoundReached) { + fillGrid(DIRECTION_NONE, recycler, state); + } + } else { + if (getDecoratedLeft(topView) > 0 && !leftBoundReached) { + fillGrid(DIRECTION_START, recycler, state); + } else if (!leftBoundReached) { + fillGrid(DIRECTION_NONE, recycler, state); + } + } + + /* + * 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, state); + } else if (!bottomBoundReached) { + fillGrid(DIRECTION_NONE, recycler, state); + } + } else { + if (getDecoratedTop(topView) > 0 && !topBoundReached) { + fillGrid(DIRECTION_UP, recycler, state); + } else if (!topBoundReached) { + fillGrid(DIRECTION_NONE, recycler, state); + } + } + + /* + * 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; + } + + /* + * 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; + } + + /** Boilerplate to extend LayoutParams for tracking row/column of attached views */ + + /* + * Even without extending LayoutParams, 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 LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT); + } + @Override + public RecyclerView.LayoutParams generateLayoutParams(Context c, AttributeSet attrs) { + return new LayoutParams(c, attrs); + } + @Override + public RecyclerView.LayoutParams generateLayoutParams(ViewGroup.LayoutParams lp) { + if (lp instanceof ViewGroup.MarginLayoutParams) { + return new LayoutParams((ViewGroup.MarginLayoutParams) lp); + } else { + return new LayoutParams(lp); + } + } + @Override + public boolean checkLayoutParams(RecyclerView.LayoutParams lp) { + return lp instanceof LayoutParams; + } + + public static class LayoutParams extends RecyclerView.LayoutParams { + + //Current row in the grid + public int row; + //Current column in the grid + public int column; + + public LayoutParams(Context c, AttributeSet attrs) { + super(c, attrs); + } + public LayoutParams(int width, int height) { + super(width, height); + } + public LayoutParams(ViewGroup.MarginLayoutParams source) { + super(source); + } + public LayoutParams(ViewGroup.LayoutParams source) { + super(source); + } + public LayoutParams(RecyclerView.LayoutParams source) { + super(source); + } + } + + /** Animation Layout Helpers */ + + /* Helper to obtain and place extra appearing views */ + private void layoutAppearingViews(RecyclerView.Recycler recycler, View referenceView, int referencePosition, int extraCount, int offset) { + //Nothing to do... + if (extraCount < 1) return; + + //FIXME: This code currently causes double layout of views that are still visibleā€¦ + for (int extra = 1; extra <= extraCount; extra++) { + //Grab the next position after the reference + final int extraPosition = referencePosition + extra; + if (extraPosition < 0 || extraPosition >= getItemCount()) { + //Can't do anything with this + continue; + } + + /* + * Obtain additional position views that we expect to appear + * as part of the animation. + */ + View appearing = recycler.getViewForPosition(extraPosition); + addView(appearing); + + //Find layout delta from reference position + final int newRow = getGlobalRowOfPosition(extraPosition + offset); + final int rowDelta = newRow - getGlobalRowOfPosition(referencePosition + offset); + final int newCol = getGlobalColumnOfPosition(extraPosition + offset); + final int colDelta = newCol - getGlobalColumnOfPosition(referencePosition + offset); + + layoutTempChildView(appearing, rowDelta, colDelta, referenceView); + } + } + + /* Helper to place a disappearing view */ + private void layoutDisappearingView(View disappearingChild) { + /* + * LayoutManager has a special method for attaching views that + * will only be around long enough to animate. + */ + addDisappearingView(disappearingChild); + + //Adjust each disappearing view to its proper place + final LayoutParams lp = (LayoutParams) disappearingChild.getLayoutParams(); + + final int newRow = getGlobalRowOfPosition(lp.getViewAdapterPosition()); + final int rowDelta = newRow - lp.row; + final int newCol = getGlobalColumnOfPosition(lp.getViewAdapterPosition()); + final int colDelta = newCol - lp.column; + + layoutTempChildView(disappearingChild, rowDelta, colDelta, disappearingChild); + } + + + /* Helper to lay out appearing/disappearing children */ + private void layoutTempChildView(View child, int rowDelta, int colDelta, View referenceView) { + //Set the layout position to the global row/column difference from the reference view + int layoutTop = getDecoratedTop(referenceView) + rowDelta * mDecoratedChildHeight; + int layoutLeft = getDecoratedLeft(referenceView) + colDelta * mDecoratedChildWidth; + + measureChildWithMargins(child, 0, 0); + layoutDecorated(child, layoutLeft, layoutTop, + layoutLeft + mDecoratedChildWidth, + layoutTop + mDecoratedChildHeight); + } + + /** Private Helpers and Metrics Accessors */ + + /* Return the overall column index of this position in the global layout */ + private int getGlobalColumnOfPosition(int position) { + return position % mTotalColumnCount; + } + /* Return the overall row index of this position in the global layout */ + private int getGlobalRowOfPosition(int position) { + return position / mTotalColumnCount; + } + + /* + * 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() { + if (getItemCount() < mTotalColumnCount) { + return getItemCount(); + } + + return mTotalColumnCount; + } + + private int getTotalRowCount() { + if (getItemCount() == 0 || mTotalColumnCount == 0) { + return 0; + } + 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(); + } +} \ No newline at end of file diff --git a/common/src/main/res/layout-watch/fragment_level.xml b/common/src/main/res/layout-watch/fragment_level.xml index 6c025af6..cb88f8cd 100644 --- a/common/src/main/res/layout-watch/fragment_level.xml +++ b/common/src/main/res/layout-watch/fragment_level.xml @@ -1,34 +1,19 @@ - + + + android:clipChildren="false" + android:clipToPadding="false" + android:fadeScrollbars="true" + android:overScrollMode="never" + android:padding="78dp" + android:scrollbars="none" + android:alpha="0" /> - - - - - - + diff --git a/common/src/main/res/layout/fragment_level.xml b/common/src/main/res/layout/fragment_level.xml index 68d66f25..c3079b16 100644 --- a/common/src/main/res/layout/fragment_level.xml +++ b/common/src/main/res/layout/fragment_level.xml @@ -1,31 +1,20 @@ - - + android:alpha="0" /> - - - - +