StaggeredGridLayoutManager for recycler view
Change-Id: Ie5466806aa0428a9f9f2b9eb6b85dbcfbbaebe5f
diff --git a/v7/recyclerview/src/android/support/v7/widget/LayoutState.java b/v7/recyclerview/src/android/support/v7/widget/LayoutState.java
new file mode 100644
index 0000000..e62a80a
--- /dev/null
+++ b/v7/recyclerview/src/android/support/v7/widget/LayoutState.java
@@ -0,0 +1,87 @@
+/*
+ * Copyright (C) 2014 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific languag`e governing permissions and
+ * limitations under the License.
+ */
+
+package android.support.v7.widget;
+import android.view.View;
+
+/**
+ * Helper class that keeps temporary state while {LayoutManager} is filling out the empty
+ * space.
+ */
+class LayoutState {
+
+ final static String TAG = "LayoutState";
+
+ final static int LAYOUT_START = -1;
+
+ final static int LAYOUT_END = 1;
+
+ final static int INVALID_LAYOUT = Integer.MIN_VALUE;
+
+ final static int ITEM_DIRECTION_HEAD = -1;
+
+ final static int ITEM_DIRECTION_TAIL = 1;
+
+ final static int SCOLLING_OFFSET_NaN = Integer.MIN_VALUE;
+
+ /**
+ * Number of pixels that we should fill, in the layout direction.
+ */
+ int mAvailable;
+
+ /**
+ * Current position on the adapter to get the next item.
+ */
+ int mCurrentPosition;
+
+ /**
+ * Defines the direction in which the data adapter is traversed.
+ * Should be {@link #ITEM_DIRECTION_HEAD} or {@link #ITEM_DIRECTION_TAIL}
+ */
+ int mItemDirection;
+
+ /**
+ * Defines the direction in which the layout is filled.
+ * Should be {@link #LAYOUT_START} or {@link #LAYOUT_END}
+ */
+ int mLayoutDirection;
+
+ /**
+ * Used if you want to pre-layout items that are not yet visible.
+ * The difference with {@link #mAvailable} is that, when recycling, distance rendered for
+ * {@link #mExtra} is not considered not to recycle visible children.
+ */
+ int mExtra = 0;
+
+ /**
+ * @return true if there are more items in the data adapter
+ */
+ boolean hasMore(RecyclerView.State state) {
+ return mCurrentPosition >= 0 && mCurrentPosition < state.getItemCount();
+ }
+
+ /**
+ * Gets the view for the next element that we should render.
+ * Also updates current item index to the next item, based on {@link #mItemDirection}
+ *
+ * @return The next element that we should render.
+ */
+ View next(RecyclerView.Recycler recycler) {
+ final View view = recycler.getViewForPosition(mCurrentPosition);
+ mCurrentPosition += mItemDirection;
+ return view;
+ }
+}
diff --git a/v7/recyclerview/src/android/support/v7/widget/StaggeredGridLayoutManager.java b/v7/recyclerview/src/android/support/v7/widget/StaggeredGridLayoutManager.java
new file mode 100644
index 0000000..7904f8b
--- /dev/null
+++ b/v7/recyclerview/src/android/support/v7/widget/StaggeredGridLayoutManager.java
@@ -0,0 +1,1909 @@
+/*
+ * Copyright (C) 2014 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package android.support.v7.widget;
+
+import android.content.Context;
+import android.graphics.PointF;
+import android.graphics.Rect;
+import android.os.Parcel;
+import android.os.Parcelable;
+import android.support.v4.view.ViewCompat;
+import android.util.AttributeSet;
+import android.util.Log;
+import android.view.View;
+import android.view.ViewGroup;
+
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.BitSet;
+import java.util.List;
+
+
+import static android.support.v7.widget.LayoutState.LAYOUT_START;
+import static android.support.v7.widget.LayoutState.LAYOUT_END;
+import static android.support.v7.widget.LayoutState.ITEM_DIRECTION_HEAD;
+import static android.support.v7.widget.LayoutState.ITEM_DIRECTION_TAIL;
+import static android.support.v7.widget.OrientationHelper.HORIZONTAL;
+import static android.support.v7.widget.OrientationHelper.VERTICAL;
+
+/**
+ * A LayoutManager that lays out children in a staggered grid formation.
+ * It supports horizontal & vertical layout as well as an ability to layout children in reverse.
+ * <p>
+ * Staggered grids are likely to have gaps at the edges of the layout. To avoid these gaps,
+ * StaggeredGridLayoutManager can offset spans independently or move items between spans. You can
+ * control this behavior via {@link #setGapStrategy(int)}.
+ */
+public class StaggeredGridLayoutManager extends RecyclerView.LayoutManager {
+
+ public static final String TAG = "StaggeredGridLayoutManager";
+
+ private static final boolean DEBUG = false;
+
+ /**
+ * Does not do anything to hide gaps
+ */
+ public static final int GAP_HANDLING_NONE = 0;
+
+ /**
+ * Scroll the shorter span slower to avoid gaps in the UI.
+ * <p>
+ * For example, if LayoutManager ends up with the following layout:
+ * <code>
+ * BXC
+ * DEF
+ * </code>
+ * Where B has two spans height, if user scrolls down it will keep the positions of 2nd and 3rd
+ * columns,
+ * which will result in:
+ * <code>
+ * BXC
+ * BEF
+ * </code>
+ * instead of
+ * <code>
+ * B
+ * BEF
+ * </code>
+ */
+ public static final int GAP_HANDLING_LAZY = 1;
+
+ /**
+ * On scroll, LayoutManager checks for a view that is assigned to wrong span.
+ * When such a situation is detected, LayoutManager will wait until scroll is complete and then
+ * move children to their correct spans.
+ * <p>
+ * For example, if LayoutManager ends up with the following layout due to adapter changes:
+ * <code>
+ * AAA
+ * _BC
+ * DDD
+ * </code>
+ * It will animate to the following state:
+ * <code>
+ * AAA
+ * BC_
+ * DDD
+ * </code>
+ */
+ public static final int GAP_HANDLING_MOVE_ITEMS_BETWEEN_SPANS = 2;
+
+ private static final int INVALID_OFFSET = Integer.MIN_VALUE;
+
+ /**
+ * Number of spans
+ */
+ private int mSpanCount = -1;
+
+ private Span[] mSpans;
+
+ /**
+ * Primary orientation is the layout's orientation, secondary orientation is the orientation
+ * for spans. Having both makes code much cleaner for calculations.
+ */
+ OrientationHelper mPrimaryOrientation;
+ OrientationHelper mSecondaryOrientation;
+
+ private int mOrientation;
+
+ /**
+ * The width or height per span, depending on the orientation.
+ */
+ private int mSizePerSpan;
+
+ private LayoutState mLayoutState;
+
+ private boolean mReverseLayout = false;
+
+ /**
+ * Aggregated reverse layout value that takes RTL into account.
+ */
+ private boolean mShouldReverseLayout = false;
+
+ /**
+ * Temporary variable used during fill method to check which spans needs to be filled.
+ */
+ private BitSet mRemainingSpans;
+
+ /**
+ * When LayoutManager needs to scroll to a position, it sets this variable and requests a
+ * layout which will check this variable and re-layout accordingly.
+ */
+ private int mPendingScrollPosition = RecyclerView.NO_POSITION;
+
+ /**
+ * Used to keep the offset value when {@link #scrollToPositionWithOffset(int, int)} is
+ * called.
+ */
+ private int mPendingScrollPositionOffset = INVALID_OFFSET;
+
+ /**
+ * Keeps the mapping between the adapter positions and spans. This is necessary to provide
+ * a consistent experience when user scrolls the list.
+ */
+ LazySpanLookup mLazySpanLookup = new LazySpanLookup();
+
+ /**
+ * how we handle gaps in UI.
+ */
+ private int mGapStrategy = GAP_HANDLING_MOVE_ITEMS_BETWEEN_SPANS;
+
+ /**
+ * This list is used to keep track of views that we should remove. Due to complexity of not
+ * adding views, StaggeredGridLayoutManager keeps a list of children to be removed and removes
+ * them after first layout pass.
+ * TODO this will not be necessary when RV is updated not to require laying out not-added
+ * children
+ */
+ private final List<View> mChildrenToBeRemoved = new ArrayList<View>();
+
+ /**
+ * Saved state needs this information to properly layout on restore.
+ */
+ private boolean mLastLayoutFromEnd;
+
+ /**
+ * SavedState is not handled until a layout happens. This is where we keep it until next
+ * layout.
+ */
+ private SavedState mPendingSavedState;
+
+ /**
+ * If LayoutManager detects an unwanted gap in the layout, it sets this flag which will trigger
+ * a runnable after scrolling ends and will re-check. If invalid view state is still present,
+ * it will request a layout to fix it.
+ */
+ private boolean mHasGaps;
+
+ /**
+ * Creates a StaggeredGridLayoutManager with given parameters.
+ *
+ * @param spanCount If orientation is vertical, spanCount is number of columns. If
+ * orientation is horizontal, spanCount is number of rows.
+ * @param orientation {@link OrientationHelper#VERTICAL} or {@link OrientationHelper#HORIZONTAL}
+ */
+ public StaggeredGridLayoutManager(int spanCount, int orientation) {
+ mOrientation = orientation;
+ setSpanCount(spanCount);
+ }
+
+ @Override
+ public void onScrollStateChanged(int state) {
+ if (state == RecyclerView.SCROLL_STATE_IDLE && mHasGaps) {
+ // re-check for gaps
+ View gapView = hasGapsToFix(0, getChildCount());
+ if (gapView == null) {
+ mHasGaps = false; // yay, gap disappeared :)
+ // We should invalidate positions after the last visible child. No reason to
+ // re-layout.
+ final int lastVisiblePosition = mShouldReverseLayout ? getFirstChildPosition()
+ : getLastChildPosition();
+ mLazySpanLookup.invalidateAfter(lastVisiblePosition + 1);
+ } else {
+ mLazySpanLookup.invalidateAfter(getPosition(gapView));
+ requestLayout(); // Trigger a re-layout which will fix the layout assignments.
+ }
+ }
+ }
+
+ /**
+ * Sets the number of spans for the layout. This will invalidate all of the span assignments
+ * for Views.
+ * <p>
+ * Calling this method will automatically result in a new layout request unless the spanCount
+ * parameter is equal to current span count.
+ *
+ * @param spanCount Number of spans to layout
+ */
+ public void setSpanCount(int spanCount) {
+ if (mPendingSavedState != null && mPendingSavedState.mSpanCount != spanCount) {
+ // invalidate span info in saved state
+ mPendingSavedState.invalidateSpanInfo();
+ mPendingSavedState.mSpanCount = spanCount;
+ }
+ if (spanCount != mSpanCount) {
+ invalidateSpanAssignments();
+ mSpanCount = spanCount;
+ mRemainingSpans = new BitSet(mSpanCount);
+ mSpans = new Span[mSpanCount];
+ for (int i = 0; i < mSpanCount; i++) {
+ mSpans[i] = new Span(i);
+ }
+ requestLayout();
+ }
+ }
+
+ /**
+ * Sets the orientation of the layout. StaggeredGridLayoutManager will do its best to keep
+ * scroll position.
+ *
+ * @param orientation {@link OrientationHelper#HORIZONTAL} or {@link OrientationHelper#VERTICAL}
+ */
+ public void setOrientation(int orientation) {
+ if (orientation != HORIZONTAL && orientation != VERTICAL) {
+ throw new IllegalArgumentException("invalid orientation.");
+ }
+ if (mPendingSavedState != null && mPendingSavedState.mOrientation != orientation) {
+ // override pending state
+ mPendingSavedState.mOrientation = orientation;
+ }
+ if (orientation == mOrientation) {
+ return;
+ }
+ mOrientation = orientation;
+ if (mPrimaryOrientation != null && mSecondaryOrientation != null) {
+ // swap
+ OrientationHelper tmp = mPrimaryOrientation;
+ mPrimaryOrientation = mSecondaryOrientation;
+ mSecondaryOrientation = tmp;
+ }
+ requestLayout();
+ }
+
+ /**
+ * Sets whether LayoutManager should start laying out items from the end of the UI. The order
+ * items are traversed is not affected by this call.
+ * <p>
+ * This behaves similar to the layout change for RTL views. When set to true, first item is
+ * laid out at the end of the ViewGroup, second item is laid out before it etc.
+ * <p>
+ * For horizontal layouts, it depends on the layout direction.
+ * When set to true, If {@link RecyclerView} is LTR, than it will layout from RTL, if
+ * {@link RecyclerView}} is RTL, it will layout from LTR.
+ *
+ * @param reverseLayout Whether layout should be in reverse or not
+ */
+ public void setReverseLayout(boolean reverseLayout) {
+ if (mPendingSavedState != null && mPendingSavedState.mReverseLayout != reverseLayout) {
+ mPendingSavedState.mReverseLayout = reverseLayout;
+ }
+ mReverseLayout = reverseLayout;
+ requestLayout();
+ }
+
+ /**
+ * Returns the current gap handling strategy for StaggeredGridLayoutManager.
+ * <p>
+ * Staggered grid may have gaps in the layout as items may have different sizes. To avoid gaps,
+ * StaggeredGridLayoutManager provides 3 options. Check {@link #GAP_HANDLING_NONE},
+ * {@link #GAP_HANDLING_MOVE_ITEMS_BETWEEN_SPANS}, {@link #GAP_HANDLING_LAZY} for details.
+ * <p>
+ * By default, StaggeredGridLayoutManager uses {@link #GAP_HANDLING_MOVE_ITEMS_BETWEEN_SPANS}.
+ *
+ * @return Current gap handling strategy.
+ * @see #setGapStrategy(int)
+ * @see #GAP_HANDLING_NONE
+ * @see #GAP_HANDLING_LAZY
+ * @see #GAP_HANDLING_MOVE_ITEMS_BETWEEN_SPANS
+ */
+ public int getGapStrategy() {
+ return mGapStrategy;
+ }
+
+ /**
+ * Sets the gap handling strategy for StaggeredGridLayoutManager. If the gapStrategy parameter
+ * is different than the current strategy, calling this method will trigger a layout request.
+ *
+ * @param gapStrategy The new gap handling strategy. Should be {@link #GAP_HANDLING_LAZY}
+ * , {@link #GAP_HANDLING_MOVE_ITEMS_BETWEEN_SPANS} or
+ * {@link #GAP_HANDLING_NONE}
+ * @see #getGapStrategy()
+ */
+ public void setGapStrategy(int gapStrategy) {
+ if (mPendingSavedState != null && mPendingSavedState.mGapStrategy != gapStrategy) {
+ mPendingSavedState.mGapStrategy = gapStrategy;
+ }
+ if (gapStrategy == mGapStrategy) {
+ return;
+ }
+ if (gapStrategy != GAP_HANDLING_LAZY && gapStrategy != GAP_HANDLING_NONE &&
+ gapStrategy != GAP_HANDLING_MOVE_ITEMS_BETWEEN_SPANS) {
+ throw new IllegalArgumentException("invalid gap strategy. Must be GAP_HANDLING_NONE "
+ + ", GAP_HANDLING_LAZY or GAP_HANDLING_MOVE_ITEMS_BETWEEN_SPANS");
+ }
+ mGapStrategy = gapStrategy;
+ requestLayout();
+ }
+
+ /**
+ * Returns the number of spans laid out by StaggeredGridLayoutManager.
+ *
+ * @return Number of spans in the layout
+ */
+ public int getSpanCount() {
+ return mSpanCount;
+ }
+
+ /**
+ * For consistency, StaggeredGridLayoutManager keeps a mapping between spans and items.
+ * <p>
+ * If you need to cancel current assignments, you can call this method which will clear all
+ * assignments and request a new layout.
+ */
+ public void invalidateSpanAssignments() {
+ mLazySpanLookup.clear();
+ requestLayout();
+ }
+
+ private void ensureOrientationHelper() {
+ if (mPrimaryOrientation == null) {
+ mPrimaryOrientation = OrientationHelper.createOrientationHelper(this, mOrientation);
+ mSecondaryOrientation = OrientationHelper
+ .createOrientationHelper(this, 1 - mOrientation);
+ mLayoutState = new LayoutState();
+ }
+ }
+
+ /**
+ * Calculates the views' layout order. (e.g. from end to start or start to end)
+ * RTL layout support is applied automatically. So if layout is RTL and
+ * {@link #getReverseLayout()} is {@code true}, elements will be laid out starting from left.
+ */
+ private void resolveShouldLayoutReverse() {
+ // A == B is the same result, but we rather keep it readable
+ if (mOrientation == VERTICAL || !isLayoutRTL()) {
+ mShouldReverseLayout = mReverseLayout;
+ } else {
+ mShouldReverseLayout = !mReverseLayout;
+ }
+ }
+
+ private boolean isLayoutRTL() {
+ return getLayoutDirection() == ViewCompat.LAYOUT_DIRECTION_RTL;
+ }
+
+ /**
+ * Returns whether views are laid out in reverse order or not.
+ * <p>
+ * Not that this value is not affected by RecyclerView's layout direction.
+ *
+ * @return True if layout is reversed, false otherwise
+ * @see #setReverseLayout(boolean)
+ */
+ public boolean getReverseLayout() {
+ return mReverseLayout;
+ }
+
+ @Override
+ public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
+ ensureOrientationHelper();
+ // Update adapter size.
+ mLazySpanLookup.mAdapterSize = state.getItemCount();
+ int anchorItemPosition;
+ int anchorOffset;
+ // This value may change if we are jumping to a position.
+ boolean layoutFromEnd;
+
+ // If set to true, spans will clear their offsets and they'll be laid out from start
+ // depending on the layout direction. Invalidating span offsets is necessary to be able
+ // to jump to a position.
+ boolean invalidateSpanOffsets = false;
+
+ if (mPendingSavedState != null) {
+ if (DEBUG) {
+ Log.d(TAG, "found saved state: " + mPendingSavedState);
+ }
+ setOrientation(mPendingSavedState.mOrientation);
+ setSpanCount(mPendingSavedState.mSpanCount);
+ setGapStrategy(mPendingSavedState.mGapStrategy);
+ setReverseLayout(mPendingSavedState.mReverseLayout);
+ resolveShouldLayoutReverse();
+
+ if (mPendingSavedState.mAnchorPosition != RecyclerView.NO_POSITION) {
+ mPendingScrollPosition = mPendingSavedState.mAnchorPosition;
+ layoutFromEnd = mPendingSavedState.mAnchorLayoutFromEnd;
+ } else {
+ layoutFromEnd = mShouldReverseLayout;
+ }
+ if (mPendingSavedState.mHasSpanOffsets) {
+ for (int i = 0; i < mSpanCount; i++) {
+ mSpans[i].clear();
+ mSpans[i].setLine(mPendingSavedState.mSpanOffsets[i]);
+ }
+ }
+ if (mPendingSavedState.mSpanLookupSize > 1) {
+ mLazySpanLookup.mData = mPendingSavedState.mSpanLookup;
+ }
+
+ } else {
+ resolveShouldLayoutReverse();
+ layoutFromEnd = mShouldReverseLayout; // get updated value.
+ }
+
+ // Validate scroll position if exists.
+ if (mPendingScrollPosition != RecyclerView.NO_POSITION) {
+ // Validate it.
+ if (mPendingScrollPosition < 0 || mPendingScrollPosition >= state.getItemCount()) {
+ mPendingScrollPosition = RecyclerView.NO_POSITION;
+ mPendingScrollPositionOffset = INVALID_OFFSET;
+ }
+ }
+
+ if (mPendingScrollPosition != RecyclerView.NO_POSITION) {
+ if (mPendingSavedState == null
+ || mPendingSavedState.mAnchorPosition == RecyclerView.NO_POSITION
+ || !mPendingSavedState.mHasSpanOffsets) {
+ // If item is visible, make it fully visible.
+ final View child = findViewByPosition(mPendingScrollPosition);
+ if (child != null) {
+ if (mPendingScrollPositionOffset != INVALID_OFFSET) {
+ // Use regular anchor position.
+ anchorItemPosition = mShouldReverseLayout ? getLastChildPosition()
+ : getFirstChildPosition();
+ if (layoutFromEnd) {
+ final int target = mPrimaryOrientation.getEndAfterPadding() -
+ mPendingScrollPositionOffset;
+ anchorOffset = target - mPrimaryOrientation.getDecoratedEnd(child);
+ } else {
+ final int target = mPrimaryOrientation.getStartAfterPadding() +
+ mPendingScrollPositionOffset;
+ anchorOffset = target - mPrimaryOrientation.getDecoratedStart(child);
+ }
+ } else {
+ final int startGap = mPrimaryOrientation.getDecoratedStart(child)
+ - mPrimaryOrientation.getStartAfterPadding();
+ final int endGap = mPrimaryOrientation.getEndAfterPadding() -
+ mPrimaryOrientation.getDecoratedEnd(child);
+ final int childSize = mPrimaryOrientation.getDecoratedMeasurement(child);
+ // Use regular anchor item, just offset the layout.
+ anchorItemPosition = mShouldReverseLayout ? getLastChildPosition()
+ : getFirstChildPosition();
+ if (childSize > mPrimaryOrientation.getTotalSpace()) {
+ // Item does not fit. Fix depending on layout direction.
+ anchorOffset = layoutFromEnd ? mPrimaryOrientation.getEndAfterPadding()
+ : mPrimaryOrientation.getStartAfterPadding();
+ } else if (startGap < 0) {
+ anchorOffset = -startGap;
+ } else if (endGap < 0) {
+ anchorOffset = endGap;
+ } else {
+ // Nothing to do, just layout normal.
+ anchorItemPosition = mShouldReverseLayout ? getLastChildPosition()
+ : getFirstChildPosition();
+ anchorOffset = INVALID_OFFSET;
+ }
+ }
+ } else {
+ // Child is not visible. Set anchor coordinate depending on in which direction
+ // child will be visible.
+ anchorItemPosition = mPendingScrollPosition;
+ if (mPendingScrollPositionOffset == INVALID_OFFSET) {
+ final int position = calculateScrollDirectionForPosition(
+ anchorItemPosition);
+ if (position == LAYOUT_START) {
+ anchorOffset = mPrimaryOrientation.getStartAfterPadding();
+ layoutFromEnd = false;
+ } else {
+ anchorOffset = mPrimaryOrientation.getEndAfterPadding();
+ layoutFromEnd = true;
+ }
+ } else {
+ if (layoutFromEnd) {
+ anchorOffset = mPrimaryOrientation.getEndAfterPadding()
+ - mPendingScrollPositionOffset;
+ } else {
+ anchorOffset = mPrimaryOrientation.getStartAfterPadding()
+ + mPendingScrollPositionOffset;
+ }
+ }
+ invalidateSpanOffsets = true;
+ }
+ } else {
+ anchorOffset = INVALID_OFFSET;
+ anchorItemPosition = mPendingScrollPosition;
+ }
+
+ } else {
+ // We don't recycle views out of adapter order. This way, we can rely on the first or
+ // last child as the anchor position.
+ anchorItemPosition = mShouldReverseLayout ? getLastChildPosition()
+ : getFirstChildPosition();
+ anchorOffset = INVALID_OFFSET;
+ }
+ if (getChildCount() > 0 && (mPendingSavedState == null
+ || !mPendingSavedState.mHasSpanOffsets)) {
+ if (invalidateSpanOffsets) {
+ for (int i = 0; i < mSpanCount; i++) {
+ // Scroll to position is set, clear.
+ mSpans[i].clear();
+ if (anchorOffset != INVALID_OFFSET) {
+ mSpans[i].setLine(anchorOffset);
+ }
+ }
+ } else {
+ for (int i = 0; i < mSpanCount; i++) {
+ mSpans[i].cacheReferenceLineAndClear(mShouldReverseLayout, anchorOffset);
+ }
+ if (DEBUG) {
+ for (int i = 0; i < mSpanCount; i++) {
+ Log.d(TAG, "cached start-end lines for " + i + ":" +
+ mSpans[i].mCachedStart + ":" + mSpans[i].mCachedEnd);
+ }
+ }
+ }
+ }
+ mSizePerSpan = mSecondaryOrientation.getTotalSpace() / mSpanCount;
+ detachAndScrapAttachedViews(recycler);
+ // Layout start.
+ updateLayoutStateToFillStart(anchorItemPosition, state);
+ if (!layoutFromEnd) {
+ mLayoutState.mCurrentPosition += mLayoutState.mItemDirection;
+ }
+ fill(recycler, mLayoutState, state);
+
+ // Layout end.
+ updateLayoutStateToFillEnd(anchorItemPosition, state);
+ if (layoutFromEnd) {
+ mLayoutState.mCurrentPosition += mLayoutState.mItemDirection;
+ }
+ fill(recycler, mLayoutState, state);
+
+ if (getChildCount() > 0) {
+ if (mShouldReverseLayout) {
+ fixEndGap(recycler, state, true);
+ fixStartGap(recycler, state, false);
+ } else {
+ fixStartGap(recycler, state, true);
+ fixEndGap(recycler, state, false);
+ }
+ }
+
+ // TODO remove this once RecyclerView is updated not to require this.
+ final int removedChildCount = mChildrenToBeRemoved.size();
+ for (int i = removedChildCount - 1; i >= 0; i--) {
+ View view = mChildrenToBeRemoved.get(i);
+ removeView(view);
+ }
+ mChildrenToBeRemoved.clear();
+ mPendingScrollPosition = RecyclerView.NO_POSITION;
+ mPendingScrollPositionOffset = INVALID_OFFSET;
+ mLastLayoutFromEnd = layoutFromEnd;
+ mPendingSavedState = null; // we don't need this anymore
+ }
+
+ /**
+ * Checks if a child is assigned to the non-optimal span.
+ *
+ * @param startChildIndex Starts checking after this child, inclusive
+ * @param endChildIndex Starts checking until this child, exclusive
+ * @return The first View that is assigned to the wrong span.
+ */
+ View hasGapsToFix(int startChildIndex, int endChildIndex) {
+ // quick reject
+ if (startChildIndex >= endChildIndex) {
+ return null;
+ }
+ final int firstChildIndex, childLimit;
+ final int nextSpanDiff = mOrientation == VERTICAL && isLayoutRTL() ? 1 : -1;
+
+ if (mShouldReverseLayout) {
+ firstChildIndex = endChildIndex - 1;
+ childLimit = startChildIndex - 1;
+ } else {
+ firstChildIndex = startChildIndex;
+ childLimit = endChildIndex;
+ }
+ final int nextChildDiff = firstChildIndex < childLimit ? 1 : -1;
+ for (int i = firstChildIndex; i != childLimit; i += nextChildDiff) {
+ View child = getChildAt(i);
+ final int start = mPrimaryOrientation.getDecoratedStart(child);
+ final int end = mPrimaryOrientation.getDecoratedEnd(child);
+ LayoutParams layoutParams = (LayoutParams) child.getLayoutParams();
+ if (layoutParams.mFullSpan) {
+ continue; // quick reject
+ }
+ int nextSpanIndex = layoutParams.getSpanIndex() + nextSpanDiff;
+ while (nextSpanIndex >= 0 && nextSpanIndex < mSpanCount) {
+ Span nextSpan = mSpans[nextSpanIndex];
+ if (nextSpan.isEmpty(start, end)) {
+ return child;
+ }
+ nextSpanIndex += nextSpanDiff;
+ }
+ }
+ // everything looks good
+ return null;
+ }
+
+ @Override
+ public boolean supportsPredictiveItemAnimations() {
+ return true;
+ }
+
+ private void measureChildWithDecorationsAndMargin(View child, int widthSpec,
+ int heightSpec) {
+ final Rect insets = mRecyclerView.getItemDecorInsetsForChild(child);
+ LayoutParams lp = (LayoutParams) child.getLayoutParams();
+ widthSpec = updateSpecWithExtra(widthSpec, lp.leftMargin + insets.left,
+ lp.rightMargin + insets.right);
+ heightSpec = updateSpecWithExtra(heightSpec, lp.topMargin + insets.top,
+ lp.bottomMargin + insets.bottom);
+ child.measure(widthSpec, heightSpec);
+ }
+
+ private int updateSpecWithExtra(int spec, int startInset, int endInset) {
+ if (startInset == 0 && endInset == 0) {
+ return spec;
+ }
+ final int mode = View.MeasureSpec.getMode(spec);
+ if (mode == View.MeasureSpec.AT_MOST || mode == View.MeasureSpec.EXACTLY) {
+ return View.MeasureSpec.makeMeasureSpec(
+ View.MeasureSpec.getSize(spec) - startInset - endInset, mode);
+ }
+ return spec;
+ }
+
+ @Override
+ public void onRestoreInstanceState(Parcelable state) {
+ if (state instanceof SavedState) {
+ mPendingSavedState = (SavedState) state;
+ requestLayout();
+ } else if (DEBUG) {
+ Log.d(TAG, "invalid saved state class");
+ }
+ }
+
+ @Override
+ public Parcelable onSaveInstanceState() {
+ if (mPendingSavedState != null) {
+ return new SavedState(mPendingSavedState);
+ }
+ SavedState state = new SavedState();
+ state.mOrientation = mOrientation;
+ state.mReverseLayout = mReverseLayout;
+ state.mSpanCount = mSpanCount;
+ state.mAnchorLayoutFromEnd = mLastLayoutFromEnd;
+ state.mGapStrategy = mGapStrategy;
+
+ if (mLazySpanLookup != null && mLazySpanLookup.mData != null) {
+ state.mSpanLookup = mLazySpanLookup.mData;
+ state.mSpanLookupSize = state.mSpanLookup.length;
+ } else {
+ state.mSpanLookupSize = 0;
+ }
+
+ if (getChildCount() > 0) {
+ state.mAnchorPosition = mLastLayoutFromEnd ? getLastChildPosition()
+ : getFirstChildPosition();
+ state.mHasSpanOffsets = true;
+ state.mSpanOffsets = new int[mSpanCount];
+ for (int i = 0; i < mSpanCount; i++) {
+ state.mSpanOffsets[i] = mLastLayoutFromEnd ? mSpans[i].getEndLine()
+ : mSpans[i].getStartLine();
+ }
+ } else {
+ state.mAnchorPosition = RecyclerView.NO_POSITION;
+ state.mHasSpanOffsets = false;
+ }
+ if (DEBUG) {
+ Log.d(TAG, "saved state:\n" + state);
+ }
+ return state;
+ }
+
+ private void fixEndGap(RecyclerView.Recycler recycler, RecyclerView.State state,
+ boolean canOffsetChildren) {
+ final int maxEndLine = getMaxEnd(mPrimaryOrientation.getEndAfterPadding());
+ int gap = mPrimaryOrientation.getEndAfterPadding() - maxEndLine;
+ int fixOffset;
+ if (gap > 0) {
+ fixOffset = -scrollBy(-gap, recycler, state);
+ } else {
+ return; // nothing to fix
+ }
+ gap -= fixOffset;
+ if (canOffsetChildren && gap > 0) {
+ mPrimaryOrientation.offsetChildren(gap);
+ }
+ }
+
+ private void fixStartGap(RecyclerView.Recycler recycler, RecyclerView.State state,
+ boolean canOffsetChildren) {
+ final int minStartLine = getMinStart(mPrimaryOrientation.getStartAfterPadding());
+ int gap = minStartLine - mPrimaryOrientation.getStartAfterPadding();
+ int fixOffset;
+ if (gap > 0) {
+ fixOffset = scrollBy(gap, recycler, state);
+ } else {
+ return; // nothing to fix
+ }
+ gap -= fixOffset;
+ if (canOffsetChildren && gap > 0) {
+ mPrimaryOrientation.offsetChildren(-gap);
+ }
+ }
+
+ private void updateLayoutStateToFillStart(int anchorPosition, RecyclerView.State state) {
+ mLayoutState.mAvailable = 0;
+ mLayoutState.mCurrentPosition = anchorPosition;
+ if (isSmoothScrolling()) {
+ final int targetPos = state.getTargetScrollPosition();
+ if (mShouldReverseLayout == targetPos < anchorPosition) {
+ mLayoutState.mExtra = 0;
+ } else {
+ mLayoutState.mExtra = mPrimaryOrientation.getTotalSpace();
+ }
+ } else {
+ mLayoutState.mExtra = 0;
+ }
+ mLayoutState.mLayoutDirection = LAYOUT_START;
+ mLayoutState.mItemDirection = mShouldReverseLayout ? ITEM_DIRECTION_TAIL
+ : ITEM_DIRECTION_HEAD;
+ }
+
+ private void updateLayoutStateToFillEnd(int anchorPosition, RecyclerView.State state) {
+ mLayoutState.mAvailable = 0;
+ mLayoutState.mCurrentPosition = anchorPosition;
+ if (isSmoothScrolling()) {
+ final int targetPos = state.getTargetScrollPosition();
+ if (mShouldReverseLayout == targetPos > anchorPosition) {
+ mLayoutState.mExtra = 0;
+ } else {
+ mLayoutState.mExtra = mPrimaryOrientation.getTotalSpace();
+ }
+ } else {
+ mLayoutState.mExtra = 0;
+ }
+ mLayoutState.mLayoutDirection = LAYOUT_END;
+ mLayoutState.mItemDirection = mShouldReverseLayout ? ITEM_DIRECTION_HEAD
+ : ITEM_DIRECTION_TAIL;
+ }
+
+ @Override
+ public void offsetChildrenHorizontal(int dx) {
+ super.offsetChildrenHorizontal(dx);
+ for (int i = 0; i < mSpanCount; i++) {
+ mSpans[i].onOffset(dx);
+ }
+ }
+
+ @Override
+ public void offsetChildrenVertical(int dy) {
+ super.offsetChildrenVertical(dy);
+ for (int i = 0; i < mSpanCount; i++) {
+ mSpans[i].onOffset(dy);
+ }
+ }
+
+ @Override
+ public void onItemsRemoved(RecyclerView recyclerView, int positionStart, int itemCount) {
+ if (!considerSpanInvalidate(positionStart, itemCount)) {
+ // If positions are not invalidated, move span offsets.
+ mLazySpanLookup.offsetForRemoval(positionStart, itemCount);
+ }
+ }
+
+ @Override
+ public void onItemsAdded(RecyclerView recyclerView, int positionStart, int itemCount) {
+ if (!considerSpanInvalidate(positionStart, itemCount)) {
+ // If positions are not invalidated, move span offsets.
+ mLazySpanLookup.offsetForAddition(positionStart, itemCount);
+ }
+ }
+
+ /**
+ * Checks whether it should invalidate span assignments in response to an adapter change.
+ */
+ private boolean considerSpanInvalidate(int positionStart, int itemCount) {
+ int minPosition = mShouldReverseLayout ? getLastChildPosition() : getFirstChildPosition();
+ if (positionStart + itemCount <= minPosition) {
+ return false;// nothing to update.
+ }
+ int maxPosition = mShouldReverseLayout ? getFirstChildPosition() : getLastChildPosition();
+ mLazySpanLookup.invalidateAfter(positionStart);
+ if (positionStart <= maxPosition) {
+ requestLayout();
+ }
+ return true;
+ }
+
+ private int fill(RecyclerView.Recycler recycler, LayoutState layoutState,
+ RecyclerView.State state) {
+ mRemainingSpans.set(0, mSpanCount, true);
+ // The target position we are trying to reach.
+ final int targetLine;
+
+ /*
+ * The line until which we can recycle, as long as we add views.
+ * Keep in mind, it is still the line in layout direction which means; to calculate the
+ * actual recycle line, we should subtract/add the size in orientation.
+ */
+ final int recycleLine;
+ // Line of the furthest row.
+ if (layoutState.mLayoutDirection == LAYOUT_END) {
+ recycleLine = mPrimaryOrientation.getEndAfterPadding() + mLayoutState.mAvailable;
+ targetLine = recycleLine + mLayoutState.mExtra;
+ final int defaultLine = mPrimaryOrientation.getStartAfterPadding();
+ for (int i = 0; i < mSpanCount; i++) {
+ final Span span = mSpans[i];
+ final int line = span.getEndLine(defaultLine);
+ if (line > targetLine) {
+ mRemainingSpans.set(i, false);
+ }
+ }
+ } else { // LAYOUT_START
+ recycleLine = mPrimaryOrientation.getStartAfterPadding() - mLayoutState.mAvailable;
+ targetLine = recycleLine - mLayoutState.mExtra;
+ for (int i = 0; i < mSpanCount; i++) {
+ final Span span = mSpans[i];
+ final int defaultLine = mPrimaryOrientation.getEndAfterPadding();
+ final int line = span.getStartLine(defaultLine);
+ if (line < targetLine) {
+ mRemainingSpans.set(i, false);
+ }
+ }
+ }
+
+ final int widthSpec, heightSpec;
+ if (mOrientation == VERTICAL) {
+ widthSpec = View.MeasureSpec.makeMeasureSpec(mSizePerSpan, View.MeasureSpec.EXACTLY);
+ heightSpec = View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED);
+ } else {
+ heightSpec = View.MeasureSpec.makeMeasureSpec(mSizePerSpan, View.MeasureSpec.EXACTLY);
+ widthSpec = View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED);
+ }
+
+ while (layoutState.hasMore(state) && !mRemainingSpans.isEmpty()) {
+ View view = layoutState.next(recycler);
+ LayoutParams lp = ((LayoutParams) view.getLayoutParams());
+ if (layoutState.mLayoutDirection == LAYOUT_END) {
+ addView(view);
+ } else {
+ addView(view, 0);
+ }
+ if (lp.isItemRemoved()) {
+ mChildrenToBeRemoved.add(view);
+ }
+ if (lp.mFullSpan) {
+ final int fullSizeSpec = View.MeasureSpec.makeMeasureSpec(
+ mSecondaryOrientation.getTotalSpace(), View.MeasureSpec.EXACTLY);
+ if (mOrientation == VERTICAL) {
+ measureChildWithDecorationsAndMargin(view, fullSizeSpec, heightSpec);
+ } else {
+ measureChildWithDecorationsAndMargin(view, widthSpec, fullSizeSpec);
+ }
+ } else {
+ measureChildWithDecorationsAndMargin(view, widthSpec, heightSpec);
+ }
+
+ final int position = getPosition(view);
+ final int spanIndex = mLazySpanLookup.getSpan(position);
+ Span currentSpan;
+ if (spanIndex == LayoutParams.INVALID_SPAN_ID) {
+ if (lp.mFullSpan) {
+ // assign full span items to first span
+ currentSpan = mSpans[0];
+ } else {
+ currentSpan = getNextSpan(layoutState);
+ }
+ mLazySpanLookup.setSpan(position, currentSpan);
+ } else {
+ currentSpan = mSpans[spanIndex];
+ }
+ final int start;
+ final int end;
+ if (layoutState.mLayoutDirection == LAYOUT_END) {
+ final int def = mShouldReverseLayout ? mPrimaryOrientation.getEndAfterPadding()
+ : mPrimaryOrientation.getStartAfterPadding();
+ start = lp.mFullSpan ? getMaxEnd(def) : currentSpan.getEndLine(def);
+ end = start + mPrimaryOrientation.getDecoratedMeasurement(view);
+ if (lp.mFullSpan) {
+ for (int i = 0; i < mSpanCount; i++) {
+ mSpans[i].appendToSpan(view);
+ }
+ } else {
+ currentSpan.appendToSpan(view);
+ }
+ } else {
+ final int def = mShouldReverseLayout ? mPrimaryOrientation.getEndAfterPadding()
+ : mPrimaryOrientation.getStartAfterPadding();
+ end = lp.mFullSpan ? getMinStart(def) : currentSpan.getStartLine(def);
+ start = end - mPrimaryOrientation.getDecoratedMeasurement(view);
+ if (lp.mFullSpan) {
+ for (int i = 0; i < mSpanCount; i++) {
+ mSpans[i].prependToSpan(view);
+ }
+ } else {
+ currentSpan.prependToSpan(view);
+ }
+
+ }
+ lp.mSpan = currentSpan;
+
+ if (DEBUG) {
+ Log.d(TAG, "adding view item " + lp.getViewPosition() + " between " + start + ","
+ + end);
+ }
+
+ final int otherStart = lp.mFullSpan ? mSecondaryOrientation.getStartAfterPadding()
+ : currentSpan.mIndex * mSizePerSpan + mSecondaryOrientation
+ .getStartAfterPadding();
+ final int otherEnd = otherStart + mSecondaryOrientation.getDecoratedMeasurement(view);
+ if (mOrientation == VERTICAL) {
+ layoutDecoratedWithMargins(view, otherStart, start, otherEnd, end);
+ } else {
+ layoutDecoratedWithMargins(view, start, otherStart, end, otherEnd);
+ }
+ if (lp.mFullSpan) {
+ for (int i = 0; i < mSpanCount; i++) {
+ updateRemainingSpans(mSpans[i], mLayoutState.mLayoutDirection, targetLine);
+ }
+ } else {
+ updateRemainingSpans(currentSpan, mLayoutState.mLayoutDirection, targetLine);
+ }
+ if (mLayoutState.mLayoutDirection == LAYOUT_START) {
+ // calculate recycle line
+ int maxStart = getMaxStart(currentSpan.getStartLine());
+ recycleFromEnd(recycler, Math.max(recycleLine, maxStart)
+ + mPrimaryOrientation.getTotalSpace());
+ } else {
+ // calculate recycle line
+ int minEnd = getMinEnd(currentSpan.getEndLine());
+ recycleFromStart(recycler, Math.min(recycleLine, minEnd)
+ - mPrimaryOrientation.getTotalSpace());
+ }
+ }
+ if (DEBUG) {
+ Log.d(TAG, "fill, " + getChildCount());
+ }
+ if (mLayoutState.mLayoutDirection == LAYOUT_START) {
+ final int minStart = getMinStart(mPrimaryOrientation.getStartAfterPadding());
+ return Math.max(0, mLayoutState.mAvailable + (targetLine - minStart));
+ } else {
+ final int max = getMaxEnd(mPrimaryOrientation.getEndAfterPadding());
+ return Math.max(0, mLayoutState.mAvailable + (max - targetLine));
+ }
+ }
+
+ private void layoutDecoratedWithMargins(View child, int left, int top, int right,
+ int bottom) {
+ LayoutParams lp = (LayoutParams) child.getLayoutParams();
+ layoutDecorated(child, left + lp.leftMargin, top + lp.topMargin, right - lp.rightMargin
+ , bottom - lp.bottomMargin);
+ }
+
+ private void updateRemainingSpans(Span span, int layoutDir, int targetLine) {
+ final int deletedSize = span.getDeletedSize();
+ if (layoutDir == LAYOUT_START) {
+ final int line = span.getStartLine();
+ if (line + deletedSize < targetLine) {
+ mRemainingSpans.set(span.mIndex, false);
+ }
+ } else {
+ final int line = span.getEndLine();
+ if (line - deletedSize > targetLine) {
+ mRemainingSpans.set(span.mIndex, false);
+ }
+ }
+ }
+
+ private int getMaxStart(int def) {
+ int maxStart = mSpans[0].getStartLine(def);
+ for (int i = 1; i < mSpanCount; i++) {
+ final int spanStart = mSpans[i].getStartLine(def);
+ if (spanStart > maxStart) {
+ maxStart = spanStart;
+ }
+ }
+ return maxStart;
+ }
+
+ private int getMinStart(int def) {
+ int minStart = mSpans[0].getStartLine(def);
+ for (int i = 1; i < mSpanCount; i++) {
+ final int spanStart = mSpans[i].getStartLine(def);
+ if (spanStart < minStart) {
+ minStart = spanStart;
+ }
+ }
+ return minStart;
+ }
+
+ private int getMaxEnd(int def) {
+ int maxEnd = mSpans[0].getEndLine(def);
+ for (int i = 1; i < mSpanCount; i++) {
+ final int spanEnd = mSpans[i].getEndLine(def);
+ if (spanEnd > maxEnd) {
+ maxEnd = spanEnd;
+ }
+ }
+ return maxEnd;
+ }
+
+ private int getMinEnd(int def) {
+ int minEnd = mSpans[0].getEndLine(def);
+ for (int i = 1; i < mSpanCount; i++) {
+ final int spanEnd = mSpans[i].getEndLine(def);
+ if (spanEnd < minEnd) {
+ minEnd = spanEnd;
+ }
+ }
+ return minEnd;
+ }
+
+ private void recycleFromStart(RecyclerView.Recycler recycler, int line) {
+ if (DEBUG) {
+ Log.d(TAG, "recycling from start for line " + line);
+ }
+ while (getChildCount() > 0) {
+ View child = getChildAt(0);
+ if (mPrimaryOrientation.getDecoratedEnd(child) < line) {
+ LayoutParams lp = (LayoutParams) child.getLayoutParams();
+ if (lp.mFullSpan) {
+ for (int j = 0; j < mSpanCount; j++) {
+ mSpans[j].popStart();
+ }
+ } else {
+ lp.mSpan.popStart();
+ }
+ removeAndRecycleView(child, recycler);
+ } else {
+ return;// done
+ }
+ }
+ }
+
+ private void recycleFromEnd(RecyclerView.Recycler recycler, int line) {
+ final int childCount = getChildCount();
+ int i;
+ for (i = childCount - 1; i >= 0; i--) {
+ View child = getChildAt(i);
+ if (mPrimaryOrientation.getDecoratedStart(child) > line) {
+ LayoutParams lp = (LayoutParams) child.getLayoutParams();
+ if (lp.mFullSpan) {
+ for (int j = 0; j < mSpanCount; j++) {
+ mSpans[j].popEnd();
+ }
+ } else {
+ lp.mSpan.popEnd();
+ }
+ removeAndRecycleView(child, recycler);
+ } else {
+ return;// done
+ }
+ }
+ }
+
+ /**
+ * Finds the span for the next view.
+ */
+ private Span getNextSpan(LayoutState layoutState) {
+ final boolean preferLastSpan = mOrientation == VERTICAL && isLayoutRTL();
+ if (layoutState.mLayoutDirection == LAYOUT_END) {
+ Span min = mSpans[0];
+ int minLine = min.getEndLine(mPrimaryOrientation.getStartAfterPadding());
+ final int defaultLine = mPrimaryOrientation.getStartAfterPadding();
+ for (int i = 1; i < mSpanCount; i++) {
+ final Span other = mSpans[i];
+ final int otherLine = other.getEndLine(defaultLine);
+ if (otherLine < minLine || (otherLine == minLine && preferLastSpan)) {
+ min = other;
+ minLine = otherLine;
+ }
+ }
+ return min;
+ } else {
+ Span max = mSpans[0];
+ int maxLine = max.getStartLine(mPrimaryOrientation.getEndAfterPadding());
+ final int defaultLine = mPrimaryOrientation.getEndAfterPadding();
+ for (int i = 1; i < mSpanCount; i++) {
+ final Span other = mSpans[i];
+ final int otherLine = other.getStartLine(defaultLine);
+ if (otherLine > maxLine || (otherLine == maxLine && !preferLastSpan)) {
+ max = other;
+ maxLine = otherLine;
+ }
+ }
+ return max;
+ }
+ }
+
+ @Override
+ public boolean canScrollVertically() {
+ return mOrientation == VERTICAL;
+ }
+
+ @Override
+ public boolean canScrollHorizontally() {
+ return mOrientation == HORIZONTAL;
+ }
+
+ @Override
+ public int scrollHorizontallyBy(int dx, RecyclerView.Recycler recycler,
+ RecyclerView.State state) {
+ return scrollBy(dx, recycler, state);
+ }
+
+ @Override
+ public int scrollVerticallyBy(int dy, RecyclerView.Recycler recycler,
+ RecyclerView.State state) {
+ return scrollBy(dy, recycler, state);
+ }
+
+ private int calculateScrollDirectionForPosition(int position) {
+ if (getChildCount() == 0) {
+ return mShouldReverseLayout ? LAYOUT_END : LAYOUT_START;
+ }
+ final int firstChildPos = getFirstChildPosition();
+ return position < firstChildPos != mShouldReverseLayout ? LAYOUT_START : LAYOUT_END;
+ }
+
+ @Override
+ public void smoothScrollToPosition(RecyclerView recyclerView, RecyclerView.State state,
+ int position) {
+ LinearSmoothScroller scroller = new LinearSmoothScroller(recyclerView.getContext()) {
+ @Override
+ public PointF computeScrollVectorForPosition(int targetPosition) {
+ final int direction = calculateScrollDirectionForPosition(targetPosition);
+ if (direction == 0) {
+ return null;
+ }
+ if (mOrientation == HORIZONTAL) {
+ return new PointF(direction, 0);
+ } else {
+ return new PointF(0, direction);
+ }
+ }
+ };
+ scroller.setTargetPosition(position);
+ startSmoothScroll(scroller);
+ }
+
+ @Override
+ public void scrollToPosition(int position) {
+ if (mPendingSavedState != null && mPendingSavedState.mAnchorPosition != position) {
+ mPendingSavedState.invalidateAnchorPositionInfo();
+ }
+ mPendingScrollPosition = position;
+ mPendingScrollPositionOffset = INVALID_OFFSET;
+ requestLayout();
+ }
+
+ /**
+ * Scroll to the specified adapter position with the given offset from layout start.
+ * <p>
+ * Note that scroll position change will not be reflected until the next layout call.
+ * <p>
+ * If you are just trying to make a position visible, use {@link #scrollToPosition(int)}.
+ *
+ * @param position Index (starting at 0) of the reference item.
+ * @param offset The distance (in pixels) between the start edge of the item view and
+ * start edge of the RecyclerView.
+ * @see #setReverseLayout(boolean)
+ * @see #scrollToPosition(int)
+ */
+ public void scrollToPositionWithOffset(int position, int offset) {
+ if (mPendingSavedState != null) {
+ mPendingSavedState.invalidateAnchorPositionInfo();
+ }
+ mPendingScrollPosition = position;
+ mPendingScrollPositionOffset = offset;
+ requestLayout();
+ }
+
+ private int scrollBy(int dt, RecyclerView.Recycler recycler, RecyclerView.State state) {
+ ensureOrientationHelper();
+ final int referenceChildPosition;
+ if (dt > 0) { // layout towards end
+ mLayoutState.mLayoutDirection = LAYOUT_END;
+ mLayoutState.mItemDirection = mShouldReverseLayout ? ITEM_DIRECTION_HEAD
+ : ITEM_DIRECTION_TAIL;
+ referenceChildPosition = getLastChildPosition();
+ } else {
+ mLayoutState.mLayoutDirection = LAYOUT_START;
+ mLayoutState.mItemDirection = mShouldReverseLayout ? ITEM_DIRECTION_TAIL
+ : ITEM_DIRECTION_HEAD;
+ referenceChildPosition = getFirstChildPosition();
+ }
+ mLayoutState.mCurrentPosition = referenceChildPosition + mLayoutState.mItemDirection;
+ final int absDt = Math.abs(dt);
+ mLayoutState.mAvailable = absDt;
+ mLayoutState.mExtra = isSmoothScrolling() ? mPrimaryOrientation.getTotalSpace() : 0;
+ int consumed = fill(recycler, mLayoutState, state);
+ final int totalScroll;
+ if (absDt < consumed) {
+ totalScroll = dt;
+ } else if (dt < 0) {
+ totalScroll = -consumed;
+ } else { // dt > 0
+ totalScroll = consumed;
+ }
+ if (DEBUG) {
+ Log.d(TAG, "asked " + dt + " scrolled" + totalScroll);
+ }
+
+ if (mGapStrategy == GAP_HANDLING_LAZY
+ && mLayoutState.mItemDirection == ITEM_DIRECTION_HEAD) {
+ final int targetStart = mPrimaryOrientation.getStartAfterPadding();
+ final int targetEnd = mPrimaryOrientation.getEndAfterPadding();
+ lazyOffsetSpans(-totalScroll, targetStart, targetEnd);
+ } else {
+ mPrimaryOrientation.offsetChildren(-totalScroll);
+ }
+ // always reset this if we scroll for a proper save instance state
+ mLastLayoutFromEnd = mShouldReverseLayout;
+
+ if (totalScroll != 0 && mGapStrategy != GAP_HANDLING_NONE
+ && mLayoutState.mItemDirection == ITEM_DIRECTION_HEAD && !mHasGaps) {
+ final int addedChildCount = Math.abs(mLayoutState.mCurrentPosition
+ - (referenceChildPosition + mLayoutState.mItemDirection));
+ if (addedChildCount > 0) {
+ // check if any child has been attached to wrong span. If so, trigger a re-layout
+ // after scroll
+ final View viewInWrongSpan;
+ final View referenceView = findViewByPosition(referenceChildPosition);
+ if (referenceView == null) {
+ viewInWrongSpan = hasGapsToFix(0, getChildCount());
+ } else {
+ if (mLayoutState.mLayoutDirection == LAYOUT_START) {
+ viewInWrongSpan = hasGapsToFix(0, addedChildCount);
+ } else {
+ viewInWrongSpan = hasGapsToFix(getChildCount() - addedChildCount,
+ getChildCount());
+ }
+ }
+ mHasGaps = viewInWrongSpan != null;
+ }
+ }
+ return totalScroll;
+ }
+
+ /**
+ * The actual method that implements {@link #GAP_HANDLING_LAZY}
+ */
+ private void lazyOffsetSpans(int offset, int targetStart, int targetEnd) {
+ // For each span offset children one by one.
+ // When a fullSpan item is reached, stop and wait for other spans to reach to that span.
+ // When all reach, offset fullSpan to max of others and continue.
+ int childrenToOffset = getChildCount();
+ int[] indexPerSpan = new int[mSpanCount];
+ int[] offsetPerSpan = new int[mSpanCount];
+
+ final int childOrder = offset > 0 ? ITEM_DIRECTION_TAIL : ITEM_DIRECTION_HEAD;
+ if (offset > 0) {
+ Arrays.fill(indexPerSpan, 0);
+ } else {
+ for (int i = 0; i < mSpanCount; i++) {
+ indexPerSpan[i] = mSpans[i].mViews.size() - 1;
+ }
+ }
+
+ for (int i = 0; i < mSpanCount; i++) {
+ offsetPerSpan[i] = mSpans[i].getNormalizedOffset(offset, targetStart, targetEnd);
+ }
+ if (DEBUG) {
+ Log.d(TAG, "lazy offset start. normalized: " + Arrays.toString(offsetPerSpan));
+ }
+
+ while (childrenToOffset > 0) {
+ View fullSpanView = null;
+ for (int spanIndex = 0; spanIndex < mSpanCount; spanIndex++) {
+ Span span = mSpans[spanIndex];
+ int viewIndex;
+ for (viewIndex = indexPerSpan[spanIndex];
+ viewIndex < span.mViews.size() && viewIndex >= 0; viewIndex += childOrder) {
+ View view = span.mViews.get(viewIndex);
+ if (DEBUG) {
+ Log.d(TAG, "span " + spanIndex + ", view:" + viewIndex + ", pos:"
+ + getPosition(view));
+ }
+ LayoutParams lp = (LayoutParams) view.getLayoutParams();
+ if (lp.mFullSpan) {
+ if (DEBUG) {
+ Log.d(TAG, "stopping on full span view on index " + viewIndex
+ + " in span " + spanIndex);
+ }
+ fullSpanView = view;
+ viewIndex += childOrder;// move to next view
+ break;
+ }
+ // offset this child normally
+ mPrimaryOrientation.offsetChild(view, offsetPerSpan[spanIndex]);
+ final int nextChildIndex = viewIndex + childOrder;
+ if (nextChildIndex < span.mViews.size() && nextChildIndex >= 0) {
+ View nextView = span.mViews.get(nextChildIndex);
+ // find gap between, before offset
+ if (childOrder == ITEM_DIRECTION_HEAD) {// negative
+ offsetPerSpan[spanIndex] = Math
+ .min(0, mPrimaryOrientation.getDecoratedStart(view)
+ - mPrimaryOrientation.getDecoratedEnd(nextView));
+ } else {
+ offsetPerSpan[spanIndex] = Math
+ .max(0, mPrimaryOrientation.getDecoratedEnd(view) -
+ mPrimaryOrientation.getDecoratedStart(nextView));
+ }
+ if (DEBUG) {
+ Log.d(TAG, "offset diff:" + offsetPerSpan[spanIndex] + " between "
+ + getPosition(nextView) + " and " + getPosition(view));
+ }
+ }
+ childrenToOffset--;
+ }
+ indexPerSpan[spanIndex] = viewIndex;
+ }
+ if (fullSpanView != null) {
+ // we have to offset this view. We'll offset it as the biggest amount necessary
+ int winnerSpan = 0;
+ int winnerSpanOffset = Math.abs(offsetPerSpan[winnerSpan]);
+ for (int i = 1; i < mSpanCount; i++) {
+ final int spanOffset = Math.abs(offsetPerSpan[i]);
+ if (spanOffset > winnerSpanOffset) {
+ winnerSpan = i;
+ winnerSpanOffset = spanOffset;
+ }
+ }
+ if (DEBUG) {
+ Log.d(TAG, "winner offset:" + offsetPerSpan[winnerSpan] + " of " + winnerSpan);
+ }
+ mPrimaryOrientation.offsetChild(fullSpanView, offsetPerSpan[winnerSpan]);
+ childrenToOffset--;
+
+ for (int spanIndex = 0; spanIndex < mSpanCount; spanIndex++) {
+ final int nextViewIndex = indexPerSpan[spanIndex];
+ final Span span = mSpans[spanIndex];
+ if (nextViewIndex < span.mViews.size() && nextViewIndex > 0) {
+ View nextView = span.mViews.get(nextViewIndex);
+ // find gap between, before offset
+ if (childOrder == ITEM_DIRECTION_HEAD) {// negative
+ offsetPerSpan[spanIndex] = Math
+ .min(0, mPrimaryOrientation.getDecoratedStart(fullSpanView)
+ - mPrimaryOrientation.getDecoratedEnd(nextView));
+ } else {
+ offsetPerSpan[spanIndex] = Math
+ .max(0, mPrimaryOrientation.getDecoratedEnd(fullSpanView) -
+ mPrimaryOrientation.getDecoratedStart(nextView));
+ }
+ }
+ }
+ }
+ }
+ for (int spanIndex = 0; spanIndex < mSpanCount; spanIndex++) {
+ mSpans[spanIndex].invalidateCache();
+ }
+ }
+
+ private int getLastChildPosition() {
+ final int childCount = getChildCount();
+ return childCount == 0 ? 0 : getPosition(getChildAt(childCount - 1));
+ }
+
+ private int getFirstChildPosition() {
+ final int childCount = getChildCount();
+ return childCount == 0 ? 0 : getPosition(getChildAt(0));
+ }
+
+ @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 int getOrientation() {
+ return mOrientation;
+ }
+
+
+ /**
+ * LayoutParams used by StaggeredGridLayoutManager.
+ */
+ public static class LayoutParams extends RecyclerView.LayoutParams {
+
+ /**
+ * Span Id for Views that are not laid out yet.
+ */
+ public static final int INVALID_SPAN_ID = -1;
+
+ // Package scope to be able to access from tests.
+ Span mSpan;
+
+ boolean mFullSpan;
+
+ 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);
+ }
+
+ /**
+ * When set to true, the item will layout using all span area. That means, if orientation
+ * is vertical, the view will have full width; if orientation is horizontal, the view will
+ * have full height.
+ *
+ * @param fullSpan True if this item should traverse all spans.
+ */
+ public void setFullSpan(boolean fullSpan) {
+ mFullSpan = fullSpan;
+ }
+
+ /**
+ * Returns the Span index to which this View is assigned.
+ *
+ * @return The Span index of the View. If View is not yet assigned to any span, returns
+ * {@link #INVALID_SPAN_ID}.
+ */
+ public final int getSpanIndex() {
+ if (mSpan == null) {
+ return INVALID_SPAN_ID;
+ }
+ return mSpan.mIndex;
+ }
+ }
+
+ // Package scoped to access from tests.
+ class Span {
+
+ final int INVALID_LINE = Integer.MIN_VALUE;
+
+ private ArrayList<View> mViews = new ArrayList<View>();
+
+ int mCachedStart = INVALID_LINE;
+
+ int mCachedEnd = INVALID_LINE;
+
+ int mDeletedSize = 0;
+
+ final int mIndex;
+
+ private Span(int index) {
+ mIndex = index;
+ }
+
+ int getStartLine(int def) {
+ if (mCachedStart != INVALID_LINE) {
+ return mCachedStart;
+ }
+ if (mViews.size() == 0) {
+ return def;
+ }
+ mCachedStart = mPrimaryOrientation.getDecoratedStart(mViews.get(0));
+ return mCachedStart;
+ }
+
+ // Use this one when default value does not make sense and not having a value means a bug.
+ int getStartLine() {
+ if (mCachedStart != INVALID_LINE) {
+ return mCachedStart;
+ }
+ mCachedStart = mPrimaryOrientation.getDecoratedStart(mViews.get(0));
+ return mCachedStart;
+ }
+
+ int getEndLine(int def) {
+ if (mCachedEnd != INVALID_LINE) {
+ return mCachedEnd;
+ }
+ final int size = mViews.size();
+ if (size == 0) {
+ return def;
+ }
+ mCachedEnd = mPrimaryOrientation.getDecoratedEnd(mViews.get(size - 1));
+ return mCachedEnd;
+ }
+
+ // Use this one when default value does not make sense and not having a value means a bug.
+ int getEndLine() {
+ if (mCachedEnd != INVALID_LINE) {
+ return mCachedEnd;
+ }
+ mCachedEnd = mPrimaryOrientation.getDecoratedEnd(mViews.get(mViews.size() - 1));
+ return mCachedEnd;
+ }
+
+ void prependToSpan(View view) {
+ LayoutParams lp = getLayoutParams(view);
+ lp.mSpan = this;
+ mViews.add(0, view);
+ mCachedStart = INVALID_LINE;
+ if (mViews.size() == 1) {
+ mCachedEnd = INVALID_LINE;
+ }
+ if (lp.isItemRemoved()) {
+ mDeletedSize += mPrimaryOrientation.getDecoratedMeasurement(view);
+ }
+ }
+
+ void appendToSpan(View view) {
+ LayoutParams lp = getLayoutParams(view);
+ lp.mSpan = this;
+ mViews.add(view);
+ mCachedEnd = INVALID_LINE;
+ if (mViews.size() == 1) {
+ mCachedStart = INVALID_LINE;
+ }
+ if (lp.isItemRemoved()) {
+ mDeletedSize += mPrimaryOrientation.getDecoratedMeasurement(view);
+ }
+ }
+
+ // Useful method to preserve positions on a re-layout.
+ void cacheReferenceLineAndClear(boolean reverseLayout, int offset) {
+ int reference;
+ if (reverseLayout) {
+ reference = getEndLine(INVALID_LINE);
+ } else {
+ reference = getStartLine(INVALID_LINE);
+ }
+ clear();
+ if (reference == INVALID_LINE) {
+ return;
+ }
+ if (offset != INVALID_OFFSET) {
+ reference += offset;
+ }
+ mCachedStart = mCachedEnd = reference;
+ }
+
+ void clear() {
+ mViews.clear();
+ invalidateCache();
+ mDeletedSize = 0;
+ }
+
+ void invalidateCache() {
+ mCachedStart = INVALID_LINE;
+ mCachedEnd = INVALID_LINE;
+ }
+
+ void setLine(int line) {
+ mCachedEnd = mCachedStart = line;
+ }
+
+ void popEnd() {
+ final int size = mViews.size();
+ View end = mViews.remove(size - 1);
+ final LayoutParams lp = getLayoutParams(end);
+ lp.mSpan = null;
+ if (lp.isItemRemoved()) {
+ mDeletedSize -= mPrimaryOrientation.getDecoratedMeasurement(end);
+ }
+ if (size == 1) {
+ mCachedStart = INVALID_LINE;
+ }
+ mCachedEnd = INVALID_LINE;
+ }
+
+ void popStart() {
+ View start = mViews.remove(0);
+ final LayoutParams lp = getLayoutParams(start);
+ lp.mSpan = null;
+ if (mViews.size() == 0) {
+ mCachedEnd = INVALID_LINE;
+ }
+ if (lp.isItemRemoved()) {
+ mDeletedSize -= mPrimaryOrientation.getDecoratedMeasurement(start);
+ }
+ mCachedStart = INVALID_LINE;
+ }
+
+ // TODO cache this.
+ public int getDeletedSize() {
+ return mDeletedSize;
+ }
+
+ LayoutParams getLayoutParams(View view) {
+ return (LayoutParams) view.getLayoutParams();
+ }
+
+ void onOffset(int dt) {
+ if (mCachedStart != INVALID_LINE) {
+ mCachedStart += dt;
+ }
+ if (mCachedEnd != INVALID_LINE) {
+ mCachedEnd += dt;
+ }
+ }
+
+ // normalized offset is how much this span can scroll
+ int getNormalizedOffset(int dt, int targetStart, int targetEnd) {
+ if (mViews.size() == 0) {
+ return 0;
+ }
+ if (dt < 0) {
+ final int endSpace = getEndLine() - targetEnd;
+ if (endSpace <= 0) {
+ return 0;
+ }
+ return -dt > endSpace ? -endSpace : dt;
+ } else {
+ final int startSpace = targetStart - getStartLine();
+ if (startSpace <= 0) {
+ return 0;
+ }
+ return startSpace < dt ? startSpace : dt;
+ }
+ }
+
+ /**
+ * Returns if there is no child between start-end lines
+ *
+ * @param start The start line
+ * @param end The end line
+ * @return true if a new child can be added between start and end
+ */
+ boolean isEmpty(int start, int end) {
+ final int count = mViews.size();
+ for (int i = 0; i < count; i++) {
+ final View view = mViews.get(i);
+ if (mPrimaryOrientation.getDecoratedStart(view) < end &&
+ mPrimaryOrientation.getDecoratedEnd(view) > start) {
+ return false;
+ }
+ }
+ return true;
+ }
+ }
+
+ /**
+ * An array of mappings from adapter position to span.
+ * This only grows when a write happens and it grows up to the size of the adapter.
+ */
+ static class LazySpanLookup {
+
+ private static final int MIN_SIZE = 10;
+
+ int[] mData;
+
+ int mAdapterSize; // we don't want to grow beyond that, unless it grows
+
+ void invalidateAfter(int position) {
+ if (mData == null) {
+ return;
+ }
+ if (position >= mData.length) {
+ return;
+ }
+ Arrays.fill(mData, position, mData.length, LayoutParams.INVALID_SPAN_ID);
+ }
+
+ int getSpan(int position) {
+ if (mData == null || position >= mData.length) {
+ return LayoutParams.INVALID_SPAN_ID;
+ } else {
+ return mData[position];
+ }
+ }
+
+ void setSpan(int position, Span span) {
+ ensureSize(position);
+ mData[position] = span.mIndex;
+ }
+
+ int sizeForPosition(int position) {
+ int len = mData.length;
+ while (len <= position) {
+ len *= 2;
+ }
+ if (len > mAdapterSize) {
+ len = mAdapterSize;
+ }
+ return len;
+ }
+
+ void ensureSize(int position) {
+ if (mData == null) {
+ mData = new int[Math.max(position, MIN_SIZE) + 1];
+ Arrays.fill(mData, LayoutParams.INVALID_SPAN_ID);
+ } else if (position >= mData.length) {
+ int[] old = mData;
+ mData = new int[sizeForPosition(position)];
+ System.arraycopy(old, 0, mData, 0, old.length);
+ Arrays.fill(mData, old.length, mData.length, LayoutParams.INVALID_SPAN_ID);
+ }
+ }
+
+ void clear() {
+ if (mData != null) {
+ Arrays.fill(mData, LayoutParams.INVALID_SPAN_ID);
+ }
+ }
+
+ void offsetForRemoval(int positionStart, int itemCount) {
+ ensureSize(positionStart + itemCount);
+ System.arraycopy(mData, positionStart + itemCount, mData, positionStart,
+ mData.length - positionStart - itemCount);
+ Arrays.fill(mData, mData.length - itemCount, mData.length,
+ LayoutParams.INVALID_SPAN_ID);
+ }
+
+ void offsetForAddition(int positionStart, int itemCount) {
+ ensureSize(positionStart + itemCount);
+ System.arraycopy(mData, positionStart, mData, positionStart + itemCount,
+ mData.length - positionStart - itemCount);
+ Arrays.fill(mData, positionStart, positionStart + itemCount,
+ LayoutParams.INVALID_SPAN_ID);
+ }
+ }
+
+ static class SavedState implements Parcelable {
+
+ int mOrientation;
+
+ int mSpanCount;
+
+ int mGapStrategy;
+
+ int mAnchorPosition;
+
+ int[] mSpanOffsets;
+
+ int mSpanLookupSize;
+
+ int[] mSpanLookup;
+
+ boolean mReverseLayout;
+
+ boolean mAnchorLayoutFromEnd;
+
+ boolean mHasSpanOffsets;
+
+ public SavedState() {
+ }
+
+ SavedState(Parcel in) {
+ mOrientation = in.readInt();
+ mSpanCount = in.readInt();
+ mGapStrategy = in.readInt();
+ mAnchorPosition = in.readInt();
+ mHasSpanOffsets = in.readInt() == 1;
+ if (mHasSpanOffsets) {
+ mSpanOffsets = new int[mSpanCount];
+ in.readIntArray(mSpanOffsets);
+ }
+
+ mSpanLookupSize = in.readInt();
+ if (mSpanLookupSize > 0) {
+ mSpanLookup = new int[mSpanLookupSize];
+ in.readIntArray(mSpanLookup);
+ }
+ mReverseLayout = in.readInt() == 1;
+ mAnchorLayoutFromEnd = in.readInt() == 1;
+ }
+
+ public SavedState(SavedState other) {
+ mOrientation = other.mOrientation;
+ mSpanCount = other.mSpanCount;
+ mGapStrategy = other.mGapStrategy;
+ mAnchorPosition = other.mAnchorPosition;
+ mHasSpanOffsets = other.mHasSpanOffsets;
+ mSpanOffsets = other.mSpanOffsets;
+ mSpanLookupSize = other.mSpanLookupSize;
+ mSpanLookup = other.mSpanLookup;
+ mReverseLayout = other.mReverseLayout;
+ mAnchorLayoutFromEnd = other.mAnchorLayoutFromEnd;
+ }
+
+ void invalidateSpanInfo() {
+ mSpanOffsets = null;
+ mHasSpanOffsets = false;
+ mSpanCount = -1;
+ mSpanLookupSize = 0;
+ mSpanLookup = null;
+ }
+
+ void invalidateAnchorPositionInfo() {
+ mSpanOffsets = null;
+ mHasSpanOffsets = false;
+ mAnchorPosition = RecyclerView.NO_POSITION;
+ }
+
+ @Override
+ public int describeContents() {
+ return 0;
+ }
+
+ @Override
+ public void writeToParcel(Parcel dest, int flags) {
+ dest.writeInt(mOrientation);
+ dest.writeInt(mSpanCount);
+ dest.writeInt(mGapStrategy);
+ dest.writeInt(mAnchorPosition);
+ dest.writeInt(mHasSpanOffsets ? 1 : 0);
+ if (mHasSpanOffsets) {
+ dest.writeIntArray(mSpanOffsets);
+ }
+ dest.writeInt(mSpanLookupSize);
+ if (mSpanLookupSize > 0) {
+ dest.writeIntArray(mSpanLookup);
+ }
+ dest.writeInt(mReverseLayout ? 1 : 0);
+ dest.writeInt(mAnchorLayoutFromEnd ? 1 : 0);
+ }
+
+ @Override
+ public String toString() {
+ return "SavedState{" +
+ "mOrientation=" + mOrientation +
+ ", mSpanCount=" + mSpanCount +
+ ", mGapStrategy=" + mGapStrategy +
+ ", mAnchorPosition=" + mAnchorPosition +
+ ", mSpanOffsets=" + Arrays.toString(mSpanOffsets) +
+ ", mSpanLookupSize=" + mSpanLookupSize +
+ ", mSpanLookup=" + Arrays.toString(mSpanLookup) +
+ ", mReverseLayout=" + mReverseLayout +
+ ", mAnchorLayoutFromEnd=" + mAnchorLayoutFromEnd +
+ ", mHasSpanOffsets=" + mHasSpanOffsets +
+ '}';
+ }
+
+ public static final Parcelable.Creator<SavedState> CREATOR
+ = new Parcelable.Creator<SavedState>() {
+ @Override
+ public SavedState createFromParcel(Parcel in) {
+ return new SavedState(in);
+ }
+
+ @Override
+ public SavedState[] newArray(int size) {
+ return new SavedState[size];
+ }
+ };
+ }
+}
diff --git a/v7/recyclerview/tests/src/android/support/v7/widget/BaseRecyclerViewInstrumentationTest.java b/v7/recyclerview/tests/src/android/support/v7/widget/BaseRecyclerViewInstrumentationTest.java
index a92782c..432da1e 100644
--- a/v7/recyclerview/tests/src/android/support/v7/widget/BaseRecyclerViewInstrumentationTest.java
+++ b/v7/recyclerview/tests/src/android/support/v7/widget/BaseRecyclerViewInstrumentationTest.java
@@ -264,21 +264,21 @@
final static AtomicInteger idCounter = new AtomicInteger(0);
final public int mId = idCounter.incrementAndGet();
- int originalIndex;
+ int mAdapterIndex;
- final String text;
+ final String mText;
- Item(int originalIndex, String text) {
- this.originalIndex = originalIndex;
- this.text = text;
+ Item(int adapterIndex, String text) {
+ mAdapterIndex = adapterIndex;
+ mText = text;
}
@Override
public String toString() {
return "Item{" +
"mId=" + mId +
- ", originalIndex=" + originalIndex +
- ", text='" + text + '\'' +
+ ", originalIndex=" + mAdapterIndex +
+ ", text='" + mText + '\'' +
'}';
}
}
@@ -303,7 +303,7 @@
@Override
public void onBindViewHolder(TestViewHolder holder, int position) {
final Item item = mItems.get(position);
- ((TextView) (holder.itemView)).setText(item.text);
+ ((TextView) (holder.itemView)).setText(item.mText + "(" + item.mAdapterIndex + ")");
holder.mBindedItem = item;
}
@@ -327,6 +327,12 @@
new AddRemoveRunnable(startCountTuples).runOnMainThread();
}
+ public void offsetOriginalIndices(int start, int offset) {
+ for (int i = start; i < mItems.size(); i++) {
+ mItems.get(i).mAdapterIndex += offset;
+ }
+ }
+
public void addAndNotify(final int start, final int count) throws Throwable {
addAndNotify(new int[]{start, count});
}
@@ -396,21 +402,21 @@
}
private void add(int[] tuple) {
+ // offset others
+ offsetOriginalIndices(tuple[0], tuple[1]);
for (int i = 0; i < tuple[1]; i++) {
mItems.add(tuple[0], new Item(i, "new item " + i));
}
- // offset others
- for (int i = tuple[0] + tuple[1]; i < mItems.size(); i++) {
- mItems.get(i).originalIndex += tuple[1];
- }
notifyItemRangeInserted(tuple[0], tuple[1]);
}
private void delete(int[] tuple) {
- for (int i = 0; i < -tuple[1]; i++) {
+ final int count = -tuple[1];
+ offsetOriginalIndices(tuple[0] + count, tuple[1]);
+ for (int i = 0; i < count; i++) {
mItems.remove(tuple[0]);
}
- notifyItemRangeRemoved(tuple[0], -tuple[1]);
+ notifyItemRangeRemoved(tuple[0], count);
}
}
}
diff --git a/v7/recyclerview/tests/src/android/support/v7/widget/LinearLayoutManagerTest.java b/v7/recyclerview/tests/src/android/support/v7/widget/LinearLayoutManagerTest.java
index 76bf541..adf73bf 100644
--- a/v7/recyclerview/tests/src/android/support/v7/widget/LinearLayoutManagerTest.java
+++ b/v7/recyclerview/tests/src/android/support/v7/widget/LinearLayoutManagerTest.java
@@ -431,11 +431,11 @@
Log.d(TAG, "checking rectangle equality.");
Log.d(TAG, "before:");
for (Map.Entry<Item, Rect> entry : before.entrySet()) {
- Log.d(TAG, entry.getKey().originalIndex + ":" + entry.getValue());
+ Log.d(TAG, entry.getKey().mAdapterIndex + ":" + entry.getValue());
}
Log.d(TAG, "after:");
for (Map.Entry<Item, Rect> entry : after.entrySet()) {
- Log.d(TAG, entry.getKey().originalIndex + ":" + entry.getValue());
+ Log.d(TAG, entry.getKey().mAdapterIndex + ":" + entry.getValue());
}
}
assertEquals(message + ":\nitem counts should be equal", before.size()
diff --git a/v7/recyclerview/tests/src/android/support/v7/widget/RecyclerViewAnimationsTest.java b/v7/recyclerview/tests/src/android/support/v7/widget/RecyclerViewAnimationsTest.java
index 96c9202..124658b 100644
--- a/v7/recyclerview/tests/src/android/support/v7/widget/RecyclerViewAnimationsTest.java
+++ b/v7/recyclerview/tests/src/android/support/v7/widget/RecyclerViewAnimationsTest.java
@@ -34,7 +34,7 @@
public class RecyclerViewAnimationsTest extends BaseRecyclerViewInstrumentationTest {
- private static final boolean DEBUG = false;
+ private static final boolean DEBUG = true;
private static final String TAG = "RecyclerViewAnimationsTest";
@@ -281,8 +281,7 @@
setupBasic(10, 1, 7);
mLayoutManager.expectLayouts(2);
mLayoutManager.mOnLayoutCallbacks.setExpectedItemCounts(10, 12);
- mTestAdapter.addAndNotify(0, 1);// add a new item 0 // invisible
- mTestAdapter.addAndNotify(7, 1);// add a new item after 5th (old 5, new 6)
+ mTestAdapter.addAndNotify(new int[]{0, 1}, new int[]{7, 1});// add a new item 0 // invisible
mLayoutManager.waitForLayout(2);
}
@@ -290,8 +289,7 @@
setupBasic(10, 1, 7);
mLayoutManager.expectLayouts(1);
mLayoutManager.mOnLayoutCallbacks.setExpectedItemCounts(10, 12);
- mTestAdapter.addAndNotify(0, 1);// add a new item 0
- mTestAdapter.addAndNotify(8, 1);// add a new item after 6th (old 6, new 7)
+ mTestAdapter.addAndNotify(new int[]{0, 1}, new int[]{8, 1});// add a new item 0
mLayoutManager.waitForLayout(2);
}
diff --git a/v7/recyclerview/tests/src/android/support/v7/widget/StaggeredGridLayoutManagerTest.java b/v7/recyclerview/tests/src/android/support/v7/widget/StaggeredGridLayoutManagerTest.java
new file mode 100644
index 0000000..807bea8
--- /dev/null
+++ b/v7/recyclerview/tests/src/android/support/v7/widget/StaggeredGridLayoutManagerTest.java
@@ -0,0 +1,1116 @@
+/*
+ * Copyright (C) 2014 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+
+package android.support.v7.widget;
+
+
+import android.graphics.Rect;
+import android.os.Parcel;
+import android.os.Parcelable;
+import android.util.Log;
+import android.view.View;
+import android.view.ViewGroup;
+
+import java.util.ArrayList;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import static android.support.v7.widget.OrientationHelper.*;
+import static android.support.v7.widget.LayoutState.*;
+import static android.support.v7.widget.StaggeredGridLayoutManager.*;
+
+public class StaggeredGridLayoutManagerTest extends BaseRecyclerViewInstrumentationTest {
+
+ private static final boolean DEBUG = true;
+
+ private static final String TAG = "StaggeredGridLayoutManagerTest";
+
+ WrappedLayoutManager mLayoutManager;
+
+ GridTestAdapter mAdapter;
+
+ RecyclerView mRecyclerView;
+
+ final List<Config> mBaseVariations = new ArrayList<Config>();
+
+ @Override
+ protected void setUp() throws Exception {
+ super.setUp();
+ for (int orientation : new int[]{VERTICAL, HORIZONTAL}) {
+ for (boolean reverseLayout : new boolean[]{false, true}) {
+ for (int spanCount : new int[]{1, 3}) {
+ for (int gapStrategy : new int[]{GAP_HANDLING_NONE,
+ GAP_HANDLING_MOVE_ITEMS_BETWEEN_SPANS}) {
+ mBaseVariations.add(new Config(orientation, reverseLayout, spanCount,
+ gapStrategy));
+ }
+ }
+ }
+ }
+ }
+
+ void setupByConfig(Config config) throws Throwable {
+ mAdapter = new GridTestAdapter(config.mItemCount, config.mOrientation);
+ mRecyclerView = new RecyclerView(getActivity());
+ mRecyclerView.setAdapter(mAdapter);
+ mLayoutManager = new WrappedLayoutManager(config.mSpanCount,
+ config.mOrientation);
+ mLayoutManager.setGapStrategy(config.mGapStrategy);
+ mLayoutManager.setReverseLayout(config.mReverseLayout);
+ mRecyclerView.setLayoutManager(mLayoutManager);
+ }
+
+ LayoutParams getLp(View view) {
+ return (LayoutParams) view.getLayoutParams();
+ }
+
+ public void testInnerGapHandling() throws Throwable {
+ innerGapHandlingTest(StaggeredGridLayoutManager.GAP_HANDLING_NONE);
+ innerGapHandlingTest(StaggeredGridLayoutManager.GAP_HANDLING_MOVE_ITEMS_BETWEEN_SPANS);
+ }
+
+ public void innerGapHandlingTest(int strategy) throws Throwable {
+ Config config = new Config().spanCount(3).itemCount(500);
+ setupByConfig(config);
+ mLayoutManager.setGapStrategy(strategy);
+ mAdapter.mFullSpanItems.add(100);
+ mAdapter.mFullSpanItems.add(104);
+ mAdapter.mViewsHaveEqualSize = true;
+ waitFirstLayout();
+ mLayoutManager.expectLayouts(1);
+ scrollToPosition(400);
+ mLayoutManager.waitForLayout(2);
+ mLayoutManager.expectLayouts(2);
+ mAdapter.addAndNotify(101, 1);
+ mLayoutManager.waitForLayout(2);
+ if (strategy == GAP_HANDLING_MOVE_ITEMS_BETWEEN_SPANS) {
+ mLayoutManager.expectLayouts(1);
+ }
+
+ // state
+ // now smooth scroll to 99 to trigger a layout around 100
+ smoothScrollToPosition(99);
+ switch (strategy) {
+ case GAP_HANDLING_NONE:
+ assertSpans("gap handling:" + Config.gapStrategyName(strategy), new int[]{100, 0},
+ new int[]{101, 2}, new int[]{102, 0}, new int[]{103, 1}, new int[]{104, 2},
+ new int[]{105, 0});
+
+ // should be able to detect the gap
+ View gapView = mLayoutManager.hasGapsToFix(0, mLayoutManager.getChildCount());
+ assertSame("gap should be detected", mLayoutManager.findViewByPosition(101),
+ gapView);
+ break;
+ case GAP_HANDLING_MOVE_ITEMS_BETWEEN_SPANS:
+ mLayoutManager.waitForLayout(2);
+ assertSpans("swap items between spans", new int[]{100, 0}, new int[]{101, 0},
+ new int[]{102, 1}, new int[]{103, 2}, new int[]{104, 0}, new int[]{105, 0});
+ break;
+ }
+
+ }
+
+ public void testFullSizeSpans() throws Throwable {
+ Config config = new Config().spanCount(5).itemCount(30);
+ setupByConfig(config);
+ mAdapter.mFullSpanItems.add(3);
+ waitFirstLayout();
+ assertSpans("Testing full size span", new int[]{0, 0}, new int[]{1, 1}, new int[]{2, 2},
+ new int[]{3, 0}, new int[]{4, 0}, new int[]{5, 1}, new int[]{6, 2},
+ new int[]{7, 3}, new int[]{8, 4});
+ }
+
+ void assertSpans(String msg, int[]... childSpanTuples) {
+ for (int i = 0; i < childSpanTuples.length; i++) {
+ assertSpan(msg, childSpanTuples[i][0], childSpanTuples[i][1]);
+ }
+ }
+
+ void assertSpan(String msg, int childPosition, int expectedSpan) {
+ View view = mLayoutManager.findViewByPosition(childPosition);
+ assertNotNull(msg + "view at position " + childPosition + " should exists", view);
+ assertEquals(msg + "[child:" + childPosition + "]", expectedSpan,
+ getLp(view).mSpan.mIndex);
+ }
+
+ public void testSpanReassignmentsOnItemChange() throws Throwable {
+ Config config = new Config().spanCount(5);
+ setupByConfig(config);
+ waitFirstLayout();
+ smoothScrollToPosition(mAdapter.getItemCount() / 2);
+ final int changePosition = mAdapter.getItemCount() / 4;
+ int[] prevAssignments = mLayoutManager.mLazySpanLookup.mData.clone();
+ mLayoutManager.expectLayouts(1);
+ runTestOnUiThread(new Runnable() {
+ @Override
+ public void run() {
+ mAdapter.notifyItemChanged(changePosition);
+ }
+ });
+ mLayoutManager.waitForLayout(2);
+ // item change should not affect span assignments
+ assertSpanAssignmentEquality("item change should not affect span assignments ",
+ prevAssignments, mLayoutManager.mLazySpanLookup.mData, 0, prevAssignments.length);
+
+ // delete an item before visible area
+ int deletedPosition = mLayoutManager.getPosition(mLayoutManager.getChildAt(0)) - 2;
+ Map<Item, Rect> before = mLayoutManager.collectChildCoordinates();
+ if (DEBUG) {
+ Log.d(TAG, "before:");
+ for (Map.Entry<Item, Rect> entry : before.entrySet()) {
+ Log.d(TAG, entry.getKey().mAdapterIndex + ":" + entry.getValue());
+ }
+ }
+ mLayoutManager.expectLayouts(1);
+ // TODO move these bounds to edge case once animation changes are in.
+ mAdapter.deleteAndNotify(deletedPosition, 1);
+ mLayoutManager.waitForLayout(2);
+ assertRectSetsEqual(config + " when an item towards the head of the list is deleted, it "
+ + "should not affect the layout if it is not visible", before,
+ mLayoutManager.collectChildCoordinates()
+ );
+ deletedPosition = mLayoutManager.getPosition(mLayoutManager.getChildAt(2));
+ mLayoutManager.expectLayouts(1);
+ mAdapter.deleteAndNotify(deletedPosition, 1);
+ mLayoutManager.waitForLayout(2);
+ assertRectSetsNotEqual(config + " when a visible item is deleted, it should affect the "
+ + "layout", before, mLayoutManager.collectChildCoordinates());
+ }
+
+ void assertSpanAssignmentEquality(String msg, int[] set1, int[] set2, int start, int end) {
+ for (int i = start; i < end; i++) {
+ assertEquals(msg + " ind:" + i, set1[i], set2[i]);
+ }
+ }
+
+ void assertSpanAssignmentEquality(String msg, int[] set1, int[] set2, int start1, int start2,
+ int length) {
+ for (int i = 0; i < length; i++) {
+ assertEquals(msg + " ind1:" + (start1 + i) + ", ind2:" + (start2 + i), set1[start1 + i],
+ set2[start2 + i]);
+ }
+ }
+
+ public void testViewSnapping() throws Throwable {
+ for (Config config : mBaseVariations) {
+ viewSnapTest(config.itemCount(config.mSpanCount + 1));
+ removeRecyclerView();
+ }
+ }
+
+ public void viewSnapTest(Config config) throws Throwable {
+ setupByConfig(config);
+ waitFirstLayout();
+ // run these tests twice. once initial layout, once after scroll
+ String logSuffix = "";
+ for (int i = 0; i < 2; i ++) {
+ Map<Item, Rect> itemRectMap = mLayoutManager.collectChildCoordinates();
+ Rect recyclerViewBounds = getDecoratedRecyclerViewBounds();
+ Rect usedLayoutBounds = new Rect();
+ for (Rect rect : itemRectMap.values()) {
+ usedLayoutBounds.union(rect);
+ }
+ if (DEBUG) {
+ Log.d(TAG, "testing view snapping (" + logSuffix + ") for config " + config);
+ }
+ if (config.mOrientation == VERTICAL) {
+ assertEquals(config + " there should be no gap on left" + logSuffix,
+ usedLayoutBounds.left, recyclerViewBounds.left);
+ assertEquals(config + " there should be no gap on right" + logSuffix,
+ usedLayoutBounds.right, recyclerViewBounds.right);
+ if (config.mReverseLayout) {
+ assertEquals(config + " there should be no gap on bottom" + logSuffix,
+ usedLayoutBounds.bottom, recyclerViewBounds.bottom);
+ assertTrue(config + " there should be some gap on top" + logSuffix,
+ usedLayoutBounds.top > recyclerViewBounds.top);
+ } else {
+ assertEquals(config + " there should be no gap on top" + logSuffix,
+ usedLayoutBounds.top, recyclerViewBounds.top);
+ assertTrue(config + " there should be some gap at the bottom" + logSuffix,
+ usedLayoutBounds.bottom < recyclerViewBounds.bottom);
+ }
+ } else {
+ assertEquals(config + " there should be no gap on top" + logSuffix,
+ usedLayoutBounds.top, recyclerViewBounds.top);
+ assertEquals(config + " there should be no gap at the bottom" + logSuffix,
+ usedLayoutBounds.bottom, recyclerViewBounds.bottom);
+ if (config.mReverseLayout) {
+ assertEquals(config + " there should be no on right" + logSuffix,
+ usedLayoutBounds.right, recyclerViewBounds.right);
+ assertTrue(config + " there should be some gap on left" + logSuffix,
+ usedLayoutBounds.left > recyclerViewBounds.left);
+ } else {
+ assertEquals(config + " there should be no gap on left" + logSuffix,
+ usedLayoutBounds.left, recyclerViewBounds.left);
+ assertTrue(config + " there should be some gap on right" + logSuffix,
+ usedLayoutBounds.right < recyclerViewBounds.right);
+ }
+ }
+ final int scroll = config.mReverseLayout ? -500 : 500;
+ scrollBy(scroll);
+ logSuffix = " scrolled " + scroll;
+ }
+
+ }
+
+ public void testSpanCountChangeOnRestoreSavedState() throws Throwable {
+ Config config = new Config(HORIZONTAL, true, 5,
+ GAP_HANDLING_NONE);
+ setupByConfig(config);
+ waitFirstLayout();
+
+ int beforeChildCount = mLayoutManager.getChildCount();
+ Parcelable savedState = mRecyclerView.onSaveInstanceState();
+ // we append a suffix to the parcelable to test out of bounds
+ String parcelSuffix = UUID.randomUUID().toString();
+ Parcel parcel = Parcel.obtain();
+ savedState.writeToParcel(parcel, 0);
+ parcel.writeString(parcelSuffix);
+ removeRecyclerView();
+ // reset for reading
+ parcel.setDataPosition(0);
+ // re-create
+ savedState = RecyclerView.SavedState.CREATOR.createFromParcel(parcel);
+ removeRecyclerView();
+
+ RecyclerView restored = new RecyclerView(getActivity());
+ mLayoutManager = new WrappedLayoutManager(config.mSpanCount, config.mOrientation);
+ restored.setLayoutManager(mLayoutManager);
+ // use the same adapter for Rect matching
+ restored.setAdapter(mAdapter);
+ restored.onRestoreInstanceState(savedState);
+ mLayoutManager.setSpanCount(1);
+ mLayoutManager.expectLayouts(1);
+ setRecyclerView(restored);
+ mLayoutManager.waitForLayout(2);
+ assertEquals("on saved state, reverse layout should be preserved",
+ config.mReverseLayout, mLayoutManager.getReverseLayout());
+ assertEquals("on saved state, orientation should be preserved",
+ config.mOrientation, mLayoutManager.getOrientation());
+ assertEquals("after setting new span cound, layout manager should keep new value",
+ 1, mLayoutManager.getSpanCount());
+ assertEquals("on saved state, gap strategy should be preserved",
+ config.mGapStrategy, mLayoutManager.getGapStrategy());
+ assertTrue("when span count is dramatically changed after restore, # of child views "
+ + "should change", beforeChildCount > mLayoutManager.getChildCount());
+ // make sure LLM can layout all children. is some span info is leaked, this would crash
+ smoothScrollToPosition(mAdapter.getItemCount() - 1);
+ }
+
+ public void testSavedState() throws Throwable {
+ PostLayoutRunnable[] postLayoutOptions = new PostLayoutRunnable[]{
+ new PostLayoutRunnable() {
+ @Override
+ public void run() throws Throwable {
+ // do nothing
+ }
+
+ @Override
+ public String describe() {
+ return "doing nothing";
+ }
+ },
+ new PostLayoutRunnable() {
+ @Override
+ public void run() throws Throwable {
+ mLayoutManager.expectLayouts(1);
+ scrollToPosition(mAdapter.getItemCount() * 3 / 4);
+ mLayoutManager.waitForLayout(2);
+ }
+
+ @Override
+ public String describe() {
+ return "scroll to position";
+ }
+ },
+ new PostLayoutRunnable() {
+ @Override
+ public void run() throws Throwable {
+ mLayoutManager.expectLayouts(1);
+ scrollToPositionWithOffset(mAdapter.getItemCount() * 1 / 3,
+ 50);
+ mLayoutManager.waitForLayout(2);
+ }
+
+ @Override
+ public String describe() {
+ return "scroll to position with positive offset";
+ }
+ },
+ new PostLayoutRunnable() {
+ @Override
+ public void run() throws Throwable {
+ mLayoutManager.expectLayouts(1);
+ scrollToPositionWithOffset(mAdapter.getItemCount() * 2 / 3,
+ -50);
+ mLayoutManager.waitForLayout(2);
+ }
+
+ @Override
+ public String describe() {
+ return "scroll to position with negative offset";
+ }
+ }
+ };
+ boolean[] waitForLayoutOptions = new boolean[]{false, true};
+ for (Config config : mBaseVariations) {
+ for (PostLayoutRunnable runnable : postLayoutOptions) {
+ for (boolean waitForLayout : waitForLayoutOptions) {
+ savedStateTest(config, waitForLayout, runnable);
+ removeRecyclerView();
+ }
+ }
+ }
+ }
+
+ public void savedStateTest(Config config, boolean waitForLayout,
+ PostLayoutRunnable postLayoutOperations)
+ throws Throwable {
+ if (DEBUG) {
+ Log.d(TAG, "testing saved state with wait for layout = " + waitForLayout + " config "
+ + config + " post layout action " + postLayoutOperations.describe());
+ }
+ setupByConfig(config);
+ if (waitForLayout) {
+ waitFirstLayout();
+ postLayoutOperations.run();
+ }
+ Map<Item, Rect> before = mLayoutManager.collectChildCoordinates();
+ Parcelable savedState = mRecyclerView.onSaveInstanceState();
+ // we append a suffix to the parcelable to test out of bounds
+ String parcelSuffix = UUID.randomUUID().toString();
+ Parcel parcel = Parcel.obtain();
+ savedState.writeToParcel(parcel, 0);
+ parcel.writeString(parcelSuffix);
+ removeRecyclerView();
+ // reset for reading
+ parcel.setDataPosition(0);
+ // re-create
+ savedState = RecyclerView.SavedState.CREATOR.createFromParcel(parcel);
+ removeRecyclerView();
+
+ RecyclerView restored = new RecyclerView(getActivity());
+ mLayoutManager = new WrappedLayoutManager(config.mSpanCount, config.mOrientation);
+ restored.setLayoutManager(mLayoutManager);
+ // use the same adapter for Rect matching
+ restored.setAdapter(mAdapter);
+ restored.onRestoreInstanceState(savedState);
+ assertEquals("Parcel reading should not go out of bounds", parcelSuffix,
+ parcel.readString());
+ mLayoutManager.expectLayouts(1);
+ setRecyclerView(restored);
+ mLayoutManager.waitForLayout(2);
+ assertEquals(config + " on saved state, reverse layout should be preserved",
+ config.mReverseLayout, mLayoutManager.getReverseLayout());
+ assertEquals(config + " on saved state, orientation should be preserved",
+ config.mOrientation, mLayoutManager.getOrientation());
+ assertEquals(config + " on saved state, span count should be preserved",
+ config.mSpanCount, mLayoutManager.getSpanCount());
+ assertEquals(config + " on saved state, gap strategy should be preserved",
+ config.mGapStrategy, mLayoutManager.getGapStrategy());
+ if (waitForLayout) {
+ assertRectSetsEqual(config + "\npost layout op:" + postLayoutOperations.describe()
+ + ": on restore, previous view positions should be preserved",
+ before, mLayoutManager.collectChildCoordinates()
+ );
+ }
+ // TODO add tests for changing values after restore before layout
+ }
+
+ public void testScrollToPositionWithOffset() throws Throwable {
+ for (Config config : mBaseVariations) {
+ scrollToPositionWithOffsetTest(config);
+ removeRecyclerView();
+ }
+ }
+
+ public void scrollToPositionWithOffsetTest(Config config) throws Throwable {
+ setupByConfig(config);
+ waitFirstLayout();
+ OrientationHelper orientationHelper = OrientationHelper
+ .createOrientationHelper(mLayoutManager, config.mOrientation);
+ Rect layoutBounds = getDecoratedRecyclerViewBounds();
+ // try scrolling towards head, should not affect anything
+ Map<Item, Rect> before = mLayoutManager.collectChildCoordinates();
+ scrollToPositionWithOffset(0, 20);
+ assertRectSetsEqual(config + " trying to over scroll with offset should be no-op",
+ before, mLayoutManager.collectChildCoordinates());
+ // try offsetting some visible children
+ int testCount = 10;
+ while (testCount-- > 0) {
+ // get middle child
+ final View child = mLayoutManager.getChildAt(mLayoutManager.getChildCount() / 2);
+ final int position = mRecyclerView.getChildPosition(child);
+ final int startOffset = config.mReverseLayout ?
+ orientationHelper.getEndAfterPadding() - orientationHelper
+ .getDecoratedEnd(child)
+ : orientationHelper.getDecoratedStart(child) - orientationHelper
+ .getStartAfterPadding();
+ final int scrollOffset = startOffset / 2;
+ mLayoutManager.expectLayouts(1);
+ scrollToPositionWithOffset(position, scrollOffset);
+ mLayoutManager.waitForLayout(2);
+ final int finalOffset = config.mReverseLayout ?
+ orientationHelper.getEndAfterPadding() - orientationHelper
+ .getDecoratedEnd(child)
+ : orientationHelper.getDecoratedStart(child) - orientationHelper
+ .getStartAfterPadding();
+ assertEquals(config + " scroll with offset on a visible child should work fine",
+ scrollOffset, finalOffset);
+ }
+
+ // try scrolling to invisible children
+ testCount = 10;
+ // we test above and below, one by one
+ int offsetMultiplier = -1;
+ while (testCount-- > 0) {
+ final TargetTuple target = findInvisibleTarget(config);
+ mLayoutManager.expectLayouts(1);
+ final int offset = offsetMultiplier
+ * orientationHelper.getDecoratedMeasurement(mLayoutManager.getChildAt(0)) / 3;
+ scrollToPositionWithOffset(target.mPosition, offset);
+ mLayoutManager.waitForLayout(2);
+ final View child = mLayoutManager.findViewByPosition(target.mPosition);
+ assertNotNull(config + " scrolling to a mPosition with offset " + offset
+ + " should layout it", child);
+ final Rect bounds = mLayoutManager.getViewBounds(child);
+ if (DEBUG) {
+ Log.d(TAG, config + " post scroll to invisible mPosition " + bounds + " in "
+ + layoutBounds + " with offset " + offset);
+ }
+
+ if (config.mReverseLayout) {
+ assertEquals(config + " when scrolling with offset to an invisible in reverse "
+ + "layout, its end should align with recycler view's end - offset",
+ orientationHelper.getEndAfterPadding() - offset,
+ orientationHelper.getDecoratedEnd(child)
+ );
+ } else {
+ assertEquals(config + " when scrolling with offset to an invisible child in normal"
+ + " layout its start should align with recycler view's start + "
+ + "offset",
+ orientationHelper.getStartAfterPadding() + offset,
+ orientationHelper.getDecoratedStart(child)
+ );
+ }
+ offsetMultiplier *= -1;
+ }
+ }
+
+ public void testScrollToPosition() throws Throwable {
+ for (Config config : mBaseVariations) {
+ scrollToPositionTest(config);
+ removeRecyclerView();
+ }
+ }
+
+ public Rect getDecoratedRecyclerViewBounds() {
+ return new Rect(
+ mRecyclerView.getPaddingLeft(),
+ mRecyclerView.getPaddingTop(),
+ mRecyclerView.getPaddingLeft() + mRecyclerView.getWidth(),
+ mRecyclerView.getPaddingTop() + mRecyclerView.getHeight()
+ );
+ }
+
+ private TargetTuple findInvisibleTarget(Config config) {
+ int minPosition = Integer.MAX_VALUE, maxPosition = Integer.MIN_VALUE;
+ for (int i = 0; i < mLayoutManager.getChildCount(); i++) {
+ View child = mLayoutManager.getChildAt(i);
+ int position = mRecyclerView.getChildPosition(child);
+ if (position < minPosition) {
+ minPosition = position;
+ }
+ if (position > maxPosition) {
+ maxPosition = position;
+ }
+ }
+ final int tailTarget = maxPosition + (mAdapter.getItemCount() - maxPosition) / 2;
+ final int headTarget = minPosition / 2;
+ final int target;
+ // where will the child come from ?
+ final int itemLayoutDirection;
+ if (Math.abs(tailTarget - maxPosition) > Math.abs(headTarget - minPosition)) {
+ target = tailTarget;
+ itemLayoutDirection = config.mReverseLayout ? LAYOUT_START : LAYOUT_END;
+ } else {
+ target = headTarget;
+ itemLayoutDirection = config.mReverseLayout ? LAYOUT_END : LAYOUT_START;
+ }
+ if (DEBUG) {
+ Log.d(TAG,
+ config + " target:" + target + " min:" + minPosition + ", max:" + maxPosition);
+ }
+ return new TargetTuple(target, itemLayoutDirection);
+ }
+
+ public void scrollToPositionTest(Config config) throws Throwable {
+ setupByConfig(config);
+ waitFirstLayout();
+ OrientationHelper orientationHelper = OrientationHelper
+ .createOrientationHelper(mLayoutManager, config.mOrientation);
+ Rect layoutBounds = getDecoratedRecyclerViewBounds();
+ for (int i = 0; i < mLayoutManager.getChildCount(); i++) {
+ View view = mLayoutManager.getChildAt(i);
+ Rect bounds = mLayoutManager.getViewBounds(view);
+ if (layoutBounds.contains(bounds)) {
+ Map<Item, Rect> initialBounds = mLayoutManager.collectChildCoordinates();
+ final int position = mRecyclerView.getChildPosition(view);
+ LayoutParams layoutParams
+ = (LayoutParams) (view.getLayoutParams());
+ TestViewHolder vh = (TestViewHolder) layoutParams.mViewHolder;
+ assertEquals("recycler view mPosition should match adapter mPosition", position,
+ vh.mBindedItem.mAdapterIndex);
+ if (DEBUG) {
+ Log.d(TAG, "testing scroll to visible mPosition at " + position
+ + " " + bounds + " inside " + layoutBounds);
+ }
+ mLayoutManager.expectLayouts(1);
+ scrollToPosition(position);
+ mLayoutManager.waitForLayout(2);
+ if (DEBUG) {
+ view = mLayoutManager.findViewByPosition(position);
+ Rect newBounds = mLayoutManager.getViewBounds(view);
+ Log.d(TAG, "after scrolling to visible mPosition " +
+ bounds + " equals " + newBounds);
+ }
+
+ assertRectSetsEqual(
+ config + "scroll to mPosition on fully visible child should be no-op",
+ initialBounds, mLayoutManager.collectChildCoordinates());
+ } else {
+ final int position = mRecyclerView.getChildPosition(view);
+ if (DEBUG) {
+ Log.d(TAG,
+ "child(" + position + ") not fully visible " + bounds + " not inside "
+ + layoutBounds
+ + mRecyclerView.getChildPosition(view)
+ );
+ }
+ mLayoutManager.expectLayouts(1);
+ runTestOnUiThread(new Runnable() {
+ @Override
+ public void run() {
+ mLayoutManager.scrollToPosition(position);
+ }
+ });
+ mLayoutManager.waitForLayout(2);
+ view = mLayoutManager.findViewByPosition(position);
+ bounds = mLayoutManager.getViewBounds(view);
+ if (DEBUG) {
+ Log.d(TAG, "after scroll to partially visible child " + bounds + " in "
+ + layoutBounds);
+ }
+ assertTrue(config
+ + " after scrolling to a partially visible child, it should become fully "
+ + " visible. " + bounds + " not inside " + layoutBounds,
+ layoutBounds.contains(bounds)
+ );
+ assertTrue(config + " when scrolling to a partially visible item, one of its edges "
+ + "should be on the boundaries", orientationHelper.getStartAfterPadding() ==
+ orientationHelper.getDecoratedStart(view)
+ || orientationHelper.getEndAfterPadding() ==
+ orientationHelper.getDecoratedEnd(view));
+ }
+ }
+
+ // try scrolling to invisible children
+ int testCount = 10;
+ while (testCount-- > 0) {
+ final TargetTuple target = findInvisibleTarget(config);
+ mLayoutManager.expectLayouts(1);
+ scrollToPosition(target.mPosition);
+ mLayoutManager.waitForLayout(2);
+ final View child = mLayoutManager.findViewByPosition(target.mPosition);
+ assertNotNull(config + " scrolling to a mPosition should lay it out", child);
+ final Rect bounds = mLayoutManager.getViewBounds(child);
+ if (DEBUG) {
+ Log.d(TAG, config + " post scroll to invisible mPosition " + bounds + " in "
+ + layoutBounds);
+ }
+ assertTrue(config + " scrolling to a mPosition should make it fully visible",
+ layoutBounds.contains(bounds));
+ if (target.mLayoutDirection == LAYOUT_START) {
+ assertEquals(
+ config + " when scrolling to an invisible child above, its start should"
+ + " align with recycler view's start",
+ orientationHelper.getStartAfterPadding(),
+ orientationHelper.getDecoratedStart(child)
+ );
+ } else {
+ assertEquals(config + " when scrolling to an invisible child below, its end "
+ + "should align with recycler view's end",
+ orientationHelper.getEndAfterPadding(),
+ orientationHelper.getDecoratedEnd(child)
+ );
+ }
+ }
+ }
+
+ private void scrollToPositionWithOffset(final int position, final int offset) throws Throwable {
+ runTestOnUiThread(new Runnable() {
+ @Override
+ public void run() {
+ mLayoutManager.scrollToPositionWithOffset(position, offset);
+ }
+ });
+ }
+
+ public void testLayoutOrder() throws Throwable {
+ for (Config config : mBaseVariations) {
+ layoutOrderTest(config);
+ removeRecyclerView();
+ }
+ }
+
+ public void layoutOrderTest(Config config) throws Throwable {
+ setupByConfig(config);
+ assertViewPositions(config);
+ }
+
+ void assertViewPositions(Config config) {
+ ArrayList<ArrayList<View>> viewsBySpan = mLayoutManager.collectChildrenBySpan();
+ OrientationHelper orientationHelper = OrientationHelper
+ .createOrientationHelper(mLayoutManager, config.mOrientation);
+ for (ArrayList<View> span : viewsBySpan) {
+ // validate all children's order. first child should have min start mPosition
+ final int count = span.size();
+ for (int i = 0, j = 1; j < count; i++, j++) {
+ View prev = span.get(i);
+ View next = span.get(j);
+ assertTrue(config + " prev item should be above next item",
+ orientationHelper.getDecoratedEnd(prev) <= orientationHelper
+ .getDecoratedStart(next)
+ );
+
+ }
+ }
+ }
+
+ public void testScrollBy() throws Throwable {
+ for (Config config : mBaseVariations) {
+ scrollByTest(config);
+ removeRecyclerView();
+ }
+ }
+
+ void waitFirstLayout() throws Throwable {
+ mLayoutManager.expectLayouts(1);
+ setRecyclerView(mRecyclerView);
+ mLayoutManager.waitForLayout(2);
+ }
+
+ public void scrollByTest(Config config) throws Throwable {
+ setupByConfig(config);
+ waitFirstLayout();
+ // try invalid scroll. should not happen
+ final View first = mLayoutManager.getChildAt(0);
+ OrientationHelper primaryOrientation = OrientationHelper
+ .createOrientationHelper(mLayoutManager, config.mOrientation);
+ int scrollDist;
+ if (config.mReverseLayout) {
+ scrollDist = primaryOrientation.getDecoratedMeasurement(first) / 2;
+ } else {
+ scrollDist = -primaryOrientation.getDecoratedMeasurement(first) / 2;
+ }
+ Map<Item, Rect> before = mLayoutManager.collectChildCoordinates();
+ scrollBy(scrollDist);
+ Map<Item, Rect> after = mLayoutManager.collectChildCoordinates();
+ assertRectSetsEqual(
+ config + " if there are no more items, scroll should not happen (dt:" + scrollDist
+ + ")",
+ before, after
+ );
+
+ scrollDist = -scrollDist * 3;
+ before = mLayoutManager.collectChildCoordinates();
+ scrollBy(scrollDist);
+ after = mLayoutManager.collectChildCoordinates();
+ int layoutStart = primaryOrientation.getStartAfterPadding();
+ int layoutEnd = primaryOrientation.getEndAfterPadding();
+ for (Map.Entry<Item, Rect> entry : before.entrySet()) {
+ Rect afterRect = after.get(entry.getKey());
+ // offset rect
+ if (config.mOrientation == VERTICAL) {
+ entry.getValue().offset(0, -scrollDist);
+ } else {
+ entry.getValue().offset(-scrollDist, 0);
+ }
+ if (afterRect == null || afterRect.isEmpty()) {
+ // assert item is out of bounds
+ int start, end;
+ if (config.mOrientation == VERTICAL) {
+ start = entry.getValue().top;
+ end = entry.getValue().bottom;
+ } else {
+ start = entry.getValue().left;
+ end = entry.getValue().right;
+ }
+ assertTrue(
+ config + " if item is missing after relayout, it should be out of bounds."
+ + "item start: " + start + ", end:" + end + " layout start:"
+ + layoutStart +
+ ", layout end:" + layoutEnd,
+ start <= layoutStart && end <= layoutEnd ||
+ start >= layoutEnd && end >= layoutEnd
+ );
+ } else {
+ assertEquals(config + " Item should be laid out at the scroll offset coordinates",
+ entry.getValue(),
+ afterRect);
+ }
+ }
+ assertViewPositions(config);
+ }
+
+ public void testConsistentRelayout() throws Throwable {
+ for (Config config : mBaseVariations) {
+ for (boolean firstChildMultiSpan : new boolean[]{false, true}) {
+ consistentRelayoutTest(config, firstChildMultiSpan);
+ }
+ removeRecyclerView();
+ }
+ }
+
+ public void consistentRelayoutTest(Config config, boolean firstChildMultiSpan)
+ throws Throwable {
+ setupByConfig(config);
+ if (firstChildMultiSpan) {
+ mAdapter.mFullSpanItems.add(0);
+ }
+ waitFirstLayout();
+ // record all child positions
+ Map<Item, Rect> before = mLayoutManager.collectChildCoordinates();
+ requestLayoutOnUIThread(mRecyclerView);
+ Map<Item, Rect> after = mLayoutManager.collectChildCoordinates();
+ assertRectSetsEqual(
+ config + " simple re-layout, firstChildMultiSpan:" + firstChildMultiSpan, before,
+ after);
+ // scroll some to create inconsistency
+ View firstChild = mLayoutManager.getChildAt(0);
+ final int firstChildStartBeforeScroll = mLayoutManager.mPrimaryOrientation
+ .getDecoratedStart(firstChild);
+ int distance = mLayoutManager.mPrimaryOrientation.getDecoratedMeasurement(firstChild) / 2;
+ if (config.mReverseLayout) {
+ distance *= -1;
+ }
+ scrollBy(distance);
+ waitForMainThread(2);
+ assertTrue("scroll by should move children", firstChildStartBeforeScroll !=
+ mLayoutManager.mPrimaryOrientation.getDecoratedStart(firstChild));
+ before = mLayoutManager.collectChildCoordinates();
+ mLayoutManager.expectLayouts(1);
+ requestLayoutOnUIThread(mRecyclerView);
+ mLayoutManager.waitForLayout(2);
+ after = mLayoutManager.collectChildCoordinates();
+ assertRectSetsEqual(config + " simple re-layout after scroll", before, after);
+ }
+
+ /**
+ * enqueues an empty runnable to main thread so that we can be assured it did run
+ *
+ * @param count Number of times to run
+ */
+ private void waitForMainThread(int count) throws Throwable {
+ final AtomicInteger i = new AtomicInteger(count);
+ while (i.get() > 0) {
+ runTestOnUiThread(new Runnable() {
+ @Override
+ public void run() {
+ i.decrementAndGet();
+ }
+ });
+ }
+ }
+
+ public void assertRectSetsNotEqual(String message, Map<Item, Rect> before,
+ Map<Item, Rect> after) {
+ Throwable throwable = null;
+ try {
+ assertRectSetsEqual("NOT " + message, before, after);
+ } catch (Throwable t) {
+ throwable = t;
+ }
+ assertNotNull(message + " two layout should be different", throwable);
+ }
+
+ public void assertRectSetsEqual(String message, Map<Item, Rect> before, Map<Item, Rect> after) {
+ if (DEBUG) {
+ Log.d(TAG, "checking rectangle equality.");
+ Log.d(TAG, "before:");
+ for (Map.Entry<Item, Rect> entry : before.entrySet()) {
+ Log.d(TAG, entry.getKey().mAdapterIndex + ":" + entry.getValue());
+ }
+ Log.d(TAG, "after:");
+ for (Map.Entry<Item, Rect> entry : after.entrySet()) {
+ Log.d(TAG, entry.getKey().mAdapterIndex + ":" + entry.getValue());
+ }
+ }
+ assertEquals(message + ": item counts should be equal", before.size()
+ , after.size());
+ for (Map.Entry<Item, Rect> entry : before.entrySet()) {
+ Rect afterRect = after.get(entry.getKey());
+ assertNotNull(message + ": Same item should be visible after simple re-layout",
+ afterRect);
+ assertEquals(message + ": Item should be laid out at the same coordinates",
+ entry.getValue(),
+ afterRect);
+ }
+ }
+
+ // test layout params assignment
+
+ class WrappedLayoutManager extends StaggeredGridLayoutManager {
+
+ CountDownLatch layoutLatch;
+
+ public void expectLayouts(int count) {
+ layoutLatch = new CountDownLatch(count);
+ }
+
+ public void waitForLayout(long timeout) throws InterruptedException {
+ waitForLayout(timeout * (DEBUG ? 1000 : 1), TimeUnit.SECONDS);
+ }
+
+ public void waitForLayout(long timeout, TimeUnit timeUnit) throws InterruptedException {
+ layoutLatch.await(timeout, timeUnit);
+ assertEquals("all expected layouts should be executed at the expected time",
+ 0, layoutLatch.getCount());
+ }
+
+ @Override
+ public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
+ super.onLayoutChildren(recycler, state);
+ layoutLatch.countDown();
+ }
+
+ public WrappedLayoutManager(int spanCount, int orientation) {
+ super(spanCount, orientation);
+ }
+
+ ArrayList<ArrayList<View>> collectChildrenBySpan() {
+ ArrayList<ArrayList<View>> viewsBySpan = new ArrayList<ArrayList<View>>();
+ for (int i = 0; i < getSpanCount(); i++) {
+ viewsBySpan.add(new ArrayList<View>());
+ }
+ for (int i = 0; i < getChildCount(); i++) {
+ View view = getChildAt(i);
+ LayoutParams lp
+ = (LayoutParams) view
+ .getLayoutParams();
+ viewsBySpan.get(lp.mSpan.mIndex).add(view);
+ }
+ return viewsBySpan;
+ }
+
+ Rect getViewBounds(View view) {
+ if (getOrientation() == HORIZONTAL) {
+ return new Rect(
+ mPrimaryOrientation.getDecoratedStart(view),
+ mSecondaryOrientation.getDecoratedStart(view),
+ mPrimaryOrientation.getDecoratedEnd(view),
+ mSecondaryOrientation.getDecoratedEnd(view));
+ } else {
+ return new Rect(
+ mSecondaryOrientation.getDecoratedStart(view),
+ mPrimaryOrientation.getDecoratedStart(view),
+ mSecondaryOrientation.getDecoratedEnd(view),
+ mPrimaryOrientation.getDecoratedEnd(view));
+ }
+
+ }
+
+ Map<Item, Rect> collectChildCoordinates() throws Throwable {
+ final Map<Item, Rect> items = new LinkedHashMap<Item, Rect>();
+ runTestOnUiThread(new Runnable() {
+ @Override
+ public void run() {
+ final int childCount = getChildCount();
+ for (int i = 0; i < childCount; i++) {
+ View child = getChildAt(i);
+ LayoutParams lp = (LayoutParams) child
+ .getLayoutParams();
+ TestViewHolder vh = (TestViewHolder) lp.mViewHolder;
+ items.put(vh.mBindedItem, getViewBounds(child));
+ }
+ }
+ });
+ return items;
+ }
+ }
+
+ class GridTestAdapter extends TestAdapter {
+
+ int mOrientation;
+
+ // original ids of items that should be full span
+ HashSet<Integer> mFullSpanItems = new HashSet<Integer>();
+
+ private boolean mViewsHaveEqualSize = false; // size in the scrollable direction
+
+ GridTestAdapter(int count, int orientation) {
+ super(count);
+ mOrientation = orientation;
+ }
+
+ @Override
+ public void offsetOriginalIndices(int start, int offset) {
+ if (mFullSpanItems.size() > 0) {
+ HashSet<Integer> old = mFullSpanItems;
+ mFullSpanItems = new HashSet<Integer>();
+ for (Integer i : old) {
+ if (i < start) {
+ mFullSpanItems.add(i);
+ } else if (offset > 0 || (start + Math.abs(offset)) <= i) {
+ mFullSpanItems.add(i + offset);
+ } else if (DEBUG) {
+ Log.d(TAG, "removed full span item " + i);
+ }
+ }
+ }
+ super.offsetOriginalIndices(start, offset);
+ }
+
+ @Override
+ public void onBindViewHolder(TestViewHolder holder,
+ int position) {
+ super.onBindViewHolder(holder, position);
+ Item item = mItems.get(position);
+ final int minSize = mViewsHaveEqualSize ? 200 : 200 + 20 * (position % 10);
+ if (mOrientation == OrientationHelper.HORIZONTAL) {
+ holder.itemView.setMinimumWidth(minSize);
+ } else {
+ holder.itemView.setMinimumHeight(minSize);
+ }
+ RecyclerView.LayoutParams lp = (RecyclerView.LayoutParams) holder.itemView
+ .getLayoutParams();
+ if (lp instanceof LayoutParams) {
+ ((LayoutParams) lp).setFullSpan(mFullSpanItems.contains(item.mAdapterIndex));
+ } else {
+ LayoutParams slp = new LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT,
+ ViewGroup.LayoutParams.WRAP_CONTENT);
+ holder.itemView.setLayoutParams(slp);
+ slp.setFullSpan(mFullSpanItems.contains(item.mAdapterIndex));
+ lp = slp;
+ }
+ lp.topMargin = 3;
+ lp.leftMargin = 5;
+ lp.rightMargin = 7;
+ lp.bottomMargin = 9;
+ }
+ }
+
+ static class Config {
+
+ private static final int DEFAULT_ITEM_COUNT = 300;
+
+ int mOrientation = OrientationHelper.VERTICAL;
+
+ boolean mReverseLayout = false;
+
+ int mSpanCount = 3;
+
+ int mGapStrategy = GAP_HANDLING_MOVE_ITEMS_BETWEEN_SPANS;
+
+ int mItemCount = DEFAULT_ITEM_COUNT;
+
+ OrientationHelper mOrientationHelper;
+
+ Config(int orientation, boolean reverseLayout, int spanCount, int gapStrategy) {
+ mOrientation = orientation;
+ mReverseLayout = reverseLayout;
+ mSpanCount = spanCount;
+ mGapStrategy = gapStrategy;
+ }
+
+ public Config() {
+
+ }
+
+ Config orientation(int orientation) {
+ mOrientation = orientation;
+ return this;
+ }
+
+ Config reverseLayout(boolean reverseLayout) {
+ mReverseLayout = reverseLayout;
+ return this;
+ }
+
+ Config spanCount(int spanCount) {
+ mSpanCount = spanCount;
+ return this;
+ }
+
+ Config gapStrategy(int gapStrategy) {
+ mGapStrategy = gapStrategy;
+ return this;
+ }
+
+ public Config itemCount(int itemCount) {
+ mItemCount = itemCount;
+ return this;
+ }
+
+ @Override
+ public String toString() {
+ return "[CONFIG:" +
+ " span:" + mSpanCount + "," +
+ " orientation:" + (mOrientation == HORIZONTAL ? "horz," : "vert,") +
+ " reverse:" + (mReverseLayout ? "T" : "F") +
+ " gap strategy: " + gapStrategyName(mGapStrategy);
+ }
+
+ private static String gapStrategyName(int gapStrategy) {
+ switch (gapStrategy) {
+ case GAP_HANDLING_NONE:
+ return "none";
+ case GAP_HANDLING_MOVE_ITEMS_BETWEEN_SPANS:
+ return "move spans";
+ case GAP_HANDLING_LAZY:
+ return "lazy";
+ }
+ return "gap strategy: unknown";
+ }
+ }
+
+ private static class TargetTuple {
+
+ final int mPosition;
+
+ final int mLayoutDirection;
+
+ TargetTuple(int position, int layoutDirection) {
+ this.mPosition = position;
+ this.mLayoutDirection = layoutDirection;
+ }
+ }
+
+ private interface PostLayoutRunnable {
+
+ void run() throws Throwable;
+
+ String describe();
+ }
+
+}
\ No newline at end of file