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
17package androidx.recyclerview.widget;
18
19import static androidx.recyclerview.widget.LayoutState.LAYOUT_START;
20import static androidx.recyclerview.widget.LinearLayoutManager.VERTICAL;
21import static androidx.recyclerview.widget.StaggeredGridLayoutManager.HORIZONTAL;
22
23import static org.hamcrest.CoreMatchers.hasItem;
24import static org.hamcrest.CoreMatchers.is;
25import static org.hamcrest.CoreMatchers.not;
26import static org.hamcrest.CoreMatchers.sameInstance;
27import static org.junit.Assert.assertEquals;
28import static org.junit.Assert.assertNotNull;
29import static org.junit.Assert.assertThat;
30import static org.junit.Assert.assertTrue;
31
32import android.graphics.Rect;
33import android.os.Looper;
34import android.os.Parcel;
35import android.os.Parcelable;
36import android.support.test.filters.FlakyTest;
37import android.support.test.filters.LargeTest;
38import android.support.test.filters.Suppress;
39import android.util.Log;
40import android.view.View;
41import android.view.ViewParent;
42
43import androidx.annotation.NonNull;
44
45import org.junit.Test;
46import org.junit.runner.RunWith;
47import org.junit.runners.Parameterized;
48
49import java.util.Arrays;
50import java.util.BitSet;
51import java.util.List;
52import java.util.Map;
53import java.util.UUID;
54
55@RunWith(Parameterized.class)
56@LargeTest
57public class StaggeredGridLayoutManagerBaseConfigSetTest
58        extends BaseStaggeredGridLayoutManagerTest {
59
60    @Parameterized.Parameters(name = "{0}")
61    public static List<Config> getParams() {
62        return createBaseVariations();
63    }
64
65    private final Config mConfig;
66
67    public StaggeredGridLayoutManagerBaseConfigSetTest(Config config)
68            throws CloneNotSupportedException {
69        mConfig = (Config) config.clone();
70    }
71
72    @Test
73    public void rTL() throws Throwable {
74        rtlTest(false, false);
75    }
76
77    @Test
78    public void rTLChangeAfter() throws Throwable {
79        rtlTest(true, false);
80    }
81
82    @Test
83    public void rTLItemWrapContent() throws Throwable {
84        rtlTest(false, true);
85    }
86
87    @Test
88    public void rTLChangeAfterItemWrapContent() throws Throwable {
89        rtlTest(true, true);
90    }
91
92    void rtlTest(boolean changeRtlAfter, final boolean wrapContent) throws Throwable {
93        if (mConfig.mSpanCount == 1) {
94            mConfig.mSpanCount = 2;
95        }
96        String logPrefix = mConfig + ", changeRtlAfterLayout:" + changeRtlAfter;
97        setupByConfig(mConfig.itemCount(5),
98                new GridTestAdapter(mConfig.mItemCount, mConfig.mOrientation) {
99                    @Override
100                    public void onBindViewHolder(@NonNull TestViewHolder holder,
101                            int position) {
102                        super.onBindViewHolder(holder, position);
103                        if (wrapContent) {
104                            if (mOrientation == HORIZONTAL) {
105                                holder.itemView.getLayoutParams().height
106                                        = RecyclerView.LayoutParams.WRAP_CONTENT;
107                            } else {
108                                holder.itemView.getLayoutParams().width
109                                        = RecyclerView.LayoutParams.MATCH_PARENT;
110                            }
111                        }
112                    }
113                });
114        if (changeRtlAfter) {
115            waitFirstLayout();
116            mLayoutManager.expectLayouts(1);
117            mLayoutManager.setFakeRtl(true);
118            mLayoutManager.waitForLayout(2);
119        } else {
120            mLayoutManager.mFakeRTL = true;
121            waitFirstLayout();
122        }
123
124        assertEquals("view should become rtl", true, mLayoutManager.isLayoutRTL());
125        OrientationHelper helper = OrientationHelper.createHorizontalHelper(mLayoutManager);
126        View child0 = mLayoutManager.findViewByPosition(0);
127        View child1 = mLayoutManager.findViewByPosition(mConfig.mOrientation == VERTICAL ? 1
128                : mConfig.mSpanCount);
129        assertNotNull(logPrefix + " child position 0 should be laid out", child0);
130        assertNotNull(logPrefix + " child position 0 should be laid out", child1);
131        logPrefix += " child1 pos:" + mLayoutManager.getPosition(child1);
132        if (mConfig.mOrientation == VERTICAL || !mConfig.mReverseLayout) {
133            assertTrue(logPrefix + " second child should be to the left of first child",
134                    helper.getDecoratedEnd(child0) > helper.getDecoratedEnd(child1));
135            assertEquals(logPrefix + " first child should be right aligned",
136                    helper.getDecoratedEnd(child0), helper.getEndAfterPadding());
137        } else {
138            assertTrue(logPrefix + " first child should be to the left of second child",
139                    helper.getDecoratedStart(child1) >= helper.getDecoratedStart(child0));
140            assertEquals(logPrefix + " first child should be left aligned",
141                    helper.getDecoratedStart(child0), helper.getStartAfterPadding());
142        }
143        checkForMainThreadException();
144    }
145
146    @Test
147    public void scrollBackAndPreservePositions() throws Throwable {
148        scrollBackAndPreservePositionsTest(false);
149    }
150
151    @Test
152    public void scrollBackAndPreservePositionsWithRestore() throws Throwable {
153        scrollBackAndPreservePositionsTest(true);
154    }
155
156    public void scrollBackAndPreservePositionsTest(final boolean saveRestoreInBetween)
157            throws Throwable {
158        setupByConfig(mConfig);
159        mAdapter.mOnBindCallback = new OnBindCallback() {
160            @Override
161            public void onBoundItem(TestViewHolder vh, int position) {
162                StaggeredGridLayoutManager.LayoutParams
163                        lp = (StaggeredGridLayoutManager.LayoutParams) vh.itemView
164                        .getLayoutParams();
165                lp.setFullSpan((position * 7) % (mConfig.mSpanCount + 1) == 0);
166            }
167        };
168        waitFirstLayout();
169        final int[] globalPositions = new int[mAdapter.getItemCount()];
170        Arrays.fill(globalPositions, Integer.MIN_VALUE);
171        final int scrollStep = (mLayoutManager.mPrimaryOrientation.getTotalSpace() / 10)
172                * (mConfig.mReverseLayout ? -1 : 1);
173
174        final int[] globalPos = new int[1];
175        mActivityRule.runOnUiThread(new Runnable() {
176            @Override
177            public void run() {
178                int globalScrollPosition = 0;
179                while (globalPositions[mAdapter.getItemCount() - 1] == Integer.MIN_VALUE) {
180                    for (int i = 0; i < mRecyclerView.getChildCount(); i++) {
181                        View child = mRecyclerView.getChildAt(i);
182                        final int pos = mRecyclerView.getChildLayoutPosition(child);
183                        if (globalPositions[pos] != Integer.MIN_VALUE) {
184                            continue;
185                        }
186                        if (mConfig.mReverseLayout) {
187                            globalPositions[pos] = globalScrollPosition +
188                                    mLayoutManager.mPrimaryOrientation.getDecoratedEnd(child);
189                        } else {
190                            globalPositions[pos] = globalScrollPosition +
191                                    mLayoutManager.mPrimaryOrientation.getDecoratedStart(child);
192                        }
193                    }
194                    globalScrollPosition += mLayoutManager.scrollBy(scrollStep,
195                            mRecyclerView.mRecycler, mRecyclerView.mState);
196                }
197                if (DEBUG) {
198                    Log.d(TAG, "done recording positions " + Arrays.toString(globalPositions));
199                }
200                globalPos[0] = globalScrollPosition;
201            }
202        });
203        checkForMainThreadException();
204
205        if (saveRestoreInBetween) {
206            saveRestore(mConfig);
207        }
208
209        checkForMainThreadException();
210        mActivityRule.runOnUiThread(new Runnable() {
211            @Override
212            public void run() {
213                int globalScrollPosition = globalPos[0];
214                // now scroll back and make sure global positions match
215                BitSet shouldTest = new BitSet(mAdapter.getItemCount());
216                shouldTest.set(0, mAdapter.getItemCount() - 1, true);
217                String assertPrefix = mConfig + ", restored in between:" + saveRestoreInBetween
218                        + " global pos must match when scrolling in reverse for position ";
219                int scrollAmount = Integer.MAX_VALUE;
220                while (!shouldTest.isEmpty() && scrollAmount != 0) {
221                    for (int i = 0; i < mRecyclerView.getChildCount(); i++) {
222                        View child = mRecyclerView.getChildAt(i);
223                        int pos = mRecyclerView.getChildLayoutPosition(child);
224                        if (!shouldTest.get(pos)) {
225                            continue;
226                        }
227                        shouldTest.clear(pos);
228                        int globalPos;
229                        if (mConfig.mReverseLayout) {
230                            globalPos = globalScrollPosition +
231                                    mLayoutManager.mPrimaryOrientation.getDecoratedEnd(child);
232                        } else {
233                            globalPos = globalScrollPosition +
234                                    mLayoutManager.mPrimaryOrientation.getDecoratedStart(child);
235                        }
236                        assertEquals(assertPrefix + pos,
237                                globalPositions[pos], globalPos);
238                    }
239                    scrollAmount = mLayoutManager.scrollBy(-scrollStep,
240                            mRecyclerView.mRecycler, mRecyclerView.mState);
241                    globalScrollPosition += scrollAmount;
242                }
243                assertTrue("all views should be seen", shouldTest.isEmpty());
244            }
245        });
246        checkForMainThreadException();
247    }
248
249    private void saveRestore(final Config config) throws Throwable {
250        mActivityRule.runOnUiThread(new Runnable() {
251            @Override
252            public void run() {
253                try {
254                    Parcelable savedState = mRecyclerView.onSaveInstanceState();
255                    // we append a suffix to the parcelable to test out of bounds
256                    String parcelSuffix = UUID.randomUUID().toString();
257                    Parcel parcel = Parcel.obtain();
258                    savedState.writeToParcel(parcel, 0);
259                    parcel.writeString(parcelSuffix);
260                    removeRecyclerView();
261                    // reset for reading
262                    parcel.setDataPosition(0);
263                    // re-create
264                    savedState = RecyclerView.SavedState.CREATOR.createFromParcel(parcel);
265                    RecyclerView restored = new RecyclerView(getActivity());
266                    mLayoutManager = new WrappedLayoutManager(config.mSpanCount,
267                            config.mOrientation);
268                    mLayoutManager.setGapStrategy(config.mGapStrategy);
269                    restored.setLayoutManager(mLayoutManager);
270                    // use the same adapter for Rect matching
271                    restored.setAdapter(mAdapter);
272                    restored.onRestoreInstanceState(savedState);
273                    if (Looper.myLooper() == Looper.getMainLooper()) {
274                        mLayoutManager.expectLayouts(1);
275                        setRecyclerView(restored);
276                    } else {
277                        mLayoutManager.expectLayouts(1);
278                        setRecyclerView(restored);
279                        mLayoutManager.waitForLayout(2);
280                    }
281                } catch (Throwable t) {
282                    postExceptionToInstrumentation(t);
283                }
284            }
285        });
286        checkForMainThreadException();
287    }
288
289    @Test
290    public void getFirstLastChildrenTest() throws Throwable {
291        getFirstLastChildrenTest(false);
292    }
293
294    @Test
295    public void getFirstLastChildrenTestProvideArray() throws Throwable {
296        getFirstLastChildrenTest(true);
297    }
298
299    public void getFirstLastChildrenTest(final boolean provideArr) throws Throwable {
300        setupByConfig(mConfig);
301        waitFirstLayout();
302        Runnable viewInBoundsTest = new Runnable() {
303            @Override
304            public void run() {
305                VisibleChildren visibleChildren = mLayoutManager.traverseAndFindVisibleChildren();
306                final String boundsLog = mLayoutManager.getBoundsLog();
307                VisibleChildren queryResult = new VisibleChildren(mLayoutManager.getSpanCount());
308                queryResult.findFirstPartialVisibleClosestToStart = mLayoutManager
309                        .findFirstVisibleItemClosestToStart(false);
310                queryResult.findFirstPartialVisibleClosestToEnd = mLayoutManager
311                        .findFirstVisibleItemClosestToEnd(false);
312                queryResult.firstFullyVisiblePositions = mLayoutManager
313                        .findFirstCompletelyVisibleItemPositions(
314                                provideArr ? new int[mLayoutManager.getSpanCount()] : null);
315                queryResult.firstVisiblePositions = mLayoutManager
316                        .findFirstVisibleItemPositions(
317                                provideArr ? new int[mLayoutManager.getSpanCount()] : null);
318                queryResult.lastFullyVisiblePositions = mLayoutManager
319                        .findLastCompletelyVisibleItemPositions(
320                                provideArr ? new int[mLayoutManager.getSpanCount()] : null);
321                queryResult.lastVisiblePositions = mLayoutManager
322                        .findLastVisibleItemPositions(
323                                provideArr ? new int[mLayoutManager.getSpanCount()] : null);
324                assertEquals(mConfig + ":\nfirst visible child should match traversal result\n"
325                        + "traversed:" + visibleChildren + "\n"
326                        + "queried:" + queryResult + "\n"
327                        + boundsLog, visibleChildren, queryResult
328                );
329            }
330        };
331        mActivityRule.runOnUiThread(viewInBoundsTest);
332        // smooth scroll to end of the list and keep testing meanwhile. This will test pre-caching
333        // case
334        final int scrollPosition = mAdapter.getItemCount();
335        mActivityRule.runOnUiThread(new Runnable() {
336            @Override
337            public void run() {
338                mRecyclerView.smoothScrollToPosition(scrollPosition);
339            }
340        });
341        while (mLayoutManager.isSmoothScrolling() ||
342                mRecyclerView.getScrollState() != RecyclerView.SCROLL_STATE_IDLE) {
343            mActivityRule.runOnUiThread(viewInBoundsTest);
344            checkForMainThreadException();
345            Thread.sleep(400);
346        }
347        // delete all items
348        mLayoutManager.expectLayouts(2);
349        mAdapter.deleteAndNotify(0, mAdapter.getItemCount());
350        mLayoutManager.waitForLayout(2);
351        // test empty case
352        mActivityRule.runOnUiThread(viewInBoundsTest);
353        // set a new adapter with huge items to test full bounds check
354        mLayoutManager.expectLayouts(1);
355        final int totalSpace = mLayoutManager.mPrimaryOrientation.getTotalSpace();
356        final TestAdapter newAdapter = new TestAdapter(100) {
357            @Override
358            public void onBindViewHolder(@NonNull TestViewHolder holder,
359                    int position) {
360                super.onBindViewHolder(holder, position);
361                if (mConfig.mOrientation == LinearLayoutManager.HORIZONTAL) {
362                    holder.itemView.setMinimumWidth(totalSpace + 100);
363                } else {
364                    holder.itemView.setMinimumHeight(totalSpace + 100);
365                }
366            }
367        };
368        mActivityRule.runOnUiThread(new Runnable() {
369            @Override
370            public void run() {
371                mRecyclerView.setAdapter(newAdapter);
372            }
373        });
374        mLayoutManager.waitForLayout(2);
375        mActivityRule.runOnUiThread(viewInBoundsTest);
376        checkForMainThreadException();
377
378        // smooth scroll to end of the list and keep testing meanwhile. This will test pre-caching
379        // case
380        mActivityRule.runOnUiThread(new Runnable() {
381            @Override
382            public void run() {
383                final int diff;
384                if (mConfig.mReverseLayout) {
385                    diff = -1;
386                } else {
387                    diff = 1;
388                }
389                final int distance = diff * 10;
390                if (mConfig.mOrientation == HORIZONTAL) {
391                    mRecyclerView.scrollBy(distance, 0);
392                } else {
393                    mRecyclerView.scrollBy(0, distance);
394                }
395            }
396        });
397        mActivityRule.runOnUiThread(viewInBoundsTest);
398        checkForMainThreadException();
399    }
400
401    @Test
402    public void viewSnapTest() throws Throwable {
403        final Config config = ((Config) mConfig.clone()).itemCount(mConfig.mSpanCount + 1);
404        setupByConfig(config);
405        mAdapter.mOnBindCallback = new OnBindCallback() {
406            @Override
407            void onBoundItem(TestViewHolder vh, int position) {
408                StaggeredGridLayoutManager.LayoutParams
409                        lp = (StaggeredGridLayoutManager.LayoutParams) vh.itemView
410                        .getLayoutParams();
411                if (config.mOrientation == HORIZONTAL) {
412                    lp.width = mRecyclerView.getWidth() / 3;
413                } else {
414                    lp.height = mRecyclerView.getHeight() / 3;
415                }
416            }
417
418            @Override
419            boolean assignRandomSize() {
420                return false;
421            }
422        };
423        waitFirstLayout();
424        // run these tests twice. once initial layout, once after scroll
425        String logSuffix = "";
426        for (int i = 0; i < 2; i++) {
427            Map<Item, Rect> itemRectMap = mLayoutManager.collectChildCoordinates();
428            Rect recyclerViewBounds = getDecoratedRecyclerViewBounds();
429            // workaround for SGLM's span distribution issue. Right now, it may leave gaps so we
430            // avoid it by setting its layout params directly
431            if (config.mOrientation == HORIZONTAL) {
432                recyclerViewBounds.bottom -= recyclerViewBounds.height() % config.mSpanCount;
433            } else {
434                recyclerViewBounds.right -= recyclerViewBounds.width() % config.mSpanCount;
435            }
436
437            Rect usedLayoutBounds = new Rect();
438            for (Rect rect : itemRectMap.values()) {
439                usedLayoutBounds.union(rect);
440            }
441
442            if (DEBUG) {
443                Log.d(TAG, "testing view snapping (" + logSuffix + ") for config " + config);
444            }
445            if (config.mOrientation == VERTICAL) {
446                assertEquals(config + " there should be no gap on left" + logSuffix,
447                        usedLayoutBounds.left, recyclerViewBounds.left);
448                assertEquals(config + " there should be no gap on right" + logSuffix,
449                        usedLayoutBounds.right, recyclerViewBounds.right);
450                if (config.mReverseLayout) {
451                    assertEquals(config + " there should be no gap on bottom" + logSuffix,
452                            usedLayoutBounds.bottom, recyclerViewBounds.bottom);
453                    assertTrue(config + " there should be some gap on top" + logSuffix,
454                            usedLayoutBounds.top > recyclerViewBounds.top);
455                } else {
456                    assertEquals(config + " there should be no gap on top" + logSuffix,
457                            usedLayoutBounds.top, recyclerViewBounds.top);
458                    assertTrue(config + " there should be some gap at the bottom" + logSuffix,
459                            usedLayoutBounds.bottom < recyclerViewBounds.bottom);
460                }
461            } else {
462                assertEquals(config + " there should be no gap on top" + logSuffix,
463                        usedLayoutBounds.top, recyclerViewBounds.top);
464                assertEquals(config + " there should be no gap at the bottom" + logSuffix,
465                        usedLayoutBounds.bottom, recyclerViewBounds.bottom);
466                if (config.mReverseLayout) {
467                    assertEquals(config + " there should be no on right" + logSuffix,
468                            usedLayoutBounds.right, recyclerViewBounds.right);
469                    assertTrue(config + " there should be some gap on left" + logSuffix,
470                            usedLayoutBounds.left > recyclerViewBounds.left);
471                } else {
472                    assertEquals(config + " there should be no gap on left" + logSuffix,
473                            usedLayoutBounds.left, recyclerViewBounds.left);
474                    assertTrue(config + " there should be some gap on right" + logSuffix,
475                            usedLayoutBounds.right < recyclerViewBounds.right);
476                }
477            }
478            final int scroll = config.mReverseLayout ? -500 : 500;
479            scrollBy(scroll);
480            logSuffix = " scrolled " + scroll;
481        }
482    }
483
484    @Test
485    public void scrollToPositionWithOffsetTest() throws Throwable {
486        setupByConfig(mConfig);
487        waitFirstLayout();
488        OrientationHelper orientationHelper = OrientationHelper
489                .createOrientationHelper(mLayoutManager, mConfig.mOrientation);
490        Rect layoutBounds = getDecoratedRecyclerViewBounds();
491        // try scrolling towards head, should not affect anything
492        Map<Item, Rect> before = mLayoutManager.collectChildCoordinates();
493        scrollToPositionWithOffset(0, 20);
494        assertRectSetsEqual(mConfig + " trying to over scroll with offset should be no-op",
495                before, mLayoutManager.collectChildCoordinates());
496        // try offsetting some visible children
497        int testCount = 10;
498        while (testCount-- > 0) {
499            // get middle child
500            final View child = mLayoutManager.getChildAt(mLayoutManager.getChildCount() / 2);
501            final int position = mRecyclerView.getChildLayoutPosition(child);
502            final int startOffset = mConfig.mReverseLayout ?
503                    orientationHelper.getEndAfterPadding() - orientationHelper
504                            .getDecoratedEnd(child)
505                    : orientationHelper.getDecoratedStart(child) - orientationHelper
506                            .getStartAfterPadding();
507            final int scrollOffset = startOffset / 2;
508            mLayoutManager.expectLayouts(1);
509            scrollToPositionWithOffset(position, scrollOffset);
510            mLayoutManager.waitForLayout(2);
511            final int finalOffset = mConfig.mReverseLayout ?
512                    orientationHelper.getEndAfterPadding() - orientationHelper
513                            .getDecoratedEnd(child)
514                    : orientationHelper.getDecoratedStart(child) - orientationHelper
515                            .getStartAfterPadding();
516            assertEquals(mConfig + " scroll with offset on a visible child should work fine",
517                    scrollOffset, finalOffset);
518        }
519
520        // try scrolling to invisible children
521        testCount = 10;
522        // we test above and below, one by one
523        int offsetMultiplier = -1;
524        while (testCount-- > 0) {
525            final TargetTuple target = findInvisibleTarget(mConfig);
526            mLayoutManager.expectLayouts(1);
527            final int offset = offsetMultiplier
528                    * orientationHelper.getDecoratedMeasurement(mLayoutManager.getChildAt(0)) / 3;
529            scrollToPositionWithOffset(target.mPosition, offset);
530            mLayoutManager.waitForLayout(2);
531            final View child = mLayoutManager.findViewByPosition(target.mPosition);
532            assertNotNull(mConfig + " scrolling to a mPosition with offset " + offset
533                    + " should layout it", child);
534            final Rect bounds = mLayoutManager.getViewBounds(child);
535            if (DEBUG) {
536                Log.d(TAG, mConfig + " post scroll to invisible mPosition " + bounds + " in "
537                        + layoutBounds + " with offset " + offset);
538            }
539
540            if (mConfig.mReverseLayout) {
541                assertEquals(mConfig + " when scrolling with offset to an invisible in reverse "
542                                + "layout, its end should align with recycler view's end - offset",
543                        orientationHelper.getEndAfterPadding() - offset,
544                        orientationHelper.getDecoratedEnd(child)
545                );
546            } else {
547                assertEquals(mConfig + " when scrolling with offset to an invisible child in normal"
548                                + " layout its start should align with recycler view's start + "
549                                + "offset",
550                        orientationHelper.getStartAfterPadding() + offset,
551                        orientationHelper.getDecoratedStart(child)
552                );
553            }
554            offsetMultiplier *= -1;
555        }
556    }
557
558    @Test
559    public void scrollToPositionTest() throws Throwable {
560        setupByConfig(mConfig);
561        waitFirstLayout();
562        OrientationHelper orientationHelper = OrientationHelper
563                .createOrientationHelper(mLayoutManager, mConfig.mOrientation);
564        Rect layoutBounds = getDecoratedRecyclerViewBounds();
565        for (int i = 0; i < mLayoutManager.getChildCount(); i++) {
566            View view = mLayoutManager.getChildAt(i);
567            Rect bounds = mLayoutManager.getViewBounds(view);
568            if (layoutBounds.contains(bounds)) {
569                Map<Item, Rect> initialBounds = mLayoutManager.collectChildCoordinates();
570                final int position = mRecyclerView.getChildLayoutPosition(view);
571                StaggeredGridLayoutManager.LayoutParams layoutParams
572                        = (StaggeredGridLayoutManager.LayoutParams) (view.getLayoutParams());
573                TestViewHolder vh = (TestViewHolder) layoutParams.mViewHolder;
574                assertEquals("recycler view mPosition should match adapter mPosition", position,
575                        vh.mBoundItem.mAdapterIndex);
576                if (DEBUG) {
577                    Log.d(TAG, "testing scroll to visible mPosition at " + position
578                            + " " + bounds + " inside " + layoutBounds);
579                }
580                mLayoutManager.expectLayouts(1);
581                scrollToPosition(position);
582                mLayoutManager.waitForLayout(2);
583                if (DEBUG) {
584                    view = mLayoutManager.findViewByPosition(position);
585                    Rect newBounds = mLayoutManager.getViewBounds(view);
586                    Log.d(TAG, "after scrolling to visible mPosition " +
587                            bounds + " equals " + newBounds);
588                }
589
590                assertRectSetsEqual(
591                        mConfig + "scroll to mPosition on fully visible child should be no-op",
592                        initialBounds, mLayoutManager.collectChildCoordinates());
593            } else {
594                final int position = mRecyclerView.getChildLayoutPosition(view);
595                if (DEBUG) {
596                    Log.d(TAG,
597                            "child(" + position + ") not fully visible " + bounds + " not inside "
598                                    + layoutBounds
599                                    + mRecyclerView.getChildLayoutPosition(view)
600                    );
601                }
602                mLayoutManager.expectLayouts(1);
603                mActivityRule.runOnUiThread(new Runnable() {
604                    @Override
605                    public void run() {
606                        mLayoutManager.scrollToPosition(position);
607                    }
608                });
609                mLayoutManager.waitForLayout(2);
610                view = mLayoutManager.findViewByPosition(position);
611                bounds = mLayoutManager.getViewBounds(view);
612                if (DEBUG) {
613                    Log.d(TAG, "after scroll to partially visible child " + bounds + " in "
614                            + layoutBounds);
615                }
616                assertTrue(mConfig
617                                + " after scrolling to a partially visible child, it should become fully "
618                                + " visible. " + bounds + " not inside " + layoutBounds,
619                        layoutBounds.contains(bounds)
620                );
621                assertTrue(
622                        mConfig + " when scrolling to a partially visible item, one of its edges "
623                                + "should be on the boundaries",
624                        orientationHelper.getStartAfterPadding() ==
625                                orientationHelper.getDecoratedStart(view)
626                                || orientationHelper.getEndAfterPadding() ==
627                                orientationHelper.getDecoratedEnd(view));
628            }
629        }
630
631        // try scrolling to invisible children
632        int testCount = 10;
633        while (testCount-- > 0) {
634            final TargetTuple target = findInvisibleTarget(mConfig);
635            mLayoutManager.expectLayouts(1);
636            scrollToPosition(target.mPosition);
637            mLayoutManager.waitForLayout(2);
638            final View child = mLayoutManager.findViewByPosition(target.mPosition);
639            assertNotNull(mConfig + " scrolling to a mPosition should lay it out", child);
640            final Rect bounds = mLayoutManager.getViewBounds(child);
641            if (DEBUG) {
642                Log.d(TAG, mConfig + " post scroll to invisible mPosition " + bounds + " in "
643                        + layoutBounds);
644            }
645            assertTrue(mConfig + " scrolling to a mPosition should make it fully visible",
646                    layoutBounds.contains(bounds));
647            if (target.mLayoutDirection == LAYOUT_START) {
648                assertEquals(
649                        mConfig + " when scrolling to an invisible child above, its start should"
650                                + " align with recycler view's start",
651                        orientationHelper.getStartAfterPadding(),
652                        orientationHelper.getDecoratedStart(child)
653                );
654            } else {
655                assertEquals(mConfig + " when scrolling to an invisible child below, its end "
656                                + "should align with recycler view's end",
657                        orientationHelper.getEndAfterPadding(),
658                        orientationHelper.getDecoratedEnd(child)
659                );
660            }
661        }
662    }
663
664    @Test
665    public void scollByTest() throws Throwable {
666        setupByConfig(mConfig);
667        waitFirstLayout();
668        // try invalid scroll. should not happen
669        final View first = mLayoutManager.getChildAt(0);
670        OrientationHelper primaryOrientation = OrientationHelper
671                .createOrientationHelper(mLayoutManager, mConfig.mOrientation);
672        int scrollDist;
673        if (mConfig.mReverseLayout) {
674            scrollDist = primaryOrientation.getDecoratedMeasurement(first) / 2;
675        } else {
676            scrollDist = -primaryOrientation.getDecoratedMeasurement(first) / 2;
677        }
678        Map<Item, Rect> before = mLayoutManager.collectChildCoordinates();
679        scrollBy(scrollDist);
680        Map<Item, Rect> after = mLayoutManager.collectChildCoordinates();
681        assertRectSetsEqual(
682                mConfig + " if there are no more items, scroll should not happen (dt:" + scrollDist
683                        + ")",
684                before, after
685        );
686
687        scrollDist = -scrollDist * 3;
688        before = mLayoutManager.collectChildCoordinates();
689        scrollBy(scrollDist);
690        after = mLayoutManager.collectChildCoordinates();
691        int layoutStart = primaryOrientation.getStartAfterPadding();
692        int layoutEnd = primaryOrientation.getEndAfterPadding();
693        for (Map.Entry<Item, Rect> entry : before.entrySet()) {
694            Rect afterRect = after.get(entry.getKey());
695            // offset rect
696            if (mConfig.mOrientation == VERTICAL) {
697                entry.getValue().offset(0, -scrollDist);
698            } else {
699                entry.getValue().offset(-scrollDist, 0);
700            }
701            if (afterRect == null || afterRect.isEmpty()) {
702                // assert item is out of bounds
703                int start, end;
704                if (mConfig.mOrientation == VERTICAL) {
705                    start = entry.getValue().top;
706                    end = entry.getValue().bottom;
707                } else {
708                    start = entry.getValue().left;
709                    end = entry.getValue().right;
710                }
711                assertTrue(
712                        mConfig + " if item is missing after relayout, it should be out of bounds."
713                                + "item start: " + start + ", end:" + end + " layout start:"
714                                + layoutStart +
715                                ", layout end:" + layoutEnd,
716                        start <= layoutStart && end <= layoutEnd ||
717                                start >= layoutEnd && end >= layoutEnd
718                );
719            } else {
720                assertEquals(mConfig + " Item should be laid out at the scroll offset coordinates",
721                        entry.getValue(),
722                        afterRect);
723            }
724        }
725        assertViewPositions(mConfig);
726    }
727
728    @Test
729    public void layoutOrderTest() throws Throwable {
730        setupByConfig(mConfig);
731        assertViewPositions(mConfig);
732    }
733
734    @Test
735    public void consistentRelayout() throws Throwable {
736        consistentRelayoutTest(mConfig, false);
737    }
738
739    @Test
740    public void consistentRelayoutWithFullSpanFirstChild() throws Throwable {
741        consistentRelayoutTest(mConfig, true);
742    }
743
744    @Suppress
745    @FlakyTest(bugId = 34158822)
746    @Test
747    @LargeTest
748    public void dontRecycleViewsTranslatedOutOfBoundsFromStart() throws Throwable {
749        final Config config = ((Config) mConfig.clone()).itemCount(1000);
750        setupByConfig(config);
751        waitFirstLayout();
752        // pick position from child count so that it is not too far away
753        int pos = mRecyclerView.getChildCount() * 2;
754        smoothScrollToPosition(pos, true);
755        final RecyclerView.ViewHolder vh = mRecyclerView.findViewHolderForAdapterPosition(pos);
756        OrientationHelper helper = mLayoutManager.mPrimaryOrientation;
757        int gap = helper.getDecoratedStart(vh.itemView);
758        scrollBy(gap);
759        gap = helper.getDecoratedStart(vh.itemView);
760        assertThat("test sanity", gap, is(0));
761
762        final int size = helper.getDecoratedMeasurement(vh.itemView);
763        AttachDetachCollector collector = new AttachDetachCollector(mRecyclerView);
764        mActivityRule.runOnUiThread(new Runnable() {
765            @Override
766            public void run() {
767                if (mConfig.mOrientation == HORIZONTAL) {
768                    vh.itemView.setTranslationX(size * 2);
769                } else {
770                    vh.itemView.setTranslationY(size * 2);
771                }
772            }
773        });
774        scrollBy(size * 2);
775        assertThat(collector.getDetached(), not(hasItem(sameInstance(vh.itemView))));
776        assertThat(vh.itemView.getParent(), is((ViewParent) mRecyclerView));
777        assertThat(vh.getAdapterPosition(), is(pos));
778        scrollBy(size * 2);
779        assertThat(collector.getDetached(), hasItem(sameInstance(vh.itemView)));
780    }
781
782    @Test
783    public void dontRecycleViewsTranslatedOutOfBoundsFromEnd() throws Throwable {
784        final Config config = ((Config) mConfig.clone()).itemCount(1000);
785        setupByConfig(config);
786        waitFirstLayout();
787        // pick position from child count so that it is not too far away
788        int pos = mRecyclerView.getChildCount() * 2;
789        mLayoutManager.expectLayouts(1);
790        scrollToPosition(pos);
791        mLayoutManager.waitForLayout(2);
792        final RecyclerView.ViewHolder vh = mRecyclerView.findViewHolderForAdapterPosition(pos);
793        OrientationHelper helper = mLayoutManager.mPrimaryOrientation;
794        int gap = helper.getEnd() - helper.getDecoratedEnd(vh.itemView);
795        scrollBy(-gap);
796        gap = helper.getEnd() - helper.getDecoratedEnd(vh.itemView);
797        assertThat("test sanity", gap, is(0));
798
799        final int size = helper.getDecoratedMeasurement(vh.itemView);
800        AttachDetachCollector collector = new AttachDetachCollector(mRecyclerView);
801        mActivityRule.runOnUiThread(new Runnable() {
802            @Override
803            public void run() {
804                if (mConfig.mOrientation == HORIZONTAL) {
805                    vh.itemView.setTranslationX(-size * 2);
806                } else {
807                    vh.itemView.setTranslationY(-size * 2);
808                }
809            }
810        });
811        scrollBy(-size * 2);
812        assertThat(collector.getDetached(), not(hasItem(sameInstance(vh.itemView))));
813        assertThat(vh.itemView.getParent(), is((ViewParent) mRecyclerView));
814        assertThat(vh.getAdapterPosition(), is(pos));
815        scrollBy(-size * 2);
816        assertThat(collector.getDetached(), hasItem(sameInstance(vh.itemView)));
817    }
818
819    public void consistentRelayoutTest(Config config, boolean firstChildMultiSpan)
820            throws Throwable {
821        setupByConfig(config);
822        if (firstChildMultiSpan) {
823            mAdapter.mFullSpanItems.add(0);
824        }
825        waitFirstLayout();
826        // record all child positions
827        Map<Item, Rect> before = mLayoutManager.collectChildCoordinates();
828        requestLayoutOnUIThread(mRecyclerView);
829        Map<Item, Rect> after = mLayoutManager.collectChildCoordinates();
830        assertRectSetsEqual(
831                config + " simple re-layout, firstChildMultiSpan:" + firstChildMultiSpan, before,
832                after);
833        // scroll some to create inconsistency
834        View firstChild = mLayoutManager.getChildAt(0);
835        final int firstChildStartBeforeScroll = mLayoutManager.mPrimaryOrientation
836                .getDecoratedStart(firstChild);
837        int distance = mLayoutManager.mPrimaryOrientation.getDecoratedMeasurement(firstChild) / 2;
838        if (config.mReverseLayout) {
839            distance *= -1;
840        }
841        scrollBy(distance);
842        waitForMainThread(2);
843        assertTrue("scroll by should move children", firstChildStartBeforeScroll !=
844                mLayoutManager.mPrimaryOrientation.getDecoratedStart(firstChild));
845        before = mLayoutManager.collectChildCoordinates();
846        mLayoutManager.expectLayouts(1);
847        requestLayoutOnUIThread(mRecyclerView);
848        mLayoutManager.waitForLayout(2);
849        after = mLayoutManager.collectChildCoordinates();
850        assertRectSetsEqual(config + " simple re-layout after scroll", before, after);
851    }
852}
853