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