1 /*
2  * Copyright (C) 2014 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 
18 package android.support.v7.widget;
19 
20 
21 import android.graphics.Rect;
22 import android.os.Looper;
23 import android.os.Parcel;
24 import android.os.Parcelable;
25 import android.support.annotation.Nullable;
26 import android.support.v4.view.AccessibilityDelegateCompat;
27 import android.support.v4.view.accessibility.AccessibilityEventCompat;
28 import android.support.v4.view.accessibility.AccessibilityRecordCompat;
29 import android.util.Log;
30 import android.view.View;
31 import android.view.ViewGroup;
32 import android.view.accessibility.AccessibilityEvent;
33 
34 import java.util.ArrayList;
35 import java.util.Arrays;
36 import java.util.BitSet;
37 import java.util.HashMap;
38 import java.util.HashSet;
39 import java.util.LinkedHashMap;
40 import java.util.List;
41 import java.util.Map;
42 import java.util.UUID;
43 import java.util.concurrent.CountDownLatch;
44 import java.util.concurrent.TimeUnit;
45 import java.util.concurrent.atomic.AtomicInteger;
46 
47 import static android.support.v7.widget.LayoutState.*;
48 import static android.support.v7.widget.LinearLayoutManager.VERTICAL;
49 import static android.support.v7.widget.StaggeredGridLayoutManager.*;
50 
51 public class StaggeredGridLayoutManagerTest extends BaseRecyclerViewInstrumentationTest {
52 
53     private static final boolean DEBUG = false;
54 
55     private static final int AVG_ITEM_PER_VIEW = 3;
56 
57     private static final String TAG = "StaggeredGridLayoutManagerTest";
58 
59     volatile WrappedLayoutManager mLayoutManager;
60 
61     GridTestAdapter mAdapter;
62 
63     final List<Config> mBaseVariations = new ArrayList<Config>();
64 
65     @Override
setUp()66     protected void setUp() throws Exception {
67         super.setUp();
68         for (int orientation : new int[]{VERTICAL, HORIZONTAL}) {
69             for (boolean reverseLayout : new boolean[]{false, true}) {
70                 for (int spanCount : new int[]{1, 3}) {
71                     for (int gapStrategy : new int[]{GAP_HANDLING_NONE,
72                             GAP_HANDLING_MOVE_ITEMS_BETWEEN_SPANS}) {
73                         mBaseVariations.add(new Config(orientation, reverseLayout, spanCount,
74                                 gapStrategy));
75                     }
76                 }
77             }
78         }
79     }
80 
setupByConfig(Config config)81     void setupByConfig(Config config) throws Throwable {
82         mAdapter = new GridTestAdapter(config.mItemCount, config.mOrientation);
83         mRecyclerView = new RecyclerView(getActivity());
84         mRecyclerView.setAdapter(mAdapter);
85         mRecyclerView.setHasFixedSize(true);
86         mLayoutManager = new WrappedLayoutManager(config.mSpanCount,
87                 config.mOrientation);
88         mLayoutManager.setGapStrategy(config.mGapStrategy);
89         mLayoutManager.setReverseLayout(config.mReverseLayout);
90         mRecyclerView.setLayoutManager(mLayoutManager);
91         mRecyclerView.addItemDecoration(new RecyclerView.ItemDecoration() {
92             @Override
93             public void getItemOffsets(Rect outRect, View view, RecyclerView parent,
94                     RecyclerView.State state) {
95                 try {
96                     LayoutParams lp = (LayoutParams) view.getLayoutParams();
97                     assertNotNull("view should have layout params assigned", lp);
98                     assertNotNull("when item offsets are requested, view should have a valid span",
99                             lp.mSpan);
100                 } catch (Throwable t) {
101                     postExceptionToInstrumentation(t);
102                 }
103             }
104         });
105     }
106 
testAreAllStartsTheSame()107     public void testAreAllStartsTheSame() throws Throwable {
108         setupByConfig(new Config(VERTICAL, false, 3, GAP_HANDLING_NONE).itemCount(300));
109         waitFirstLayout();
110         smoothScrollToPosition(100);
111         mLayoutManager.expectLayouts(1);
112         mAdapter.deleteAndNotify(0, 2);
113         mLayoutManager.waitForLayout(2);
114         smoothScrollToPosition(0);
115         assertFalse("all starts should not be the same", mLayoutManager.areAllStartsEqual());
116     }
117 
testAreAllEndsTheSame()118     public void testAreAllEndsTheSame() throws Throwable {
119         setupByConfig(new Config(VERTICAL, true, 3, GAP_HANDLING_NONE).itemCount(300));
120         waitFirstLayout();
121         smoothScrollToPosition(100);
122         mLayoutManager.expectLayouts(1);
123         mAdapter.deleteAndNotify(0, 2);
124         mLayoutManager.waitForLayout(2);
125         smoothScrollToPosition(0);
126         assertFalse("all ends should not be the same", mLayoutManager.areAllEndsEqual());
127     }
128 
testFindLastInUnevenDistribution()129     public void testFindLastInUnevenDistribution() throws Throwable {
130         setupByConfig(new Config(VERTICAL, false, 2, GAP_HANDLING_MOVE_ITEMS_BETWEEN_SPANS)
131                 .itemCount(5));
132         mAdapter.mOnBindCallback = new OnBindCallback() {
133             @Override
134             void onBoundItem(TestViewHolder vh, int position) {
135                 LayoutParams lp = (LayoutParams) vh.itemView.getLayoutParams();
136                 if (position == 1) {
137                     lp.height = mRecyclerView.getHeight() - 10;
138                 } else {
139                     lp.height = 5;
140                 }
141                 vh.itemView.setMinimumHeight(0);
142             }
143         };
144         waitFirstLayout();
145         int[] into = new int[2];
146         mLayoutManager.findFirstCompletelyVisibleItemPositions(into);
147         assertEquals("first completely visible item from span 0 should be 0", 0, into[0]);
148         assertEquals("first completely visible item from span 1 should be 1", 1, into[1]);
149         mLayoutManager.findLastCompletelyVisibleItemPositions(into);
150         assertEquals("last completely visible item from span 0 should be 4", 4, into[0]);
151         assertEquals("last completely visible item from span 1 should be 1", 1, into[1]);
152         assertEquals("first fully visible child should be at position",
153                 0, mRecyclerView.getChildViewHolder(mLayoutManager.
154                         findFirstVisibleItemClosestToStart(true, true)).getPosition());
155         assertEquals("last fully visible child should be at position",
156                 4, mRecyclerView.getChildViewHolder(mLayoutManager.
157                         findFirstVisibleItemClosestToEnd(true, true)).getPosition());
158 
159         assertEquals("first visible child should be at position",
160                 0, mRecyclerView.getChildViewHolder(mLayoutManager.
161                         findFirstVisibleItemClosestToStart(false, true)).getPosition());
162         assertEquals("last visible child should be at position",
163                 4, mRecyclerView.getChildViewHolder(mLayoutManager.
164                         findFirstVisibleItemClosestToEnd(false, true)).getPosition());
165 
166     }
167 
testCustomWidthInHorizontal()168     public void testCustomWidthInHorizontal() throws Throwable {
169         customSizeInScrollDirectionTest(
170                 new Config(HORIZONTAL, false, 3, GAP_HANDLING_MOVE_ITEMS_BETWEEN_SPANS));
171     }
172 
testCustomHeightInVertical()173     public void testCustomHeightInVertical() throws Throwable {
174         customSizeInScrollDirectionTest(
175                 new Config(VERTICAL, false, 3, GAP_HANDLING_MOVE_ITEMS_BETWEEN_SPANS));
176     }
177 
customSizeInScrollDirectionTest(final Config config)178     public void customSizeInScrollDirectionTest(final Config config) throws Throwable {
179         setupByConfig(config);
180         final Map<View, Integer> sizeMap = new HashMap<View, Integer>();
181         mAdapter.mOnBindCallback = new OnBindCallback() {
182             @Override
183             void onBoundItem(TestViewHolder vh, int position) {
184                 final ViewGroup.LayoutParams layoutParams = vh.itemView.getLayoutParams();
185                 final int size = 1 + position * 5;
186                 if (config.mOrientation == HORIZONTAL) {
187                     layoutParams.width = size;
188                 } else {
189                     layoutParams.height = size;
190                 }
191                 sizeMap.put(vh.itemView, size);
192                 if (position == 3) {
193                     getLp(vh.itemView).setFullSpan(true);
194                 }
195             }
196 
197             @Override
198             boolean assignRandomSize() {
199                 return false;
200             }
201         };
202         waitFirstLayout();
203         assertTrue("[test sanity] some views should be laid out", sizeMap.size() > 0);
204         for (int i = 0; i < mRecyclerView.getChildCount(); i++) {
205             View child = mRecyclerView.getChildAt(i);
206             final int size = config.mOrientation == HORIZONTAL ? child.getWidth()
207                     : child.getHeight();
208             assertEquals("child " + i + " should have the size specified in its layout params",
209                     sizeMap.get(child).intValue(), size);
210         }
211         checkForMainThreadException();
212     }
213 
testRTL()214     public void testRTL() throws Throwable {
215         for (boolean changeRtlAfter : new boolean[]{false, true}) {
216             for (Config config : mBaseVariations) {
217                 rtlTest(config, changeRtlAfter);
218                 removeRecyclerView();
219             }
220         }
221     }
222 
rtlTest(Config config, boolean changeRtlAfter)223     void rtlTest(Config config, boolean changeRtlAfter) throws Throwable {
224         if (config.mSpanCount == 1) {
225             config.mSpanCount = 2;
226         }
227         String logPrefix = config + ", changeRtlAfterLayout:" + changeRtlAfter;
228         setupByConfig(config.itemCount(5));
229         if (changeRtlAfter) {
230             waitFirstLayout();
231             mLayoutManager.expectLayouts(1);
232             mLayoutManager.setFakeRtl(true);
233             mLayoutManager.waitForLayout(2);
234         } else {
235             mLayoutManager.mFakeRTL = true;
236             waitFirstLayout();
237         }
238 
239         assertEquals("view should become rtl", true, mLayoutManager.isLayoutRTL());
240         OrientationHelper helper = OrientationHelper.createHorizontalHelper(mLayoutManager);
241         View child0 = mLayoutManager.findViewByPosition(0);
242         View child1 = mLayoutManager.findViewByPosition(config.mOrientation == VERTICAL ? 1
243                 : config.mSpanCount);
244         assertNotNull(logPrefix + " child position 0 should be laid out", child0);
245         assertNotNull(logPrefix + " child position 0 should be laid out", child1);
246         logPrefix += " child1 pos:" + mLayoutManager.getPosition(child1);
247         if (config.mOrientation == VERTICAL || !config.mReverseLayout) {
248             assertTrue(logPrefix + " second child should be to the left of first child",
249                     helper.getDecoratedEnd(child0) > helper.getDecoratedEnd(child1));
250             assertEquals(logPrefix + " first child should be right aligned",
251                     helper.getDecoratedEnd(child0), helper.getEndAfterPadding());
252         } else {
253             assertTrue(logPrefix + " first child should be to the left of second child",
254                     helper.getDecoratedStart(child1) >= helper.getDecoratedStart(child0));
255             assertEquals(logPrefix + " first child should be left aligned",
256                     helper.getDecoratedStart(child0), helper.getStartAfterPadding());
257         }
258         checkForMainThreadException();
259     }
260 
testGapHandlingWhenItemMovesToTop()261     public void testGapHandlingWhenItemMovesToTop() throws Throwable {
262         gapHandlingWhenItemMovesToTopTest();
263     }
264 
testGapHandlingWhenItemMovesToTopWithFullSpan()265     public void testGapHandlingWhenItemMovesToTopWithFullSpan() throws Throwable {
266         gapHandlingWhenItemMovesToTopTest(0);
267     }
268 
testGapHandlingWhenItemMovesToTopWithFullSpan2()269     public void testGapHandlingWhenItemMovesToTopWithFullSpan2() throws Throwable {
270         gapHandlingWhenItemMovesToTopTest(1);
271     }
272 
gapHandlingWhenItemMovesToTopTest(int... fullSpanIndices)273     public void gapHandlingWhenItemMovesToTopTest(int... fullSpanIndices) throws Throwable {
274         Config config = new Config(VERTICAL, false, 2, GAP_HANDLING_MOVE_ITEMS_BETWEEN_SPANS);
275         config.itemCount(3);
276         setupByConfig(config);
277         mAdapter.mOnBindCallback = new OnBindCallback() {
278             @Override
279             void onBoundItem(TestViewHolder vh, int position) {
280             }
281 
282             @Override
283             boolean assignRandomSize() {
284                 return false;
285             }
286         };
287         for (int i : fullSpanIndices) {
288             mAdapter.mFullSpanItems.add(i);
289         }
290         waitFirstLayout();
291         mLayoutManager.expectLayouts(1);
292         mAdapter.moveItem(1, 0, true);
293         mLayoutManager.waitForLayout(2);
294         final Map<Item, Rect> desiredPositions = mLayoutManager.collectChildCoordinates();
295         // move back.
296         mLayoutManager.expectLayouts(1);
297         mAdapter.moveItem(0, 1, true);
298         mLayoutManager.waitForLayout(2);
299         mLayoutManager.expectLayouts(2);
300         mAdapter.moveAndNotify(1, 0);
301         mLayoutManager.waitForLayout(2);
302         Thread.sleep(1000);
303         getInstrumentation().waitForIdleSync();
304         checkForMainThreadException();
305         // item should be positioned properly
306         assertRectSetsEqual("final position after a move", desiredPositions,
307                 mLayoutManager.collectChildCoordinates());
308 
309     }
310 
311 
testScrollBackAndPreservePositions()312     public void testScrollBackAndPreservePositions() throws Throwable {
313         for (boolean saveRestore : new boolean[]{false, true}) {
314             for (Config config : mBaseVariations) {
315                 scrollBackAndPreservePositionsTest(config, saveRestore);
316                 removeRecyclerView();
317             }
318         }
319     }
320 
scrollBackAndPreservePositionsTest(final Config config, final boolean saveRestoreInBetween)321     public void scrollBackAndPreservePositionsTest(final Config config,
322             final boolean saveRestoreInBetween)
323             throws Throwable {
324         setupByConfig(config);
325         mAdapter.mOnBindCallback = new OnBindCallback() {
326             @Override
327             public void onBoundItem(TestViewHolder vh, int position) {
328                 LayoutParams lp = (LayoutParams) vh.itemView.getLayoutParams();
329                 lp.setFullSpan((position * 7) % (config.mSpanCount + 1) == 0);
330             }
331         };
332         waitFirstLayout();
333         final int[] globalPositions = new int[mAdapter.getItemCount()];
334         Arrays.fill(globalPositions, Integer.MIN_VALUE);
335         final int scrollStep = (mLayoutManager.mPrimaryOrientation.getTotalSpace() / 10)
336                 * (config.mReverseLayout ? -1 : 1);
337 
338         final int[] globalPos = new int[1];
339         runTestOnUiThread(new Runnable() {
340             @Override
341             public void run() {
342                 int globalScrollPosition = 0;
343                 while (globalPositions[mAdapter.getItemCount() - 1] == Integer.MIN_VALUE) {
344                     for (int i = 0; i < mRecyclerView.getChildCount(); i++) {
345                         View child = mRecyclerView.getChildAt(i);
346                         final int pos = mRecyclerView.getChildLayoutPosition(child);
347                         if (globalPositions[pos] != Integer.MIN_VALUE) {
348                             continue;
349                         }
350                         if (config.mReverseLayout) {
351                             globalPositions[pos] = globalScrollPosition +
352                                     mLayoutManager.mPrimaryOrientation.getDecoratedEnd(child);
353                         } else {
354                             globalPositions[pos] = globalScrollPosition +
355                                     mLayoutManager.mPrimaryOrientation.getDecoratedStart(child);
356                         }
357                     }
358                     globalScrollPosition += mLayoutManager.scrollBy(scrollStep,
359                             mRecyclerView.mRecycler, mRecyclerView.mState);
360                 }
361                 if (DEBUG) {
362                     Log.d(TAG, "done recording positions " + Arrays.toString(globalPositions));
363                 }
364                 globalPos[0] = globalScrollPosition;
365             }
366         });
367         checkForMainThreadException();
368 
369         if (saveRestoreInBetween) {
370             saveRestore(config);
371         }
372 
373         checkForMainThreadException();
374         runTestOnUiThread(new Runnable() {
375             @Override
376             public void run() {
377                 int globalScrollPosition = globalPos[0];
378                 // now scroll back and make sure global positions match
379                 BitSet shouldTest = new BitSet(mAdapter.getItemCount());
380                 shouldTest.set(0, mAdapter.getItemCount() - 1, true);
381                 String assertPrefix = config + ", restored in between:" + saveRestoreInBetween
382                         + " global pos must match when scrolling in reverse for position ";
383                 int scrollAmount = Integer.MAX_VALUE;
384                 while (!shouldTest.isEmpty() && scrollAmount != 0) {
385                     for (int i = 0; i < mRecyclerView.getChildCount(); i++) {
386                         View child = mRecyclerView.getChildAt(i);
387                         int pos = mRecyclerView.getChildLayoutPosition(child);
388                         if (!shouldTest.get(pos)) {
389                             continue;
390                         }
391                         shouldTest.clear(pos);
392                         int globalPos;
393                         if (config.mReverseLayout) {
394                             globalPos = globalScrollPosition +
395                                     mLayoutManager.mPrimaryOrientation.getDecoratedEnd(child);
396                         } else {
397                             globalPos = globalScrollPosition +
398                                     mLayoutManager.mPrimaryOrientation.getDecoratedStart(child);
399                         }
400                         assertEquals(assertPrefix + pos,
401                                 globalPositions[pos], globalPos);
402                     }
403                     scrollAmount = mLayoutManager.scrollBy(-scrollStep,
404                             mRecyclerView.mRecycler, mRecyclerView.mState);
405                     globalScrollPosition += scrollAmount;
406                 }
407                 assertTrue("all views should be seen", shouldTest.isEmpty());
408             }
409         });
410         checkForMainThreadException();
411     }
412 
testScrollToPositionWithPredictive()413     public void testScrollToPositionWithPredictive() throws Throwable {
414         scrollToPositionWithPredictive(0, LinearLayoutManager.INVALID_OFFSET);
415         removeRecyclerView();
416         scrollToPositionWithPredictive(Config.DEFAULT_ITEM_COUNT / 2,
417                 LinearLayoutManager.INVALID_OFFSET);
418         removeRecyclerView();
419         scrollToPositionWithPredictive(9, 20);
420         removeRecyclerView();
421         scrollToPositionWithPredictive(Config.DEFAULT_ITEM_COUNT / 2, 10);
422 
423     }
424 
scrollToPositionWithPredictive(final int scrollPosition, final int scrollOffset)425     public void scrollToPositionWithPredictive(final int scrollPosition, final int scrollOffset)
426             throws Throwable {
427         setupByConfig(new Config(StaggeredGridLayoutManager.VERTICAL,
428                 false, 3, StaggeredGridLayoutManager.GAP_HANDLING_NONE));
429         waitFirstLayout();
430         mLayoutManager.mOnLayoutListener = new OnLayoutListener() {
431             @Override
432             void after(RecyclerView.Recycler recycler, RecyclerView.State state) {
433                 RecyclerView rv = mLayoutManager.mRecyclerView;
434                 if (state.isPreLayout()) {
435                     assertEquals("pending scroll position should still be pending",
436                             scrollPosition, mLayoutManager.mPendingScrollPosition);
437                     if (scrollOffset != LinearLayoutManager.INVALID_OFFSET) {
438                         assertEquals("pending scroll position offset should still be pending",
439                                 scrollOffset, mLayoutManager.mPendingScrollPositionOffset);
440                     }
441                 } else {
442                     RecyclerView.ViewHolder vh = rv.findViewHolderForLayoutPosition(scrollPosition);
443                     assertNotNull("scroll to position should work", vh);
444                     if (scrollOffset != LinearLayoutManager.INVALID_OFFSET) {
445                         assertEquals("scroll offset should be applied properly",
446                                 mLayoutManager.getPaddingTop() + scrollOffset
447                                         + ((RecyclerView.LayoutParams) vh.itemView
448                                         .getLayoutParams()).topMargin,
449                                 mLayoutManager.getDecoratedTop(vh.itemView));
450                     }
451                 }
452             }
453         };
454         mLayoutManager.expectLayouts(2);
455         runTestOnUiThread(new Runnable() {
456             @Override
457             public void run() {
458                 try {
459                     mAdapter.addAndNotify(0, 1);
460                     if (scrollOffset == LinearLayoutManager.INVALID_OFFSET) {
461                         mLayoutManager.scrollToPosition(scrollPosition);
462                     } else {
463                         mLayoutManager.scrollToPositionWithOffset(scrollPosition,
464                                 scrollOffset);
465                     }
466 
467                 } catch (Throwable throwable) {
468                     throwable.printStackTrace();
469                 }
470 
471             }
472         });
473         mLayoutManager.waitForLayout(2);
474         checkForMainThreadException();
475     }
476 
getLp(View view)477     LayoutParams getLp(View view) {
478         return (LayoutParams) view.getLayoutParams();
479     }
480 
testGetFirstLastChildrenTest()481     public void testGetFirstLastChildrenTest() throws Throwable {
482         for (boolean provideArr : new boolean[]{true, false}) {
483             for (Config config : mBaseVariations) {
484                 getFirstLastChildrenTest(config, provideArr);
485                 removeRecyclerView();
486             }
487         }
488     }
489 
getFirstLastChildrenTest(final Config config, final boolean provideArr)490     public void getFirstLastChildrenTest(final Config config, final boolean provideArr)
491             throws Throwable {
492         setupByConfig(config);
493         waitFirstLayout();
494         Runnable viewInBoundsTest = new Runnable() {
495             @Override
496             public void run() {
497                 VisibleChildren visibleChildren = mLayoutManager.traverseAndFindVisibleChildren();
498                 final String boundsLog = mLayoutManager.getBoundsLog();
499                 VisibleChildren queryResult = new VisibleChildren(mLayoutManager.getSpanCount());
500                 queryResult.findFirstPartialVisibleClosestToStart = mLayoutManager
501                         .findFirstVisibleItemClosestToStart(false, true);
502                 queryResult.findFirstPartialVisibleClosestToEnd = mLayoutManager
503                         .findFirstVisibleItemClosestToEnd(false, true);
504                 queryResult.firstFullyVisiblePositions = mLayoutManager
505                         .findFirstCompletelyVisibleItemPositions(
506                                 provideArr ? new int[mLayoutManager.getSpanCount()] : null);
507                 queryResult.firstVisiblePositions = mLayoutManager
508                         .findFirstVisibleItemPositions(
509                                 provideArr ? new int[mLayoutManager.getSpanCount()] : null);
510                 queryResult.lastFullyVisiblePositions = mLayoutManager
511                         .findLastCompletelyVisibleItemPositions(
512                                 provideArr ? new int[mLayoutManager.getSpanCount()] : null);
513                 queryResult.lastVisiblePositions = mLayoutManager
514                         .findLastVisibleItemPositions(
515                                 provideArr ? new int[mLayoutManager.getSpanCount()] : null);
516                 assertEquals(config + ":\nfirst visible child should match traversal result\n"
517                                 + "traversed:" + visibleChildren + "\n"
518                                 + "queried:" + queryResult + "\n"
519                                 + boundsLog, visibleChildren, queryResult
520                 );
521             }
522         };
523         runTestOnUiThread(viewInBoundsTest);
524         // smooth scroll to end of the list and keep testing meanwhile. This will test pre-caching
525         // case
526         final int scrollPosition = mAdapter.getItemCount();
527         runTestOnUiThread(new Runnable() {
528             @Override
529             public void run() {
530                 mRecyclerView.smoothScrollToPosition(scrollPosition);
531             }
532         });
533         while (mLayoutManager.isSmoothScrolling() ||
534                 mRecyclerView.getScrollState() != RecyclerView.SCROLL_STATE_IDLE) {
535             runTestOnUiThread(viewInBoundsTest);
536             checkForMainThreadException();
537             Thread.sleep(400);
538         }
539         // delete all items
540         mLayoutManager.expectLayouts(2);
541         mAdapter.deleteAndNotify(0, mAdapter.getItemCount());
542         mLayoutManager.waitForLayout(2);
543         // test empty case
544         runTestOnUiThread(viewInBoundsTest);
545         // set a new adapter with huge items to test full bounds check
546         mLayoutManager.expectLayouts(1);
547         final int totalSpace = mLayoutManager.mPrimaryOrientation.getTotalSpace();
548         final TestAdapter newAdapter = new TestAdapter(100) {
549             @Override
550             public void onBindViewHolder(TestViewHolder holder,
551                     int position) {
552                 super.onBindViewHolder(holder, position);
553                 if (config.mOrientation == LinearLayoutManager.HORIZONTAL) {
554                     holder.itemView.setMinimumWidth(totalSpace + 100);
555                 } else {
556                     holder.itemView.setMinimumHeight(totalSpace + 100);
557                 }
558             }
559         };
560         runTestOnUiThread(new Runnable() {
561             @Override
562             public void run() {
563                 mRecyclerView.setAdapter(newAdapter);
564             }
565         });
566         mLayoutManager.waitForLayout(2);
567         runTestOnUiThread(viewInBoundsTest);
568         checkForMainThreadException();
569 
570         // smooth scroll to end of the list and keep testing meanwhile. This will test pre-caching
571         // case
572         runTestOnUiThread(new Runnable() {
573             @Override
574             public void run() {
575                 final int diff;
576                 if (config.mReverseLayout) {
577                     diff = -1;
578                 } else {
579                     diff = 1;
580                 }
581                 final int distance = diff * 10;
582                 if (config.mOrientation == HORIZONTAL) {
583                     mRecyclerView.scrollBy(distance, 0);
584                 } else {
585                     mRecyclerView.scrollBy(0, distance);
586                 }
587             }
588         });
589         runTestOnUiThread(viewInBoundsTest);
590         checkForMainThreadException();
591     }
592 
testMoveGapHandling()593     public void testMoveGapHandling() throws Throwable {
594         Config config = new Config().spanCount(2).itemCount(40);
595         setupByConfig(config);
596         waitFirstLayout();
597         mLayoutManager.expectLayouts(2);
598         mAdapter.moveAndNotify(4, 1);
599         mLayoutManager.waitForLayout(2);
600         assertNull("moving item to upper should not cause gaps", mLayoutManager.hasGapsToFix());
601     }
602 
testUpdateAfterFullSpan()603     public void testUpdateAfterFullSpan() throws Throwable {
604         updateAfterFullSpanGapHandlingTest(0);
605     }
606 
testUpdateAfterFullSpan2()607     public void testUpdateAfterFullSpan2() throws Throwable {
608         updateAfterFullSpanGapHandlingTest(20);
609     }
610 
testTemporaryGapHandling()611     public void testTemporaryGapHandling() throws Throwable {
612         int fullSpanIndex = 200;
613         setupByConfig(new Config().spanCount(2).itemCount(500));
614         mAdapter.mFullSpanItems.add(fullSpanIndex);
615         waitFirstLayout();
616         smoothScrollToPosition(fullSpanIndex + 200);// go far away
617         assertNull("test sanity. full span item should not be visible",
618                 mRecyclerView.findViewHolderForAdapterPosition(fullSpanIndex));
619         mLayoutManager.expectLayouts(1);
620         mAdapter.deleteAndNotify(fullSpanIndex + 1, 3);
621         mLayoutManager.waitForLayout(1);
622         smoothScrollToPosition(0);
623         mLayoutManager.expectLayouts(1);
624         smoothScrollToPosition(fullSpanIndex + 2 * (AVG_ITEM_PER_VIEW - 1));
625         String log = mLayoutManager.layoutToString("post gap");
626         mLayoutManager.assertNoLayout("if an interim gap is fixed, it should not cause a "
627                 + "relayout " + log, 2);
628         View fullSpan = mLayoutManager.findViewByPosition(fullSpanIndex);
629         assertNotNull("full span item should be there:\n" + log, fullSpan);
630         View view1 = mLayoutManager.findViewByPosition(fullSpanIndex + 1);
631         assertNotNull("next view should be there\n" + log, view1);
632         View view2 = mLayoutManager.findViewByPosition(fullSpanIndex + 2);
633         assertNotNull("+2 view should be there\n" + log, view2);
634 
635         LayoutParams lp1 = (LayoutParams) view1.getLayoutParams();
636         LayoutParams lp2 = (LayoutParams) view2.getLayoutParams();
637         assertEquals("view 1 span index", 0, lp1.getSpanIndex());
638         assertEquals("view 2 span index", 1, lp2.getSpanIndex());
639         assertEquals("no gap between span and view 1",
640                 mLayoutManager.mPrimaryOrientation.getDecoratedEnd(fullSpan),
641                 mLayoutManager.mPrimaryOrientation.getDecoratedStart(view1));
642         assertEquals("no gap between span and view 2",
643                 mLayoutManager.mPrimaryOrientation.getDecoratedEnd(fullSpan),
644                 mLayoutManager.mPrimaryOrientation.getDecoratedStart(view2));
645     }
646 
updateAfterFullSpanGapHandlingTest(int fullSpanIndex)647     public void updateAfterFullSpanGapHandlingTest(int fullSpanIndex) throws Throwable {
648         setupByConfig(new Config().spanCount(2).itemCount(100));
649         mAdapter.mFullSpanItems.add(fullSpanIndex);
650         waitFirstLayout();
651         smoothScrollToPosition(fullSpanIndex + 30);
652         mLayoutManager.expectLayouts(1);
653         mAdapter.deleteAndNotify(fullSpanIndex + 1, 3);
654         mLayoutManager.waitForLayout(1);
655         smoothScrollToPosition(fullSpanIndex);
656         // give it some time to fix the gap
657         Thread.sleep(500);
658         View fullSpan = mLayoutManager.findViewByPosition(fullSpanIndex);
659 
660         View view1 = mLayoutManager.findViewByPosition(fullSpanIndex + 1);
661         View view2 = mLayoutManager.findViewByPosition(fullSpanIndex + 2);
662 
663         LayoutParams lp1 = (LayoutParams) view1.getLayoutParams();
664         LayoutParams lp2 = (LayoutParams) view2.getLayoutParams();
665         assertEquals("view 1 span index", 0, lp1.getSpanIndex());
666         assertEquals("view 2 span index", 1, lp2.getSpanIndex());
667         assertEquals("no gap between span and view 1",
668                 mLayoutManager.mPrimaryOrientation.getDecoratedEnd(fullSpan),
669                 mLayoutManager.mPrimaryOrientation.getDecoratedStart(view1));
670         assertEquals("no gap between span and view 2",
671                 mLayoutManager.mPrimaryOrientation.getDecoratedEnd(fullSpan),
672                 mLayoutManager.mPrimaryOrientation.getDecoratedStart(view2));
673     }
674 
testInnerGapHandling()675     public void testInnerGapHandling() throws Throwable {
676         innerGapHandlingTest(StaggeredGridLayoutManager.GAP_HANDLING_NONE);
677         innerGapHandlingTest(StaggeredGridLayoutManager.GAP_HANDLING_MOVE_ITEMS_BETWEEN_SPANS);
678     }
679 
innerGapHandlingTest(int strategy)680     public void innerGapHandlingTest(int strategy) throws Throwable {
681         Config config = new Config().spanCount(3).itemCount(500);
682         setupByConfig(config);
683         mLayoutManager.setGapStrategy(strategy);
684         mAdapter.mFullSpanItems.add(100);
685         mAdapter.mFullSpanItems.add(104);
686         mAdapter.mViewsHaveEqualSize = true;
687         mAdapter.mOnBindCallback = new OnBindCallback() {
688             @Override
689             void onBoundItem(TestViewHolder vh, int position) {
690 
691             }
692 
693             @Override
694             void onCreatedViewHolder(TestViewHolder vh) {
695                 super.onCreatedViewHolder(vh);
696                 //make sure we have enough views
697                 mAdapter.mSizeReference = mRecyclerView.getHeight() / 5;
698             }
699         };
700         waitFirstLayout();
701         mLayoutManager.expectLayouts(1);
702         scrollToPosition(400);
703         mLayoutManager.waitForLayout(2);
704         View view400 = mLayoutManager.findViewByPosition(400);
705         assertNotNull("test sanity, scrollToPos should succeed", view400);
706         assertTrue("test sanity, view should be visible top",
707                 mLayoutManager.mPrimaryOrientation.getDecoratedStart(view400) >=
708                         mLayoutManager.mPrimaryOrientation.getStartAfterPadding());
709         assertTrue("test sanity, view should be visible bottom",
710                 mLayoutManager.mPrimaryOrientation.getDecoratedEnd(view400) <=
711                         mLayoutManager.mPrimaryOrientation.getEndAfterPadding());
712         mLayoutManager.expectLayouts(2);
713         mAdapter.addAndNotify(101, 1);
714         mLayoutManager.waitForLayout(2);
715         checkForMainThreadException();
716         if (strategy == GAP_HANDLING_MOVE_ITEMS_BETWEEN_SPANS) {
717             mLayoutManager.expectLayouts(1);
718         }
719         // state
720         // now smooth scroll to 99 to trigger a layout around 100
721         mLayoutManager.validateChildren();
722         smoothScrollToPosition(99);
723         switch (strategy) {
724             case GAP_HANDLING_NONE:
725                 assertSpans("gap handling:" + Config.gapStrategyName(strategy), new int[]{100, 0},
726                         new int[]{101, 2}, new int[]{102, 0}, new int[]{103, 1}, new int[]{104, 2},
727                         new int[]{105, 0});
728                 break;
729             case GAP_HANDLING_MOVE_ITEMS_BETWEEN_SPANS:
730                 mLayoutManager.waitForLayout(2);
731                 assertSpans("swap items between spans", new int[]{100, 0}, new int[]{101, 0},
732                         new int[]{102, 1}, new int[]{103, 2}, new int[]{104, 0}, new int[]{105, 0});
733                 break;
734         }
735 
736     }
737 
testFullSizeSpans()738     public void testFullSizeSpans() throws Throwable {
739         Config config = new Config().spanCount(5).itemCount(30);
740         setupByConfig(config);
741         mAdapter.mFullSpanItems.add(3);
742         waitFirstLayout();
743         assertSpans("Testing full size span", new int[]{0, 0}, new int[]{1, 1}, new int[]{2, 2},
744                 new int[]{3, 0}, new int[]{4, 0}, new int[]{5, 1}, new int[]{6, 2},
745                 new int[]{7, 3}, new int[]{8, 4});
746     }
747 
assertSpans(String msg, int[]... childSpanTuples)748     void assertSpans(String msg, int[]... childSpanTuples) {
749         msg = msg + mLayoutManager.layoutToString("\n\n");
750         for (int i = 0; i < childSpanTuples.length; i++) {
751             assertSpan(msg, childSpanTuples[i][0], childSpanTuples[i][1]);
752         }
753     }
754 
assertSpan(String msg, int childPosition, int expectedSpan)755     void assertSpan(String msg, int childPosition, int expectedSpan) {
756         View view = mLayoutManager.findViewByPosition(childPosition);
757         assertNotNull(msg + " view at position " + childPosition + " should exists", view);
758         assertEquals(msg + "[child:" + childPosition + "]", expectedSpan,
759                 getLp(view).mSpan.mIndex);
760     }
761 
testGapAtTheBeginning()762     public void testGapAtTheBeginning() throws Throwable {
763         for (Config config : mBaseVariations) {
764             for (int deleteCount = 1; deleteCount < config.mSpanCount * 2; deleteCount++) {
765                 for (int deletePosition = config.mSpanCount - 1;
766                         deletePosition < config.mSpanCount + 2; deletePosition++) {
767                     gapAtTheBeginningOfTheListTest(config, deletePosition, deleteCount);
768                     removeRecyclerView();
769                 }
770             }
771         }
772     }
773 
gapAtTheBeginningOfTheListTest(final Config config, int deletePosition, int deleteCount)774     public void gapAtTheBeginningOfTheListTest(final Config config, int deletePosition,
775             int deleteCount) throws Throwable {
776         if (config.mSpanCount < 2 || config.mGapStrategy == GAP_HANDLING_NONE) {
777             return;
778         }
779         if (config.mItemCount < 100) {
780             config.itemCount(100);
781         }
782         final String logPrefix = config + ", deletePos:" + deletePosition + ", deleteCount:"
783                 + deleteCount;
784         setupByConfig(config);
785         final RecyclerView.Adapter adapter = mAdapter;
786         waitFirstLayout();
787         // scroll far away
788         smoothScrollToPosition(config.mItemCount / 2);
789         checkForMainThreadException();
790         // assert to be deleted child is not visible
791         assertNull(logPrefix + " test sanity, to be deleted child should be invisible",
792                 mRecyclerView.findViewHolderForLayoutPosition(deletePosition));
793         // delete the child and notify
794         mAdapter.deleteAndNotify(deletePosition, deleteCount);
795         getInstrumentation().waitForIdleSync();
796         mLayoutManager.expectLayouts(1);
797         smoothScrollToPosition(0);
798         mLayoutManager.waitForLayout(2);
799         checkForMainThreadException();
800         // due to data changes, first item may become visible before others which will cause
801         // smooth scrolling to stop. Triggering it twice more is a naive hack.
802         // Until we have time to consider it as a bug, this is the only workaround.
803         smoothScrollToPosition(0);
804         checkForMainThreadException();
805         Thread.sleep(300);
806         smoothScrollToPosition(0);
807         checkForMainThreadException();
808         Thread.sleep(500);
809         // some animations should happen and we should recover layout
810         final Map<Item, Rect> actualCoords = mLayoutManager.collectChildCoordinates();
811 
812         // now layout another RV with same adapter
813         removeRecyclerView();
814         setupByConfig(config);
815         mRecyclerView.setAdapter(adapter);// use same adapter so that items can be matched
816         waitFirstLayout();
817         final Map<Item, Rect> desiredCoords = mLayoutManager.collectChildCoordinates();
818         assertRectSetsEqual(logPrefix + " when an item from the start of the list is deleted, "
819                         + "layout should recover the state once scrolling is stopped",
820                 desiredCoords, actualCoords);
821         checkForMainThreadException();
822     }
823 
testPartialSpanInvalidation()824     public void testPartialSpanInvalidation() throws Throwable {
825         Config config = new Config().spanCount(5).itemCount(100);
826         setupByConfig(config);
827         for (int i = 20; i < mAdapter.getItemCount(); i += 20) {
828             mAdapter.mFullSpanItems.add(i);
829         }
830         waitFirstLayout();
831         smoothScrollToPosition(50);
832         int prevSpanId = mLayoutManager.mLazySpanLookup.mData[30];
833         mAdapter.changeAndNotify(15, 2);
834         Thread.sleep(200);
835         assertEquals("Invalidation should happen within full span item boundaries", prevSpanId,
836                 mLayoutManager.mLazySpanLookup.mData[30]);
837         assertEquals("item in invalidated range should have clear span id",
838                 LayoutParams.INVALID_SPAN_ID, mLayoutManager.mLazySpanLookup.mData[16]);
839         smoothScrollToPosition(85);
840         int[] prevSpans = copyOfRange(mLayoutManager.mLazySpanLookup.mData, 62, 85);
841         mAdapter.deleteAndNotify(55, 2);
842         Thread.sleep(200);
843         assertEquals("item in invalidated range should have clear span id",
844                 LayoutParams.INVALID_SPAN_ID, mLayoutManager.mLazySpanLookup.mData[16]);
845         int[] newSpans = copyOfRange(mLayoutManager.mLazySpanLookup.mData, 60, 83);
846         assertSpanAssignmentEquality("valid spans should be shifted for deleted item", prevSpans,
847                 newSpans, 0, 0, newSpans.length);
848     }
849 
850     // Same as Arrays.copyOfRange but for API 7
copyOfRange(int[] original, int from, int to)851     private int[] copyOfRange(int[] original, int from, int to) {
852         int newLength = to - from;
853         if (newLength < 0) {
854             throw new IllegalArgumentException(from + " > " + to);
855         }
856         int[] copy = new int[newLength];
857         System.arraycopy(original, from, copy, 0,
858                 Math.min(original.length - from, newLength));
859         return copy;
860     }
861 
testSpanReassignmentsOnItemChange()862     public void testSpanReassignmentsOnItemChange() throws Throwable {
863         Config config = new Config().spanCount(5);
864         setupByConfig(config);
865         waitFirstLayout();
866         smoothScrollToPosition(mAdapter.getItemCount() / 2);
867         final int changePosition = mAdapter.getItemCount() / 4;
868         mLayoutManager.expectLayouts(1);
869         mAdapter.changeAndNotify(changePosition, 1);
870         mLayoutManager.assertNoLayout("no layout should happen when an invisible child is updated",
871                 1);
872         // delete an item before visible area
873         int deletedPosition = mLayoutManager.getPosition(mLayoutManager.getChildAt(0)) - 2;
874         assertTrue("test sanity", deletedPosition >= 0);
875         Map<Item, Rect> before = mLayoutManager.collectChildCoordinates();
876         if (DEBUG) {
877             Log.d(TAG, "before:");
878             for (Map.Entry<Item, Rect> entry : before.entrySet()) {
879                 Log.d(TAG, entry.getKey().mAdapterIndex + ":" + entry.getValue());
880             }
881         }
882         mLayoutManager.expectLayouts(1);
883         mAdapter.deleteAndNotify(deletedPosition, 1);
884         mLayoutManager.waitForLayout(2);
885         assertRectSetsEqual(config + " when an item towards the head of the list is deleted, it "
886                         + "should not affect the layout if it is not visible", before,
887                 mLayoutManager.collectChildCoordinates()
888         );
889         deletedPosition = mLayoutManager.getPosition(mLayoutManager.getChildAt(2));
890         mLayoutManager.expectLayouts(1);
891         mAdapter.deleteAndNotify(deletedPosition, 1);
892         mLayoutManager.waitForLayout(2);
893         assertRectSetsNotEqual(config + " when a visible item is deleted, it should affect the "
894                 + "layout", before, mLayoutManager.collectChildCoordinates());
895     }
896 
assertSpanAssignmentEquality(String msg, int[] set1, int[] set2, int start, int end)897     void assertSpanAssignmentEquality(String msg, int[] set1, int[] set2, int start, int end) {
898         for (int i = start; i < end; i++) {
899             assertEquals(msg + " ind:" + i, set1[i], set2[i]);
900         }
901     }
902 
assertSpanAssignmentEquality(String msg, int[] set1, int[] set2, int start1, int start2, int length)903     void assertSpanAssignmentEquality(String msg, int[] set1, int[] set2, int start1, int start2,
904             int length) {
905         for (int i = 0; i < length; i++) {
906             assertEquals(msg + " ind1:" + (start1 + i) + ", ind2:" + (start2 + i), set1[start1 + i],
907                     set2[start2 + i]);
908         }
909     }
910 
testViewSnapping()911     public void testViewSnapping() throws Throwable {
912         for (Config config : mBaseVariations) {
913             viewSnapTest(config.itemCount(config.mSpanCount + 1));
914             removeRecyclerView();
915         }
916     }
917 
viewSnapTest(final Config config)918     public void viewSnapTest(final Config config) throws Throwable {
919         setupByConfig(config);
920         mAdapter.mOnBindCallback = new OnBindCallback() {
921             @Override
922             void onBoundItem(TestViewHolder vh, int position) {
923                 LayoutParams lp = (LayoutParams) vh.itemView.getLayoutParams();
924                 if (config.mOrientation == HORIZONTAL) {
925                     lp.width = mRecyclerView.getWidth() / 3;
926                 } else {
927                     lp.height = mRecyclerView.getHeight() / 3;
928                 }
929             }
930             @Override
931             boolean assignRandomSize() {
932                 return false;
933             }
934         };
935         waitFirstLayout();
936         // run these tests twice. once initial layout, once after scroll
937         String logSuffix = "";
938         for (int i = 0; i < 2; i++) {
939             Map<Item, Rect> itemRectMap = mLayoutManager.collectChildCoordinates();
940             Rect recyclerViewBounds = getDecoratedRecyclerViewBounds();
941             // workaround for SGLM's span distribution issue. Right now, it may leave gaps so we
942             // avoid it by setting its layout params directly
943             if(config.mOrientation == HORIZONTAL) {
944                 recyclerViewBounds.bottom -= recyclerViewBounds.height() % config.mSpanCount;
945             } else {
946                 recyclerViewBounds.right -= recyclerViewBounds.width() % config.mSpanCount;
947             }
948 
949             Rect usedLayoutBounds = new Rect();
950             for (Rect rect : itemRectMap.values()) {
951                 usedLayoutBounds.union(rect);
952             }
953 
954             if (DEBUG) {
955                 Log.d(TAG, "testing view snapping (" + logSuffix + ") for config " + config);
956             }
957             if (config.mOrientation == VERTICAL) {
958                 assertEquals(config + " there should be no gap on left" + logSuffix,
959                         usedLayoutBounds.left, recyclerViewBounds.left);
960                 assertEquals(config + " there should be no gap on right" + logSuffix,
961                         usedLayoutBounds.right, recyclerViewBounds.right);
962                 if (config.mReverseLayout) {
963                     assertEquals(config + " there should be no gap on bottom" + logSuffix,
964                             usedLayoutBounds.bottom, recyclerViewBounds.bottom);
965                     assertTrue(config + " there should be some gap on top" + logSuffix,
966                             usedLayoutBounds.top > recyclerViewBounds.top);
967                 } else {
968                     assertEquals(config + " there should be no gap on top" + logSuffix,
969                             usedLayoutBounds.top, recyclerViewBounds.top);
970                     assertTrue(config + " there should be some gap at the bottom" + logSuffix,
971                             usedLayoutBounds.bottom < recyclerViewBounds.bottom);
972                 }
973             } else {
974                 assertEquals(config + " there should be no gap on top" + logSuffix,
975                         usedLayoutBounds.top, recyclerViewBounds.top);
976                 assertEquals(config + " there should be no gap at the bottom" + logSuffix,
977                         usedLayoutBounds.bottom, recyclerViewBounds.bottom);
978                 if (config.mReverseLayout) {
979                     assertEquals(config + " there should be no on right" + logSuffix,
980                             usedLayoutBounds.right, recyclerViewBounds.right);
981                     assertTrue(config + " there should be some gap on left" + logSuffix,
982                             usedLayoutBounds.left > recyclerViewBounds.left);
983                 } else {
984                     assertEquals(config + " there should be no gap on left" + logSuffix,
985                             usedLayoutBounds.left, recyclerViewBounds.left);
986                     assertTrue(config + " there should be some gap on right" + logSuffix,
987                             usedLayoutBounds.right < recyclerViewBounds.right);
988                 }
989             }
990             final int scroll = config.mReverseLayout ? -500 : 500;
991             scrollBy(scroll);
992             logSuffix = " scrolled " + scroll;
993         }
994 
995     }
996 
testSpanCountChangeOnRestoreSavedState()997     public void testSpanCountChangeOnRestoreSavedState() throws Throwable {
998         Config config = new Config(HORIZONTAL, true, 5, GAP_HANDLING_NONE);
999         setupByConfig(config);
1000         waitFirstLayout();
1001 
1002         int beforeChildCount = mLayoutManager.getChildCount();
1003         Parcelable savedState = mRecyclerView.onSaveInstanceState();
1004         // we append a suffix to the parcelable to test out of bounds
1005         String parcelSuffix = UUID.randomUUID().toString();
1006         Parcel parcel = Parcel.obtain();
1007         savedState.writeToParcel(parcel, 0);
1008         parcel.writeString(parcelSuffix);
1009         removeRecyclerView();
1010         // reset for reading
1011         parcel.setDataPosition(0);
1012         // re-create
1013         savedState = RecyclerView.SavedState.CREATOR.createFromParcel(parcel);
1014         removeRecyclerView();
1015 
1016         RecyclerView restored = new RecyclerView(getActivity());
1017         mLayoutManager = new WrappedLayoutManager(config.mSpanCount, config.mOrientation);
1018         mLayoutManager.setReverseLayout(config.mReverseLayout);
1019         mLayoutManager.setGapStrategy(config.mGapStrategy);
1020         restored.setLayoutManager(mLayoutManager);
1021         // use the same adapter for Rect matching
1022         restored.setAdapter(mAdapter);
1023         restored.onRestoreInstanceState(savedState);
1024         mLayoutManager.setSpanCount(1);
1025         mLayoutManager.expectLayouts(1);
1026         setRecyclerView(restored);
1027         mLayoutManager.waitForLayout(2);
1028         assertEquals("on saved state, reverse layout should be preserved",
1029                 config.mReverseLayout, mLayoutManager.getReverseLayout());
1030         assertEquals("on saved state, orientation should be preserved",
1031                 config.mOrientation, mLayoutManager.getOrientation());
1032         assertEquals("after setting new span count, layout manager should keep new value",
1033                 1, mLayoutManager.getSpanCount());
1034         assertEquals("on saved state, gap strategy should be preserved",
1035                 config.mGapStrategy, mLayoutManager.getGapStrategy());
1036         assertTrue("when span count is dramatically changed after restore, # of child views "
1037                 + "should change", beforeChildCount > mLayoutManager.getChildCount());
1038         // make sure LLM can layout all children. is some span info is leaked, this would crash
1039         smoothScrollToPosition(mAdapter.getItemCount() - 1);
1040     }
1041 
testSavedState()1042     public void testSavedState() throws Throwable {
1043         PostLayoutRunnable[] postLayoutOptions = new PostLayoutRunnable[]{
1044                 new PostLayoutRunnable() {
1045                     @Override
1046                     public void run() throws Throwable {
1047                         // do nothing
1048                     }
1049 
1050                     @Override
1051                     public String describe() {
1052                         return "doing nothing";
1053                     }
1054                 },
1055                 new PostLayoutRunnable() {
1056                     @Override
1057                     public void run() throws Throwable {
1058                         mLayoutManager.expectLayouts(1);
1059                         scrollToPosition(mAdapter.getItemCount() * 3 / 4);
1060                         mLayoutManager.waitForLayout(2);
1061                     }
1062 
1063                     @Override
1064                     public String describe() {
1065                         return "scroll to position " + (mAdapter == null ? "" :
1066                                 mAdapter.getItemCount() * 3 / 4);
1067                     }
1068                 },
1069                 new PostLayoutRunnable() {
1070                     @Override
1071                     public void run() throws Throwable {
1072                         mLayoutManager.expectLayouts(1);
1073                         scrollToPositionWithOffset(mAdapter.getItemCount() / 3,
1074                                 50);
1075                         mLayoutManager.waitForLayout(2);
1076                     }
1077 
1078                     @Override
1079                     public String describe() {
1080                         return "scroll to position " + (mAdapter == null ? "" :
1081                                 mAdapter.getItemCount() / 3) + "with positive offset";
1082                     }
1083                 },
1084                 new PostLayoutRunnable() {
1085                     @Override
1086                     public void run() throws Throwable {
1087                         mLayoutManager.expectLayouts(1);
1088                         scrollToPositionWithOffset(mAdapter.getItemCount() * 2 / 3,
1089                                 -50);
1090                         mLayoutManager.waitForLayout(2);
1091                     }
1092 
1093                     @Override
1094                     public String describe() {
1095                         return "scroll to position with negative offset";
1096                     }
1097                 }
1098         };
1099         boolean[] waitForLayoutOptions = new boolean[]{false, true};
1100         boolean[] loadDataAfterRestoreOptions = new boolean[]{false, true};
1101         List<Config> testVariations = new ArrayList<Config>();
1102         testVariations.addAll(mBaseVariations);
1103         for (Config config : mBaseVariations) {
1104             if (config.mSpanCount < 2) {
1105                 continue;
1106             }
1107             final Config clone = (Config) config.clone();
1108             clone.mItemCount = clone.mSpanCount - 1;
1109             testVariations.add(clone);
1110         }
1111         for (Config config : testVariations) {
1112             for (PostLayoutRunnable runnable : postLayoutOptions) {
1113                 for (boolean waitForLayout : waitForLayoutOptions) {
1114                     for (boolean loadDataAfterRestore : loadDataAfterRestoreOptions) {
1115                         savedStateTest(config, waitForLayout, loadDataAfterRestore, runnable);
1116                         removeRecyclerView();
1117                         checkForMainThreadException();
1118                     }
1119                 }
1120             }
1121         }
1122     }
1123 
saveRestore(final Config config)1124     private void saveRestore(final Config config) throws Throwable {
1125         runTestOnUiThread(new Runnable() {
1126             @Override
1127             public void run() {
1128                 try {
1129                     Parcelable savedState = mRecyclerView.onSaveInstanceState();
1130                     // we append a suffix to the parcelable to test out of bounds
1131                     String parcelSuffix = UUID.randomUUID().toString();
1132                     Parcel parcel = Parcel.obtain();
1133                     savedState.writeToParcel(parcel, 0);
1134                     parcel.writeString(parcelSuffix);
1135                     removeRecyclerView();
1136                     // reset for reading
1137                     parcel.setDataPosition(0);
1138                     // re-create
1139                     savedState = RecyclerView.SavedState.CREATOR.createFromParcel(parcel);
1140                     RecyclerView restored = new RecyclerView(getActivity());
1141                     mLayoutManager = new WrappedLayoutManager(config.mSpanCount,
1142                             config.mOrientation);
1143                     mLayoutManager.setGapStrategy(config.mGapStrategy);
1144                     restored.setLayoutManager(mLayoutManager);
1145                     // use the same adapter for Rect matching
1146                     restored.setAdapter(mAdapter);
1147                     restored.onRestoreInstanceState(savedState);
1148                     if (Looper.myLooper() == Looper.getMainLooper()) {
1149                         mLayoutManager.expectLayouts(1);
1150                         setRecyclerView(restored);
1151                     } else {
1152                         mLayoutManager.expectLayouts(1);
1153                         setRecyclerView(restored);
1154                         mLayoutManager.waitForLayout(2);
1155                     }
1156                 } catch (Throwable t) {
1157                     postExceptionToInstrumentation(t);
1158                 }
1159             }
1160         });
1161         checkForMainThreadException();
1162     }
1163 
savedStateTest(Config config, boolean waitForLayout, boolean loadDataAfterRestore, PostLayoutRunnable postLayoutOperations)1164     public void savedStateTest(Config config, boolean waitForLayout, boolean loadDataAfterRestore,
1165             PostLayoutRunnable postLayoutOperations)
1166             throws Throwable {
1167         if (DEBUG) {
1168             Log.d(TAG, "testing saved state with wait for layout = " + waitForLayout + " config "
1169                     + config + " post layout action " + postLayoutOperations.describe());
1170         }
1171         setupByConfig(config);
1172         if (loadDataAfterRestore) {
1173             // We are going to re-create items, force non-random item size.
1174             mAdapter.mOnBindCallback = new OnBindCallback() {
1175                 @Override
1176                 void onBoundItem(TestViewHolder vh, int position) {
1177                 }
1178 
1179                 boolean assignRandomSize() {
1180                     return false;
1181                 }
1182             };
1183         }
1184         waitFirstLayout();
1185         if (waitForLayout) {
1186             postLayoutOperations.run();
1187             // ugly thread sleep but since post op is anything, we need to give it time to settle.
1188             Thread.sleep(500);
1189         }
1190         getInstrumentation().waitForIdleSync();
1191         final int firstCompletelyVisiblePosition = mLayoutManager.findFirstVisibleItemPositionInt();
1192         Map<Item, Rect> before = mLayoutManager.collectChildCoordinates();
1193         Parcelable savedState = mRecyclerView.onSaveInstanceState();
1194         // we append a suffix to the parcelable to test out of bounds
1195         String parcelSuffix = UUID.randomUUID().toString();
1196         Parcel parcel = Parcel.obtain();
1197         savedState.writeToParcel(parcel, 0);
1198         parcel.writeString(parcelSuffix);
1199         removeRecyclerView();
1200         // reset for reading
1201         parcel.setDataPosition(0);
1202         // re-create
1203         savedState = RecyclerView.SavedState.CREATOR.createFromParcel(parcel);
1204         removeRecyclerView();
1205 
1206         final int itemCount = mAdapter.getItemCount();
1207         if (loadDataAfterRestore) {
1208             mAdapter.deleteAndNotify(0, itemCount);
1209         }
1210 
1211         RecyclerView restored = new RecyclerView(getActivity());
1212         mLayoutManager = new WrappedLayoutManager(config.mSpanCount, config.mOrientation);
1213         mLayoutManager.setGapStrategy(config.mGapStrategy);
1214         restored.setLayoutManager(mLayoutManager);
1215         // use the same adapter for Rect matching
1216         restored.setAdapter(mAdapter);
1217         restored.onRestoreInstanceState(savedState);
1218 
1219         if (loadDataAfterRestore) {
1220             mAdapter.addAndNotify(itemCount);
1221         }
1222 
1223         assertEquals("Parcel reading should not go out of bounds", parcelSuffix,
1224                 parcel.readString());
1225         mLayoutManager.expectLayouts(1);
1226         setRecyclerView(restored);
1227         mLayoutManager.waitForLayout(2);
1228         assertEquals(config + " on saved state, reverse layout should be preserved",
1229                 config.mReverseLayout, mLayoutManager.getReverseLayout());
1230         assertEquals(config + " on saved state, orientation should be preserved",
1231                 config.mOrientation, mLayoutManager.getOrientation());
1232         assertEquals(config + " on saved state, span count should be preserved",
1233                 config.mSpanCount, mLayoutManager.getSpanCount());
1234         assertEquals(config + " on saved state, gap strategy should be preserved",
1235                 config.mGapStrategy, mLayoutManager.getGapStrategy());
1236         assertEquals(config + " on saved state, first completely visible child position should"
1237                         + " be preserved", firstCompletelyVisiblePosition,
1238                 mLayoutManager.findFirstVisibleItemPositionInt());
1239         if (waitForLayout) {
1240             final boolean strictItemEquality = !loadDataAfterRestore;
1241             assertRectSetsEqual(config + "\npost layout op:" + postLayoutOperations.describe()
1242                             + ": on restore, previous view positions should be preserved",
1243                     before, mLayoutManager.collectChildCoordinates(), strictItemEquality);
1244         }
1245         // TODO add tests for changing values after restore before layout
1246     }
1247 
testScrollAndClear()1248     public void testScrollAndClear() throws Throwable {
1249         setupByConfig(new Config());
1250         waitFirstLayout();
1251 
1252         assertTrue("Children not laid out", mLayoutManager.collectChildCoordinates().size() > 0);
1253 
1254         mLayoutManager.expectLayouts(1);
1255         runTestOnUiThread(new Runnable() {
1256             @Override
1257             public void run() {
1258                 mLayoutManager.scrollToPositionWithOffset(1, 0);
1259                 mAdapter.clearOnUIThread();
1260             }
1261         });
1262         mLayoutManager.waitForLayout(2);
1263 
1264         assertEquals("Remaining children", 0, mLayoutManager.collectChildCoordinates().size());
1265     }
1266 
testScrollToPositionWithOffset()1267     public void testScrollToPositionWithOffset() throws Throwable {
1268         for (Config config : mBaseVariations) {
1269             scrollToPositionWithOffsetTest(config);
1270             removeRecyclerView();
1271         }
1272     }
1273 
scrollToPositionWithOffsetTest(Config config)1274     public void scrollToPositionWithOffsetTest(Config config) throws Throwable {
1275         setupByConfig(config);
1276         waitFirstLayout();
1277         OrientationHelper orientationHelper = OrientationHelper
1278                 .createOrientationHelper(mLayoutManager, config.mOrientation);
1279         Rect layoutBounds = getDecoratedRecyclerViewBounds();
1280         // try scrolling towards head, should not affect anything
1281         Map<Item, Rect> before = mLayoutManager.collectChildCoordinates();
1282         scrollToPositionWithOffset(0, 20);
1283         assertRectSetsEqual(config + " trying to over scroll with offset should be no-op",
1284                 before, mLayoutManager.collectChildCoordinates());
1285         // try offsetting some visible children
1286         int testCount = 10;
1287         while (testCount-- > 0) {
1288             // get middle child
1289             final View child = mLayoutManager.getChildAt(mLayoutManager.getChildCount() / 2);
1290             final int position = mRecyclerView.getChildLayoutPosition(child);
1291             final int startOffset = config.mReverseLayout ?
1292                     orientationHelper.getEndAfterPadding() - orientationHelper
1293                             .getDecoratedEnd(child)
1294                     : orientationHelper.getDecoratedStart(child) - orientationHelper
1295                             .getStartAfterPadding();
1296             final int scrollOffset = startOffset / 2;
1297             mLayoutManager.expectLayouts(1);
1298             scrollToPositionWithOffset(position, scrollOffset);
1299             mLayoutManager.waitForLayout(2);
1300             final int finalOffset = config.mReverseLayout ?
1301                     orientationHelper.getEndAfterPadding() - orientationHelper
1302                             .getDecoratedEnd(child)
1303                     : orientationHelper.getDecoratedStart(child) - orientationHelper
1304                             .getStartAfterPadding();
1305             assertEquals(config + " scroll with offset on a visible child should work fine",
1306                     scrollOffset, finalOffset);
1307         }
1308 
1309         // try scrolling to invisible children
1310         testCount = 10;
1311         // we test above and below, one by one
1312         int offsetMultiplier = -1;
1313         while (testCount-- > 0) {
1314             final TargetTuple target = findInvisibleTarget(config);
1315             mLayoutManager.expectLayouts(1);
1316             final int offset = offsetMultiplier
1317                     * orientationHelper.getDecoratedMeasurement(mLayoutManager.getChildAt(0)) / 3;
1318             scrollToPositionWithOffset(target.mPosition, offset);
1319             mLayoutManager.waitForLayout(2);
1320             final View child = mLayoutManager.findViewByPosition(target.mPosition);
1321             assertNotNull(config + " scrolling to a mPosition with offset " + offset
1322                     + " should layout it", child);
1323             final Rect bounds = mLayoutManager.getViewBounds(child);
1324             if (DEBUG) {
1325                 Log.d(TAG, config + " post scroll to invisible mPosition " + bounds + " in "
1326                         + layoutBounds + " with offset " + offset);
1327             }
1328 
1329             if (config.mReverseLayout) {
1330                 assertEquals(config + " when scrolling with offset to an invisible in reverse "
1331                                 + "layout, its end should align with recycler view's end - offset",
1332                         orientationHelper.getEndAfterPadding() - offset,
1333                         orientationHelper.getDecoratedEnd(child)
1334                 );
1335             } else {
1336                 assertEquals(config + " when scrolling with offset to an invisible child in normal"
1337                                 + " layout its start should align with recycler view's start + "
1338                                 + "offset",
1339                         orientationHelper.getStartAfterPadding() + offset,
1340                         orientationHelper.getDecoratedStart(child)
1341                 );
1342             }
1343             offsetMultiplier *= -1;
1344         }
1345     }
1346 
testScrollToPosition()1347     public void testScrollToPosition() throws Throwable {
1348         for (Config config : mBaseVariations) {
1349             scrollToPositionTest(config);
1350             removeRecyclerView();
1351         }
1352     }
1353 
findInvisibleTarget(Config config)1354     private TargetTuple findInvisibleTarget(Config config) {
1355         int minPosition = Integer.MAX_VALUE, maxPosition = Integer.MIN_VALUE;
1356         for (int i = 0; i < mLayoutManager.getChildCount(); i++) {
1357             View child = mLayoutManager.getChildAt(i);
1358             int position = mRecyclerView.getChildLayoutPosition(child);
1359             if (position < minPosition) {
1360                 minPosition = position;
1361             }
1362             if (position > maxPosition) {
1363                 maxPosition = position;
1364             }
1365         }
1366         final int tailTarget = maxPosition + (mAdapter.getItemCount() - maxPosition) / 2;
1367         final int headTarget = minPosition / 2;
1368         final int target;
1369         // where will the child come from ?
1370         final int itemLayoutDirection;
1371         if (Math.abs(tailTarget - maxPosition) > Math.abs(headTarget - minPosition)) {
1372             target = tailTarget;
1373             itemLayoutDirection = config.mReverseLayout ? LAYOUT_START : LAYOUT_END;
1374         } else {
1375             target = headTarget;
1376             itemLayoutDirection = config.mReverseLayout ? LAYOUT_END : LAYOUT_START;
1377         }
1378         if (DEBUG) {
1379             Log.d(TAG,
1380                     config + " target:" + target + " min:" + minPosition + ", max:" + maxPosition);
1381         }
1382         return new TargetTuple(target, itemLayoutDirection);
1383     }
1384 
scrollToPositionTest(Config config)1385     public void scrollToPositionTest(Config config) throws Throwable {
1386         setupByConfig(config);
1387         waitFirstLayout();
1388         OrientationHelper orientationHelper = OrientationHelper
1389                 .createOrientationHelper(mLayoutManager, config.mOrientation);
1390         Rect layoutBounds = getDecoratedRecyclerViewBounds();
1391         for (int i = 0; i < mLayoutManager.getChildCount(); i++) {
1392             View view = mLayoutManager.getChildAt(i);
1393             Rect bounds = mLayoutManager.getViewBounds(view);
1394             if (layoutBounds.contains(bounds)) {
1395                 Map<Item, Rect> initialBounds = mLayoutManager.collectChildCoordinates();
1396                 final int position = mRecyclerView.getChildLayoutPosition(view);
1397                 LayoutParams layoutParams
1398                         = (LayoutParams) (view.getLayoutParams());
1399                 TestViewHolder vh = (TestViewHolder) layoutParams.mViewHolder;
1400                 assertEquals("recycler view mPosition should match adapter mPosition", position,
1401                         vh.mBoundItem.mAdapterIndex);
1402                 if (DEBUG) {
1403                     Log.d(TAG, "testing scroll to visible mPosition at " + position
1404                             + " " + bounds + " inside " + layoutBounds);
1405                 }
1406                 mLayoutManager.expectLayouts(1);
1407                 scrollToPosition(position);
1408                 mLayoutManager.waitForLayout(2);
1409                 if (DEBUG) {
1410                     view = mLayoutManager.findViewByPosition(position);
1411                     Rect newBounds = mLayoutManager.getViewBounds(view);
1412                     Log.d(TAG, "after scrolling to visible mPosition " +
1413                             bounds + " equals " + newBounds);
1414                 }
1415 
1416                 assertRectSetsEqual(
1417                         config + "scroll to mPosition on fully visible child should be no-op",
1418                         initialBounds, mLayoutManager.collectChildCoordinates());
1419             } else {
1420                 final int position = mRecyclerView.getChildLayoutPosition(view);
1421                 if (DEBUG) {
1422                     Log.d(TAG,
1423                             "child(" + position + ") not fully visible " + bounds + " not inside "
1424                                     + layoutBounds
1425                                     + mRecyclerView.getChildLayoutPosition(view)
1426                     );
1427                 }
1428                 mLayoutManager.expectLayouts(1);
1429                 runTestOnUiThread(new Runnable() {
1430                     @Override
1431                     public void run() {
1432                         mLayoutManager.scrollToPosition(position);
1433                     }
1434                 });
1435                 mLayoutManager.waitForLayout(2);
1436                 view = mLayoutManager.findViewByPosition(position);
1437                 bounds = mLayoutManager.getViewBounds(view);
1438                 if (DEBUG) {
1439                     Log.d(TAG, "after scroll to partially visible child " + bounds + " in "
1440                             + layoutBounds);
1441                 }
1442                 assertTrue(config
1443                                 + " after scrolling to a partially visible child, it should become fully "
1444                                 + " visible. " + bounds + " not inside " + layoutBounds,
1445                         layoutBounds.contains(bounds)
1446                 );
1447                 assertTrue(config + " when scrolling to a partially visible item, one of its edges "
1448                         + "should be on the boundaries", orientationHelper.getStartAfterPadding() ==
1449                         orientationHelper.getDecoratedStart(view)
1450                         || orientationHelper.getEndAfterPadding() ==
1451                         orientationHelper.getDecoratedEnd(view));
1452             }
1453         }
1454 
1455         // try scrolling to invisible children
1456         int testCount = 10;
1457         while (testCount-- > 0) {
1458             final TargetTuple target = findInvisibleTarget(config);
1459             mLayoutManager.expectLayouts(1);
1460             scrollToPosition(target.mPosition);
1461             mLayoutManager.waitForLayout(2);
1462             final View child = mLayoutManager.findViewByPosition(target.mPosition);
1463             assertNotNull(config + " scrolling to a mPosition should lay it out", child);
1464             final Rect bounds = mLayoutManager.getViewBounds(child);
1465             if (DEBUG) {
1466                 Log.d(TAG, config + " post scroll to invisible mPosition " + bounds + " in "
1467                         + layoutBounds);
1468             }
1469             assertTrue(config + " scrolling to a mPosition should make it fully visible",
1470                     layoutBounds.contains(bounds));
1471             if (target.mLayoutDirection == LAYOUT_START) {
1472                 assertEquals(
1473                         config + " when scrolling to an invisible child above, its start should"
1474                                 + " align with recycler view's start",
1475                         orientationHelper.getStartAfterPadding(),
1476                         orientationHelper.getDecoratedStart(child)
1477                 );
1478             } else {
1479                 assertEquals(config + " when scrolling to an invisible child below, its end "
1480                                 + "should align with recycler view's end",
1481                         orientationHelper.getEndAfterPadding(),
1482                         orientationHelper.getDecoratedEnd(child)
1483                 );
1484             }
1485         }
1486     }
1487 
scrollToPositionWithOffset(final int position, final int offset)1488     private void scrollToPositionWithOffset(final int position, final int offset) throws Throwable {
1489         runTestOnUiThread(new Runnable() {
1490             @Override
1491             public void run() {
1492                 mLayoutManager.scrollToPositionWithOffset(position, offset);
1493             }
1494         });
1495     }
1496 
testLayoutOrder()1497     public void testLayoutOrder() throws Throwable {
1498         for (Config config : mBaseVariations) {
1499             layoutOrderTest(config);
1500             removeRecyclerView();
1501         }
1502     }
1503 
layoutOrderTest(Config config)1504     public void layoutOrderTest(Config config) throws Throwable {
1505         setupByConfig(config);
1506         assertViewPositions(config);
1507     }
1508 
assertViewPositions(Config config)1509     void assertViewPositions(Config config) {
1510         ArrayList<ArrayList<View>> viewsBySpan = mLayoutManager.collectChildrenBySpan();
1511         OrientationHelper orientationHelper = OrientationHelper
1512                 .createOrientationHelper(mLayoutManager, config.mOrientation);
1513         for (ArrayList<View> span : viewsBySpan) {
1514             // validate all children's order. first child should have min start mPosition
1515             final int count = span.size();
1516             for (int i = 0, j = 1; j < count; i++, j++) {
1517                 View prev = span.get(i);
1518                 View next = span.get(j);
1519                 assertTrue(config + " prev item should be above next item",
1520                         orientationHelper.getDecoratedEnd(prev) <= orientationHelper
1521                                 .getDecoratedStart(next)
1522                 );
1523 
1524             }
1525         }
1526     }
1527 
testScrollBy()1528     public void testScrollBy() throws Throwable {
1529         for (Config config : mBaseVariations) {
1530             scollByTest(config);
1531             removeRecyclerView();
1532         }
1533     }
1534 
waitFirstLayout()1535     void waitFirstLayout() throws Throwable {
1536         mLayoutManager.expectLayouts(1);
1537         setRecyclerView(mRecyclerView);
1538         mLayoutManager.waitForLayout(2);
1539         getInstrumentation().waitForIdleSync();
1540     }
1541 
scollByTest(Config config)1542     public void scollByTest(Config config) throws Throwable {
1543         setupByConfig(config);
1544         waitFirstLayout();
1545         // try invalid scroll. should not happen
1546         final View first = mLayoutManager.getChildAt(0);
1547         OrientationHelper primaryOrientation = OrientationHelper
1548                 .createOrientationHelper(mLayoutManager, config.mOrientation);
1549         int scrollDist;
1550         if (config.mReverseLayout) {
1551             scrollDist = primaryOrientation.getDecoratedMeasurement(first) / 2;
1552         } else {
1553             scrollDist = -primaryOrientation.getDecoratedMeasurement(first) / 2;
1554         }
1555         Map<Item, Rect> before = mLayoutManager.collectChildCoordinates();
1556         scrollBy(scrollDist);
1557         Map<Item, Rect> after = mLayoutManager.collectChildCoordinates();
1558         assertRectSetsEqual(
1559                 config + " if there are no more items, scroll should not happen (dt:" + scrollDist
1560                         + ")",
1561                 before, after
1562         );
1563 
1564         scrollDist = -scrollDist * 3;
1565         before = mLayoutManager.collectChildCoordinates();
1566         scrollBy(scrollDist);
1567         after = mLayoutManager.collectChildCoordinates();
1568         int layoutStart = primaryOrientation.getStartAfterPadding();
1569         int layoutEnd = primaryOrientation.getEndAfterPadding();
1570         for (Map.Entry<Item, Rect> entry : before.entrySet()) {
1571             Rect afterRect = after.get(entry.getKey());
1572             // offset rect
1573             if (config.mOrientation == VERTICAL) {
1574                 entry.getValue().offset(0, -scrollDist);
1575             } else {
1576                 entry.getValue().offset(-scrollDist, 0);
1577             }
1578             if (afterRect == null || afterRect.isEmpty()) {
1579                 // assert item is out of bounds
1580                 int start, end;
1581                 if (config.mOrientation == VERTICAL) {
1582                     start = entry.getValue().top;
1583                     end = entry.getValue().bottom;
1584                 } else {
1585                     start = entry.getValue().left;
1586                     end = entry.getValue().right;
1587                 }
1588                 assertTrue(
1589                         config + " if item is missing after relayout, it should be out of bounds."
1590                                 + "item start: " + start + ", end:" + end + " layout start:"
1591                                 + layoutStart +
1592                                 ", layout end:" + layoutEnd,
1593                         start <= layoutStart && end <= layoutEnd ||
1594                                 start >= layoutEnd && end >= layoutEnd
1595                 );
1596             } else {
1597                 assertEquals(config + " Item should be laid out at the scroll offset coordinates",
1598                         entry.getValue(),
1599                         afterRect);
1600             }
1601         }
1602         assertViewPositions(config);
1603     }
1604 
testAccessibilityPositions()1605     public void testAccessibilityPositions() throws Throwable {
1606         setupByConfig(new Config(VERTICAL, false, 3, GAP_HANDLING_NONE));
1607         waitFirstLayout();
1608         final AccessibilityDelegateCompat delegateCompat = mRecyclerView
1609                 .getCompatAccessibilityDelegate();
1610         final AccessibilityEvent event = AccessibilityEvent.obtain();
1611         runTestOnUiThread(new Runnable() {
1612             @Override
1613             public void run() {
1614                 delegateCompat.onInitializeAccessibilityEvent(mRecyclerView, event);
1615             }
1616         });
1617         final AccessibilityRecordCompat record = AccessibilityEventCompat
1618                 .asRecord(event);
1619         final int start = mRecyclerView
1620                 .getChildLayoutPosition(
1621                         mLayoutManager.findFirstVisibleItemClosestToStart(false, true));
1622         final int end = mRecyclerView
1623                 .getChildLayoutPosition(
1624                         mLayoutManager.findFirstVisibleItemClosestToEnd(false, true));
1625         assertEquals("first item position should match",
1626                 Math.min(start, end), record.getFromIndex());
1627         assertEquals("last item position should match",
1628                 Math.max(start, end), record.getToIndex());
1629 
1630     }
1631 
testConsistentRelayout()1632     public void testConsistentRelayout() throws Throwable {
1633         for (Config config : mBaseVariations) {
1634             for (boolean firstChildMultiSpan : new boolean[]{false, true}) {
1635                 consistentRelayoutTest(config, firstChildMultiSpan);
1636             }
1637             removeRecyclerView();
1638         }
1639     }
1640 
consistentRelayoutTest(Config config, boolean firstChildMultiSpan)1641     public void consistentRelayoutTest(Config config, boolean firstChildMultiSpan)
1642             throws Throwable {
1643         setupByConfig(config);
1644         if (firstChildMultiSpan) {
1645             mAdapter.mFullSpanItems.add(0);
1646         }
1647         waitFirstLayout();
1648         // record all child positions
1649         Map<Item, Rect> before = mLayoutManager.collectChildCoordinates();
1650         requestLayoutOnUIThread(mRecyclerView);
1651         Map<Item, Rect> after = mLayoutManager.collectChildCoordinates();
1652         assertRectSetsEqual(
1653                 config + " simple re-layout, firstChildMultiSpan:" + firstChildMultiSpan, before,
1654                 after);
1655         // scroll some to create inconsistency
1656         View firstChild = mLayoutManager.getChildAt(0);
1657         final int firstChildStartBeforeScroll = mLayoutManager.mPrimaryOrientation
1658                 .getDecoratedStart(firstChild);
1659         int distance = mLayoutManager.mPrimaryOrientation.getDecoratedMeasurement(firstChild) / 2;
1660         if (config.mReverseLayout) {
1661             distance *= -1;
1662         }
1663         scrollBy(distance);
1664         waitForMainThread(2);
1665         assertTrue("scroll by should move children", firstChildStartBeforeScroll !=
1666                 mLayoutManager.mPrimaryOrientation.getDecoratedStart(firstChild));
1667         before = mLayoutManager.collectChildCoordinates();
1668         mLayoutManager.expectLayouts(1);
1669         requestLayoutOnUIThread(mRecyclerView);
1670         mLayoutManager.waitForLayout(2);
1671         after = mLayoutManager.collectChildCoordinates();
1672         assertRectSetsEqual(config + " simple re-layout after scroll", before, after);
1673     }
1674 
1675     /**
1676      * enqueues an empty runnable to main thread so that we can be assured it did run
1677      *
1678      * @param count Number of times to run
1679      */
waitForMainThread(int count)1680     private void waitForMainThread(int count) throws Throwable {
1681         final AtomicInteger i = new AtomicInteger(count);
1682         while (i.get() > 0) {
1683             runTestOnUiThread(new Runnable() {
1684                 @Override
1685                 public void run() {
1686                     i.decrementAndGet();
1687                 }
1688             });
1689         }
1690     }
1691 
assertRectSetsNotEqual(String message, Map<Item, Rect> before, Map<Item, Rect> after)1692     public void assertRectSetsNotEqual(String message, Map<Item, Rect> before,
1693             Map<Item, Rect> after) {
1694         Throwable throwable = null;
1695         try {
1696             assertRectSetsEqual("NOT " + message, before, after);
1697         } catch (Throwable t) {
1698             throwable = t;
1699         }
1700         assertNotNull(message + " two layout should be different", throwable);
1701     }
1702 
assertRectSetsEqual(String message, Map<Item, Rect> before, Map<Item, Rect> after)1703     public void assertRectSetsEqual(String message, Map<Item, Rect> before, Map<Item, Rect> after) {
1704         assertRectSetsEqual(message, before, after, true);
1705     }
1706 
assertRectSetsEqual(String message, Map<Item, Rect> before, Map<Item, Rect> after, boolean strictItemEquality)1707     public void assertRectSetsEqual(String message, Map<Item, Rect> before, Map<Item, Rect> after,
1708             boolean strictItemEquality) {
1709         StringBuilder log = new StringBuilder();
1710         if (DEBUG) {
1711             log.append("checking rectangle equality.\n");
1712             log.append("total space:" + mLayoutManager.mPrimaryOrientation.getTotalSpace());
1713             log.append("before:");
1714             for (Map.Entry<Item, Rect> entry : before.entrySet()) {
1715                 log.append("\n").append(entry.getKey().mAdapterIndex).append(":")
1716                         .append(entry.getValue());
1717             }
1718             log.append("\nafter:");
1719             for (Map.Entry<Item, Rect> entry : after.entrySet()) {
1720                 log.append("\n").append(entry.getKey().mAdapterIndex).append(":")
1721                         .append(entry.getValue());
1722             }
1723             message += "\n\n" + log.toString();
1724         }
1725         assertEquals(message + ": item counts should be equal", before.size()
1726                 , after.size());
1727         for (Map.Entry<Item, Rect> entry : before.entrySet()) {
1728             final Item beforeItem = entry.getKey();
1729             Rect afterRect = null;
1730             if (strictItemEquality) {
1731                 afterRect = after.get(beforeItem);
1732                 assertNotNull(message + ": Same item should be visible after simple re-layout",
1733                         afterRect);
1734             } else {
1735                 for (Map.Entry<Item, Rect> afterEntry : after.entrySet()) {
1736                     final Item afterItem = afterEntry.getKey();
1737                     if (afterItem.mAdapterIndex == beforeItem.mAdapterIndex) {
1738                         afterRect = afterEntry.getValue();
1739                         break;
1740                     }
1741                 }
1742                 assertNotNull(message + ": Item with same adapter index should be visible " +
1743                                 "after simple re-layout",
1744                         afterRect);
1745             }
1746             assertEquals(message + ": Item should be laid out at the same coordinates",
1747                     entry.getValue(),
1748                     afterRect);
1749         }
1750     }
1751 
1752     // test layout params assignment
1753 
1754     static class OnLayoutListener {
1755 
before(RecyclerView.Recycler recycler, RecyclerView.State state)1756         void before(RecyclerView.Recycler recycler, RecyclerView.State state) {
1757         }
1758 
after(RecyclerView.Recycler recycler, RecyclerView.State state)1759         void after(RecyclerView.Recycler recycler, RecyclerView.State state) {
1760         }
1761     }
1762 
1763     class WrappedLayoutManager extends StaggeredGridLayoutManager {
1764 
1765         CountDownLatch layoutLatch;
1766         OnLayoutListener mOnLayoutListener;
1767         // gradle does not yet let us customize manifest for tests which is necessary to test RTL.
1768         // until bug is fixed, we'll fake it.
1769         // public issue id: 57819
1770         Boolean mFakeRTL;
1771 
1772         @Override
isLayoutRTL()1773         boolean isLayoutRTL() {
1774             return mFakeRTL == null ? super.isLayoutRTL() : mFakeRTL;
1775         }
1776 
expectLayouts(int count)1777         public void expectLayouts(int count) {
1778             layoutLatch = new CountDownLatch(count);
1779         }
1780 
waitForLayout(long timeout)1781         public void waitForLayout(long timeout) throws InterruptedException {
1782             waitForLayout(timeout * (DEBUG ? 1000 : 1), TimeUnit.SECONDS);
1783         }
1784 
waitForLayout(long timeout, TimeUnit timeUnit)1785         public void waitForLayout(long timeout, TimeUnit timeUnit) throws InterruptedException {
1786             layoutLatch.await(timeout, timeUnit);
1787             assertEquals("all expected layouts should be executed at the expected time",
1788                     0, layoutLatch.getCount());
1789         }
1790 
assertNoLayout(String msg, long timeout)1791         public void assertNoLayout(String msg, long timeout) throws Throwable {
1792             layoutLatch.await(timeout, TimeUnit.SECONDS);
1793             assertFalse(msg, layoutLatch.getCount() == 0);
1794         }
1795 
1796         @Override
onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state)1797         public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
1798             String before;
1799             if (DEBUG) {
1800                 before = layoutToString("before");
1801             } else {
1802                 before = "enable DEBUG";
1803             }
1804             try {
1805                 if (mOnLayoutListener != null) {
1806                     mOnLayoutListener.before(recycler, state);
1807                 }
1808                 super.onLayoutChildren(recycler, state);
1809                 if (mOnLayoutListener != null) {
1810                     mOnLayoutListener.after(recycler, state);
1811                 }
1812                 validateChildren(before);
1813             } catch (Throwable t) {
1814                 postExceptionToInstrumentation(t);
1815             }
1816 
1817             layoutLatch.countDown();
1818         }
1819 
1820         @Override
scrollBy(int dt, RecyclerView.Recycler recycler, RecyclerView.State state)1821         int scrollBy(int dt, RecyclerView.Recycler recycler, RecyclerView.State state) {
1822             try {
1823                 int result = super.scrollBy(dt, recycler, state);
1824                 validateChildren();
1825                 return result;
1826             } catch (Throwable t) {
1827                 postExceptionToInstrumentation(t);
1828             }
1829 
1830             return 0;
1831         }
1832 
WrappedLayoutManager(int spanCount, int orientation)1833         public WrappedLayoutManager(int spanCount, int orientation) {
1834             super(spanCount, orientation);
1835         }
1836 
collectChildrenBySpan()1837         ArrayList<ArrayList<View>> collectChildrenBySpan() {
1838             ArrayList<ArrayList<View>> viewsBySpan = new ArrayList<ArrayList<View>>();
1839             for (int i = 0; i < getSpanCount(); i++) {
1840                 viewsBySpan.add(new ArrayList<View>());
1841             }
1842             for (int i = 0; i < getChildCount(); i++) {
1843                 View view = getChildAt(i);
1844                 LayoutParams lp
1845                         = (LayoutParams) view
1846                         .getLayoutParams();
1847                 viewsBySpan.get(lp.mSpan.mIndex).add(view);
1848             }
1849             return viewsBySpan;
1850         }
1851 
1852         @Nullable
1853         @Override
onFocusSearchFailed(View focused, int direction, RecyclerView.Recycler recycler, RecyclerView.State state)1854         public View onFocusSearchFailed(View focused, int direction, RecyclerView.Recycler recycler,
1855                 RecyclerView.State state) {
1856             View result = null;
1857             try {
1858                 result = super.onFocusSearchFailed(focused, direction, recycler, state);
1859                 validateChildren();
1860             } catch (Throwable t) {
1861                 postExceptionToInstrumentation(t);
1862             }
1863             return result;
1864         }
1865 
getViewBounds(View view)1866         Rect getViewBounds(View view) {
1867             if (getOrientation() == HORIZONTAL) {
1868                 return new Rect(
1869                         mPrimaryOrientation.getDecoratedStart(view),
1870                         mSecondaryOrientation.getDecoratedStart(view),
1871                         mPrimaryOrientation.getDecoratedEnd(view),
1872                         mSecondaryOrientation.getDecoratedEnd(view));
1873             } else {
1874                 return new Rect(
1875                         mSecondaryOrientation.getDecoratedStart(view),
1876                         mPrimaryOrientation.getDecoratedStart(view),
1877                         mSecondaryOrientation.getDecoratedEnd(view),
1878                         mPrimaryOrientation.getDecoratedEnd(view));
1879             }
1880         }
1881 
getBoundsLog()1882         public String getBoundsLog() {
1883             StringBuilder sb = new StringBuilder();
1884             sb.append("view bounds:[start:").append(mPrimaryOrientation.getStartAfterPadding())
1885                     .append(",").append(" end").append(mPrimaryOrientation.getEndAfterPadding());
1886             sb.append("\nchildren bounds\n");
1887             final int childCount = getChildCount();
1888             for (int i = 0; i < childCount; i++) {
1889                 View child = getChildAt(i);
1890                 sb.append("child (ind:").append(i).append(", pos:").append(getPosition(child))
1891                         .append("[").append("start:").append(
1892                         mPrimaryOrientation.getDecoratedStart(child)).append(", end:")
1893                         .append(mPrimaryOrientation.getDecoratedEnd(child)).append("]\n");
1894             }
1895             return sb.toString();
1896         }
1897 
traverseAndFindVisibleChildren()1898         public VisibleChildren traverseAndFindVisibleChildren() {
1899             int childCount = getChildCount();
1900             final VisibleChildren visibleChildren = new VisibleChildren(getSpanCount());
1901             final int start = mPrimaryOrientation.getStartAfterPadding();
1902             final int end = mPrimaryOrientation.getEndAfterPadding();
1903             for (int i = 0; i < childCount; i++) {
1904                 View child = getChildAt(i);
1905                 final int childStart = mPrimaryOrientation.getDecoratedStart(child);
1906                 final int childEnd = mPrimaryOrientation.getDecoratedEnd(child);
1907                 final boolean fullyVisible = childStart >= start && childEnd <= end;
1908                 final boolean hidden = childEnd <= start || childStart >= end;
1909                 if (hidden) {
1910                     continue;
1911                 }
1912                 final int position = getPosition(child);
1913                 final int span = getLp(child).getSpanIndex();
1914                 if (fullyVisible) {
1915                     if (position < visibleChildren.firstFullyVisiblePositions[span] ||
1916                             visibleChildren.firstFullyVisiblePositions[span]
1917                                     == RecyclerView.NO_POSITION) {
1918                         visibleChildren.firstFullyVisiblePositions[span] = position;
1919                     }
1920 
1921                     if (position > visibleChildren.lastFullyVisiblePositions[span]) {
1922                         visibleChildren.lastFullyVisiblePositions[span] = position;
1923                     }
1924                 }
1925 
1926                 if (position < visibleChildren.firstVisiblePositions[span] ||
1927                         visibleChildren.firstVisiblePositions[span] == RecyclerView.NO_POSITION) {
1928                     visibleChildren.firstVisiblePositions[span] = position;
1929                 }
1930 
1931                 if (position > visibleChildren.lastVisiblePositions[span]) {
1932                     visibleChildren.lastVisiblePositions[span] = position;
1933                 }
1934                 if (visibleChildren.findFirstPartialVisibleClosestToStart == null) {
1935                     visibleChildren.findFirstPartialVisibleClosestToStart = child;
1936                 }
1937                 visibleChildren.findFirstPartialVisibleClosestToEnd = child;
1938             }
1939             return visibleChildren;
1940         }
1941 
collectChildCoordinates()1942         Map<Item, Rect> collectChildCoordinates() throws Throwable {
1943             final Map<Item, Rect> items = new LinkedHashMap<Item, Rect>();
1944             runTestOnUiThread(new Runnable() {
1945                 @Override
1946                 public void run() {
1947                     final int childCount = getChildCount();
1948                     for (int i = 0; i < childCount; i++) {
1949                         View child = getChildAt(i);
1950                         // do it if and only if child is visible
1951                         if (child.getRight() < 0 || child.getBottom() < 0 ||
1952                                 child.getLeft() >= getWidth() || child.getTop() >= getHeight()) {
1953                             // invisible children may be drawn in cases like scrolling so we should
1954                             // ignore them
1955                             continue;
1956                         }
1957                         LayoutParams lp = (LayoutParams) child
1958                                 .getLayoutParams();
1959                         TestViewHolder vh = (TestViewHolder) lp.mViewHolder;
1960                         items.put(vh.mBoundItem, getViewBounds(child));
1961                     }
1962                 }
1963             });
1964             return items;
1965         }
1966 
1967 
setFakeRtl(Boolean fakeRtl)1968         public void setFakeRtl(Boolean fakeRtl) {
1969             mFakeRTL = fakeRtl;
1970             try {
1971                 requestLayoutOnUIThread(mRecyclerView);
1972             } catch (Throwable throwable) {
1973                 postExceptionToInstrumentation(throwable);
1974             }
1975         }
1976 
layoutToString(String hint)1977         private String layoutToString(String hint) {
1978             StringBuilder sb = new StringBuilder();
1979             sb.append("LAYOUT POSITIONS AND INDICES ").append(hint).append("\n");
1980             for (int i = 0; i < getChildCount(); i++) {
1981                 final View view = getChildAt(i);
1982                 final LayoutParams layoutParams = (LayoutParams) view.getLayoutParams();
1983                 sb.append(String.format("index: %d pos: %d top: %d bottom: %d span: %d isFull:%s",
1984                         i, getPosition(view),
1985                         mPrimaryOrientation.getDecoratedStart(view),
1986                         mPrimaryOrientation.getDecoratedEnd(view),
1987                         layoutParams.getSpanIndex(), layoutParams.isFullSpan())).append("\n");
1988             }
1989             return sb.toString();
1990         }
1991 
validateChildren()1992         private void validateChildren() {
1993             validateChildren(null);
1994         }
1995 
validateChildren(String msg)1996         private void validateChildren(String msg) {
1997             if (getChildCount() == 0 || mRecyclerView.mState.isPreLayout()) {
1998                 return;
1999             }
2000             final int dir = mShouldReverseLayout ? -1 : 1;
2001             int i = 0;
2002             int pos = -1;
2003             while (i < getChildCount()) {
2004                 LayoutParams lp = (LayoutParams) getChildAt(i).getLayoutParams();
2005                 if (lp.isItemRemoved()) {
2006                     i++;
2007                     continue;
2008                 }
2009                 pos = getPosition(getChildAt(i));
2010                 break;
2011             }
2012             if (pos == -1) {
2013                 return;
2014             }
2015             while (++i < getChildCount()) {
2016                 LayoutParams lp = (LayoutParams) getChildAt(i).getLayoutParams();
2017                 if (lp.isItemRemoved()) {
2018                     continue;
2019                 }
2020                 pos += dir;
2021                 if (getPosition(getChildAt(i)) != pos) {
2022                     throw new RuntimeException("INVALID POSITION FOR CHILD " + i + "\n" +
2023                             layoutToString("ERROR") + "\n msg:" + msg);
2024                 }
2025             }
2026         }
2027     }
2028 
2029     static class VisibleChildren {
2030 
2031         int[] firstVisiblePositions;
2032 
2033         int[] firstFullyVisiblePositions;
2034 
2035         int[] lastVisiblePositions;
2036 
2037         int[] lastFullyVisiblePositions;
2038 
2039         View findFirstPartialVisibleClosestToStart;
2040         View findFirstPartialVisibleClosestToEnd;
2041 
VisibleChildren(int spanCount)2042         VisibleChildren(int spanCount) {
2043             firstFullyVisiblePositions = new int[spanCount];
2044             firstVisiblePositions = new int[spanCount];
2045             lastVisiblePositions = new int[spanCount];
2046             lastFullyVisiblePositions = new int[spanCount];
2047             for (int i = 0; i < spanCount; i++) {
2048                 firstFullyVisiblePositions[i] = RecyclerView.NO_POSITION;
2049                 firstVisiblePositions[i] = RecyclerView.NO_POSITION;
2050                 lastVisiblePositions[i] = RecyclerView.NO_POSITION;
2051                 lastFullyVisiblePositions[i] = RecyclerView.NO_POSITION;
2052             }
2053         }
2054 
2055         @Override
equals(Object o)2056         public boolean equals(Object o) {
2057             if (this == o) {
2058                 return true;
2059             }
2060             if (o == null || getClass() != o.getClass()) {
2061                 return false;
2062             }
2063 
2064             VisibleChildren that = (VisibleChildren) o;
2065 
2066             if (!Arrays.equals(firstFullyVisiblePositions, that.firstFullyVisiblePositions)) {
2067                 return false;
2068             }
2069             if (findFirstPartialVisibleClosestToStart
2070                     != null ? !findFirstPartialVisibleClosestToStart
2071                     .equals(that.findFirstPartialVisibleClosestToStart)
2072                     : that.findFirstPartialVisibleClosestToStart != null) {
2073                 return false;
2074             }
2075             if (!Arrays.equals(firstVisiblePositions, that.firstVisiblePositions)) {
2076                 return false;
2077             }
2078             if (!Arrays.equals(lastFullyVisiblePositions, that.lastFullyVisiblePositions)) {
2079                 return false;
2080             }
2081             if (findFirstPartialVisibleClosestToEnd != null ? !findFirstPartialVisibleClosestToEnd
2082                     .equals(that.findFirstPartialVisibleClosestToEnd)
2083                     : that.findFirstPartialVisibleClosestToEnd
2084                             != null) {
2085                 return false;
2086             }
2087             if (!Arrays.equals(lastVisiblePositions, that.lastVisiblePositions)) {
2088                 return false;
2089             }
2090 
2091             return true;
2092         }
2093 
2094         @Override
hashCode()2095         public int hashCode() {
2096             int result = Arrays.hashCode(firstVisiblePositions);
2097             result = 31 * result + Arrays.hashCode(firstFullyVisiblePositions);
2098             result = 31 * result + Arrays.hashCode(lastVisiblePositions);
2099             result = 31 * result + Arrays.hashCode(lastFullyVisiblePositions);
2100             result = 31 * result + (findFirstPartialVisibleClosestToStart != null
2101                     ? findFirstPartialVisibleClosestToStart
2102                     .hashCode() : 0);
2103             result = 31 * result + (findFirstPartialVisibleClosestToEnd != null
2104                     ? findFirstPartialVisibleClosestToEnd
2105                     .hashCode()
2106                     : 0);
2107             return result;
2108         }
2109 
2110         @Override
toString()2111         public String toString() {
2112             return "VisibleChildren{" +
2113                     "firstVisiblePositions=" + Arrays.toString(firstVisiblePositions) +
2114                     ", firstFullyVisiblePositions=" + Arrays.toString(firstFullyVisiblePositions) +
2115                     ", lastVisiblePositions=" + Arrays.toString(lastVisiblePositions) +
2116                     ", lastFullyVisiblePositions=" + Arrays.toString(lastFullyVisiblePositions) +
2117                     ", findFirstPartialVisibleClosestToStart=" +
2118                     viewToString(findFirstPartialVisibleClosestToStart) +
2119                     ", findFirstPartialVisibleClosestToEnd=" +
2120                     viewToString(findFirstPartialVisibleClosestToEnd) +
2121                     '}';
2122         }
2123 
viewToString(View view)2124         private String viewToString(View view) {
2125             if (view == null) {
2126                 return null;
2127             }
2128             ViewGroup.LayoutParams lp = view.getLayoutParams();
2129             if (lp instanceof RecyclerView.LayoutParams == false) {
2130                 return System.identityHashCode(view) + "(?)";
2131             }
2132             RecyclerView.LayoutParams rvlp = (RecyclerView.LayoutParams) lp;
2133             return System.identityHashCode(view) + "(" + rvlp.getViewAdapterPosition() + ")";
2134         }
2135     }
2136 
2137     class GridTestAdapter extends TestAdapter {
2138 
2139         int mOrientation;
2140         int mRecyclerViewWidth;
2141         int mRecyclerViewHeight;
2142         Integer mSizeReference = null;
2143 
2144         // original ids of items that should be full span
2145         HashSet<Integer> mFullSpanItems = new HashSet<Integer>();
2146 
2147         private boolean mViewsHaveEqualSize = false; // size in the scrollable direction
2148 
2149         private OnBindCallback mOnBindCallback;
2150 
GridTestAdapter(int count, int orientation)2151         GridTestAdapter(int count, int orientation) {
2152             super(count);
2153             mOrientation = orientation;
2154         }
2155 
2156         @Override
onCreateViewHolder(ViewGroup parent, int viewType)2157         public TestViewHolder onCreateViewHolder(ViewGroup parent,
2158                 int viewType) {
2159             mRecyclerViewWidth = parent.getWidth();
2160             mRecyclerViewHeight = parent.getHeight();
2161             TestViewHolder vh = super.onCreateViewHolder(parent, viewType);
2162             if (mOnBindCallback != null) {
2163                 mOnBindCallback.onCreatedViewHolder(vh);
2164             }
2165             return vh;
2166         }
2167 
2168         @Override
offsetOriginalIndices(int start, int offset)2169         public void offsetOriginalIndices(int start, int offset) {
2170             if (mFullSpanItems.size() > 0) {
2171                 HashSet<Integer> old = mFullSpanItems;
2172                 mFullSpanItems = new HashSet<Integer>();
2173                 for (Integer i : old) {
2174                     if (i < start) {
2175                         mFullSpanItems.add(i);
2176                     } else if (offset > 0 || (start + Math.abs(offset)) <= i) {
2177                         mFullSpanItems.add(i + offset);
2178                     } else if (DEBUG) {
2179                         Log.d(TAG, "removed full span item " + i);
2180                     }
2181                 }
2182             }
2183             super.offsetOriginalIndices(start, offset);
2184         }
2185 
2186         @Override
moveInUIThread(int from, int to)2187         protected void moveInUIThread(int from, int to) {
2188             boolean setAsFullSpanAgain = mFullSpanItems.contains(from);
2189             super.moveInUIThread(from, to);
2190             if (setAsFullSpanAgain) {
2191                 mFullSpanItems.add(to);
2192             }
2193         }
2194 
2195         @Override
onBindViewHolder(TestViewHolder holder, int position)2196         public void onBindViewHolder(TestViewHolder holder,
2197                 int position) {
2198             if (mSizeReference == null) {
2199                 mSizeReference = mOrientation == OrientationHelper.HORIZONTAL ? mRecyclerViewWidth
2200                         / AVG_ITEM_PER_VIEW : mRecyclerViewHeight / AVG_ITEM_PER_VIEW;
2201             }
2202             super.onBindViewHolder(holder, position);
2203             Item item = mItems.get(position);
2204             RecyclerView.LayoutParams lp = (RecyclerView.LayoutParams) holder.itemView
2205                     .getLayoutParams();
2206             if (lp instanceof LayoutParams) {
2207                 ((LayoutParams) lp).setFullSpan(mFullSpanItems.contains(item.mAdapterIndex));
2208             } else {
2209                 LayoutParams slp = new LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT,
2210                         ViewGroup.LayoutParams.WRAP_CONTENT);
2211                 holder.itemView.setLayoutParams(slp);
2212                 slp.setFullSpan(mFullSpanItems.contains(item.mAdapterIndex));
2213                 lp = slp;
2214             }
2215 
2216             if (mOnBindCallback == null || mOnBindCallback.assignRandomSize()) {
2217                 final int minSize = mViewsHaveEqualSize ? mSizeReference :
2218                         mSizeReference + 20 * (item.mId % 10);
2219                 if (mOrientation == OrientationHelper.HORIZONTAL) {
2220                     holder.itemView.setMinimumWidth(minSize);
2221                 } else {
2222                     holder.itemView.setMinimumHeight(minSize);
2223                 }
2224                 lp.topMargin = 3;
2225                 lp.leftMargin = 5;
2226                 lp.rightMargin = 7;
2227                 lp.bottomMargin = 9;
2228             }
2229 
2230             if (mOnBindCallback != null) {
2231                 mOnBindCallback.onBoundItem(holder, position);
2232             }
2233         }
2234     }
2235 
2236     abstract static class OnBindCallback {
2237 
onBoundItem(TestViewHolder vh, int position)2238         abstract void onBoundItem(TestViewHolder vh, int position);
2239 
assignRandomSize()2240         boolean assignRandomSize() {
2241             return true;
2242         }
2243 
onCreatedViewHolder(TestViewHolder vh)2244         void onCreatedViewHolder(TestViewHolder vh) {
2245         }
2246     }
2247 
2248     static class Config implements Cloneable {
2249 
2250         private static final int DEFAULT_ITEM_COUNT = 300;
2251 
2252         int mOrientation = OrientationHelper.VERTICAL;
2253 
2254         boolean mReverseLayout = false;
2255 
2256         int mSpanCount = 3;
2257 
2258         int mGapStrategy = GAP_HANDLING_MOVE_ITEMS_BETWEEN_SPANS;
2259 
2260         int mItemCount = DEFAULT_ITEM_COUNT;
2261 
Config(int orientation, boolean reverseLayout, int spanCount, int gapStrategy)2262         Config(int orientation, boolean reverseLayout, int spanCount, int gapStrategy) {
2263             mOrientation = orientation;
2264             mReverseLayout = reverseLayout;
2265             mSpanCount = spanCount;
2266             mGapStrategy = gapStrategy;
2267         }
2268 
Config()2269         public Config() {
2270 
2271         }
2272 
orientation(int orientation)2273         Config orientation(int orientation) {
2274             mOrientation = orientation;
2275             return this;
2276         }
2277 
reverseLayout(boolean reverseLayout)2278         Config reverseLayout(boolean reverseLayout) {
2279             mReverseLayout = reverseLayout;
2280             return this;
2281         }
2282 
spanCount(int spanCount)2283         Config spanCount(int spanCount) {
2284             mSpanCount = spanCount;
2285             return this;
2286         }
2287 
gapStrategy(int gapStrategy)2288         Config gapStrategy(int gapStrategy) {
2289             mGapStrategy = gapStrategy;
2290             return this;
2291         }
2292 
itemCount(int itemCount)2293         public Config itemCount(int itemCount) {
2294             mItemCount = itemCount;
2295             return this;
2296         }
2297 
2298         @Override
toString()2299         public String toString() {
2300             return "[CONFIG:" +
2301                     " span:" + mSpanCount + "," +
2302                     " orientation:" + (mOrientation == HORIZONTAL ? "horz," : "vert,") +
2303                     " reverse:" + (mReverseLayout ? "T" : "F") +
2304                     " itemCount:" + mItemCount +
2305                     " gap strategy: " + gapStrategyName(mGapStrategy);
2306         }
2307 
gapStrategyName(int gapStrategy)2308         private static String gapStrategyName(int gapStrategy) {
2309             switch (gapStrategy) {
2310                 case GAP_HANDLING_NONE:
2311                     return "none";
2312                 case GAP_HANDLING_MOVE_ITEMS_BETWEEN_SPANS:
2313                     return "move spans";
2314             }
2315             return "gap strategy: unknown";
2316         }
2317 
2318         @Override
clone()2319         public Object clone() throws CloneNotSupportedException {
2320             return super.clone();
2321         }
2322     }
2323 
2324     private interface PostLayoutRunnable {
2325 
run()2326         void run() throws Throwable;
2327 
describe()2328         String describe();
2329     }
2330 
2331 }
2332