StaggeredGridLayoutManagerTest.java revision c50c4cad31d73e574b27bb3d7581542975e37263
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
18package android.support.v7.widget;
19
20
21import android.graphics.Rect;
22import android.os.Parcel;
23import android.os.Parcelable;
24import android.util.Log;
25import android.view.View;
26import android.view.ViewGroup;
27
28import java.util.ArrayList;
29import java.util.Arrays;
30import java.util.HashSet;
31import java.util.LinkedHashMap;
32import java.util.List;
33import java.util.Map;
34import java.util.UUID;
35import java.util.concurrent.CountDownLatch;
36import java.util.concurrent.TimeUnit;
37import java.util.concurrent.atomic.AtomicInteger;
38
39import static android.support.v7.widget.LayoutState.*;
40import static android.support.v7.widget.StaggeredGridLayoutManager.*;
41
42public class StaggeredGridLayoutManagerTest extends BaseRecyclerViewInstrumentationTest {
43
44    private static final boolean DEBUG = false;
45
46    private static final String TAG = "StaggeredGridLayoutManagerTest";
47
48    WrappedLayoutManager mLayoutManager;
49
50    GridTestAdapter mAdapter;
51
52    RecyclerView mRecyclerView;
53
54    final List<Config> mBaseVariations = new ArrayList<Config>();
55
56    @Override
57    protected void setUp() throws Exception {
58        super.setUp();
59        for (int orientation : new int[]{VERTICAL, HORIZONTAL}) {
60            for (boolean reverseLayout : new boolean[]{false, true}) {
61                for (int spanCount : new int[]{1, 3}) {
62                    for (int gapStrategy : new int[]{GAP_HANDLING_NONE,
63                            GAP_HANDLING_MOVE_ITEMS_BETWEEN_SPANS}) {
64                        mBaseVariations.add(new Config(orientation, reverseLayout, spanCount,
65                                gapStrategy));
66                    }
67                }
68            }
69        }
70    }
71
72    void setupByConfig(Config config) throws Throwable {
73        mAdapter = new GridTestAdapter(config.mItemCount, config.mOrientation);
74        mRecyclerView = new RecyclerView(getActivity());
75        mRecyclerView.setAdapter(mAdapter);
76        mRecyclerView.setHasFixedSize(true);
77        mLayoutManager = new WrappedLayoutManager(config.mSpanCount,
78                config.mOrientation);
79        mLayoutManager.setGapStrategy(config.mGapStrategy);
80        mLayoutManager.setReverseLayout(config.mReverseLayout);
81        mRecyclerView.setLayoutManager(mLayoutManager);
82    }
83
84    public void testScrollToPositionWithPredictive() throws Throwable {
85        scrollToPositionWithPredictive(0, LinearLayoutManager.INVALID_OFFSET);
86        removeRecyclerView();
87        scrollToPositionWithPredictive(Config.DEFAULT_ITEM_COUNT / 2,
88                LinearLayoutManager.INVALID_OFFSET);
89        removeRecyclerView();
90        scrollToPositionWithPredictive(9, 20);
91        removeRecyclerView();
92        scrollToPositionWithPredictive(Config.DEFAULT_ITEM_COUNT / 2, 10);
93
94    }
95
96    public void scrollToPositionWithPredictive(final int scrollPosition, final int scrollOffset)
97            throws Throwable {
98        setupByConfig(new Config(StaggeredGridLayoutManager.VERTICAL,
99                false, 3, StaggeredGridLayoutManager.GAP_HANDLING_NONE));
100        waitFirstLayout();
101        mLayoutManager.mOnLayoutListener = new OnLayoutListener() {
102            @Override
103            void after(RecyclerView.Recycler recycler, RecyclerView.State state) {
104                if (state.isPreLayout()) {
105                    assertEquals("pending scroll position should still be pending",
106                            scrollPosition, mLayoutManager.mPendingScrollPosition);
107                    if (scrollOffset != LinearLayoutManager.INVALID_OFFSET) {
108                        assertEquals("pending scroll position offset should still be pending",
109                                scrollOffset, mLayoutManager.mPendingScrollPositionOffset);
110                    }
111                } else {
112                    RecyclerView.ViewHolder vh =
113                            mRecyclerView.findViewHolderForPosition(scrollPosition);
114                    assertNotNull("scroll to position should work", vh);
115                    if (scrollOffset != LinearLayoutManager.INVALID_OFFSET) {
116                        assertEquals("scroll offset should be applied properly",
117                                mLayoutManager.getPaddingTop() + scrollOffset
118                                        + ((RecyclerView.LayoutParams) vh.itemView
119                                            .getLayoutParams()).topMargin,
120                                mLayoutManager.getDecoratedTop(vh.itemView));
121                    }
122                }
123            }
124        };
125        mLayoutManager.expectLayouts(2);
126        runTestOnUiThread(new Runnable() {
127            @Override
128            public void run() {
129                try {
130                    mAdapter.addAndNotify(0, 1);
131                    if (scrollOffset == LinearLayoutManager.INVALID_OFFSET) {
132                        mLayoutManager.scrollToPosition(scrollPosition);
133                    } else {
134                        mLayoutManager.scrollToPositionWithOffset(scrollPosition,
135                                scrollOffset);
136                    }
137
138                } catch (Throwable throwable) {
139                    throwable.printStackTrace();
140                }
141
142            }
143        });
144        mLayoutManager.waitForLayout(2);
145        checkForMainThreadException();
146    }
147
148    LayoutParams getLp(View view) {
149        return (LayoutParams) view.getLayoutParams();
150    }
151
152    public void testGetFirstLastChildrenTest() throws Throwable {
153        for (boolean provideArr : new boolean[]{true, false}) {
154            for (Config config : mBaseVariations) {
155                getFirstLastChildrenTest(config, provideArr);
156                removeRecyclerView();
157            }
158        }
159    }
160
161    public void getFirstLastChildrenTest(final Config config, final boolean provideArr)
162            throws Throwable {
163        setupByConfig(config);
164        waitFirstLayout();
165        Runnable viewInBoundsTest = new Runnable() {
166            @Override
167            public void run() {
168                VisibleChildren visibleChildren = mLayoutManager.traverseAndFindVisibleChildren();
169                final String boundsLog = mLayoutManager.getBoundsLog();
170                VisibleChildren queryResult = new VisibleChildren(mLayoutManager.getSpanCount());
171                queryResult.firstFullyVisiblePositions = mLayoutManager
172                        .findFirstCompletelyVisibleItemPositions(
173                                provideArr ? new int[mLayoutManager.getSpanCount()] : null);
174                queryResult.firstVisiblePositions = mLayoutManager
175                        .findFirstVisibleItemPositions(
176                                provideArr ? new int[mLayoutManager.getSpanCount()] : null);
177                queryResult.lastFullyVisiblePositions = mLayoutManager
178                        .findLastCompletelyVisibleItemPositions(
179                                provideArr ? new int[mLayoutManager.getSpanCount()] : null);
180                queryResult.lastVisiblePositions = mLayoutManager
181                        .findLastVisibleItemPositions(
182                                provideArr ? new int[mLayoutManager.getSpanCount()] : null);
183                assertEquals(config + ":\nfirst visible child should match traversal result\n"
184                                + "traversed:" + visibleChildren + "\n"
185                                + "queried:" + queryResult + "\n"
186                                + boundsLog, visibleChildren, queryResult
187                );
188            }
189        };
190        runTestOnUiThread(viewInBoundsTest);
191        // smooth scroll to end of the list and keep testing meanwhile. This will test pre-caching
192        // case
193        final int scrollPosition = mAdapter.getItemCount();
194        runTestOnUiThread(new Runnable() {
195            @Override
196            public void run() {
197                mRecyclerView.smoothScrollToPosition(scrollPosition);
198            }
199        });
200        while (mLayoutManager.isSmoothScrolling() ||
201                mRecyclerView.getScrollState() != RecyclerView.SCROLL_STATE_IDLE) {
202            runTestOnUiThread(viewInBoundsTest);
203            Thread.sleep(400);
204        }
205        // delete all items
206        mLayoutManager.expectLayouts(2);
207        mAdapter.deleteAndNotify(0, mAdapter.getItemCount());
208        mLayoutManager.waitForLayout(2);
209        // test empty case
210        runTestOnUiThread(viewInBoundsTest);
211        // set a new adapter with huge items to test full bounds check
212        mLayoutManager.expectLayouts(1);
213        final int totalSpace = mLayoutManager.mPrimaryOrientation.getTotalSpace();
214        final TestAdapter newAdapter = new TestAdapter(100) {
215            @Override
216            public void onBindViewHolder(TestViewHolder holder,
217                    int position) {
218                super.onBindViewHolder(holder, position);
219                if (config.mOrientation == LinearLayoutManager.HORIZONTAL) {
220                    holder.itemView.setMinimumWidth(totalSpace + 5);
221                } else {
222                    holder.itemView.setMinimumHeight(totalSpace + 5);
223                }
224            }
225        };
226        runTestOnUiThread(new Runnable() {
227            @Override
228            public void run() {
229                mRecyclerView.setAdapter(newAdapter);
230            }
231        });
232        mLayoutManager.waitForLayout(2);
233        runTestOnUiThread(viewInBoundsTest);
234    }
235
236    public void testInnerGapHandling() throws Throwable {
237        innerGapHandlingTest(StaggeredGridLayoutManager.GAP_HANDLING_NONE);
238        innerGapHandlingTest(StaggeredGridLayoutManager.GAP_HANDLING_MOVE_ITEMS_BETWEEN_SPANS);
239    }
240
241    public void innerGapHandlingTest(int strategy) throws Throwable {
242        Config config = new Config().spanCount(3).itemCount(500);
243        setupByConfig(config);
244        mLayoutManager.setGapStrategy(strategy);
245        mAdapter.mFullSpanItems.add(100);
246        mAdapter.mFullSpanItems.add(104);
247        mAdapter.mViewsHaveEqualSize = true;
248        waitFirstLayout();
249        mLayoutManager.expectLayouts(1);
250        scrollToPosition(400);
251        mLayoutManager.waitForLayout(2);
252        mLayoutManager.expectLayouts(2);
253        mAdapter.addAndNotify(101, 1);
254        mLayoutManager.waitForLayout(2);
255        if (strategy == GAP_HANDLING_MOVE_ITEMS_BETWEEN_SPANS) {
256            mLayoutManager.expectLayouts(1);
257        }
258
259        // state
260        // now smooth scroll to 99 to trigger a layout around 100
261        smoothScrollToPosition(99);
262        switch (strategy) {
263            case GAP_HANDLING_NONE:
264                assertSpans("gap handling:" + Config.gapStrategyName(strategy), new int[]{100, 0},
265                        new int[]{101, 2}, new int[]{102, 0}, new int[]{103, 1}, new int[]{104, 2},
266                        new int[]{105, 0});
267
268                // should be able to detect the gap
269                View gapView = mLayoutManager.hasGapsToFix(0, mLayoutManager.getChildCount());
270                assertSame("gap should be detected", mLayoutManager.findViewByPosition(101),
271                        gapView);
272                break;
273            case GAP_HANDLING_MOVE_ITEMS_BETWEEN_SPANS:
274                mLayoutManager.waitForLayout(2);
275                assertSpans("swap items between spans", new int[]{100, 0}, new int[]{101, 0},
276                        new int[]{102, 1}, new int[]{103, 2}, new int[]{104, 0}, new int[]{105, 0});
277                break;
278        }
279
280    }
281
282    public void testFullSizeSpans() throws Throwable {
283        Config config = new Config().spanCount(5).itemCount(30);
284        setupByConfig(config);
285        mAdapter.mFullSpanItems.add(3);
286        waitFirstLayout();
287        assertSpans("Testing full size span", new int[]{0, 0}, new int[]{1, 1}, new int[]{2, 2},
288                new int[]{3, 0}, new int[]{4, 0}, new int[]{5, 1}, new int[]{6, 2},
289                new int[]{7, 3}, new int[]{8, 4});
290    }
291
292    void assertSpans(String msg, int[]... childSpanTuples) {
293        for (int i = 0; i < childSpanTuples.length; i++) {
294            assertSpan(msg, childSpanTuples[i][0], childSpanTuples[i][1]);
295        }
296    }
297
298    void assertSpan(String msg, int childPosition, int expectedSpan) {
299        View view = mLayoutManager.findViewByPosition(childPosition);
300        assertNotNull(msg + "view at position " + childPosition + " should exists", view);
301        assertEquals(msg + "[child:" + childPosition + "]", expectedSpan,
302                getLp(view).mSpan.mIndex);
303    }
304
305    public void testSpanReassignmentsOnItemChange() throws Throwable {
306        Config config = new Config().spanCount(5);
307        setupByConfig(config);
308        waitFirstLayout();
309        smoothScrollToPosition(mAdapter.getItemCount() / 2);
310        final int changePosition = mAdapter.getItemCount() / 4;
311        int[] prevAssignments = mLayoutManager.mLazySpanLookup.mData.clone();
312        mLayoutManager.expectLayouts(1);
313        runTestOnUiThread(new Runnable() {
314            @Override
315            public void run() {
316                mAdapter.notifyItemChanged(changePosition);
317            }
318        });
319        mLayoutManager.assertNoLayout("no layout should happen when an invisible child is updated",
320                1);
321        // item change should not affect span assignments
322        assertSpanAssignmentEquality("item change should not affect span assignments ",
323                prevAssignments, mLayoutManager.mLazySpanLookup.mData, 0, prevAssignments.length);
324
325        // delete an item before visible area
326        int deletedPosition = mLayoutManager.getPosition(mLayoutManager.getChildAt(0)) - 2;
327        Map<Item, Rect> before = mLayoutManager.collectChildCoordinates();
328        if (DEBUG) {
329            Log.d(TAG, "before:");
330            for (Map.Entry<Item, Rect> entry : before.entrySet()) {
331                Log.d(TAG, entry.getKey().mAdapterIndex + ":" + entry.getValue());
332            }
333        }
334        mLayoutManager.expectLayouts(1);
335        // TODO move these bounds to edge case once animation changes are in.
336        mAdapter.deleteAndNotify(deletedPosition, 1);
337        mLayoutManager.waitForLayout(2);
338        assertRectSetsEqual(config + " when an item towards the head of the list is deleted, it "
339                        + "should not affect the layout if it is not visible", before,
340                mLayoutManager.collectChildCoordinates()
341        );
342        deletedPosition = mLayoutManager.getPosition(mLayoutManager.getChildAt(2));
343        mLayoutManager.expectLayouts(1);
344        mAdapter.deleteAndNotify(deletedPosition, 1);
345        mLayoutManager.waitForLayout(2);
346        assertRectSetsNotEqual(config + " when a visible item is deleted, it should affect the "
347                + "layout", before, mLayoutManager.collectChildCoordinates());
348    }
349
350    void assertSpanAssignmentEquality(String msg, int[] set1, int[] set2, int start, int end) {
351        for (int i = start; i < end; i++) {
352            assertEquals(msg + " ind:" + i, set1[i], set2[i]);
353        }
354    }
355
356    void assertSpanAssignmentEquality(String msg, int[] set1, int[] set2, int start1, int start2,
357            int length) {
358        for (int i = 0; i < length; i++) {
359            assertEquals(msg + " ind1:" + (start1 + i) + ", ind2:" + (start2 + i), set1[start1 + i],
360                    set2[start2 + i]);
361        }
362    }
363
364    public void testViewSnapping() throws Throwable {
365        for (Config config : mBaseVariations) {
366            viewSnapTest(config.itemCount(config.mSpanCount + 1));
367            removeRecyclerView();
368        }
369    }
370
371    public void viewSnapTest(Config config) throws Throwable {
372        setupByConfig(config);
373        waitFirstLayout();
374        // run these tests twice. once initial layout, once after scroll
375        String logSuffix = "";
376        for (int i = 0; i < 2; i++) {
377            Map<Item, Rect> itemRectMap = mLayoutManager.collectChildCoordinates();
378            Rect recyclerViewBounds = getDecoratedRecyclerViewBounds();
379            Rect usedLayoutBounds = new Rect();
380            for (Rect rect : itemRectMap.values()) {
381                usedLayoutBounds.union(rect);
382            }
383            if (DEBUG) {
384                Log.d(TAG, "testing view snapping (" + logSuffix + ") for config " + config);
385            }
386            if (config.mOrientation == VERTICAL) {
387                assertEquals(config + " there should be no gap on left" + logSuffix,
388                        usedLayoutBounds.left, recyclerViewBounds.left);
389                assertEquals(config + " there should be no gap on right" + logSuffix,
390                        usedLayoutBounds.right, recyclerViewBounds.right);
391                if (config.mReverseLayout) {
392                    assertEquals(config + " there should be no gap on bottom" + logSuffix,
393                            usedLayoutBounds.bottom, recyclerViewBounds.bottom);
394                    assertTrue(config + " there should be some gap on top" + logSuffix,
395                            usedLayoutBounds.top > recyclerViewBounds.top);
396                } else {
397                    assertEquals(config + " there should be no gap on top" + logSuffix,
398                            usedLayoutBounds.top, recyclerViewBounds.top);
399                    assertTrue(config + " there should be some gap at the bottom" + logSuffix,
400                            usedLayoutBounds.bottom < recyclerViewBounds.bottom);
401                }
402            } else {
403                assertEquals(config + " there should be no gap on top" + logSuffix,
404                        usedLayoutBounds.top, recyclerViewBounds.top);
405                assertEquals(config + " there should be no gap at the bottom" + logSuffix,
406                        usedLayoutBounds.bottom, recyclerViewBounds.bottom);
407                if (config.mReverseLayout) {
408                    assertEquals(config + " there should be no on right" + logSuffix,
409                            usedLayoutBounds.right, recyclerViewBounds.right);
410                    assertTrue(config + " there should be some gap on left" + logSuffix,
411                            usedLayoutBounds.left > recyclerViewBounds.left);
412                } else {
413                    assertEquals(config + " there should be no gap on left" + logSuffix,
414                            usedLayoutBounds.left, recyclerViewBounds.left);
415                    assertTrue(config + " there should be some gap on right" + logSuffix,
416                            usedLayoutBounds.right < recyclerViewBounds.right);
417                }
418            }
419            final int scroll = config.mReverseLayout ? -500 : 500;
420            scrollBy(scroll);
421            logSuffix = " scrolled " + scroll;
422        }
423
424    }
425
426    public void testSpanCountChangeOnRestoreSavedState() throws Throwable {
427        Config config = new Config(HORIZONTAL, true, 5, GAP_HANDLING_NONE);
428        setupByConfig(config);
429        waitFirstLayout();
430
431        int beforeChildCount = mLayoutManager.getChildCount();
432        Parcelable savedState = mRecyclerView.onSaveInstanceState();
433        // we append a suffix to the parcelable to test out of bounds
434        String parcelSuffix = UUID.randomUUID().toString();
435        Parcel parcel = Parcel.obtain();
436        savedState.writeToParcel(parcel, 0);
437        parcel.writeString(parcelSuffix);
438        removeRecyclerView();
439        // reset for reading
440        parcel.setDataPosition(0);
441        // re-create
442        savedState = RecyclerView.SavedState.CREATOR.createFromParcel(parcel);
443        removeRecyclerView();
444
445        RecyclerView restored = new RecyclerView(getActivity());
446        mLayoutManager = new WrappedLayoutManager(config.mSpanCount, config.mOrientation);
447        mLayoutManager.setReverseLayout(config.mReverseLayout);
448        mLayoutManager.setGapStrategy(config.mGapStrategy);
449        restored.setLayoutManager(mLayoutManager);
450        // use the same adapter for Rect matching
451        restored.setAdapter(mAdapter);
452        restored.onRestoreInstanceState(savedState);
453        mLayoutManager.setSpanCount(1);
454        mLayoutManager.expectLayouts(1);
455        setRecyclerView(restored);
456        mLayoutManager.waitForLayout(2);
457        assertEquals("on saved state, reverse layout should be preserved",
458                config.mReverseLayout, mLayoutManager.getReverseLayout());
459        assertEquals("on saved state, orientation should be preserved",
460                config.mOrientation, mLayoutManager.getOrientation());
461        assertEquals("after setting new span count, layout manager should keep new value",
462                1, mLayoutManager.getSpanCount());
463        assertEquals("on saved state, gap strategy should be preserved",
464                config.mGapStrategy, mLayoutManager.getGapStrategy());
465        assertTrue("when span count is dramatically changed after restore, # of child views "
466                + "should change", beforeChildCount > mLayoutManager.getChildCount());
467        // make sure LLM can layout all children. is some span info is leaked, this would crash
468        smoothScrollToPosition(mAdapter.getItemCount() - 1);
469    }
470
471    public void testSavedState() throws Throwable {
472        PostLayoutRunnable[] postLayoutOptions = new PostLayoutRunnable[]{
473                new PostLayoutRunnable() {
474                    @Override
475                    public void run() throws Throwable {
476                        // do nothing
477                    }
478
479                    @Override
480                    public String describe() {
481                        return "doing nothing";
482                    }
483                },
484                new PostLayoutRunnable() {
485                    @Override
486                    public void run() throws Throwable {
487                        mLayoutManager.expectLayouts(1);
488                        scrollToPosition(mAdapter.getItemCount() * 3 / 4);
489                        mLayoutManager.waitForLayout(2);
490                    }
491
492                    @Override
493                    public String describe() {
494                        return "scroll to position";
495                    }
496                },
497                new PostLayoutRunnable() {
498                    @Override
499                    public void run() throws Throwable {
500                        mLayoutManager.expectLayouts(1);
501                        scrollToPositionWithOffset(mAdapter.getItemCount() * 1 / 3,
502                                50);
503                        mLayoutManager.waitForLayout(2);
504                    }
505
506                    @Override
507                    public String describe() {
508                        return "scroll to position with positive offset";
509                    }
510                },
511                new PostLayoutRunnable() {
512                    @Override
513                    public void run() throws Throwable {
514                        mLayoutManager.expectLayouts(1);
515                        scrollToPositionWithOffset(mAdapter.getItemCount() * 2 / 3,
516                                -50);
517                        mLayoutManager.waitForLayout(2);
518                    }
519
520                    @Override
521                    public String describe() {
522                        return "scroll to position with negative offset";
523                    }
524                }
525        };
526        boolean[] waitForLayoutOptions = new boolean[]{false, true};
527        List<Config> testVariations = new ArrayList<Config>();
528        testVariations.addAll(mBaseVariations);
529        for (Config config : mBaseVariations) {
530            if (config.mSpanCount < 2) {
531                continue;
532            }
533            final Config clone = (Config) config.clone();
534            clone.mItemCount = clone.mSpanCount - 1;
535            testVariations.add(clone);
536        }
537
538        for (Config config : testVariations) {
539            for (PostLayoutRunnable runnable : postLayoutOptions) {
540                for (boolean waitForLayout : waitForLayoutOptions) {
541                    savedStateTest(config, waitForLayout, runnable);
542                    removeRecyclerView();
543                }
544            }
545        }
546    }
547
548    public void savedStateTest(Config config, boolean waitForLayout,
549            PostLayoutRunnable postLayoutOperations)
550            throws Throwable {
551        if (DEBUG) {
552            Log.d(TAG, "testing saved state with wait for layout = " + waitForLayout + " config "
553                    + config + " post layout action " + postLayoutOperations.describe());
554        }
555        setupByConfig(config);
556        waitFirstLayout();
557        if (waitForLayout) {
558            postLayoutOperations.run();
559        }
560        final int firstCompletelyVisiblePosition = mLayoutManager.findFirstVisibleItemPositionInt();
561        Map<Item, Rect> before = mLayoutManager.collectChildCoordinates();
562        Parcelable savedState = mRecyclerView.onSaveInstanceState();
563        // we append a suffix to the parcelable to test out of bounds
564        String parcelSuffix = UUID.randomUUID().toString();
565        Parcel parcel = Parcel.obtain();
566        savedState.writeToParcel(parcel, 0);
567        parcel.writeString(parcelSuffix);
568        removeRecyclerView();
569        // reset for reading
570        parcel.setDataPosition(0);
571        // re-create
572        savedState = RecyclerView.SavedState.CREATOR.createFromParcel(parcel);
573        removeRecyclerView();
574
575        RecyclerView restored = new RecyclerView(getActivity());
576        mLayoutManager = new WrappedLayoutManager(config.mSpanCount, config.mOrientation);
577        mLayoutManager.setGapStrategy(config.mGapStrategy);
578        restored.setLayoutManager(mLayoutManager);
579        // use the same adapter for Rect matching
580        restored.setAdapter(mAdapter);
581        restored.onRestoreInstanceState(savedState);
582        assertEquals("Parcel reading should not go out of bounds", parcelSuffix,
583                parcel.readString());
584        mLayoutManager.expectLayouts(1);
585        setRecyclerView(restored);
586        mLayoutManager.waitForLayout(2);
587        assertEquals(config + " on saved state, reverse layout should be preserved",
588                config.mReverseLayout, mLayoutManager.getReverseLayout());
589        assertEquals(config + " on saved state, orientation should be preserved",
590                config.mOrientation, mLayoutManager.getOrientation());
591        assertEquals(config + " on saved state, span count should be preserved",
592                config.mSpanCount, mLayoutManager.getSpanCount());
593        assertEquals(config + " on saved state, gap strategy should be preserved",
594                config.mGapStrategy, mLayoutManager.getGapStrategy());
595        assertEquals(config + " on saved state, first completely visible child position should"
596                + " be preserved", firstCompletelyVisiblePosition,
597                mLayoutManager.findFirstVisibleItemPositionInt());
598        if (waitForLayout) {
599            assertRectSetsEqual(config + "\npost layout op:" + postLayoutOperations.describe()
600                            + ": on restore, previous view positions should be preserved",
601                    before, mLayoutManager.collectChildCoordinates()
602            );
603        }
604        // TODO add tests for changing values after restore before layout
605    }
606
607    public void testScrollToPositionWithOffset() throws Throwable {
608        for (Config config : mBaseVariations) {
609            scrollToPositionWithOffsetTest(config);
610            removeRecyclerView();
611        }
612    }
613
614    public void scrollToPositionWithOffsetTest(Config config) throws Throwable {
615        setupByConfig(config);
616        waitFirstLayout();
617        OrientationHelper orientationHelper = OrientationHelper
618                .createOrientationHelper(mLayoutManager, config.mOrientation);
619        Rect layoutBounds = getDecoratedRecyclerViewBounds();
620        // try scrolling towards head, should not affect anything
621        Map<Item, Rect> before = mLayoutManager.collectChildCoordinates();
622        scrollToPositionWithOffset(0, 20);
623        assertRectSetsEqual(config + " trying to over scroll with offset should be no-op",
624                before, mLayoutManager.collectChildCoordinates());
625        // try offsetting some visible children
626        int testCount = 10;
627        while (testCount-- > 0) {
628            // get middle child
629            final View child = mLayoutManager.getChildAt(mLayoutManager.getChildCount() / 2);
630            final int position = mRecyclerView.getChildPosition(child);
631            final int startOffset = config.mReverseLayout ?
632                    orientationHelper.getEndAfterPadding() - orientationHelper
633                            .getDecoratedEnd(child)
634                    : orientationHelper.getDecoratedStart(child) - orientationHelper
635                            .getStartAfterPadding();
636            final int scrollOffset = startOffset / 2;
637            mLayoutManager.expectLayouts(1);
638            scrollToPositionWithOffset(position, scrollOffset);
639            mLayoutManager.waitForLayout(2);
640            final int finalOffset = config.mReverseLayout ?
641                    orientationHelper.getEndAfterPadding() - orientationHelper
642                            .getDecoratedEnd(child)
643                    : orientationHelper.getDecoratedStart(child) - orientationHelper
644                            .getStartAfterPadding();
645            assertEquals(config + " scroll with offset on a visible child should work fine",
646                    scrollOffset, finalOffset);
647        }
648
649        // try scrolling to invisible children
650        testCount = 10;
651        // we test above and below, one by one
652        int offsetMultiplier = -1;
653        while (testCount-- > 0) {
654            final TargetTuple target = findInvisibleTarget(config);
655            mLayoutManager.expectLayouts(1);
656            final int offset = offsetMultiplier
657                    * orientationHelper.getDecoratedMeasurement(mLayoutManager.getChildAt(0)) / 3;
658            scrollToPositionWithOffset(target.mPosition, offset);
659            mLayoutManager.waitForLayout(2);
660            final View child = mLayoutManager.findViewByPosition(target.mPosition);
661            assertNotNull(config + " scrolling to a mPosition with offset " + offset
662                    + " should layout it", child);
663            final Rect bounds = mLayoutManager.getViewBounds(child);
664            if (DEBUG) {
665                Log.d(TAG, config + " post scroll to invisible mPosition " + bounds + " in "
666                        + layoutBounds + " with offset " + offset);
667            }
668
669            if (config.mReverseLayout) {
670                assertEquals(config + " when scrolling with offset to an invisible in reverse "
671                                + "layout, its end should align with recycler view's end - offset",
672                        orientationHelper.getEndAfterPadding() - offset,
673                        orientationHelper.getDecoratedEnd(child)
674                );
675            } else {
676                assertEquals(config + " when scrolling with offset to an invisible child in normal"
677                                + " layout its start should align with recycler view's start + "
678                                + "offset",
679                        orientationHelper.getStartAfterPadding() + offset,
680                        orientationHelper.getDecoratedStart(child)
681                );
682            }
683            offsetMultiplier *= -1;
684        }
685    }
686
687    public void testScrollToPosition() throws Throwable {
688        for (Config config : mBaseVariations) {
689            scrollToPositionTest(config);
690            removeRecyclerView();
691        }
692    }
693
694    private TargetTuple findInvisibleTarget(Config config) {
695        int minPosition = Integer.MAX_VALUE, maxPosition = Integer.MIN_VALUE;
696        for (int i = 0; i < mLayoutManager.getChildCount(); i++) {
697            View child = mLayoutManager.getChildAt(i);
698            int position = mRecyclerView.getChildPosition(child);
699            if (position < minPosition) {
700                minPosition = position;
701            }
702            if (position > maxPosition) {
703                maxPosition = position;
704            }
705        }
706        final int tailTarget = maxPosition + (mAdapter.getItemCount() - maxPosition) / 2;
707        final int headTarget = minPosition / 2;
708        final int target;
709        // where will the child come from ?
710        final int itemLayoutDirection;
711        if (Math.abs(tailTarget - maxPosition) > Math.abs(headTarget - minPosition)) {
712            target = tailTarget;
713            itemLayoutDirection = config.mReverseLayout ? LAYOUT_START : LAYOUT_END;
714        } else {
715            target = headTarget;
716            itemLayoutDirection = config.mReverseLayout ? LAYOUT_END : LAYOUT_START;
717        }
718        if (DEBUG) {
719            Log.d(TAG,
720                    config + " target:" + target + " min:" + minPosition + ", max:" + maxPosition);
721        }
722        return new TargetTuple(target, itemLayoutDirection);
723    }
724
725    public void scrollToPositionTest(Config config) throws Throwable {
726        setupByConfig(config);
727        waitFirstLayout();
728        OrientationHelper orientationHelper = OrientationHelper
729                .createOrientationHelper(mLayoutManager, config.mOrientation);
730        Rect layoutBounds = getDecoratedRecyclerViewBounds();
731        for (int i = 0; i < mLayoutManager.getChildCount(); i++) {
732            View view = mLayoutManager.getChildAt(i);
733            Rect bounds = mLayoutManager.getViewBounds(view);
734            if (layoutBounds.contains(bounds)) {
735                Map<Item, Rect> initialBounds = mLayoutManager.collectChildCoordinates();
736                final int position = mRecyclerView.getChildPosition(view);
737                LayoutParams layoutParams
738                        = (LayoutParams) (view.getLayoutParams());
739                TestViewHolder vh = (TestViewHolder) layoutParams.mViewHolder;
740                assertEquals("recycler view mPosition should match adapter mPosition", position,
741                        vh.mBindedItem.mAdapterIndex);
742                if (DEBUG) {
743                    Log.d(TAG, "testing scroll to visible mPosition at " + position
744                            + " " + bounds + " inside " + layoutBounds);
745                }
746                mLayoutManager.expectLayouts(1);
747                scrollToPosition(position);
748                mLayoutManager.waitForLayout(2);
749                if (DEBUG) {
750                    view = mLayoutManager.findViewByPosition(position);
751                    Rect newBounds = mLayoutManager.getViewBounds(view);
752                    Log.d(TAG, "after scrolling to visible mPosition " +
753                            bounds + " equals " + newBounds);
754                }
755
756                assertRectSetsEqual(
757                        config + "scroll to mPosition on fully visible child should be no-op",
758                        initialBounds, mLayoutManager.collectChildCoordinates());
759            } else {
760                final int position = mRecyclerView.getChildPosition(view);
761                if (DEBUG) {
762                    Log.d(TAG,
763                            "child(" + position + ") not fully visible " + bounds + " not inside "
764                                    + layoutBounds
765                                    + mRecyclerView.getChildPosition(view)
766                    );
767                }
768                mLayoutManager.expectLayouts(1);
769                runTestOnUiThread(new Runnable() {
770                    @Override
771                    public void run() {
772                        mLayoutManager.scrollToPosition(position);
773                    }
774                });
775                mLayoutManager.waitForLayout(2);
776                view = mLayoutManager.findViewByPosition(position);
777                bounds = mLayoutManager.getViewBounds(view);
778                if (DEBUG) {
779                    Log.d(TAG, "after scroll to partially visible child " + bounds + " in "
780                            + layoutBounds);
781                }
782                assertTrue(config
783                                + " after scrolling to a partially visible child, it should become fully "
784                                + " visible. " + bounds + " not inside " + layoutBounds,
785                        layoutBounds.contains(bounds)
786                );
787                assertTrue(config + " when scrolling to a partially visible item, one of its edges "
788                        + "should be on the boundaries", orientationHelper.getStartAfterPadding() ==
789                        orientationHelper.getDecoratedStart(view)
790                        || orientationHelper.getEndAfterPadding() ==
791                        orientationHelper.getDecoratedEnd(view));
792            }
793        }
794
795        // try scrolling to invisible children
796        int testCount = 10;
797        while (testCount-- > 0) {
798            final TargetTuple target = findInvisibleTarget(config);
799            mLayoutManager.expectLayouts(1);
800            scrollToPosition(target.mPosition);
801            mLayoutManager.waitForLayout(2);
802            final View child = mLayoutManager.findViewByPosition(target.mPosition);
803            assertNotNull(config + " scrolling to a mPosition should lay it out", child);
804            final Rect bounds = mLayoutManager.getViewBounds(child);
805            if (DEBUG) {
806                Log.d(TAG, config + " post scroll to invisible mPosition " + bounds + " in "
807                        + layoutBounds);
808            }
809            assertTrue(config + " scrolling to a mPosition should make it fully visible",
810                    layoutBounds.contains(bounds));
811            if (target.mLayoutDirection == LAYOUT_START) {
812                assertEquals(
813                        config + " when scrolling to an invisible child above, its start should"
814                                + " align with recycler view's start",
815                        orientationHelper.getStartAfterPadding(),
816                        orientationHelper.getDecoratedStart(child)
817                );
818            } else {
819                assertEquals(config + " when scrolling to an invisible child below, its end "
820                                + "should align with recycler view's end",
821                        orientationHelper.getEndAfterPadding(),
822                        orientationHelper.getDecoratedEnd(child)
823                );
824            }
825        }
826    }
827
828    private void scrollToPositionWithOffset(final int position, final int offset) throws Throwable {
829        runTestOnUiThread(new Runnable() {
830            @Override
831            public void run() {
832                mLayoutManager.scrollToPositionWithOffset(position, offset);
833            }
834        });
835    }
836
837    public void testLayoutOrder() throws Throwable {
838        for (Config config : mBaseVariations) {
839            layoutOrderTest(config);
840            removeRecyclerView();
841        }
842    }
843
844    public void layoutOrderTest(Config config) throws Throwable {
845        setupByConfig(config);
846        assertViewPositions(config);
847    }
848
849    void assertViewPositions(Config config) {
850        ArrayList<ArrayList<View>> viewsBySpan = mLayoutManager.collectChildrenBySpan();
851        OrientationHelper orientationHelper = OrientationHelper
852                .createOrientationHelper(mLayoutManager, config.mOrientation);
853        for (ArrayList<View> span : viewsBySpan) {
854            // validate all children's order. first child should have min start mPosition
855            final int count = span.size();
856            for (int i = 0, j = 1; j < count; i++, j++) {
857                View prev = span.get(i);
858                View next = span.get(j);
859                assertTrue(config + " prev item should be above next item",
860                        orientationHelper.getDecoratedEnd(prev) <= orientationHelper
861                                .getDecoratedStart(next)
862                );
863
864            }
865        }
866    }
867
868    public void testScrollBy() throws Throwable {
869        for (Config config : mBaseVariations) {
870            scrollByTest(config);
871            removeRecyclerView();
872        }
873    }
874
875    void waitFirstLayout() throws Throwable {
876        mLayoutManager.expectLayouts(1);
877        setRecyclerView(mRecyclerView);
878        mLayoutManager.waitForLayout(2);
879    }
880
881    public void scrollByTest(Config config) throws Throwable {
882        setupByConfig(config);
883        waitFirstLayout();
884        // try invalid scroll. should not happen
885        final View first = mLayoutManager.getChildAt(0);
886        OrientationHelper primaryOrientation = OrientationHelper
887                .createOrientationHelper(mLayoutManager, config.mOrientation);
888        int scrollDist;
889        if (config.mReverseLayout) {
890            scrollDist = primaryOrientation.getDecoratedMeasurement(first) / 2;
891        } else {
892            scrollDist = -primaryOrientation.getDecoratedMeasurement(first) / 2;
893        }
894        Map<Item, Rect> before = mLayoutManager.collectChildCoordinates();
895        scrollBy(scrollDist);
896        Map<Item, Rect> after = mLayoutManager.collectChildCoordinates();
897        assertRectSetsEqual(
898                config + " if there are no more items, scroll should not happen (dt:" + scrollDist
899                        + ")",
900                before, after
901        );
902
903        scrollDist = -scrollDist * 3;
904        before = mLayoutManager.collectChildCoordinates();
905        scrollBy(scrollDist);
906        after = mLayoutManager.collectChildCoordinates();
907        int layoutStart = primaryOrientation.getStartAfterPadding();
908        int layoutEnd = primaryOrientation.getEndAfterPadding();
909        for (Map.Entry<Item, Rect> entry : before.entrySet()) {
910            Rect afterRect = after.get(entry.getKey());
911            // offset rect
912            if (config.mOrientation == VERTICAL) {
913                entry.getValue().offset(0, -scrollDist);
914            } else {
915                entry.getValue().offset(-scrollDist, 0);
916            }
917            if (afterRect == null || afterRect.isEmpty()) {
918                // assert item is out of bounds
919                int start, end;
920                if (config.mOrientation == VERTICAL) {
921                    start = entry.getValue().top;
922                    end = entry.getValue().bottom;
923                } else {
924                    start = entry.getValue().left;
925                    end = entry.getValue().right;
926                }
927                assertTrue(
928                        config + " if item is missing after relayout, it should be out of bounds."
929                                + "item start: " + start + ", end:" + end + " layout start:"
930                                + layoutStart +
931                                ", layout end:" + layoutEnd,
932                        start <= layoutStart && end <= layoutEnd ||
933                                start >= layoutEnd && end >= layoutEnd
934                );
935            } else {
936                assertEquals(config + " Item should be laid out at the scroll offset coordinates",
937                        entry.getValue(),
938                        afterRect);
939            }
940        }
941        assertViewPositions(config);
942    }
943
944    public void testConsistentRelayout() throws Throwable {
945        for (Config config : mBaseVariations) {
946            for (boolean firstChildMultiSpan : new boolean[]{false, true}) {
947                consistentRelayoutTest(config, firstChildMultiSpan);
948            }
949            removeRecyclerView();
950        }
951    }
952
953    public void consistentRelayoutTest(Config config, boolean firstChildMultiSpan)
954            throws Throwable {
955        setupByConfig(config);
956        if (firstChildMultiSpan) {
957            mAdapter.mFullSpanItems.add(0);
958        }
959        waitFirstLayout();
960        // record all child positions
961        Map<Item, Rect> before = mLayoutManager.collectChildCoordinates();
962        requestLayoutOnUIThread(mRecyclerView);
963        Map<Item, Rect> after = mLayoutManager.collectChildCoordinates();
964        assertRectSetsEqual(
965                config + " simple re-layout, firstChildMultiSpan:" + firstChildMultiSpan, before,
966                after);
967        // scroll some to create inconsistency
968        View firstChild = mLayoutManager.getChildAt(0);
969        final int firstChildStartBeforeScroll = mLayoutManager.mPrimaryOrientation
970                .getDecoratedStart(firstChild);
971        int distance = mLayoutManager.mPrimaryOrientation.getDecoratedMeasurement(firstChild) / 2;
972        if (config.mReverseLayout) {
973            distance *= -1;
974        }
975        scrollBy(distance);
976        waitForMainThread(2);
977        assertTrue("scroll by should move children", firstChildStartBeforeScroll !=
978                mLayoutManager.mPrimaryOrientation.getDecoratedStart(firstChild));
979        before = mLayoutManager.collectChildCoordinates();
980        mLayoutManager.expectLayouts(1);
981        requestLayoutOnUIThread(mRecyclerView);
982        mLayoutManager.waitForLayout(2);
983        after = mLayoutManager.collectChildCoordinates();
984        assertRectSetsEqual(config + " simple re-layout after scroll", before, after);
985    }
986
987    /**
988     * enqueues an empty runnable to main thread so that we can be assured it did run
989     *
990     * @param count Number of times to run
991     */
992    private void waitForMainThread(int count) throws Throwable {
993        final AtomicInteger i = new AtomicInteger(count);
994        while (i.get() > 0) {
995            runTestOnUiThread(new Runnable() {
996                @Override
997                public void run() {
998                    i.decrementAndGet();
999                }
1000            });
1001        }
1002    }
1003
1004    public void assertRectSetsNotEqual(String message, Map<Item, Rect> before,
1005            Map<Item, Rect> after) {
1006        Throwable throwable = null;
1007        try {
1008            assertRectSetsEqual("NOT " + message, before, after);
1009        } catch (Throwable t) {
1010            throwable = t;
1011        }
1012        assertNotNull(message + " two layout should be different", throwable);
1013    }
1014
1015    public void assertRectSetsEqual(String message, Map<Item, Rect> before, Map<Item, Rect> after) {
1016        StringBuilder log = new StringBuilder();
1017        if (DEBUG) {
1018            log.append("checking rectangle equality.\n");
1019            log.append("before:");
1020            for (Map.Entry<Item, Rect> entry : before.entrySet()) {
1021                log.append("\n").append(entry.getKey().mAdapterIndex).append(":")
1022                        .append(entry.getValue());
1023            }
1024            log.append("\nafter:");
1025            for (Map.Entry<Item, Rect> entry : after.entrySet()) {
1026                log.append("\n").append(entry.getKey().mAdapterIndex).append(":")
1027                        .append(entry.getValue());
1028            }
1029            message += "\n\n" + log.toString();
1030        }
1031        assertEquals(message + ": item counts should be equal", before.size()
1032                , after.size());
1033        for (Map.Entry<Item, Rect> entry : before.entrySet()) {
1034            Rect afterRect = after.get(entry.getKey());
1035            assertNotNull(message + ": Same item should be visible after simple re-layout",
1036                    afterRect);
1037            assertEquals(message + ": Item should be laid out at the same coordinates",
1038                    entry.getValue(),
1039                    afterRect);
1040        }
1041    }
1042
1043    // test layout params assignment
1044
1045    static class OnLayoutListener {
1046        void before(RecyclerView.Recycler recycler, RecyclerView.State state){}
1047        void after(RecyclerView.Recycler recycler, RecyclerView.State state){}
1048    }
1049
1050    class WrappedLayoutManager extends StaggeredGridLayoutManager {
1051
1052        CountDownLatch layoutLatch;
1053        OnLayoutListener mOnLayoutListener;
1054
1055        public void expectLayouts(int count) {
1056            layoutLatch = new CountDownLatch(count);
1057        }
1058
1059        public void waitForLayout(long timeout) throws InterruptedException {
1060            waitForLayout(timeout * (DEBUG ? 1000 : 1), TimeUnit.SECONDS);
1061        }
1062
1063        public void waitForLayout(long timeout, TimeUnit timeUnit) throws InterruptedException {
1064            layoutLatch.await(timeout, timeUnit);
1065            assertEquals("all expected layouts should be executed at the expected time",
1066                    0, layoutLatch.getCount());
1067        }
1068
1069        public void assertNoLayout(String msg, long timeout) throws Throwable {
1070            layoutLatch.await(timeout, TimeUnit.SECONDS);
1071            assertFalse(msg, layoutLatch.getCount() == 0);
1072        }
1073
1074        @Override
1075        public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
1076            try {
1077                if (mOnLayoutListener != null) {
1078                    mOnLayoutListener.before(recycler, state);
1079                }
1080                super.onLayoutChildren(recycler, state);
1081                if (mOnLayoutListener != null) {
1082                    mOnLayoutListener.after(recycler, state);
1083                }
1084            } catch (Throwable t) {
1085                postExceptionToInstrumentation(t);
1086            }
1087            layoutLatch.countDown();
1088        }
1089
1090        public WrappedLayoutManager(int spanCount, int orientation) {
1091            super(spanCount, orientation);
1092        }
1093
1094        ArrayList<ArrayList<View>> collectChildrenBySpan() {
1095            ArrayList<ArrayList<View>> viewsBySpan = new ArrayList<ArrayList<View>>();
1096            for (int i = 0; i < getSpanCount(); i++) {
1097                viewsBySpan.add(new ArrayList<View>());
1098            }
1099            for (int i = 0; i < getChildCount(); i++) {
1100                View view = getChildAt(i);
1101                LayoutParams lp
1102                        = (LayoutParams) view
1103                        .getLayoutParams();
1104                viewsBySpan.get(lp.mSpan.mIndex).add(view);
1105            }
1106            return viewsBySpan;
1107        }
1108
1109        Rect getViewBounds(View view) {
1110            if (getOrientation() == HORIZONTAL) {
1111                return new Rect(
1112                        mPrimaryOrientation.getDecoratedStart(view),
1113                        mSecondaryOrientation.getDecoratedStart(view),
1114                        mPrimaryOrientation.getDecoratedEnd(view),
1115                        mSecondaryOrientation.getDecoratedEnd(view));
1116            } else {
1117                return new Rect(
1118                        mSecondaryOrientation.getDecoratedStart(view),
1119                        mPrimaryOrientation.getDecoratedStart(view),
1120                        mSecondaryOrientation.getDecoratedEnd(view),
1121                        mPrimaryOrientation.getDecoratedEnd(view));
1122            }
1123        }
1124
1125        public String getBoundsLog() {
1126            StringBuilder sb = new StringBuilder();
1127            sb.append("view bounds:[start:").append(mPrimaryOrientation.getStartAfterPadding())
1128                    .append(",").append(" end").append(mPrimaryOrientation.getEndAfterPadding());
1129            sb.append("\nchildren bounds\n");
1130            final int childCount = getChildCount();
1131            for (int i = 0; i < childCount; i++) {
1132                View child = getChildAt(i);
1133                sb.append("child (ind:").append(i).append(", pos:").append(getPosition(child))
1134                        .append("[").append("start:").append(
1135                        mPrimaryOrientation.getDecoratedStart(child)).append(", end:")
1136                        .append(mPrimaryOrientation.getDecoratedEnd(child)).append("]\n");
1137            }
1138            return sb.toString();
1139        }
1140
1141        public VisibleChildren traverseAndFindVisibleChildren() {
1142            int childCount = getChildCount();
1143            final VisibleChildren visibleChildren = new VisibleChildren(getSpanCount());
1144            final int start = mPrimaryOrientation.getStartAfterPadding();
1145            final int end = mPrimaryOrientation.getEndAfterPadding();
1146            for (int i = 0; i < childCount; i++) {
1147                View child = getChildAt(i);
1148                final int childStart = mPrimaryOrientation.getDecoratedStart(child);
1149                final int childEnd = mPrimaryOrientation.getDecoratedEnd(child);
1150                final boolean fullyVisible = childStart >= start && childEnd <= end;
1151                final boolean hidden = childEnd <= start || childStart >= end;
1152                if (hidden) {
1153                    continue;
1154                }
1155                final int position = getPosition(child);
1156                final int span = getLp(child).getSpanIndex();
1157                if (fullyVisible) {
1158                    if (position < visibleChildren.firstFullyVisiblePositions[span] ||
1159                            visibleChildren.firstFullyVisiblePositions[span]
1160                                    == RecyclerView.NO_POSITION) {
1161                        visibleChildren.firstFullyVisiblePositions[span] = position;
1162                    }
1163
1164                    if (position > visibleChildren.lastFullyVisiblePositions[span]) {
1165                        visibleChildren.lastFullyVisiblePositions[span] = position;
1166                    }
1167                }
1168
1169                if (position < visibleChildren.firstVisiblePositions[span] ||
1170                        visibleChildren.firstVisiblePositions[span] == RecyclerView.NO_POSITION) {
1171                    visibleChildren.firstVisiblePositions[span] = position;
1172                }
1173
1174                if (position > visibleChildren.lastVisiblePositions[span]) {
1175                    visibleChildren.lastVisiblePositions[span] = position;
1176                }
1177
1178            }
1179            return visibleChildren;
1180        }
1181
1182        Map<Item, Rect> collectChildCoordinates() throws Throwable {
1183            final Map<Item, Rect> items = new LinkedHashMap<Item, Rect>();
1184            runTestOnUiThread(new Runnable() {
1185                @Override
1186                public void run() {
1187                    final int childCount = getChildCount();
1188                    for (int i = 0; i < childCount; i++) {
1189                        View child = getChildAt(i);
1190                        // do it if and only if child is visible
1191                        if (child.getRight() < 0 || child.getBottom() < 0 ||
1192                                child.getLeft() >= getWidth() || child.getTop() >= getHeight()) {
1193                            // invisible children may be drawn in cases like scrolling so we should
1194                            // ignore them
1195                            continue;
1196                        }
1197                        LayoutParams lp = (LayoutParams) child
1198                                .getLayoutParams();
1199                        TestViewHolder vh = (TestViewHolder) lp.mViewHolder;
1200                        items.put(vh.mBindedItem, getViewBounds(child));
1201                    }
1202                }
1203            });
1204            return items;
1205        }
1206
1207
1208    }
1209
1210    static class VisibleChildren {
1211
1212        int[] firstVisiblePositions;
1213
1214        int[] firstFullyVisiblePositions;
1215
1216        int[] lastVisiblePositions;
1217
1218        int[] lastFullyVisiblePositions;
1219
1220        VisibleChildren(int spanCount) {
1221            firstFullyVisiblePositions = new int[spanCount];
1222            firstVisiblePositions = new int[spanCount];
1223            lastVisiblePositions = new int[spanCount];
1224            lastFullyVisiblePositions = new int[spanCount];
1225            for (int i = 0; i < spanCount; i++) {
1226                firstFullyVisiblePositions[i] = RecyclerView.NO_POSITION;
1227                firstVisiblePositions[i] = RecyclerView.NO_POSITION;
1228                lastVisiblePositions[i] = RecyclerView.NO_POSITION;
1229                lastFullyVisiblePositions[i] = RecyclerView.NO_POSITION;
1230            }
1231        }
1232
1233        @Override
1234        public boolean equals(Object o) {
1235            if (this == o) {
1236                return true;
1237            }
1238            if (o == null || getClass() != o.getClass()) {
1239                return false;
1240            }
1241
1242            VisibleChildren that = (VisibleChildren) o;
1243
1244            if (!Arrays.equals(firstFullyVisiblePositions, that.firstFullyVisiblePositions)) {
1245                return false;
1246            }
1247            if (!Arrays.equals(firstVisiblePositions, that.firstVisiblePositions)) {
1248                return false;
1249            }
1250            if (!Arrays.equals(lastFullyVisiblePositions, that.lastFullyVisiblePositions)) {
1251                return false;
1252            }
1253            if (!Arrays.equals(lastVisiblePositions, that.lastVisiblePositions)) {
1254                return false;
1255            }
1256
1257            return true;
1258        }
1259
1260        @Override
1261        public int hashCode() {
1262            int result = firstVisiblePositions != null ? Arrays.hashCode(firstVisiblePositions) : 0;
1263            result = 31 * result + (firstFullyVisiblePositions != null ? Arrays
1264                    .hashCode(firstFullyVisiblePositions) : 0);
1265            result = 31 * result + (lastVisiblePositions != null ? Arrays
1266                    .hashCode(lastVisiblePositions)
1267                    : 0);
1268            result = 31 * result + (lastFullyVisiblePositions != null ? Arrays
1269                    .hashCode(lastFullyVisiblePositions) : 0);
1270            return result;
1271        }
1272
1273        @Override
1274        public String toString() {
1275            return "VisibleChildren{" +
1276                    "firstVisiblePositions=" + Arrays.toString(firstVisiblePositions) +
1277                    ", firstFullyVisiblePositions=" + Arrays.toString(firstFullyVisiblePositions) +
1278                    ", lastVisiblePositions=" + Arrays.toString(lastVisiblePositions) +
1279                    ", lastFullyVisiblePositions=" + Arrays.toString(lastFullyVisiblePositions) +
1280                    '}';
1281        }
1282    }
1283
1284    class GridTestAdapter extends TestAdapter {
1285
1286        int mOrientation;
1287
1288        // original ids of items that should be full span
1289        HashSet<Integer> mFullSpanItems = new HashSet<Integer>();
1290
1291        private boolean mViewsHaveEqualSize = false; // size in the scrollable direction
1292
1293        GridTestAdapter(int count, int orientation) {
1294            super(count);
1295            mOrientation = orientation;
1296        }
1297
1298        @Override
1299        public void offsetOriginalIndices(int start, int offset) {
1300            if (mFullSpanItems.size() > 0) {
1301                HashSet<Integer> old = mFullSpanItems;
1302                mFullSpanItems = new HashSet<Integer>();
1303                for (Integer i : old) {
1304                    if (i < start) {
1305                        mFullSpanItems.add(i);
1306                    } else if (offset > 0 || (start + Math.abs(offset)) <= i) {
1307                        mFullSpanItems.add(i + offset);
1308                    } else if (DEBUG) {
1309                        Log.d(TAG, "removed full span item " + i);
1310                    }
1311                }
1312            }
1313            super.offsetOriginalIndices(start, offset);
1314        }
1315
1316        @Override
1317        public void onBindViewHolder(TestViewHolder holder,
1318                int position) {
1319            super.onBindViewHolder(holder, position);
1320            Item item = mItems.get(position);
1321            final int minSize = mViewsHaveEqualSize ? 200 : 200 + 20 * (position % 10);
1322            if (mOrientation == OrientationHelper.HORIZONTAL) {
1323                holder.itemView.setMinimumWidth(minSize);
1324            } else {
1325                holder.itemView.setMinimumHeight(minSize);
1326            }
1327            RecyclerView.LayoutParams lp = (RecyclerView.LayoutParams) holder.itemView
1328                    .getLayoutParams();
1329            if (lp instanceof LayoutParams) {
1330                ((LayoutParams) lp).setFullSpan(mFullSpanItems.contains(item.mAdapterIndex));
1331            } else {
1332                LayoutParams slp = new LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT,
1333                        ViewGroup.LayoutParams.WRAP_CONTENT);
1334                holder.itemView.setLayoutParams(slp);
1335                slp.setFullSpan(mFullSpanItems.contains(item.mAdapterIndex));
1336                lp = slp;
1337            }
1338            lp.topMargin = 3;
1339            lp.leftMargin = 5;
1340            lp.rightMargin = 7;
1341            lp.bottomMargin = 9;
1342        }
1343    }
1344
1345    static class Config implements Cloneable {
1346
1347        private static final int DEFAULT_ITEM_COUNT = 300;
1348
1349        int mOrientation = OrientationHelper.VERTICAL;
1350
1351        boolean mReverseLayout = false;
1352
1353        int mSpanCount = 3;
1354
1355        int mGapStrategy = GAP_HANDLING_MOVE_ITEMS_BETWEEN_SPANS;
1356
1357        int mItemCount = DEFAULT_ITEM_COUNT;
1358
1359        Config(int orientation, boolean reverseLayout, int spanCount, int gapStrategy) {
1360            mOrientation = orientation;
1361            mReverseLayout = reverseLayout;
1362            mSpanCount = spanCount;
1363            mGapStrategy = gapStrategy;
1364        }
1365
1366        public Config() {
1367
1368        }
1369
1370        Config orientation(int orientation) {
1371            mOrientation = orientation;
1372            return this;
1373        }
1374
1375        Config reverseLayout(boolean reverseLayout) {
1376            mReverseLayout = reverseLayout;
1377            return this;
1378        }
1379
1380        Config spanCount(int spanCount) {
1381            mSpanCount = spanCount;
1382            return this;
1383        }
1384
1385        Config gapStrategy(int gapStrategy) {
1386            mGapStrategy = gapStrategy;
1387            return this;
1388        }
1389
1390        public Config itemCount(int itemCount) {
1391            mItemCount = itemCount;
1392            return this;
1393        }
1394
1395        @Override
1396        public String toString() {
1397            return "[CONFIG:" +
1398                    " span:" + mSpanCount + "," +
1399                    " orientation:" + (mOrientation == HORIZONTAL ? "horz," : "vert,") +
1400                    " reverse:" + (mReverseLayout ? "T" : "F") +
1401                    " gap strategy: " + gapStrategyName(mGapStrategy);
1402        }
1403
1404        private static String gapStrategyName(int gapStrategy) {
1405            switch (gapStrategy) {
1406                case GAP_HANDLING_NONE:
1407                    return "none";
1408                case GAP_HANDLING_MOVE_ITEMS_BETWEEN_SPANS:
1409                    return "move spans";
1410                case GAP_HANDLING_LAZY:
1411                    return "lazy";
1412            }
1413            return "gap strategy: unknown";
1414        }
1415
1416        @Override
1417        public Object clone() throws CloneNotSupportedException {
1418            return super.clone();
1419        }
1420    }
1421
1422    private interface PostLayoutRunnable {
1423
1424        void run() throws Throwable;
1425
1426        String describe();
1427    }
1428
1429}
1430