/* * 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.Debug; import android.os.Looper; import android.os.Parcel; import android.os.Parcelable; import android.support.v4.view.AccessibilityDelegateCompat; import android.support.v4.view.accessibility.AccessibilityEventCompat; import android.support.v4.view.accessibility.AccessibilityRecordCompat; import android.util.Log; import android.view.View; import android.view.ViewGroup; import android.view.accessibility.AccessibilityEvent; import java.util.ArrayList; import java.util.Arrays; import java.util.BitSet; import java.util.HashMap; 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.LayoutState.*; import static android.support.v7.widget.LinearLayoutManager.VERTICAL; import static android.support.v7.widget.StaggeredGridLayoutManager.*; public class StaggeredGridLayoutManagerTest extends BaseRecyclerViewInstrumentationTest { private static final boolean DEBUG = false; private static final String TAG = "StaggeredGridLayoutManagerTest"; volatile WrappedLayoutManager mLayoutManager; GridTestAdapter mAdapter; final List mBaseVariations = new ArrayList(); @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); mRecyclerView.setHasFixedSize(true); mLayoutManager = new WrappedLayoutManager(config.mSpanCount, config.mOrientation); mLayoutManager.setGapStrategy(config.mGapStrategy); mLayoutManager.setReverseLayout(config.mReverseLayout); mRecyclerView.setLayoutManager(mLayoutManager); mRecyclerView.addItemDecoration(new RecyclerView.ItemDecoration() { @Override public void getItemOffsets(Rect outRect, View view, RecyclerView parent, RecyclerView.State state) { try { LayoutParams lp = (LayoutParams) view.getLayoutParams(); assertNotNull("view should have layout params assigned", lp); assertNotNull("when item offsets are requested, view should have a valid span", lp.mSpan); } catch (Throwable t) { postExceptionToInstrumentation(t); } } }); } public void testAreAllStartsTheSame() throws Throwable { setupByConfig(new Config(VERTICAL, false, 3, GAP_HANDLING_NONE).itemCount(300)); waitFirstLayout(); smoothScrollToPosition(100); mLayoutManager.expectLayouts(1); mAdapter.deleteAndNotify(0, 2); mLayoutManager.waitForLayout(2); smoothScrollToPosition(0); assertFalse("all starts should not be the same", mLayoutManager.areAllStartsEqual()); } public void testAreAllEndsTheSame() throws Throwable { setupByConfig(new Config(VERTICAL, true, 3, GAP_HANDLING_NONE).itemCount(300)); waitFirstLayout(); smoothScrollToPosition(100); mLayoutManager.expectLayouts(1); mAdapter.deleteAndNotify(0, 2); mLayoutManager.waitForLayout(2); smoothScrollToPosition(0); assertFalse("all ends should not be the same", mLayoutManager.areAllEndsEqual()); } public void testFindLastInUnevenDistribution() throws Throwable { setupByConfig(new Config(VERTICAL, false, 2, GAP_HANDLING_MOVE_ITEMS_BETWEEN_SPANS) .itemCount(5)); mAdapter.mOnBindHandler = new OnBindHandler() { @Override void onBoundItem(TestViewHolder vh, int position) { LayoutParams lp = (LayoutParams) vh.itemView.getLayoutParams(); if (position == 1) { lp.height = mRecyclerView.getHeight() - 10; } else { lp.height = 5; } } }; waitFirstLayout(); int[] into = new int[2]; mLayoutManager.findFirstCompletelyVisibleItemPositions(into); assertEquals("first completely visible item from span 0 should be 0", 0, into[0]); assertEquals("first completely visible item from span 1 should be 1", 1, into[1]); mLayoutManager.findLastCompletelyVisibleItemPositions(into); assertEquals("last completely visible item from span 0 should be 4", 4, into[0]); assertEquals("last completely visible item from span 1 should be 1", 1, into[1]); assertEquals("first fully visible child should be at position", 0, mRecyclerView.getChildViewHolder(mLayoutManager. findFirstVisibleItemClosestToStart(true, true)).getPosition()); assertEquals("last fully visible child should be at position", 4, mRecyclerView.getChildViewHolder(mLayoutManager. findFirstVisibleItemClosestToEnd(true, true)).getPosition()); assertEquals("first visible child should be at position", 0, mRecyclerView.getChildViewHolder(mLayoutManager. findFirstVisibleItemClosestToStart(false, true)).getPosition()); assertEquals("last visible child should be at position", 4, mRecyclerView.getChildViewHolder(mLayoutManager. findFirstVisibleItemClosestToEnd(false, true)).getPosition()); } public void testCustomWidthInHorizontal() throws Throwable { customSizeInScrollDirectionTest( new Config(HORIZONTAL, false, 3, GAP_HANDLING_MOVE_ITEMS_BETWEEN_SPANS)); } public void testCustomHeightInVertical() throws Throwable { customSizeInScrollDirectionTest( new Config(VERTICAL, false, 3, GAP_HANDLING_MOVE_ITEMS_BETWEEN_SPANS)); } public void customSizeInScrollDirectionTest(final Config config) throws Throwable { setupByConfig(config); final Map sizeMap = new HashMap(); mAdapter.mOnBindHandler = new OnBindHandler() { @Override void onBoundItem(TestViewHolder vh, int position) { final ViewGroup.LayoutParams layoutParams = vh.itemView.getLayoutParams(); final int size = 1 + position * 5; if (config.mOrientation == HORIZONTAL) { layoutParams.width = size; } else { layoutParams.height = size; } sizeMap.put(vh.itemView, size); if (position == 3) { getLp(vh.itemView).setFullSpan(true); } } @Override boolean assignRandomSize() { return false; } }; waitFirstLayout(); assertTrue("[test sanity] some views should be laid out", sizeMap.size() > 0); for (int i = 0; i < mRecyclerView.getChildCount(); i++) { View child = mRecyclerView.getChildAt(i); final int size = config.mOrientation == HORIZONTAL ? child.getWidth() : child.getHeight(); assertEquals("child " + i + " should have the size specified in its layout params", sizeMap.get(child).intValue(), size); } checkForMainThreadException(); } public void testGrowLookup() throws Throwable { setupByConfig(new Config(VERTICAL, false, 3, GAP_HANDLING_MOVE_ITEMS_BETWEEN_SPANS)); waitFirstLayout(); mLayoutManager.expectLayouts(1); mAdapter.mItems.clear(); mAdapter.dispatchDataSetChanged(); mLayoutManager.waitForLayout(2); checkForMainThreadException(); mLayoutManager.expectLayouts(2); mAdapter.addAndNotify(0, 30); mLayoutManager.waitForLayout(2); checkForMainThreadException(); } public void testRTL() throws Throwable { for (boolean changeRtlAfter : new boolean[]{false, true}) { for (Config config : mBaseVariations) { rtlTest(config, changeRtlAfter); removeRecyclerView(); } } } void rtlTest(Config config, boolean changeRtlAfter) throws Throwable { if (config.mSpanCount == 1) { config.mSpanCount = 2; } String logPrefix = config + ", changeRtlAfterLayout:" + changeRtlAfter; setupByConfig(config.itemCount(5)); if (changeRtlAfter) { waitFirstLayout(); mLayoutManager.expectLayouts(1); mLayoutManager.setFakeRtl(true); mLayoutManager.waitForLayout(2); } else { mLayoutManager.mFakeRTL = true; waitFirstLayout(); } assertEquals("view should become rtl", true, mLayoutManager.isLayoutRTL()); OrientationHelper helper = OrientationHelper.createHorizontalHelper(mLayoutManager); View child0 = mLayoutManager.findViewByPosition(0); View child1 = mLayoutManager.findViewByPosition(config.mOrientation == VERTICAL ? 1 : config.mSpanCount); assertNotNull(logPrefix + " child position 0 should be laid out", child0); assertNotNull(logPrefix + " child position 0 should be laid out", child1); if (config.mOrientation == VERTICAL || !config.mReverseLayout) { assertTrue(logPrefix + " second child should be to the left of first child", helper.getDecoratedStart(child0) >= helper.getDecoratedEnd(child1)); assertEquals(logPrefix + " first child should be right aligned", helper.getDecoratedEnd(child0), helper.getEndAfterPadding()); } else { assertTrue(logPrefix + " first child should be to the left of second child", helper.getDecoratedStart(child1) >= helper.getDecoratedEnd(child0)); assertEquals(logPrefix + " first child should be left aligned", helper.getDecoratedStart(child0), helper.getStartAfterPadding()); } checkForMainThreadException(); } public void testScrollBackAndPreservePositions() throws Throwable { for (boolean saveRestore : new boolean[]{false, true}) { for (Config config : mBaseVariations) { scrollBackAndPreservePositionsTest(config, saveRestore); removeRecyclerView(); } } } public void scrollBackAndPreservePositionsTest(final Config config, final boolean saveRestoreInBetween) throws Throwable { setupByConfig(config); mAdapter.mOnBindHandler = new OnBindHandler() { @Override public void onBoundItem(TestViewHolder vh, int position) { LayoutParams lp = (LayoutParams) vh.itemView.getLayoutParams(); lp.setFullSpan((position * 7) % (config.mSpanCount + 1) == 0); } }; waitFirstLayout(); final int[] globalPositions = new int[mAdapter.getItemCount()]; Arrays.fill(globalPositions, Integer.MIN_VALUE); final int scrollStep = (mLayoutManager.mPrimaryOrientation.getTotalSpace() / 10) * (config.mReverseLayout ? -1 : 1); final int[] globalPos = new int[1]; runTestOnUiThread(new Runnable() { @Override public void run() { int globalScrollPosition = 0; while (globalPositions[mAdapter.getItemCount() - 1] == Integer.MIN_VALUE) { for (int i = 0; i < mRecyclerView.getChildCount(); i++) { View child = mRecyclerView.getChildAt(i); final int pos = mRecyclerView.getChildLayoutPosition(child); if (globalPositions[pos] != Integer.MIN_VALUE) { continue; } if (config.mReverseLayout) { globalPositions[pos] = globalScrollPosition + mLayoutManager.mPrimaryOrientation.getDecoratedEnd(child); } else { globalPositions[pos] = globalScrollPosition + mLayoutManager.mPrimaryOrientation.getDecoratedStart(child); } } globalScrollPosition += mLayoutManager.scrollBy(scrollStep, mRecyclerView.mRecycler, mRecyclerView.mState); } if (DEBUG) { Log.d(TAG, "done recording positions " + Arrays.toString(globalPositions)); } globalPos[0] = globalScrollPosition; } }); checkForMainThreadException(); if (saveRestoreInBetween) { saveRestore(config); } checkForMainThreadException(); runTestOnUiThread(new Runnable() { @Override public void run() { int globalScrollPosition = globalPos[0]; // now scroll back and make sure global positions match BitSet shouldTest = new BitSet(mAdapter.getItemCount()); shouldTest.set(0, mAdapter.getItemCount() - 1, true); String assertPrefix = config + ", restored in between:" + saveRestoreInBetween + " global pos must match when scrolling in reverse for position "; int scrollAmount = Integer.MAX_VALUE; while (!shouldTest.isEmpty() && scrollAmount != 0) { for (int i = 0; i < mRecyclerView.getChildCount(); i++) { View child = mRecyclerView.getChildAt(i); int pos = mRecyclerView.getChildLayoutPosition(child); if (!shouldTest.get(pos)) { continue; } shouldTest.clear(pos); int globalPos; if (config.mReverseLayout) { globalPos = globalScrollPosition + mLayoutManager.mPrimaryOrientation.getDecoratedEnd(child); } else { globalPos = globalScrollPosition + mLayoutManager.mPrimaryOrientation.getDecoratedStart(child); } assertEquals(assertPrefix + pos, globalPositions[pos], globalPos); } scrollAmount = mLayoutManager.scrollBy(-scrollStep, mRecyclerView.mRecycler, mRecyclerView.mState); globalScrollPosition += scrollAmount; } assertTrue("all views should be seen", shouldTest.isEmpty()); } }); checkForMainThreadException(); } public void testScrollToPositionWithPredictive() throws Throwable { scrollToPositionWithPredictive(0, LinearLayoutManager.INVALID_OFFSET); removeRecyclerView(); scrollToPositionWithPredictive(Config.DEFAULT_ITEM_COUNT / 2, LinearLayoutManager.INVALID_OFFSET); removeRecyclerView(); scrollToPositionWithPredictive(9, 20); removeRecyclerView(); scrollToPositionWithPredictive(Config.DEFAULT_ITEM_COUNT / 2, 10); } public void scrollToPositionWithPredictive(final int scrollPosition, final int scrollOffset) throws Throwable { setupByConfig(new Config(StaggeredGridLayoutManager.VERTICAL, false, 3, StaggeredGridLayoutManager.GAP_HANDLING_NONE)); waitFirstLayout(); mLayoutManager.mOnLayoutListener = new OnLayoutListener() { @Override void after(RecyclerView.Recycler recycler, RecyclerView.State state) { RecyclerView rv = mLayoutManager.mRecyclerView; if (state.isPreLayout()) { assertEquals("pending scroll position should still be pending", scrollPosition, mLayoutManager.mPendingScrollPosition); if (scrollOffset != LinearLayoutManager.INVALID_OFFSET) { assertEquals("pending scroll position offset should still be pending", scrollOffset, mLayoutManager.mPendingScrollPositionOffset); } } else { RecyclerView.ViewHolder vh = rv.findViewHolderForLayoutPosition(scrollPosition); assertNotNull("scroll to position should work", vh); if (scrollOffset != LinearLayoutManager.INVALID_OFFSET) { assertEquals("scroll offset should be applied properly", mLayoutManager.getPaddingTop() + scrollOffset + ((RecyclerView.LayoutParams) vh.itemView .getLayoutParams()).topMargin, mLayoutManager.getDecoratedTop(vh.itemView)); } } } }; mLayoutManager.expectLayouts(2); runTestOnUiThread(new Runnable() { @Override public void run() { try { mAdapter.addAndNotify(0, 1); if (scrollOffset == LinearLayoutManager.INVALID_OFFSET) { mLayoutManager.scrollToPosition(scrollPosition); } else { mLayoutManager.scrollToPositionWithOffset(scrollPosition, scrollOffset); } } catch (Throwable throwable) { throwable.printStackTrace(); } } }); mLayoutManager.waitForLayout(2); checkForMainThreadException(); } LayoutParams getLp(View view) { return (LayoutParams) view.getLayoutParams(); } public void testGetFirstLastChildrenTest() throws Throwable { for (boolean provideArr : new boolean[]{true, false}) { for (Config config : mBaseVariations) { getFirstLastChildrenTest(config, provideArr); removeRecyclerView(); } } } public void getFirstLastChildrenTest(final Config config, final boolean provideArr) throws Throwable { setupByConfig(config); waitFirstLayout(); Runnable viewInBoundsTest = new Runnable() { @Override public void run() { VisibleChildren visibleChildren = mLayoutManager.traverseAndFindVisibleChildren(); final String boundsLog = mLayoutManager.getBoundsLog(); VisibleChildren queryResult = new VisibleChildren(mLayoutManager.getSpanCount()); queryResult.firstFullyVisiblePositions = mLayoutManager .findFirstCompletelyVisibleItemPositions( provideArr ? new int[mLayoutManager.getSpanCount()] : null); queryResult.firstVisiblePositions = mLayoutManager .findFirstVisibleItemPositions( provideArr ? new int[mLayoutManager.getSpanCount()] : null); queryResult.lastFullyVisiblePositions = mLayoutManager .findLastCompletelyVisibleItemPositions( provideArr ? new int[mLayoutManager.getSpanCount()] : null); queryResult.lastVisiblePositions = mLayoutManager .findLastVisibleItemPositions( provideArr ? new int[mLayoutManager.getSpanCount()] : null); assertEquals(config + ":\nfirst visible child should match traversal result\n" + "traversed:" + visibleChildren + "\n" + "queried:" + queryResult + "\n" + boundsLog, visibleChildren, queryResult ); } }; runTestOnUiThread(viewInBoundsTest); // smooth scroll to end of the list and keep testing meanwhile. This will test pre-caching // case final int scrollPosition = mAdapter.getItemCount(); runTestOnUiThread(new Runnable() { @Override public void run() { mRecyclerView.smoothScrollToPosition(scrollPosition); } }); while (mLayoutManager.isSmoothScrolling() || mRecyclerView.getScrollState() != RecyclerView.SCROLL_STATE_IDLE) { runTestOnUiThread(viewInBoundsTest); checkForMainThreadException(); Thread.sleep(400); } // delete all items mLayoutManager.expectLayouts(2); mAdapter.deleteAndNotify(0, mAdapter.getItemCount()); mLayoutManager.waitForLayout(2); // test empty case runTestOnUiThread(viewInBoundsTest); // set a new adapter with huge items to test full bounds check mLayoutManager.expectLayouts(1); final int totalSpace = mLayoutManager.mPrimaryOrientation.getTotalSpace(); final TestAdapter newAdapter = new TestAdapter(100) { @Override public void onBindViewHolder(TestViewHolder holder, int position) { super.onBindViewHolder(holder, position); if (config.mOrientation == LinearLayoutManager.HORIZONTAL) { holder.itemView.setMinimumWidth(totalSpace + 5); } else { holder.itemView.setMinimumHeight(totalSpace + 5); } } }; runTestOnUiThread(new Runnable() { @Override public void run() { mRecyclerView.setAdapter(newAdapter); } }); mLayoutManager.waitForLayout(2); runTestOnUiThread(viewInBoundsTest); checkForMainThreadException(); } public void testMoveGapHandling() throws Throwable { Config config = new Config().spanCount(2).itemCount(40); setupByConfig(config); waitFirstLayout(); mLayoutManager.expectLayouts(2); mAdapter.moveAndNotify(4, 1); mLayoutManager.waitForLayout(2); assertNull("moving item to upper should not cause gaps", mLayoutManager.hasGapsToFix()); } public void testUpdateAfterFullSpan() throws Throwable { updateAfterFullSpanGapHandlingTest(0); } public void testUpdateAfterFullSpan2() throws Throwable { updateAfterFullSpanGapHandlingTest(20); } public void testTemporaryGapHandling() throws Throwable { int fullSpanIndex = 200; setupByConfig(new Config().spanCount(2).itemCount(500)); mAdapter.mFullSpanItems.add(fullSpanIndex); waitFirstLayout(); smoothScrollToPosition(fullSpanIndex + 30); mLayoutManager.expectLayouts(1); mAdapter.deleteAndNotify(fullSpanIndex + 1, 3); mLayoutManager.waitForLayout(1); smoothScrollToPosition(0); mLayoutManager.expectLayouts(1); smoothScrollToPosition(fullSpanIndex + 5); mLayoutManager.assertNoLayout("if an interim gap is fixed, it should not cause a " + "relayout", 2); View fullSpan = mLayoutManager.findViewByPosition(fullSpanIndex); View view1 = mLayoutManager.findViewByPosition(fullSpanIndex + 1); View view2 = mLayoutManager.findViewByPosition(fullSpanIndex + 2); LayoutParams lp1 = (LayoutParams) view1.getLayoutParams(); LayoutParams lp2 = (LayoutParams) view2.getLayoutParams(); assertEquals("view 1 span index", 0, lp1.getSpanIndex()); assertEquals("view 2 span index", 1, lp2.getSpanIndex()); assertEquals("no gap between span and view 1", mLayoutManager.mPrimaryOrientation.getDecoratedEnd(fullSpan), mLayoutManager.mPrimaryOrientation.getDecoratedStart(view1)); assertEquals("no gap between span and view 2", mLayoutManager.mPrimaryOrientation.getDecoratedEnd(fullSpan), mLayoutManager.mPrimaryOrientation.getDecoratedStart(view2)); } public void updateAfterFullSpanGapHandlingTest(int fullSpanIndex) throws Throwable { setupByConfig(new Config().spanCount(2).itemCount(100)); mAdapter.mFullSpanItems.add(fullSpanIndex); waitFirstLayout(); smoothScrollToPosition(fullSpanIndex + 30); mLayoutManager.expectLayouts(1); mAdapter.deleteAndNotify(fullSpanIndex + 1, 3); mLayoutManager.waitForLayout(1); smoothScrollToPosition(fullSpanIndex); // give it some time to fix the gap Thread.sleep(500); View fullSpan = mLayoutManager.findViewByPosition(fullSpanIndex); View view1 = mLayoutManager.findViewByPosition(fullSpanIndex + 1); View view2 = mLayoutManager.findViewByPosition(fullSpanIndex + 2); LayoutParams lp1 = (LayoutParams) view1.getLayoutParams(); LayoutParams lp2 = (LayoutParams) view2.getLayoutParams(); assertEquals("view 1 span index", 0, lp1.getSpanIndex()); assertEquals("view 2 span index", 1, lp2.getSpanIndex()); assertEquals("no gap between span and view 1", mLayoutManager.mPrimaryOrientation.getDecoratedEnd(fullSpan), mLayoutManager.mPrimaryOrientation.getDecoratedStart(view1)); assertEquals("no gap between span and view 2", mLayoutManager.mPrimaryOrientation.getDecoratedEnd(fullSpan), mLayoutManager.mPrimaryOrientation.getDecoratedStart(view2)); } 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}); 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 gapInTheMiddle(Config config) throws Throwable { } public void testGapAtTheBeginning() throws Throwable { for (Config config : mBaseVariations) { for (int deleteCount = 1; deleteCount < config.mSpanCount * 2; deleteCount++) { for (int deletePosition = config.mSpanCount - 1; deletePosition < config.mSpanCount + 2; deletePosition++) { gapAtTheBeginningOfTheListTest(config, deletePosition, deleteCount); removeRecyclerView(); } } } } public void gapAtTheBeginningOfTheListTest(final Config config, int deletePosition, int deleteCount) throws Throwable { if (config.mSpanCount < 2 || config.mGapStrategy == GAP_HANDLING_NONE) { return; } if (config.mItemCount < 100) { config.itemCount(100); } final String logPrefix = config + ", deletePos:" + deletePosition + ", deleteCount:" + deleteCount; setupByConfig(config); final RecyclerView.Adapter adapter = mAdapter; waitFirstLayout(); // scroll far away smoothScrollToPosition(config.mItemCount / 2); // assert to be deleted child is not visible assertNull(logPrefix + " test sanity, to be deleted child should be invisible", mRecyclerView.findViewHolderForLayoutPosition(deletePosition)); // delete the child and notify mAdapter.deleteAndNotify(deletePosition, deleteCount); getInstrumentation().waitForIdleSync(); mLayoutManager.expectLayouts(1); smoothScrollToPosition(0); mLayoutManager.waitForLayout(2); // due to data changes, first item may become visible before others which will cause // smooth scrolling to stop. Triggering it twice more is a naive hack. // Until we have time to consider it as a bug, this is the only workaround. smoothScrollToPosition(0); Thread.sleep(300); smoothScrollToPosition(0); Thread.sleep(500); // some animations should happen and we should recover layout final Map actualCoords = mLayoutManager.collectChildCoordinates(); // now layout another RV with same adapter removeRecyclerView(); setupByConfig(config); mRecyclerView.setAdapter(adapter);// use same adapter so that items can be matched waitFirstLayout(); final Map desiredCoords = mLayoutManager.collectChildCoordinates(); assertRectSetsEqual(logPrefix + " when an item from the start of the list is deleted, " + "layout should recover the state once scrolling is stopped", desiredCoords, actualCoords); } public void testPartialSpanInvalidation() throws Throwable { Config config = new Config().spanCount(5).itemCount(100); setupByConfig(config); for (int i = 20; i < mAdapter.getItemCount(); i += 20) { mAdapter.mFullSpanItems.add(i); } waitFirstLayout(); smoothScrollToPosition(50); int prevSpanId = mLayoutManager.mLazySpanLookup.mData[30]; mAdapter.changeAndNotify(15, 2); Thread.sleep(200); assertEquals("Invalidation should happen within full span item boundaries", prevSpanId, mLayoutManager.mLazySpanLookup.mData[30]); assertEquals("item in invalidated range should have clear span id", LayoutParams.INVALID_SPAN_ID, mLayoutManager.mLazySpanLookup.mData[16]); smoothScrollToPosition(85); int[] prevSpans = copyOfRange(mLayoutManager.mLazySpanLookup.mData, 62, 85); mAdapter.deleteAndNotify(55, 2); Thread.sleep(200); assertEquals("item in invalidated range should have clear span id", LayoutParams.INVALID_SPAN_ID, mLayoutManager.mLazySpanLookup.mData[16]); int[] newSpans = copyOfRange(mLayoutManager.mLazySpanLookup.mData, 60, 83); assertSpanAssignmentEquality("valid spans should be shifted for deleted item", prevSpans, newSpans, 0, 0, newSpans.length); } // Same as Arrays.copyOfRange but for API 7 private int[] copyOfRange(int[] original, int from, int to) { int newLength = to - from; if (newLength < 0) { throw new IllegalArgumentException(from + " > " + to); } int[] copy = new int[newLength]; System.arraycopy(original, from, copy, 0, Math.min(original.length - from, newLength)); return copy; } public void testSpanReassignmentsOnItemChange() throws Throwable { Config config = new Config().spanCount(5); setupByConfig(config); waitFirstLayout(); smoothScrollToPosition(mAdapter.getItemCount() / 2); final int changePosition = mAdapter.getItemCount() / 4; mLayoutManager.expectLayouts(1); mAdapter.changeAndNotify(changePosition, 1); mLayoutManager.assertNoLayout("no layout should happen when an invisible child is updated", 1); // delete an item before visible area int deletedPosition = mLayoutManager.getPosition(mLayoutManager.getChildAt(0)) - 2; Map before = mLayoutManager.collectChildCoordinates(); if (DEBUG) { Log.d(TAG, "before:"); for (Map.Entry entry : before.entrySet()) { Log.d(TAG, entry.getKey().mAdapterIndex + ":" + entry.getValue()); } } mLayoutManager.expectLayouts(1); 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 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); mLayoutManager.setReverseLayout(config.mReverseLayout); mLayoutManager.setGapStrategy(config.mGapStrategy); 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 count, 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 " + (mAdapter == null ? "" : mAdapter.getItemCount() * 3 / 4); } }, new PostLayoutRunnable() { @Override public void run() throws Throwable { mLayoutManager.expectLayouts(1); scrollToPositionWithOffset(mAdapter.getItemCount() / 3, 50); mLayoutManager.waitForLayout(2); } @Override public String describe() { return "scroll to position " + (mAdapter == null ? "" : mAdapter.getItemCount() / 3) + "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}; List testVariations = new ArrayList(); testVariations.addAll(mBaseVariations); for (Config config : mBaseVariations) { if (config.mSpanCount < 2) { continue; } final Config clone = (Config) config.clone(); clone.mItemCount = clone.mSpanCount - 1; testVariations.add(clone); } for (Config config : testVariations) { for (PostLayoutRunnable runnable : postLayoutOptions) { for (boolean waitForLayout : waitForLayoutOptions) { savedStateTest(config, waitForLayout, runnable); removeRecyclerView(); } } } } private void saveRestore(final Config config) throws Throwable { runTestOnUiThread(new Runnable() { @Override public void run() { try { 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); RecyclerView restored = new RecyclerView(getActivity()); mLayoutManager = new WrappedLayoutManager(config.mSpanCount, config.mOrientation); mLayoutManager.setGapStrategy(config.mGapStrategy); restored.setLayoutManager(mLayoutManager); // use the same adapter for Rect matching restored.setAdapter(mAdapter); restored.onRestoreInstanceState(savedState); if (Looper.myLooper() == Looper.getMainLooper()) { mLayoutManager.expectLayouts(1); setRecyclerView(restored); } else { mLayoutManager.expectLayouts(1); setRecyclerView(restored); mLayoutManager.waitForLayout(2); } } catch (Throwable t) { postExceptionToInstrumentation(t); } } }); checkForMainThreadException(); } 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); waitFirstLayout(); if (waitForLayout) { postLayoutOperations.run(); } final int firstCompletelyVisiblePosition = mLayoutManager.findFirstVisibleItemPositionInt(); Map 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); mLayoutManager.setGapStrategy(config.mGapStrategy); 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()); assertEquals(config + " on saved state, first completely visible child position should" + " be preserved", firstCompletelyVisiblePosition, mLayoutManager.findFirstVisibleItemPositionInt()); 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 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.getChildLayoutPosition(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(); } } 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.getChildLayoutPosition(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 initialBounds = mLayoutManager.collectChildCoordinates(); final int position = mRecyclerView.getChildLayoutPosition(view); LayoutParams layoutParams = (LayoutParams) (view.getLayoutParams()); TestViewHolder vh = (TestViewHolder) layoutParams.mViewHolder; assertEquals("recycler view mPosition should match adapter mPosition", position, vh.mBoundItem.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.getChildLayoutPosition(view); if (DEBUG) { Log.d(TAG, "child(" + position + ") not fully visible " + bounds + " not inside " + layoutBounds + mRecyclerView.getChildLayoutPosition(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> viewsBySpan = mLayoutManager.collectChildrenBySpan(); OrientationHelper orientationHelper = OrientationHelper .createOrientationHelper(mLayoutManager, config.mOrientation); for (ArrayList 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); getInstrumentation().waitForIdleSync(); } 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 before = mLayoutManager.collectChildCoordinates(); scrollBy(scrollDist); Map 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 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 testAccessibilityPositions() throws Throwable { setupByConfig(new Config(VERTICAL, false, 3, GAP_HANDLING_NONE)); waitFirstLayout(); final AccessibilityDelegateCompat delegateCompat = mRecyclerView .getCompatAccessibilityDelegate(); final AccessibilityEvent event = AccessibilityEvent.obtain(); runTestOnUiThread(new Runnable() { @Override public void run() { delegateCompat.onInitializeAccessibilityEvent(mRecyclerView, event); } }); final AccessibilityRecordCompat record = AccessibilityEventCompat .asRecord(event); final int start = mRecyclerView .getChildLayoutPosition( mLayoutManager.findFirstVisibleItemClosestToStart(false, true)); final int end = mRecyclerView .getChildLayoutPosition( mLayoutManager.findFirstVisibleItemClosestToEnd(false, true)); assertEquals("first item position should match", Math.min(start, end), record.getFromIndex()); assertEquals("last item position should match", Math.max(start, end), record.getToIndex()); } 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 before = mLayoutManager.collectChildCoordinates(); requestLayoutOnUIThread(mRecyclerView); Map 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 before, Map 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 before, Map after) { StringBuilder log = new StringBuilder(); if (DEBUG) { log.append("checking rectangle equality.\n"); log.append("before:"); for (Map.Entry entry : before.entrySet()) { log.append("\n").append(entry.getKey().mAdapterIndex).append(":") .append(entry.getValue()); } log.append("\nafter:"); for (Map.Entry entry : after.entrySet()) { log.append("\n").append(entry.getKey().mAdapterIndex).append(":") .append(entry.getValue()); } message += "\n\n" + log.toString(); } assertEquals(message + ": item counts should be equal", before.size() , after.size()); for (Map.Entry 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 static class OnLayoutListener { void before(RecyclerView.Recycler recycler, RecyclerView.State state) { } void after(RecyclerView.Recycler recycler, RecyclerView.State state) { } } class WrappedLayoutManager extends StaggeredGridLayoutManager { CountDownLatch layoutLatch; OnLayoutListener mOnLayoutListener; // gradle does not yet let us customize manifest for tests which is necessary to test RTL. // until bug is fixed, we'll fake it. // public issue id: 57819 Boolean mFakeRTL; @Override boolean isLayoutRTL() { return mFakeRTL == null ? super.isLayoutRTL() : mFakeRTL; } 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()); } public void assertNoLayout(String msg, long timeout) throws Throwable { layoutLatch.await(timeout, TimeUnit.SECONDS); assertFalse(msg, layoutLatch.getCount() == 0); } @Override public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) { try { if (mOnLayoutListener != null) { mOnLayoutListener.before(recycler, state); } super.onLayoutChildren(recycler, state); if (mOnLayoutListener != null) { mOnLayoutListener.after(recycler, state); } } catch (Throwable t) { postExceptionToInstrumentation(t); } layoutLatch.countDown(); } public WrappedLayoutManager(int spanCount, int orientation) { super(spanCount, orientation); } ArrayList> collectChildrenBySpan() { ArrayList> viewsBySpan = new ArrayList>(); for (int i = 0; i < getSpanCount(); i++) { viewsBySpan.add(new ArrayList()); } 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)); } } public String getBoundsLog() { StringBuilder sb = new StringBuilder(); sb.append("view bounds:[start:").append(mPrimaryOrientation.getStartAfterPadding()) .append(",").append(" end").append(mPrimaryOrientation.getEndAfterPadding()); sb.append("\nchildren bounds\n"); final int childCount = getChildCount(); for (int i = 0; i < childCount; i++) { View child = getChildAt(i); sb.append("child (ind:").append(i).append(", pos:").append(getPosition(child)) .append("[").append("start:").append( mPrimaryOrientation.getDecoratedStart(child)).append(", end:") .append(mPrimaryOrientation.getDecoratedEnd(child)).append("]\n"); } return sb.toString(); } public VisibleChildren traverseAndFindVisibleChildren() { int childCount = getChildCount(); final VisibleChildren visibleChildren = new VisibleChildren(getSpanCount()); final int start = mPrimaryOrientation.getStartAfterPadding(); final int end = mPrimaryOrientation.getEndAfterPadding(); for (int i = 0; i < childCount; i++) { View child = getChildAt(i); final int childStart = mPrimaryOrientation.getDecoratedStart(child); final int childEnd = mPrimaryOrientation.getDecoratedEnd(child); final boolean fullyVisible = childStart >= start && childEnd <= end; final boolean hidden = childEnd <= start || childStart >= end; if (hidden) { continue; } final int position = getPosition(child); final int span = getLp(child).getSpanIndex(); if (fullyVisible) { if (position < visibleChildren.firstFullyVisiblePositions[span] || visibleChildren.firstFullyVisiblePositions[span] == RecyclerView.NO_POSITION) { visibleChildren.firstFullyVisiblePositions[span] = position; } if (position > visibleChildren.lastFullyVisiblePositions[span]) { visibleChildren.lastFullyVisiblePositions[span] = position; } } if (position < visibleChildren.firstVisiblePositions[span] || visibleChildren.firstVisiblePositions[span] == RecyclerView.NO_POSITION) { visibleChildren.firstVisiblePositions[span] = position; } if (position > visibleChildren.lastVisiblePositions[span]) { visibleChildren.lastVisiblePositions[span] = position; } } return visibleChildren; } Map collectChildCoordinates() throws Throwable { final Map items = new LinkedHashMap(); runTestOnUiThread(new Runnable() { @Override public void run() { final int childCount = getChildCount(); for (int i = 0; i < childCount; i++) { View child = getChildAt(i); // do it if and only if child is visible if (child.getRight() < 0 || child.getBottom() < 0 || child.getLeft() >= getWidth() || child.getTop() >= getHeight()) { // invisible children may be drawn in cases like scrolling so we should // ignore them continue; } LayoutParams lp = (LayoutParams) child .getLayoutParams(); TestViewHolder vh = (TestViewHolder) lp.mViewHolder; items.put(vh.mBoundItem, getViewBounds(child)); } } }); return items; } public void setFakeRtl(Boolean fakeRtl) { mFakeRTL = fakeRtl; try { requestLayoutOnUIThread(mRecyclerView); } catch (Throwable throwable) { postExceptionToInstrumentation(throwable); } } } static class VisibleChildren { int[] firstVisiblePositions; int[] firstFullyVisiblePositions; int[] lastVisiblePositions; int[] lastFullyVisiblePositions; VisibleChildren(int spanCount) { firstFullyVisiblePositions = new int[spanCount]; firstVisiblePositions = new int[spanCount]; lastVisiblePositions = new int[spanCount]; lastFullyVisiblePositions = new int[spanCount]; for (int i = 0; i < spanCount; i++) { firstFullyVisiblePositions[i] = RecyclerView.NO_POSITION; firstVisiblePositions[i] = RecyclerView.NO_POSITION; lastVisiblePositions[i] = RecyclerView.NO_POSITION; lastFullyVisiblePositions[i] = RecyclerView.NO_POSITION; } } @Override public boolean equals(Object o) { if (this == o) { return true; } if (o == null || getClass() != o.getClass()) { return false; } VisibleChildren that = (VisibleChildren) o; if (!Arrays.equals(firstFullyVisiblePositions, that.firstFullyVisiblePositions)) { return false; } if (!Arrays.equals(firstVisiblePositions, that.firstVisiblePositions)) { return false; } if (!Arrays.equals(lastFullyVisiblePositions, that.lastFullyVisiblePositions)) { return false; } if (!Arrays.equals(lastVisiblePositions, that.lastVisiblePositions)) { return false; } return true; } @Override public int hashCode() { int result = firstVisiblePositions != null ? Arrays.hashCode(firstVisiblePositions) : 0; result = 31 * result + (firstFullyVisiblePositions != null ? Arrays .hashCode(firstFullyVisiblePositions) : 0); result = 31 * result + (lastVisiblePositions != null ? Arrays .hashCode(lastVisiblePositions) : 0); result = 31 * result + (lastFullyVisiblePositions != null ? Arrays .hashCode(lastFullyVisiblePositions) : 0); return result; } @Override public String toString() { return "VisibleChildren{" + "firstVisiblePositions=" + Arrays.toString(firstVisiblePositions) + ", firstFullyVisiblePositions=" + Arrays.toString(firstFullyVisiblePositions) + ", lastVisiblePositions=" + Arrays.toString(lastVisiblePositions) + ", lastFullyVisiblePositions=" + Arrays.toString(lastFullyVisiblePositions) + '}'; } } class GridTestAdapter extends TestAdapter { int mOrientation; // original ids of items that should be full span HashSet mFullSpanItems = new HashSet(); private boolean mViewsHaveEqualSize = false; // size in the scrollable direction private OnBindHandler mOnBindHandler; GridTestAdapter(int count, int orientation) { super(count); mOrientation = orientation; } @Override public void offsetOriginalIndices(int start, int offset) { if (mFullSpanItems.size() > 0) { HashSet old = mFullSpanItems; mFullSpanItems = new HashSet(); 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); 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; } if (mOnBindHandler == null || mOnBindHandler.assignRandomSize()) { final int minSize = mViewsHaveEqualSize ? 200 : 200 + 20 * (position % 10); if (mOrientation == OrientationHelper.HORIZONTAL) { holder.itemView.setMinimumWidth(minSize); } else { holder.itemView.setMinimumHeight(minSize); } lp.topMargin = 3; lp.leftMargin = 5; lp.rightMargin = 7; lp.bottomMargin = 9; } if (mOnBindHandler != null) { mOnBindHandler.onBoundItem(holder, position); } } } abstract static class OnBindHandler { abstract void onBoundItem(TestViewHolder vh, int position); boolean assignRandomSize() { return true; } } static class Config implements Cloneable { 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; 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") + " itemCount:" + mItemCount + " 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"; } return "gap strategy: unknown"; } @Override public Object clone() throws CloneNotSupportedException { return super.clone(); } } private interface PostLayoutRunnable { void run() throws Throwable; String describe(); } }