1 /*
2  * Copyright 2018 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 package androidx.recyclerview.widget;
18 
19 import static androidx.recyclerview.widget.LayoutState.LAYOUT_END;
20 import static androidx.recyclerview.widget.LayoutState.LAYOUT_START;
21 import static androidx.recyclerview.widget.LinearLayoutManager.VERTICAL;
22 import static androidx.recyclerview.widget.StaggeredGridLayoutManager
23         .GAP_HANDLING_MOVE_ITEMS_BETWEEN_SPANS;
24 import static androidx.recyclerview.widget.StaggeredGridLayoutManager.GAP_HANDLING_NONE;
25 import static androidx.recyclerview.widget.StaggeredGridLayoutManager.HORIZONTAL;
26 
27 import static org.junit.Assert.assertEquals;
28 import static org.junit.Assert.assertFalse;
29 import static org.junit.Assert.assertNotNull;
30 import static org.junit.Assert.assertTrue;
31 
32 import static java.util.concurrent.TimeUnit.SECONDS;
33 
34 import android.graphics.Color;
35 import android.graphics.Rect;
36 import android.graphics.drawable.ColorDrawable;
37 import android.graphics.drawable.StateListDrawable;
38 import android.util.Log;
39 import android.util.StateSet;
40 import android.view.View;
41 import android.view.ViewGroup;
42 
43 import org.hamcrest.CoreMatchers;
44 import org.hamcrest.MatcherAssert;
45 import org.jspecify.annotations.NonNull;
46 import org.jspecify.annotations.Nullable;
47 
48 import java.lang.reflect.Field;
49 import java.util.ArrayList;
50 import java.util.Arrays;
51 import java.util.HashSet;
52 import java.util.LinkedHashMap;
53 import java.util.List;
54 import java.util.Map;
55 import java.util.concurrent.CountDownLatch;
56 import java.util.concurrent.TimeUnit;
57 import java.util.concurrent.atomic.AtomicInteger;
58 
59 abstract class BaseStaggeredGridLayoutManagerTest extends BaseRecyclerViewInstrumentationTest {
60 
61     protected static final boolean DEBUG = false;
62     protected static final int AVG_ITEM_PER_VIEW = 3;
63     protected static final String TAG = "SGLM_TEST";
64     volatile WrappedLayoutManager mLayoutManager;
65     GridTestAdapter mAdapter;
66 
createBaseVariations()67     protected static List<Config> createBaseVariations() {
68         List<Config> variations = new ArrayList<>();
69         for (int orientation : new int[]{VERTICAL, HORIZONTAL}) {
70             for (boolean reverseLayout : new boolean[]{false, true}) {
71                 for (int spanCount : new int[]{1, 3}) {
72                     for (int gapStrategy : new int[]{GAP_HANDLING_NONE,
73                             GAP_HANDLING_MOVE_ITEMS_BETWEEN_SPANS}) {
74                         variations.add(new Config(orientation, reverseLayout, spanCount,
75                                 gapStrategy));
76                     }
77                 }
78             }
79         }
80         return variations;
81     }
82 
addConfigVariation(List<Config> base, String fieldName, Object... variations)83     protected static List<Config> addConfigVariation(List<Config> base, String fieldName,
84             Object... variations)
85             throws CloneNotSupportedException, NoSuchFieldException, IllegalAccessException {
86         List<Config> newConfigs = new ArrayList<Config>();
87         Field field = Config.class.getDeclaredField(fieldName);
88         for (Config config : base) {
89             for (Object variation : variations) {
90                 Config newConfig = (Config) config.clone();
91                 field.set(newConfig, variation);
92                 newConfigs.add(newConfig);
93             }
94         }
95         return newConfigs;
96     }
97 
setupByConfig(Config config)98     void setupByConfig(Config config) throws Throwable {
99         setupByConfig(config, new GridTestAdapter(config.mItemCount, config.mOrientation));
100     }
101 
setupByConfig(Config config, GridTestAdapter adapter)102     void setupByConfig(Config config, GridTestAdapter adapter) throws Throwable {
103         mAdapter = adapter;
104         mRecyclerView = new WrappedRecyclerView(getActivity());
105         mRecyclerView.setAdapter(mAdapter);
106         mRecyclerView.setHasFixedSize(true);
107         mLayoutManager = new WrappedLayoutManager(config.mSpanCount, config.mOrientation);
108         mLayoutManager.setGapStrategy(config.mGapStrategy);
109         mLayoutManager.setReverseLayout(config.mReverseLayout);
110         mRecyclerView.setLayoutParams(new ViewGroup.LayoutParams(
111                 config.mRecyclerViewLayoutWidth, config.mRecyclerViewLayoutHeight));
112         mRecyclerView.setLayoutManager(mLayoutManager);
113         mRecyclerView.addItemDecoration(new RecyclerView.ItemDecoration() {
114             @Override
115             public void getItemOffsets(@NonNull Rect outRect, @NonNull View view,
116                     @NonNull RecyclerView parent, RecyclerView.@NonNull State state) {
117                 try {
118                     StaggeredGridLayoutManager.LayoutParams
119                             lp = (StaggeredGridLayoutManager.LayoutParams) view.getLayoutParams();
120                     assertNotNull("view should have layout params assigned", lp);
121                     assertNotNull("when item offsets are requested, view should have a valid span",
122                             lp.mSpan);
123                 } catch (Throwable t) {
124                     postExceptionToInstrumentation(t);
125                 }
126             }
127         });
128     }
129 
getLp(View view)130     StaggeredGridLayoutManager.LayoutParams getLp(View view) {
131         return (StaggeredGridLayoutManager.LayoutParams) view.getLayoutParams();
132     }
133 
waitFirstLayout()134     void waitFirstLayout() throws Throwable {
135         mLayoutManager.expectLayouts(1);
136         setRecyclerView(mRecyclerView);
137         mLayoutManager.waitForLayout(3);
138         getInstrumentation().waitForIdleSync();
139     }
140 
141     /**
142      * enqueues an empty runnable to main thread so that we can be assured it did run
143      *
144      * @param count Number of times to run
145      */
waitForMainThread(int count)146     protected void waitForMainThread(int count) throws Throwable {
147         final AtomicInteger i = new AtomicInteger(count);
148         while (i.get() > 0) {
149             mActivityRule.runOnUiThread(new Runnable() {
150                 @Override
151                 public void run() {
152                     i.decrementAndGet();
153                 }
154             });
155         }
156     }
157 
assertRectSetsNotEqual(String message, Map<Item, Rect> before, Map<Item, Rect> after)158     public void assertRectSetsNotEqual(String message, Map<Item, Rect> before,
159             Map<Item, Rect> after) {
160         Throwable throwable = null;
161         try {
162             assertRectSetsEqual("NOT " + message, before, after);
163         } catch (Throwable t) {
164             throwable = t;
165         }
166         assertNotNull(message + " two layout should be different", throwable);
167     }
168 
assertRectSetsEqual(String message, Map<Item, Rect> before, Map<Item, Rect> after)169     public void assertRectSetsEqual(String message, Map<Item, Rect> before, Map<Item, Rect> after) {
170         assertRectSetsEqual(message, before, after, true);
171     }
172 
assertRectSetsEqual(String message, Map<Item, Rect> before, Map<Item, Rect> after, boolean strictItemEquality)173     public void assertRectSetsEqual(String message, Map<Item, Rect> before, Map<Item, Rect> after,
174             boolean strictItemEquality) {
175         StringBuilder log = new StringBuilder();
176         if (DEBUG) {
177             log.append("checking rectangle equality.\n");
178             log.append("total space:" + mLayoutManager.mPrimaryOrientation.getTotalSpace());
179             log.append("before:");
180             for (Map.Entry<Item, Rect> entry : before.entrySet()) {
181                 log.append("\n").append(entry.getKey().mAdapterIndex).append(":")
182                         .append(entry.getValue());
183             }
184             log.append("\nafter:");
185             for (Map.Entry<Item, Rect> entry : after.entrySet()) {
186                 log.append("\n").append(entry.getKey().mAdapterIndex).append(":")
187                         .append(entry.getValue());
188             }
189             message += "\n\n" + log.toString();
190         }
191         assertEquals(message + ": item counts should be equal", before.size()
192                 , after.size());
193         for (Map.Entry<Item, Rect> entry : before.entrySet()) {
194             final Item beforeItem = entry.getKey();
195             Rect afterRect = null;
196             if (strictItemEquality) {
197                 afterRect = after.get(beforeItem);
198                 assertNotNull(message + ": Same item should be visible after simple re-layout",
199                         afterRect);
200             } else {
201                 for (Map.Entry<Item, Rect> afterEntry : after.entrySet()) {
202                     final Item afterItem = afterEntry.getKey();
203                     if (afterItem.mAdapterIndex == beforeItem.mAdapterIndex) {
204                         afterRect = afterEntry.getValue();
205                         break;
206                     }
207                 }
208                 assertNotNull(message + ": Item with same adapter index should be visible " +
209                                 "after simple re-layout",
210                         afterRect);
211             }
212             assertEquals(message + ": Item should be laid out at the same coordinates",
213                     entry.getValue(),
214                     afterRect);
215         }
216     }
217 
assertViewPositions(Config config)218     protected void assertViewPositions(Config config) {
219         ArrayList<ArrayList<View>> viewsBySpan = mLayoutManager.collectChildrenBySpan();
220         OrientationHelper orientationHelper = OrientationHelper
221                 .createOrientationHelper(mLayoutManager, config.mOrientation);
222         for (ArrayList<View> span : viewsBySpan) {
223             // validate all children's order. first child should have min start mPosition
224             final int count = span.size();
225             for (int i = 0, j = 1; j < count; i++, j++) {
226                 View prev = span.get(i);
227                 View next = span.get(j);
228                 assertTrue(config + " prev item should be above next item",
229                         orientationHelper.getDecoratedEnd(prev) <= orientationHelper
230                                 .getDecoratedStart(next)
231                 );
232 
233             }
234         }
235     }
236 
findInvisibleTarget(Config config)237     protected TargetTuple findInvisibleTarget(Config config) {
238         int minPosition = Integer.MAX_VALUE, maxPosition = Integer.MIN_VALUE;
239         for (int i = 0; i < mLayoutManager.getChildCount(); i++) {
240             View child = mLayoutManager.getChildAt(i);
241             int position = mRecyclerView.getChildLayoutPosition(child);
242             if (position < minPosition) {
243                 minPosition = position;
244             }
245             if (position > maxPosition) {
246                 maxPosition = position;
247             }
248         }
249         final int tailTarget = maxPosition + (mAdapter.getItemCount() - maxPosition) / 2;
250         final int headTarget = minPosition / 2;
251         final int target;
252         // where will the child come from ?
253         final int itemLayoutDirection;
254         if (Math.abs(tailTarget - maxPosition) > Math.abs(headTarget - minPosition)) {
255             target = tailTarget;
256             itemLayoutDirection = config.mReverseLayout ? LAYOUT_START : LAYOUT_END;
257         } else {
258             target = headTarget;
259             itemLayoutDirection = config.mReverseLayout ? LAYOUT_END : LAYOUT_START;
260         }
261         if (DEBUG) {
262             Log.d(TAG,
263                     config + " target:" + target + " min:" + minPosition + ", max:" + maxPosition);
264         }
265         return new TargetTuple(target, itemLayoutDirection);
266     }
267 
scrollToPositionWithOffset(final int position, final int offset)268     protected void scrollToPositionWithOffset(final int position, final int offset)
269             throws Throwable {
270         mActivityRule.runOnUiThread(new Runnable() {
271             @Override
272             public void run() {
273                 mLayoutManager.scrollToPositionWithOffset(position, offset);
274             }
275         });
276     }
277 
278     static class OnLayoutListener {
279 
before(RecyclerView.Recycler recycler, RecyclerView.State state)280         void before(RecyclerView.Recycler recycler, RecyclerView.State state) {
281         }
282 
after(RecyclerView.Recycler recycler, RecyclerView.State state)283         void after(RecyclerView.Recycler recycler, RecyclerView.State state) {
284         }
285     }
286 
287     static class VisibleChildren {
288 
289         int[] firstVisiblePositions;
290 
291         int[] firstFullyVisiblePositions;
292 
293         int[] lastVisiblePositions;
294 
295         int[] lastFullyVisiblePositions;
296 
297         View findFirstPartialVisibleClosestToStart;
298         View findFirstPartialVisibleClosestToEnd;
299 
VisibleChildren(int spanCount)300         VisibleChildren(int spanCount) {
301             firstFullyVisiblePositions = new int[spanCount];
302             firstVisiblePositions = new int[spanCount];
303             lastVisiblePositions = new int[spanCount];
304             lastFullyVisiblePositions = new int[spanCount];
305             for (int i = 0; i < spanCount; i++) {
306                 firstFullyVisiblePositions[i] = RecyclerView.NO_POSITION;
307                 firstVisiblePositions[i] = RecyclerView.NO_POSITION;
308                 lastVisiblePositions[i] = RecyclerView.NO_POSITION;
309                 lastFullyVisiblePositions[i] = RecyclerView.NO_POSITION;
310             }
311         }
312 
313         @Override
equals(Object o)314         public boolean equals(Object o) {
315             if (this == o) {
316                 return true;
317             }
318             if (o == null || getClass() != o.getClass()) {
319                 return false;
320             }
321 
322             VisibleChildren that = (VisibleChildren) o;
323 
324             if (!Arrays.equals(firstFullyVisiblePositions, that.firstFullyVisiblePositions)) {
325                 return false;
326             }
327             if (findFirstPartialVisibleClosestToStart
328                     != null ? !findFirstPartialVisibleClosestToStart
329                     .equals(that.findFirstPartialVisibleClosestToStart)
330                     : that.findFirstPartialVisibleClosestToStart != null) {
331                 return false;
332             }
333             if (!Arrays.equals(firstVisiblePositions, that.firstVisiblePositions)) {
334                 return false;
335             }
336             if (!Arrays.equals(lastFullyVisiblePositions, that.lastFullyVisiblePositions)) {
337                 return false;
338             }
339             if (findFirstPartialVisibleClosestToEnd != null ? !findFirstPartialVisibleClosestToEnd
340                     .equals(that.findFirstPartialVisibleClosestToEnd)
341                     : that.findFirstPartialVisibleClosestToEnd
342                             != null) {
343                 return false;
344             }
345             if (!Arrays.equals(lastVisiblePositions, that.lastVisiblePositions)) {
346                 return false;
347             }
348 
349             return true;
350         }
351 
352         @Override
hashCode()353         public int hashCode() {
354             int result = Arrays.hashCode(firstVisiblePositions);
355             result = 31 * result + Arrays.hashCode(firstFullyVisiblePositions);
356             result = 31 * result + Arrays.hashCode(lastVisiblePositions);
357             result = 31 * result + Arrays.hashCode(lastFullyVisiblePositions);
358             result = 31 * result + (findFirstPartialVisibleClosestToStart != null
359                     ? findFirstPartialVisibleClosestToStart
360                     .hashCode() : 0);
361             result = 31 * result + (findFirstPartialVisibleClosestToEnd != null
362                     ? findFirstPartialVisibleClosestToEnd
363                     .hashCode()
364                     : 0);
365             return result;
366         }
367 
368         @Override
toString()369         public String toString() {
370             return "VisibleChildren{" +
371                     "firstVisiblePositions=" + Arrays.toString(firstVisiblePositions) +
372                     ", firstFullyVisiblePositions=" + Arrays.toString(firstFullyVisiblePositions) +
373                     ", lastVisiblePositions=" + Arrays.toString(lastVisiblePositions) +
374                     ", lastFullyVisiblePositions=" + Arrays.toString(lastFullyVisiblePositions) +
375                     ", findFirstPartialVisibleClosestToStart=" +
376                     viewToString(findFirstPartialVisibleClosestToStart) +
377                     ", findFirstPartialVisibleClosestToEnd=" +
378                     viewToString(findFirstPartialVisibleClosestToEnd) +
379                     '}';
380         }
381 
viewToString(View view)382         private String viewToString(View view) {
383             if (view == null) {
384                 return null;
385             }
386             ViewGroup.LayoutParams lp = view.getLayoutParams();
387             if (lp instanceof RecyclerView.LayoutParams == false) {
388                 return System.identityHashCode(view) + "(?)";
389             }
390             RecyclerView.LayoutParams rvlp = (RecyclerView.LayoutParams) lp;
391             return System.identityHashCode(view) + "(" + rvlp.getViewAdapterPosition() + ")";
392         }
393     }
394 
395     abstract static class OnBindCallback {
396 
onBoundItem(TestViewHolder vh, int position)397         abstract void onBoundItem(TestViewHolder vh, int position);
398 
assignRandomSize()399         boolean assignRandomSize() {
400             return true;
401         }
402 
onCreatedViewHolder(TestViewHolder vh)403         void onCreatedViewHolder(TestViewHolder vh) {
404         }
405     }
406 
407     static class Config implements Cloneable {
408 
409         static final int DEFAULT_ITEM_COUNT = 300;
410 
411         int mOrientation = OrientationHelper.VERTICAL;
412         boolean mReverseLayout = false;
413         int mSpanCount = 3;
414         int mGapStrategy = GAP_HANDLING_MOVE_ITEMS_BETWEEN_SPANS;
415         int mItemCount = DEFAULT_ITEM_COUNT;
416         int mRecyclerViewLayoutHeight = ViewGroup.LayoutParams.MATCH_PARENT;
417         int mRecyclerViewLayoutWidth = ViewGroup.LayoutParams.MATCH_PARENT;
418 
Config(int orientation, boolean reverseLayout, int spanCount, int gapStrategy)419         Config(int orientation, boolean reverseLayout, int spanCount, int gapStrategy) {
420             mOrientation = orientation;
421             mReverseLayout = reverseLayout;
422             mSpanCount = spanCount;
423             mGapStrategy = gapStrategy;
424         }
425 
Config()426         public Config() {
427 
428         }
429 
orientation(int orientation)430         Config orientation(int orientation) {
431             mOrientation = orientation;
432             return this;
433         }
434 
reverseLayout(boolean reverseLayout)435         Config reverseLayout(boolean reverseLayout) {
436             mReverseLayout = reverseLayout;
437             return this;
438         }
439 
spanCount(int spanCount)440         Config spanCount(int spanCount) {
441             mSpanCount = spanCount;
442             return this;
443         }
444 
gapStrategy(int gapStrategy)445         Config gapStrategy(int gapStrategy) {
446             mGapStrategy = gapStrategy;
447             return this;
448         }
449 
recyclerViewLayoutWidth(int recyclerViewLayoutWidth)450         Config recyclerViewLayoutWidth(int recyclerViewLayoutWidth) {
451             mRecyclerViewLayoutWidth = recyclerViewLayoutWidth;
452             return this;
453         }
454 
recyclerViewLayoutHeight(int recyclerViewLayoutHeight)455         Config recyclerViewLayoutHeight(int recyclerViewLayoutHeight) {
456             mRecyclerViewLayoutHeight = recyclerViewLayoutHeight;
457             return this;
458         }
459 
itemCount(int itemCount)460         public Config itemCount(int itemCount) {
461             mItemCount = itemCount;
462             return this;
463         }
464 
465         @Override
toString()466         public String toString() {
467             return "[CONFIG:"
468                     + "span:" + mSpanCount
469                     + ",orientation:" + (mOrientation == HORIZONTAL ? "horz," : "vert,")
470                     + ",reverse:" + (mReverseLayout ? "T" : "F")
471                     + ",itemCount:" + mItemCount
472                     + ",gap_strategy:" + gapStrategyName(mGapStrategy);
473         }
474 
gapStrategyName(int gapStrategy)475         protected static String gapStrategyName(int gapStrategy) {
476             switch (gapStrategy) {
477                 case GAP_HANDLING_NONE:
478                     return "none";
479                 case GAP_HANDLING_MOVE_ITEMS_BETWEEN_SPANS:
480                     return "move_spans";
481             }
482             return "gap_strategy:unknown";
483         }
484 
485         @Override
clone()486         public Object clone() throws CloneNotSupportedException {
487             return super.clone();
488         }
489     }
490 
491     class WrappedLayoutManager extends StaggeredGridLayoutManager {
492 
493         CountDownLatch layoutLatch;
494         CountDownLatch prefetchLatch;
495         OnLayoutListener mOnLayoutListener;
496         // gradle does not yet let us customize manifest for tests which is necessary to test RTL.
497         // until bug is fixed, we'll fake it.
498         // public issue id: 57819
499         Boolean mFakeRTL;
500         CountDownLatch mSnapLatch;
501 
502         @Override
isLayoutRTL()503         boolean isLayoutRTL() {
504             return mFakeRTL == null ? super.isLayoutRTL() : mFakeRTL;
505         }
506 
expectLayouts(int count)507         public void expectLayouts(int count) {
508             layoutLatch = new CountDownLatch(count);
509         }
510 
waitForLayout(int seconds)511         public void waitForLayout(int seconds) throws Throwable {
512             layoutLatch.await(seconds * (DEBUG ? 100 : 1), SECONDS);
513             checkForMainThreadException();
514             MatcherAssert.assertThat("all layouts should complete on time",
515                     layoutLatch.getCount(), CoreMatchers.is(0L));
516             // use a runnable to ensure RV layout is finished
517             getInstrumentation().runOnMainSync(new Runnable() {
518                 @Override
519                 public void run() {
520                 }
521             });
522         }
523 
expectPrefetch(int count)524         public void expectPrefetch(int count) {
525             prefetchLatch = new CountDownLatch(count);
526         }
527 
waitForPrefetch(int seconds)528         public void waitForPrefetch(int seconds) throws Throwable {
529             prefetchLatch.await(seconds * (DEBUG ? 100 : 1), SECONDS);
530             checkForMainThreadException();
531             MatcherAssert.assertThat("all prefetches should complete on time",
532                     prefetchLatch.getCount(), CoreMatchers.is(0L));
533             // use a runnable to ensure RV layout is finished
534             getInstrumentation().runOnMainSync(new Runnable() {
535                 @Override
536                 public void run() {
537                 }
538             });
539         }
540 
expectIdleState(int count)541         public void expectIdleState(int count) {
542             mSnapLatch = new CountDownLatch(count);
543             mRecyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() {
544                 @Override
545                 public void onScrollStateChanged(RecyclerView recyclerView, int newState) {
546                     super.onScrollStateChanged(recyclerView, newState);
547                     if (newState == RecyclerView.SCROLL_STATE_IDLE) {
548                         mSnapLatch.countDown();
549                         if (mSnapLatch.getCount() == 0L) {
550                             mRecyclerView.removeOnScrollListener(this);
551                         }
552                     }
553                 }
554             });
555         }
556 
waitForSnap(int seconds)557         public void waitForSnap(int seconds) throws Throwable {
558             mSnapLatch.await(seconds * (DEBUG ? 100 : 1), SECONDS);
559             checkForMainThreadException();
560             MatcherAssert.assertThat("all scrolling should complete on time",
561                     mSnapLatch.getCount(), CoreMatchers.is(0L));
562             // use a runnable to ensure RV layout is finished
563             getInstrumentation().runOnMainSync(new Runnable() {
564                 @Override
565                 public void run() {
566                 }
567             });
568         }
569 
assertNoLayout(String msg, long timeout)570         public void assertNoLayout(String msg, long timeout) throws Throwable {
571             layoutLatch.await(timeout, TimeUnit.SECONDS);
572             assertFalse(msg, layoutLatch.getCount() == 0);
573         }
574 
575         @Override
onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state)576         public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
577             String before;
578             if (DEBUG) {
579                 before = layoutToString("before");
580             } else {
581                 before = "enable DEBUG";
582             }
583             try {
584                 if (mOnLayoutListener != null) {
585                     mOnLayoutListener.before(recycler, state);
586                 }
587                 super.onLayoutChildren(recycler, state);
588                 if (mOnLayoutListener != null) {
589                     mOnLayoutListener.after(recycler, state);
590                 }
591                 validateChildren(before);
592             } catch (Throwable t) {
593                 postExceptionToInstrumentation(t);
594             }
595 
596             layoutLatch.countDown();
597         }
598 
599         @Override
scrollBy(int dt, RecyclerView.Recycler recycler, RecyclerView.State state)600         int scrollBy(int dt, RecyclerView.Recycler recycler, RecyclerView.State state) {
601             try {
602                 int result = super.scrollBy(dt, recycler, state);
603                 validateChildren();
604                 return result;
605             } catch (Throwable t) {
606                 postExceptionToInstrumentation(t);
607             }
608 
609             return 0;
610         }
611 
findFirstVisibleItemClosestToCenter()612         View findFirstVisibleItemClosestToCenter() {
613             final int boundsStart = mPrimaryOrientation.getStartAfterPadding();
614             final int boundsEnd = mPrimaryOrientation.getEndAfterPadding();
615             final int boundsCenter = (boundsStart + boundsEnd) / 2;
616             final Rect childBounds = new Rect();
617             int minDist = Integer.MAX_VALUE;
618             View closestChild = null;
619             for (int i = getChildCount() - 1; i >= 0; i--) {
620                 final View child = getChildAt(i);
621                 childBounds.setEmpty();
622                 getDecoratedBoundsWithMargins(child, childBounds);
623                 int childCenter = canScrollHorizontally()
624                         ? childBounds.centerX() : childBounds.centerY();
625                 int dist = Math.abs(boundsCenter - childCenter);
626                 if (dist < minDist) {
627                     minDist = dist;
628                     closestChild = child;
629                 }
630             }
631             return closestChild;
632         }
633 
WrappedLayoutManager(int spanCount, int orientation)634         public WrappedLayoutManager(int spanCount, int orientation) {
635             super(spanCount, orientation);
636         }
637 
collectChildrenBySpan()638         ArrayList<ArrayList<View>> collectChildrenBySpan() {
639             ArrayList<ArrayList<View>> viewsBySpan = new ArrayList<ArrayList<View>>();
640             for (int i = 0; i < getSpanCount(); i++) {
641                 viewsBySpan.add(new ArrayList<View>());
642             }
643             for (int i = 0; i < getChildCount(); i++) {
644                 View view = getChildAt(i);
645                 LayoutParams lp
646                         = (LayoutParams) view
647                         .getLayoutParams();
648                 viewsBySpan.get(lp.mSpan.mIndex).add(view);
649             }
650             return viewsBySpan;
651         }
652 
653         @Override
onFocusSearchFailed(View focused, int direction, RecyclerView.Recycler recycler, RecyclerView.State state)654         public @Nullable View onFocusSearchFailed(View focused, int direction,
655                 RecyclerView.Recycler recycler, RecyclerView.State state) {
656             View result = null;
657             try {
658                 result = super.onFocusSearchFailed(focused, direction, recycler, state);
659                 validateChildren();
660             } catch (Throwable t) {
661                 postExceptionToInstrumentation(t);
662             }
663             return result;
664         }
665 
getViewBounds(View view)666         Rect getViewBounds(View view) {
667             if (getOrientation() == HORIZONTAL) {
668                 return new Rect(
669                         mPrimaryOrientation.getDecoratedStart(view),
670                         mSecondaryOrientation.getDecoratedStart(view),
671                         mPrimaryOrientation.getDecoratedEnd(view),
672                         mSecondaryOrientation.getDecoratedEnd(view));
673             } else {
674                 return new Rect(
675                         mSecondaryOrientation.getDecoratedStart(view),
676                         mPrimaryOrientation.getDecoratedStart(view),
677                         mSecondaryOrientation.getDecoratedEnd(view),
678                         mPrimaryOrientation.getDecoratedEnd(view));
679             }
680         }
681 
getBoundsLog()682         public String getBoundsLog() {
683             StringBuilder sb = new StringBuilder();
684             sb.append("view bounds:[start:").append(mPrimaryOrientation.getStartAfterPadding())
685                     .append(",").append(" end").append(mPrimaryOrientation.getEndAfterPadding());
686             sb.append("\nchildren bounds\n");
687             final int childCount = getChildCount();
688             for (int i = 0; i < childCount; i++) {
689                 View child = getChildAt(i);
690                 sb.append("child (ind:").append(i).append(", pos:").append(getPosition(child))
691                         .append("[").append("start:").append(
692                         mPrimaryOrientation.getDecoratedStart(child)).append(", end:")
693                         .append(mPrimaryOrientation.getDecoratedEnd(child)).append("]\n");
694             }
695             return sb.toString();
696         }
697 
traverseAndFindVisibleChildren()698         public VisibleChildren traverseAndFindVisibleChildren() {
699             int childCount = getChildCount();
700             final VisibleChildren visibleChildren = new VisibleChildren(getSpanCount());
701             final int start = mPrimaryOrientation.getStartAfterPadding();
702             final int end = mPrimaryOrientation.getEndAfterPadding();
703             for (int i = 0; i < childCount; i++) {
704                 View child = getChildAt(i);
705                 final int childStart = mPrimaryOrientation.getDecoratedStart(child);
706                 final int childEnd = mPrimaryOrientation.getDecoratedEnd(child);
707                 final boolean fullyVisible = childStart >= start && childEnd <= end;
708                 final boolean hidden = childEnd <= start || childStart >= end;
709                 if (hidden) {
710                     continue;
711                 }
712                 final int position = getPosition(child);
713                 final int span = getLp(child).getSpanIndex();
714                 if (fullyVisible) {
715                     if (position < visibleChildren.firstFullyVisiblePositions[span] ||
716                             visibleChildren.firstFullyVisiblePositions[span]
717                                     == RecyclerView.NO_POSITION) {
718                         visibleChildren.firstFullyVisiblePositions[span] = position;
719                     }
720 
721                     if (position > visibleChildren.lastFullyVisiblePositions[span]) {
722                         visibleChildren.lastFullyVisiblePositions[span] = position;
723                     }
724                 }
725 
726                 if (position < visibleChildren.firstVisiblePositions[span] ||
727                         visibleChildren.firstVisiblePositions[span] == RecyclerView.NO_POSITION) {
728                     visibleChildren.firstVisiblePositions[span] = position;
729                 }
730 
731                 if (position > visibleChildren.lastVisiblePositions[span]) {
732                     visibleChildren.lastVisiblePositions[span] = position;
733                 }
734                 if (visibleChildren.findFirstPartialVisibleClosestToStart == null) {
735                     visibleChildren.findFirstPartialVisibleClosestToStart = child;
736                 }
737                 visibleChildren.findFirstPartialVisibleClosestToEnd = child;
738             }
739             return visibleChildren;
740         }
741 
collectChildCoordinates()742         Map<Item, Rect> collectChildCoordinates() throws Throwable {
743             final Map<Item, Rect> items = new LinkedHashMap<Item, Rect>();
744             mActivityRule.runOnUiThread(new Runnable() {
745                 @Override
746                 public void run() {
747                     final int start = mPrimaryOrientation.getStartAfterPadding();
748                     final int end = mPrimaryOrientation.getEndAfterPadding();
749                     final int childCount = getChildCount();
750                     for (int i = 0; i < childCount; i++) {
751                         View child = getChildAt(i);
752                         // ignore child if it fits the recycling constraints
753                         if (mPrimaryOrientation.getDecoratedStart(child) >= end
754                                 || mPrimaryOrientation.getDecoratedEnd(child) < start) {
755                             continue;
756                         }
757                         LayoutParams lp = (LayoutParams) child.getLayoutParams();
758                         TestViewHolder vh = (TestViewHolder) lp.mViewHolder;
759                         items.put(vh.mBoundItem, getViewBounds(child));
760                     }
761                 }
762             });
763             return items;
764         }
765 
766 
setFakeRtl(Boolean fakeRtl)767         public void setFakeRtl(Boolean fakeRtl) {
768             mFakeRTL = fakeRtl;
769             try {
770                 requestLayoutOnUIThread(mRecyclerView);
771             } catch (Throwable throwable) {
772                 postExceptionToInstrumentation(throwable);
773             }
774         }
775 
layoutToString(String hint)776         String layoutToString(String hint) {
777             StringBuilder sb = new StringBuilder();
778             sb.append("LAYOUT POSITIONS AND INDICES ").append(hint).append("\n");
779             for (int i = 0; i < getChildCount(); i++) {
780                 final View view = getChildAt(i);
781                 final LayoutParams layoutParams = (LayoutParams) view.getLayoutParams();
782                 sb.append(String.format("index: %d pos: %d top: %d bottom: %d span: %d isFull:%s",
783                         i, getPosition(view),
784                         mPrimaryOrientation.getDecoratedStart(view),
785                         mPrimaryOrientation.getDecoratedEnd(view),
786                         layoutParams.getSpanIndex(), layoutParams.isFullSpan())).append("\n");
787             }
788             return sb.toString();
789         }
790 
validateChildren()791         protected void validateChildren() {
792             validateChildren(null);
793         }
794 
validateChildren(String msg)795         private void validateChildren(String msg) {
796             if (getChildCount() == 0 || mRecyclerView.mState.isPreLayout()) {
797                 return;
798             }
799             final int dir = mShouldReverseLayout ? -1 : 1;
800             int i = 0;
801             int pos = -1;
802             while (i < getChildCount()) {
803                 LayoutParams lp = (LayoutParams) getChildAt(i).getLayoutParams();
804                 if (lp.isItemRemoved()) {
805                     i++;
806                     continue;
807                 }
808                 pos = getPosition(getChildAt(i));
809                 break;
810             }
811             if (pos == -1) {
812                 return;
813             }
814             while (++i < getChildCount()) {
815                 LayoutParams lp = (LayoutParams) getChildAt(i).getLayoutParams();
816                 if (lp.isItemRemoved()) {
817                     continue;
818                 }
819                 pos += dir;
820                 if (getPosition(getChildAt(i)) != pos) {
821                     throw new RuntimeException("INVALID POSITION FOR CHILD " + i + "\n" +
822                             layoutToString("ERROR") + "\n msg:" + msg);
823                 }
824             }
825         }
826 
827         @Override
collectAdjacentPrefetchPositions(int dx, int dy, RecyclerView.State state, LayoutPrefetchRegistry layoutPrefetchRegistry)828         public void collectAdjacentPrefetchPositions(int dx, int dy, RecyclerView.State state,
829                 LayoutPrefetchRegistry layoutPrefetchRegistry) {
830             if (prefetchLatch != null) prefetchLatch.countDown();
831             super.collectAdjacentPrefetchPositions(dx, dy, state, layoutPrefetchRegistry);
832         }
833     }
834 
835     class GridTestAdapter extends TestAdapter {
836 
837         int mOrientation;
838         int mRecyclerViewWidth;
839         int mRecyclerViewHeight;
840         Integer mSizeReference = null;
841 
842         // original ids of items that should be full span
843         HashSet<Integer> mFullSpanItems = new HashSet<Integer>();
844 
845         protected boolean mViewsHaveEqualSize = false; // size in the scrollable direction
846 
847         protected OnBindCallback mOnBindCallback;
848 
GridTestAdapter(int count, int orientation)849         GridTestAdapter(int count, int orientation) {
850             super(count);
851             mOrientation = orientation;
852         }
853 
854         @Override
onCreateViewHolder(@onNull ViewGroup parent, int viewType)855         public @NonNull TestViewHolder onCreateViewHolder(@NonNull ViewGroup parent,
856                 int viewType) {
857             mRecyclerViewWidth = parent.getWidth();
858             mRecyclerViewHeight = parent.getHeight();
859             TestViewHolder vh = super.onCreateViewHolder(parent, viewType);
860             if (mOnBindCallback != null) {
861                 mOnBindCallback.onCreatedViewHolder(vh);
862             }
863             return vh;
864         }
865 
866         @Override
offsetOriginalIndices(int start, int offset)867         public void offsetOriginalIndices(int start, int offset) {
868             if (mFullSpanItems.size() > 0) {
869                 HashSet<Integer> old = mFullSpanItems;
870                 mFullSpanItems = new HashSet<Integer>();
871                 for (Integer i : old) {
872                     if (i < start) {
873                         mFullSpanItems.add(i);
874                     } else if (offset > 0 || (start + Math.abs(offset)) <= i) {
875                         mFullSpanItems.add(i + offset);
876                     } else if (DEBUG) {
877                         Log.d(TAG, "removed full span item " + i);
878                     }
879                 }
880             }
881             super.offsetOriginalIndices(start, offset);
882         }
883 
884         @Override
moveInUIThread(int from, int to)885         protected void moveInUIThread(int from, int to) {
886             boolean setAsFullSpanAgain = mFullSpanItems.contains(from);
887             super.moveInUIThread(from, to);
888             if (setAsFullSpanAgain) {
889                 mFullSpanItems.add(to);
890             }
891         }
892 
893         @Override
onBindViewHolder(@onNull TestViewHolder holder, int position)894         public void onBindViewHolder(@NonNull TestViewHolder holder,
895                 int position) {
896             if (mSizeReference == null) {
897                 mSizeReference = mOrientation == OrientationHelper.HORIZONTAL ? mRecyclerViewWidth
898                         / AVG_ITEM_PER_VIEW : mRecyclerViewHeight / AVG_ITEM_PER_VIEW;
899             }
900             super.onBindViewHolder(holder, position);
901 
902             Item item = mItems.get(position);
903             RecyclerView.LayoutParams lp = (RecyclerView.LayoutParams) holder.itemView
904                     .getLayoutParams();
905             if (lp instanceof StaggeredGridLayoutManager.LayoutParams) {
906                 ((StaggeredGridLayoutManager.LayoutParams) lp)
907                         .setFullSpan(mFullSpanItems.contains(item.mAdapterIndex));
908             } else {
909                 StaggeredGridLayoutManager.LayoutParams slp
910                     = (StaggeredGridLayoutManager.LayoutParams) mLayoutManager
911                     .generateDefaultLayoutParams();
912                 holder.itemView.setLayoutParams(slp);
913                 slp.setFullSpan(mFullSpanItems.contains(item.mAdapterIndex));
914                 lp = slp;
915             }
916 
917             if (mOnBindCallback == null || mOnBindCallback.assignRandomSize()) {
918                 final int minSize = mViewsHaveEqualSize ? mSizeReference :
919                         mSizeReference + 20 * (item.mId % 10);
920                 if (mOrientation == OrientationHelper.HORIZONTAL) {
921                     lp.width = minSize;
922                 } else {
923                     lp.height = minSize;
924                 }
925                 lp.topMargin = 3;
926                 lp.leftMargin = 5;
927                 lp.rightMargin = 7;
928                 lp.bottomMargin = 9;
929             }
930             // Good to have colors for debugging
931             StateListDrawable stl = new StateListDrawable();
932             stl.addState(new int[]{android.R.attr.state_focused},
933                     new ColorDrawable(Color.RED));
934             stl.addState(StateSet.WILD_CARD, new ColorDrawable(Color.BLUE));
935             holder.itemView.setBackgroundDrawable(stl);
936             if (mOnBindCallback != null) {
937                 mOnBindCallback.onBoundItem(holder, position);
938             }
939         }
940     }
941 }
942