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
17package android.support.v7.widget;
18
19import android.content.Context;
20import android.graphics.Rect;
21import android.os.Parcel;
22import android.os.Parcelable;
23import android.support.v4.view.AccessibilityDelegateCompat;
24import android.support.v4.view.accessibility.AccessibilityEventCompat;
25import android.support.v4.view.accessibility.AccessibilityRecordCompat;
26import android.util.Log;
27import android.view.View;
28import android.view.ViewGroup;
29import android.view.accessibility.AccessibilityEvent;
30import android.widget.FrameLayout;
31
32import static android.support.v7.widget.LayoutState.LAYOUT_END;
33import static android.support.v7.widget.LayoutState.LAYOUT_START;
34import static android.support.v7.widget.LinearLayoutManager.HORIZONTAL;
35import static android.support.v7.widget.LinearLayoutManager.VERTICAL;
36import java.lang.reflect.Field;
37import java.util.ArrayList;
38import java.util.LinkedHashMap;
39import java.util.List;
40import java.util.Map;
41import java.util.UUID;
42import java.util.concurrent.CountDownLatch;
43import java.util.concurrent.TimeUnit;
44import java.util.concurrent.atomic.AtomicInteger;
45
46/**
47 * Includes tests for {@link LinearLayoutManager}.
48 * <p>
49 * Since most UI tests are not practical, these tests are focused on internal data representation
50 * and stability of LinearLayoutManager in response to different events (state change, scrolling
51 * etc) where it is very hard to do manual testing.
52 */
53public class LinearLayoutManagerTest extends BaseRecyclerViewInstrumentationTest {
54
55    private static final boolean DEBUG = false;
56
57    private static final String TAG = "LinearLayoutManagerTest";
58
59    WrappedLinearLayoutManager mLayoutManager;
60
61    TestAdapter mTestAdapter;
62
63    final List<Config> mBaseVariations = new ArrayList<Config>();
64
65    @Override
66    protected void setUp() throws Exception {
67        super.setUp();
68        for (int orientation : new int[]{VERTICAL, HORIZONTAL}) {
69            for (boolean reverseLayout : new boolean[]{false, true}) {
70                for (boolean stackFromBottom : new boolean[]{false, true}) {
71                    mBaseVariations.add(new Config(orientation, reverseLayout, stackFromBottom));
72                }
73            }
74        }
75    }
76
77    protected List<Config> addConfigVariation(List<Config> base, String fieldName,
78            Object... variations)
79            throws CloneNotSupportedException, NoSuchFieldException, IllegalAccessException {
80        List<Config> newConfigs = new ArrayList<Config>();
81        Field field = Config.class.getDeclaredField(fieldName);
82        for (Config config : base) {
83            for (Object variation : variations) {
84                Config newConfig = (Config) config.clone();
85                field.set(newConfig, variation);
86                newConfigs.add(newConfig);
87            }
88        }
89        return newConfigs;
90    }
91
92    void setupByConfig(Config config, boolean waitForFirstLayout) throws Throwable {
93        mRecyclerView = new RecyclerView(getActivity());
94        mRecyclerView.setHasFixedSize(true);
95        mTestAdapter = config.mTestAdapter == null ? new TestAdapter(config.mItemCount)
96                : config.mTestAdapter;
97        mRecyclerView.setAdapter(mTestAdapter);
98        mLayoutManager = new WrappedLinearLayoutManager(getActivity(), config.mOrientation,
99                config.mReverseLayout);
100        mLayoutManager.setStackFromEnd(config.mStackFromEnd);
101        mLayoutManager.setRecycleChildrenOnDetach(config.mRecycleChildrenOnDetach);
102        mRecyclerView.setLayoutManager(mLayoutManager);
103        if (waitForFirstLayout) {
104            waitForFirstLayout();
105        }
106    }
107
108    public void testKeepFocusOnRelayout() throws Throwable {
109        setupByConfig(new Config(VERTICAL, false, false).itemCount(500), true);
110        int center = (mLayoutManager.findLastVisibleItemPosition()
111                - mLayoutManager.findFirstVisibleItemPosition()) / 2;
112        final RecyclerView.ViewHolder vh = mRecyclerView.findViewHolderForLayoutPosition(center);
113        final int top = mLayoutManager.mOrientationHelper.getDecoratedStart(vh.itemView);
114        runTestOnUiThread(new Runnable() {
115            @Override
116            public void run() {
117                vh.itemView.requestFocus();
118            }
119        });
120        assertTrue("view should have the focus", vh.itemView.hasFocus());
121        // add a bunch of items right before that view, make sure it keeps its position
122        mLayoutManager.expectLayouts(2);
123        final int childCountToAdd = mRecyclerView.getChildCount() * 2;
124        mTestAdapter.addAndNotify(center, childCountToAdd);
125        center += childCountToAdd; // offset item
126        mLayoutManager.waitForLayout(2);
127        mLayoutManager.waitForAnimationsToEnd(20);
128        final RecyclerView.ViewHolder postVH = mRecyclerView.findViewHolderForLayoutPosition(center);
129        assertNotNull("focused child should stay in layout", postVH);
130        assertSame("same view holder should be kept for unchanged child", vh, postVH);
131        assertEquals("focused child's screen position should stay unchanged", top,
132                mLayoutManager.mOrientationHelper.getDecoratedStart(postVH.itemView));
133    }
134
135    public void testResize() throws Throwable {
136        for(Config config : addConfigVariation(mBaseVariations, "mItemCount", 5
137                , Config.DEFAULT_ITEM_COUNT)) {
138            stackFromEndTest(config);
139            removeRecyclerView();
140        }
141    }
142
143    public void testScrollToPositionWithOffset() throws Throwable {
144        for (Config config : mBaseVariations) {
145            scrollToPositionWithOffsetTest(config.itemCount(300));
146            removeRecyclerView();
147        }
148    }
149
150    public void scrollToPositionWithOffsetTest(Config config) throws Throwable {
151        setupByConfig(config, true);
152        OrientationHelper orientationHelper = OrientationHelper
153                .createOrientationHelper(mLayoutManager, config.mOrientation);
154        Rect layoutBounds = getDecoratedRecyclerViewBounds();
155        // try scrolling towards head, should not affect anything
156        Map<Item, Rect> before = mLayoutManager.collectChildCoordinates();
157        if (config.mStackFromEnd) {
158            scrollToPositionWithOffset(mTestAdapter.getItemCount() - 1,
159                    mLayoutManager.mOrientationHelper.getEnd() - 500);
160        } else {
161            scrollToPositionWithOffset(0, 20);
162        }
163        assertRectSetsEqual(config + " trying to over scroll with offset should be no-op",
164                before, mLayoutManager.collectChildCoordinates());
165        // try offsetting some visible children
166        int testCount = 10;
167        while (testCount-- > 0) {
168            // get middle child
169            final View child = mLayoutManager.getChildAt(mLayoutManager.getChildCount() / 2);
170            final int position = mRecyclerView.getChildLayoutPosition(child);
171            final int startOffset = config.mReverseLayout ?
172                    orientationHelper.getEndAfterPadding() - orientationHelper
173                            .getDecoratedEnd(child)
174                    : orientationHelper.getDecoratedStart(child) - orientationHelper
175                            .getStartAfterPadding();
176            final int scrollOffset = config.mStackFromEnd ? startOffset + startOffset / 2
177                    : startOffset / 2;
178            mLayoutManager.expectLayouts(1);
179            scrollToPositionWithOffset(position, scrollOffset);
180            mLayoutManager.waitForLayout(2);
181            final int finalOffset = config.mReverseLayout ?
182                    orientationHelper.getEndAfterPadding() - orientationHelper
183                            .getDecoratedEnd(child)
184                    : orientationHelper.getDecoratedStart(child) - orientationHelper
185                            .getStartAfterPadding();
186            assertEquals(config + " scroll with offset on a visible child should work fine " +
187                    " offset:" + finalOffset + " , existing offset:" + startOffset + ", "
188                            + "child " + position,
189                    scrollOffset, finalOffset);
190        }
191
192        // try scrolling to invisible children
193        testCount = 10;
194        // we test above and below, one by one
195        int offsetMultiplier = -1;
196        while (testCount-- > 0) {
197            final TargetTuple target = findInvisibleTarget(config);
198            final String logPrefix = config + " " + target;
199            mLayoutManager.expectLayouts(1);
200            final int offset = offsetMultiplier
201                    * orientationHelper.getDecoratedMeasurement(mLayoutManager.getChildAt(0)) / 3;
202            scrollToPositionWithOffset(target.mPosition, offset);
203            mLayoutManager.waitForLayout(2);
204            final View child = mLayoutManager.findViewByPosition(target.mPosition);
205            assertNotNull(logPrefix + " scrolling to a mPosition with offset " + offset
206                    + " should layout it", child);
207            final Rect bounds = mLayoutManager.getViewBounds(child);
208            if (DEBUG) {
209                Log.d(TAG, logPrefix + " post scroll to invisible mPosition " + bounds + " in "
210                        + layoutBounds + " with offset " + offset);
211            }
212
213            if (config.mReverseLayout) {
214                assertEquals(logPrefix + " when scrolling with offset to an invisible in reverse "
215                                + "layout, its end should align with recycler view's end - offset",
216                        orientationHelper.getEndAfterPadding() - offset,
217                        orientationHelper.getDecoratedEnd(child)
218                );
219            } else {
220                assertEquals(logPrefix + " when scrolling with offset to an invisible child in normal"
221                                + " layout its start should align with recycler view's start + "
222                                + "offset",
223                        orientationHelper.getStartAfterPadding() + offset,
224                        orientationHelper.getDecoratedStart(child)
225                );
226            }
227            offsetMultiplier *= -1;
228        }
229    }
230
231    private TargetTuple findInvisibleTarget(Config config) {
232        int minPosition = Integer.MAX_VALUE, maxPosition = Integer.MIN_VALUE;
233        for (int i = 0; i < mLayoutManager.getChildCount(); i++) {
234            View child = mLayoutManager.getChildAt(i);
235            int position = mRecyclerView.getChildLayoutPosition(child);
236            if (position < minPosition) {
237                minPosition = position;
238            }
239            if (position > maxPosition) {
240                maxPosition = position;
241            }
242        }
243        final int tailTarget = maxPosition +
244                (mRecyclerView.getAdapter().getItemCount() - maxPosition) / 2;
245        final int headTarget = minPosition / 2;
246        final int target;
247        // where will the child come from ?
248        final int itemLayoutDirection;
249        if (Math.abs(tailTarget - maxPosition) > Math.abs(headTarget - minPosition)) {
250            target = tailTarget;
251            itemLayoutDirection = config.mReverseLayout ? LAYOUT_START : LAYOUT_END;
252        } else {
253            target = headTarget;
254            itemLayoutDirection = config.mReverseLayout ? LAYOUT_END : LAYOUT_START;
255        }
256        if (DEBUG) {
257            Log.d(TAG,
258                    config + " target:" + target + " min:" + minPosition + ", max:" + maxPosition);
259        }
260        return new TargetTuple(target, itemLayoutDirection);
261    }
262
263    public void stackFromEndTest(final Config config) throws Throwable {
264        final FrameLayout container = getRecyclerViewContainer();
265        runTestOnUiThread(new Runnable() {
266            @Override
267            public void run() {
268                container.setPadding(0, 0, 0, 0);
269            }
270        });
271
272        setupByConfig(config, true);
273        int lastVisibleItemPosition = mLayoutManager.findLastVisibleItemPosition();
274        int firstVisibleItemPosition = mLayoutManager.findFirstVisibleItemPosition();
275        int lastCompletelyVisibleItemPosition = mLayoutManager.findLastCompletelyVisibleItemPosition();
276        int firstCompletelyVisibleItemPosition = mLayoutManager.findFirstCompletelyVisibleItemPosition();
277        mLayoutManager.expectLayouts(1);
278        // resize the recycler view to half
279        runTestOnUiThread(new Runnable() {
280            @Override
281            public void run() {
282                if (config.mOrientation == HORIZONTAL) {
283                    container.setPadding(0, 0, container.getWidth() / 2, 0);
284                } else {
285                    container.setPadding(0, 0, 0, container.getWidth() / 2);
286                }
287            }
288        });
289        mLayoutManager.waitForLayout(1);
290        if (config.mStackFromEnd) {
291            assertEquals("[" + config + "]: last visible position should not change.",
292                    lastVisibleItemPosition, mLayoutManager.findLastVisibleItemPosition());
293            assertEquals("[" + config + "]: last completely visible position should not change",
294                    lastCompletelyVisibleItemPosition,
295                    mLayoutManager.findLastCompletelyVisibleItemPosition());
296        } else {
297            assertEquals("[" + config + "]: first visible position should not change.",
298                    firstVisibleItemPosition, mLayoutManager.findFirstVisibleItemPosition());
299            assertEquals("[" + config + "]: last completely visible position should not change",
300                    firstCompletelyVisibleItemPosition,
301                    mLayoutManager.findFirstCompletelyVisibleItemPosition());
302        }
303    }
304
305    public void testScrollToPositionWithPredictive() throws Throwable {
306        scrollToPositionWithPredictive(0, LinearLayoutManager.INVALID_OFFSET);
307        removeRecyclerView();
308        scrollToPositionWithPredictive(3, 20);
309        removeRecyclerView();
310        scrollToPositionWithPredictive(Config.DEFAULT_ITEM_COUNT / 2,
311                LinearLayoutManager.INVALID_OFFSET);
312        removeRecyclerView();
313        scrollToPositionWithPredictive(Config.DEFAULT_ITEM_COUNT / 2, 10);
314    }
315
316    public void scrollToPositionWithPredictive(final int scrollPosition, final int scrollOffset)
317            throws Throwable {
318        setupByConfig(new Config(VERTICAL, false, false), true);
319
320        mLayoutManager.mOnLayoutListener = new OnLayoutListener() {
321            @Override
322            void after(RecyclerView.Recycler recycler, RecyclerView.State state) {
323                if (state.isPreLayout()) {
324                    assertEquals("pending scroll position should still be pending",
325                            scrollPosition, mLayoutManager.mPendingScrollPosition);
326                    if (scrollOffset != LinearLayoutManager.INVALID_OFFSET) {
327                        assertEquals("pending scroll position offset should still be pending",
328                                scrollOffset, mLayoutManager.mPendingScrollPositionOffset);
329                    }
330                } else {
331                    RecyclerView.ViewHolder vh =
332                            mRecyclerView.findViewHolderForLayoutPosition(scrollPosition);
333                    assertNotNull("scroll to position should work", vh);
334                    if (scrollOffset != LinearLayoutManager.INVALID_OFFSET) {
335                        assertEquals("scroll offset should be applied properly",
336                                mLayoutManager.getPaddingTop() + scrollOffset +
337                                        ((RecyclerView.LayoutParams) vh.itemView
338                                                .getLayoutParams()).topMargin,
339                                mLayoutManager.getDecoratedTop(vh.itemView));
340                    }
341                }
342            }
343        };
344        mLayoutManager.expectLayouts(2);
345        runTestOnUiThread(new Runnable() {
346            @Override
347            public void run() {
348                try {
349                    mTestAdapter.addAndNotify(0, 1);
350                    if (scrollOffset == LinearLayoutManager.INVALID_OFFSET) {
351                        mLayoutManager.scrollToPosition(scrollPosition);
352                    } else {
353                        mLayoutManager.scrollToPositionWithOffset(scrollPosition,
354                                scrollOffset);
355                    }
356
357                } catch (Throwable throwable) {
358                    throwable.printStackTrace();
359                }
360
361            }
362        });
363        mLayoutManager.waitForLayout(2);
364        checkForMainThreadException();
365    }
366
367    private void waitForFirstLayout() throws Throwable {
368        mLayoutManager.expectLayouts(1);
369        setRecyclerView(mRecyclerView);
370        mLayoutManager.waitForLayout(2);
371    }
372
373    public void testRecycleDuringAnimations() throws Throwable {
374        final AtomicInteger childCount = new AtomicInteger(0);
375        final TestAdapter adapter = new TestAdapter(300) {
376            @Override
377            public TestViewHolder onCreateViewHolder(ViewGroup parent,
378                    int viewType) {
379                final int cnt = childCount.incrementAndGet();
380                final TestViewHolder testViewHolder = super.onCreateViewHolder(parent, viewType);
381                if (DEBUG) {
382                    Log.d(TAG, "CHILD_CNT(create):" + cnt + ", " + testViewHolder);
383                }
384                return testViewHolder;
385            }
386        };
387        setupByConfig(new Config(VERTICAL, false, false).itemCount(300)
388                .adapter(adapter), true);
389
390        final RecyclerView.RecycledViewPool pool = new RecyclerView.RecycledViewPool() {
391            @Override
392            public void putRecycledView(RecyclerView.ViewHolder scrap) {
393                super.putRecycledView(scrap);
394                int cnt = childCount.decrementAndGet();
395                if (DEBUG) {
396                    Log.d(TAG, "CHILD_CNT(put):" + cnt + ", " + scrap);
397                }
398            }
399
400            @Override
401            public RecyclerView.ViewHolder getRecycledView(int viewType) {
402                final RecyclerView.ViewHolder recycledView = super.getRecycledView(viewType);
403                if (recycledView != null) {
404                    final int cnt = childCount.incrementAndGet();
405                    if (DEBUG) {
406                        Log.d(TAG, "CHILD_CNT(get):" + cnt + ", " + recycledView);
407                    }
408                }
409                return recycledView;
410            }
411        };
412        pool.setMaxRecycledViews(mTestAdapter.getItemViewType(0), 500);
413        mRecyclerView.setRecycledViewPool(pool);
414
415
416        // now keep adding children to trigger more children being created etc.
417        for (int i = 0; i < 100; i ++) {
418            adapter.addAndNotify(15, 1);
419            Thread.sleep(15);
420        }
421        getInstrumentation().waitForIdleSync();
422        waitForAnimations(2);
423        assertEquals("Children count should add up", childCount.get(),
424                mRecyclerView.getChildCount() + mRecyclerView.mRecycler.mCachedViews.size());
425
426        // now trigger lots of add again, followed by a scroll to position
427        for (int i = 0; i < 100; i ++) {
428            adapter.addAndNotify(5 + (i % 3) * 3, 1);
429            Thread.sleep(25);
430        }
431        smoothScrollToPosition(mLayoutManager.findLastVisibleItemPosition() + 20);
432        waitForAnimations(2);
433        getInstrumentation().waitForIdleSync();
434        assertEquals("Children count should add up", childCount.get(),
435                mRecyclerView.getChildCount() + mRecyclerView.mRecycler.mCachedViews.size());
436    }
437
438
439    public void testGetFirstLastChildrenTest() throws Throwable {
440        for (Config config : mBaseVariations) {
441            getFirstLastChildrenTest(config);
442        }
443    }
444
445    public void testDontRecycleChildrenOnDetach() throws Throwable {
446        setupByConfig(new Config().recycleChildrenOnDetach(false), true);
447        runTestOnUiThread(new Runnable() {
448            @Override
449            public void run() {
450                int recyclerSize = mRecyclerView.mRecycler.getRecycledViewPool().size();
451                mRecyclerView.setLayoutManager(new TestLayoutManager());
452                assertEquals("No views are recycled", recyclerSize,
453                        mRecyclerView.mRecycler.getRecycledViewPool().size());
454            }
455        });
456    }
457
458    public void testRecycleChildrenOnDetach() throws Throwable {
459        setupByConfig(new Config().recycleChildrenOnDetach(true), true);
460        final int childCount = mLayoutManager.getChildCount();
461        runTestOnUiThread(new Runnable() {
462            @Override
463            public void run() {
464                int recyclerSize = mRecyclerView.mRecycler.getRecycledViewPool().size();
465                mRecyclerView.mRecycler.getRecycledViewPool().setMaxRecycledViews(
466                        mTestAdapter.getItemViewType(0), recyclerSize + childCount);
467                mRecyclerView.setLayoutManager(new TestLayoutManager());
468                assertEquals("All children should be recycled", childCount + recyclerSize,
469                        mRecyclerView.mRecycler.getRecycledViewPool().size());
470            }
471        });
472    }
473
474    public void getFirstLastChildrenTest(final Config config) throws Throwable {
475        setupByConfig(config, true);
476        Runnable viewInBoundsTest = new Runnable() {
477            @Override
478            public void run() {
479                VisibleChildren visibleChildren = mLayoutManager.traverseAndFindVisibleChildren();
480                final String boundsLog = mLayoutManager.getBoundsLog();
481                assertEquals(config + ":\nfirst visible child should match traversal result\n"
482                                + boundsLog, visibleChildren.firstVisiblePosition,
483                        mLayoutManager.findFirstVisibleItemPosition()
484                );
485                assertEquals(
486                        config + ":\nfirst fully visible child should match traversal result\n"
487                                + boundsLog, visibleChildren.firstFullyVisiblePosition,
488                        mLayoutManager.findFirstCompletelyVisibleItemPosition()
489                );
490
491                assertEquals(config + ":\nlast visible child should match traversal result\n"
492                                + boundsLog, visibleChildren.lastVisiblePosition,
493                        mLayoutManager.findLastVisibleItemPosition()
494                );
495                assertEquals(
496                        config + ":\nlast fully visible child should match traversal result\n"
497                                + boundsLog, visibleChildren.lastFullyVisiblePosition,
498                        mLayoutManager.findLastCompletelyVisibleItemPosition()
499                );
500            }
501        };
502        runTestOnUiThread(viewInBoundsTest);
503        // smooth scroll to end of the list and keep testing meanwhile. This will test pre-caching
504        // case
505        final int scrollPosition = config.mStackFromEnd ? 0 : mTestAdapter.getItemCount();
506        runTestOnUiThread(new Runnable() {
507            @Override
508            public void run() {
509                mRecyclerView.smoothScrollToPosition(scrollPosition);
510            }
511        });
512        while (mLayoutManager.isSmoothScrolling() ||
513                mRecyclerView.getScrollState() != RecyclerView.SCROLL_STATE_IDLE) {
514            runTestOnUiThread(viewInBoundsTest);
515            Thread.sleep(400);
516        }
517        // delete all items
518        mLayoutManager.expectLayouts(2);
519        mTestAdapter.deleteAndNotify(0, mTestAdapter.getItemCount());
520        mLayoutManager.waitForLayout(2);
521        // test empty case
522        runTestOnUiThread(viewInBoundsTest);
523        // set a new adapter with huge items to test full bounds check
524        mLayoutManager.expectLayouts(1);
525        final int totalSpace = mLayoutManager.mOrientationHelper.getTotalSpace();
526        final TestAdapter newAdapter = new TestAdapter(100) {
527            @Override
528            public void onBindViewHolder(TestViewHolder holder,
529                    int position) {
530                super.onBindViewHolder(holder, position);
531                if (config.mOrientation == HORIZONTAL) {
532                    holder.itemView.setMinimumWidth(totalSpace + 5);
533                } else {
534                    holder.itemView.setMinimumHeight(totalSpace + 5);
535                }
536            }
537        };
538        runTestOnUiThread(new Runnable() {
539            @Override
540            public void run() {
541                mRecyclerView.setAdapter(newAdapter);
542            }
543        });
544        mLayoutManager.waitForLayout(2);
545        runTestOnUiThread(viewInBoundsTest);
546    }
547
548    public void testSavedState() throws Throwable {
549        PostLayoutRunnable[] postLayoutOptions = new PostLayoutRunnable[]{
550                new PostLayoutRunnable() {
551                    @Override
552                    public void run() throws Throwable {
553                        // do nothing
554                    }
555
556                    @Override
557                    public String describe() {
558                        return "doing nothing";
559                    }
560                },
561                new PostLayoutRunnable() {
562                    @Override
563                    public void run() throws Throwable {
564                        mLayoutManager.expectLayouts(1);
565                        scrollToPosition(mTestAdapter.getItemCount() * 3 / 4);
566                        mLayoutManager.waitForLayout(2);
567                    }
568
569                    @Override
570                    public String describe() {
571                        return "scroll to position";
572                    }
573                },
574                new PostLayoutRunnable() {
575                    @Override
576                    public void run() throws Throwable {
577                        mLayoutManager.expectLayouts(1);
578                        scrollToPositionWithOffset(mTestAdapter.getItemCount() * 1 / 3,
579                                50);
580                        mLayoutManager.waitForLayout(2);
581                    }
582
583                    @Override
584                    public String describe() {
585                        return "scroll to position with positive offset";
586                    }
587                },
588                new PostLayoutRunnable() {
589                    @Override
590                    public void run() throws Throwable {
591                        mLayoutManager.expectLayouts(1);
592                        scrollToPositionWithOffset(mTestAdapter.getItemCount() * 2 / 3,
593                                -50);
594                        mLayoutManager.waitForLayout(2);
595                    }
596
597                    @Override
598                    public String describe() {
599                        return "scroll to position with negative offset";
600                    }
601                }
602        };
603
604        PostRestoreRunnable[] postRestoreOptions = new PostRestoreRunnable[]{
605                new PostRestoreRunnable() {
606                    @Override
607                    public String describe() {
608                        return "Doing nothing";
609                    }
610                },
611                new PostRestoreRunnable() {
612                    @Override
613                    void onAfterRestore(Config config) throws Throwable {
614                        // update config as well so that restore assertions will work
615                        config.mOrientation = 1 - config.mOrientation;
616                        mLayoutManager.setOrientation(config.mOrientation);
617                    }
618
619                    @Override
620                    boolean shouldLayoutMatch(Config config) {
621                        return config.mItemCount == 0;
622                    }
623
624                    @Override
625                    public String describe() {
626                        return "Changing orientation";
627                    }
628                },
629                new PostRestoreRunnable() {
630                    @Override
631                    void onAfterRestore(Config config) throws Throwable {
632                        config.mStackFromEnd = !config.mStackFromEnd;
633                        mLayoutManager.setStackFromEnd(config.mStackFromEnd);
634                    }
635
636                    @Override
637                    boolean shouldLayoutMatch(Config config) {
638                        return true; //stack from end should not move items on change
639                    }
640
641                    @Override
642                    public String describe() {
643                        return "Changing stack from end";
644                    }
645                },
646                new PostRestoreRunnable() {
647                    @Override
648                    void onAfterRestore(Config config) throws Throwable {
649                        config.mReverseLayout = !config.mReverseLayout;
650                        mLayoutManager.setReverseLayout(config.mReverseLayout);
651                    }
652
653                    @Override
654                    boolean shouldLayoutMatch(Config config) {
655                        return config.mItemCount == 0;
656                    }
657
658                    @Override
659                    public String describe() {
660                        return "Changing reverse layout";
661                    }
662                },
663                new PostRestoreRunnable() {
664                    @Override
665                    void onAfterRestore(Config config) throws Throwable {
666                        config.mRecycleChildrenOnDetach = !config.mRecycleChildrenOnDetach;
667                        mLayoutManager.setRecycleChildrenOnDetach(config.mRecycleChildrenOnDetach);
668                    }
669
670                    @Override
671                    boolean shouldLayoutMatch(Config config) {
672                        return true;
673                    }
674
675                    @Override
676                    String describe() {
677                        return "Change should recycle children";
678                    }
679                },
680                new PostRestoreRunnable() {
681                    int position;
682                    @Override
683                    void onAfterRestore(Config config) throws Throwable {
684                        position = mTestAdapter.getItemCount() / 2;
685                        mLayoutManager.scrollToPosition(position);
686                    }
687
688                    @Override
689                    boolean shouldLayoutMatch(Config config) {
690                        return mTestAdapter.getItemCount() == 0;
691                    }
692
693                    @Override
694                    String describe() {
695                        return "Scroll to position " + position ;
696                    }
697
698                    @Override
699                    void onAfterReLayout(Config config) {
700                        if (mTestAdapter.getItemCount() > 0) {
701                            assertEquals(config + ":scrolled view should be last completely visible",
702                                    position,
703                                    config.mStackFromEnd ?
704                                            mLayoutManager.findLastCompletelyVisibleItemPosition()
705                                        : mLayoutManager.findFirstCompletelyVisibleItemPosition());
706                        }
707                    }
708                }
709        };
710        boolean[] waitForLayoutOptions = new boolean[]{true, false};
711        List<Config> variations = addConfigVariation(mBaseVariations, "mItemCount", 0, 300);
712        variations = addConfigVariation(variations, "mRecycleChildrenOnDetach", true);
713        for (Config config : variations) {
714            for (PostLayoutRunnable postLayoutRunnable : postLayoutOptions) {
715                for (boolean waitForLayout : waitForLayoutOptions) {
716                    for (PostRestoreRunnable postRestoreRunnable : postRestoreOptions) {
717                        savedStateTest((Config) config.clone(), waitForLayout, postLayoutRunnable,
718                                postRestoreRunnable);
719                        removeRecyclerView();
720                    }
721
722                }
723            }
724        }
725    }
726
727    public void savedStateTest(Config config, boolean waitForLayout,
728            PostLayoutRunnable postLayoutOperation, PostRestoreRunnable postRestoreOperation)
729            throws Throwable {
730        if (DEBUG) {
731            Log.d(TAG, "testing saved state with wait for layout = " + waitForLayout + " config " +
732                    config + " post layout action " + postLayoutOperation.describe() +
733                    "post restore action " + postRestoreOperation.describe());
734        }
735        setupByConfig(config, false);
736        if (waitForLayout) {
737            waitForFirstLayout();
738            postLayoutOperation.run();
739        }
740        Map<Item, Rect> before = mLayoutManager.collectChildCoordinates();
741        Parcelable savedState = mRecyclerView.onSaveInstanceState();
742        // we append a suffix to the parcelable to test out of bounds
743        String parcelSuffix = UUID.randomUUID().toString();
744        Parcel parcel = Parcel.obtain();
745        savedState.writeToParcel(parcel, 0);
746        parcel.writeString(parcelSuffix);
747        removeRecyclerView();
748        // reset for reading
749        parcel.setDataPosition(0);
750        // re-create
751        savedState = RecyclerView.SavedState.CREATOR.createFromParcel(parcel);
752        removeRecyclerView();
753
754        RecyclerView restored = new RecyclerView(getActivity());
755        // this config should be no op.
756        mLayoutManager = new WrappedLinearLayoutManager(getActivity(),
757                config.mOrientation, config.mReverseLayout);
758        mLayoutManager.setStackFromEnd(config.mStackFromEnd);
759        restored.setLayoutManager(mLayoutManager);
760        // use the same adapter for Rect matching
761        restored.setAdapter(mTestAdapter);
762        restored.onRestoreInstanceState(savedState);
763        postRestoreOperation.onAfterRestore(config);
764        assertEquals("Parcel reading should not go out of bounds", parcelSuffix,
765                parcel.readString());
766        mLayoutManager.expectLayouts(1);
767        setRecyclerView(restored);
768        mLayoutManager.waitForLayout(2);
769        // calculate prefix here instead of above to include post restore changes
770        final String logPrefix = config + "\npostLayout:" + postLayoutOperation.describe() +
771                "\npostRestore:" + postRestoreOperation.describe() + "\n";
772        assertEquals(logPrefix + " on saved state, reverse layout should be preserved",
773                config.mReverseLayout, mLayoutManager.getReverseLayout());
774        assertEquals(logPrefix + " on saved state, orientation should be preserved",
775                config.mOrientation, mLayoutManager.getOrientation());
776        assertEquals(logPrefix + " on saved state, stack from end should be preserved",
777                config.mStackFromEnd, mLayoutManager.getStackFromEnd());
778        if (waitForLayout) {
779            if (postRestoreOperation.shouldLayoutMatch(config)) {
780                assertRectSetsEqual(
781                        logPrefix + ": on restore, previous view positions should be preserved",
782                        before, mLayoutManager.collectChildCoordinates());
783            } else {
784                assertRectSetsNotEqual(
785                        logPrefix
786                                + ": on restore with changes, previous view positions should NOT "
787                                + "be preserved",
788                        before, mLayoutManager.collectChildCoordinates());
789            }
790            postRestoreOperation.onAfterReLayout(config);
791        }
792    }
793
794    void scrollToPositionWithOffset(final int position, final int offset) throws Throwable {
795        runTestOnUiThread(new Runnable() {
796            @Override
797            public void run() {
798                mLayoutManager.scrollToPositionWithOffset(position, offset);
799            }
800        });
801    }
802
803    public void assertRectSetsNotEqual(String message, Map<Item, Rect> before,
804            Map<Item, Rect> after) {
805        Throwable throwable = null;
806        try {
807            assertRectSetsEqual("NOT " + message, before, after);
808        } catch (Throwable t) {
809            throwable = t;
810        }
811        assertNotNull(message + "\ntwo layout should be different", throwable);
812    }
813
814    public void assertRectSetsEqual(String message, Map<Item, Rect> before, Map<Item, Rect> after) {
815        StringBuilder sb = new StringBuilder();
816        sb.append("checking rectangle equality.");
817         sb.append("before:\n");
818        for (Map.Entry<Item, Rect> entry : before.entrySet()) {
819            sb.append(entry.getKey().mAdapterIndex + ":" + entry.getValue()).append("\n");
820        }
821        sb.append("after:\n");
822        for (Map.Entry<Item, Rect> entry : after.entrySet()) {
823            sb.append(entry.getKey().mAdapterIndex + ":" + entry.getValue()).append("\n");
824        }
825        message = message + "\n" + sb.toString();
826        assertEquals(message + ":\nitem counts should be equal", before.size()
827                , after.size());
828        for (Map.Entry<Item, Rect> entry : before.entrySet()) {
829            Rect afterRect = after.get(entry.getKey());
830            assertNotNull(message + ":\nSame item should be visible after simple re-layout",
831                    afterRect);
832            assertEquals(message + ":\nItem should be laid out at the same coordinates",
833                    entry.getValue(), afterRect);
834        }
835    }
836
837    public void testAccessibilityPositions() throws Throwable {
838        setupByConfig(new Config(VERTICAL, false, false), true);
839        final AccessibilityDelegateCompat delegateCompat = mRecyclerView
840                .getCompatAccessibilityDelegate();
841        final AccessibilityEvent event = AccessibilityEvent.obtain();
842        runTestOnUiThread(new Runnable() {
843            @Override
844            public void run() {
845                delegateCompat.onInitializeAccessibilityEvent(mRecyclerView, event);
846            }
847        });
848        final AccessibilityRecordCompat record = AccessibilityEventCompat
849                .asRecord(event);
850        assertEquals("result should have first position",
851                record.getFromIndex(),
852                mLayoutManager.findFirstVisibleItemPosition());
853        assertEquals("result should have last position",
854                record.getToIndex(),
855                mLayoutManager.findLastVisibleItemPosition());
856    }
857
858    static class VisibleChildren {
859
860        int firstVisiblePosition = RecyclerView.NO_POSITION;
861
862        int firstFullyVisiblePosition = RecyclerView.NO_POSITION;
863
864        int lastVisiblePosition = RecyclerView.NO_POSITION;
865
866        int lastFullyVisiblePosition = RecyclerView.NO_POSITION;
867
868        @Override
869        public String toString() {
870            return "VisibleChildren{" +
871                    "firstVisiblePosition=" + firstVisiblePosition +
872                    ", firstFullyVisiblePosition=" + firstFullyVisiblePosition +
873                    ", lastVisiblePosition=" + lastVisiblePosition +
874                    ", lastFullyVisiblePosition=" + lastFullyVisiblePosition +
875                    '}';
876        }
877    }
878
879    abstract private class PostLayoutRunnable {
880
881        abstract void run() throws Throwable;
882
883        abstract String describe();
884    }
885
886    abstract private class PostRestoreRunnable {
887
888        void onAfterRestore(Config config) throws Throwable {
889        }
890
891        abstract String describe();
892
893        boolean shouldLayoutMatch(Config config) {
894            return true;
895        }
896
897        void onAfterReLayout(Config config) {
898
899        };
900    }
901
902    class WrappedLinearLayoutManager extends LinearLayoutManager {
903
904        CountDownLatch layoutLatch;
905
906        OrientationHelper mSecondaryOrientation;
907
908        OnLayoutListener mOnLayoutListener;
909
910        public WrappedLinearLayoutManager(Context context, int orientation, boolean reverseLayout) {
911            super(context, orientation, reverseLayout);
912        }
913
914        public void expectLayouts(int count) {
915            layoutLatch = new CountDownLatch(count);
916        }
917
918        public void waitForLayout(long timeout) throws InterruptedException {
919            waitForLayout(timeout, TimeUnit.SECONDS);
920        }
921
922        @Override
923        public void setOrientation(int orientation) {
924            super.setOrientation(orientation);
925            mSecondaryOrientation = null;
926        }
927
928        @Override
929        public void removeAndRecycleView(View child, RecyclerView.Recycler recycler) {
930            if (DEBUG) {
931                Log.d(TAG, "recycling view " + mRecyclerView.getChildViewHolder(child));
932            }
933            super.removeAndRecycleView(child, recycler);
934        }
935
936        @Override
937        public void removeAndRecycleViewAt(int index, RecyclerView.Recycler recycler) {
938            if (DEBUG) {
939                Log.d(TAG, "recycling view at" + mRecyclerView.getChildViewHolder(getChildAt(index)));
940            }
941            super.removeAndRecycleViewAt(index, recycler);
942        }
943
944        @Override
945        void ensureLayoutState() {
946            super.ensureLayoutState();
947            if (mSecondaryOrientation == null) {
948                mSecondaryOrientation = OrientationHelper.createOrientationHelper(this,
949                        1 - getOrientation());
950            }
951        }
952
953        private void waitForLayout(long timeout, TimeUnit timeUnit) throws InterruptedException {
954            layoutLatch.await(timeout * (DEBUG ? 100 : 1), timeUnit);
955            assertEquals("all expected layouts should be executed at the expected time",
956                    0, layoutLatch.getCount());
957            getInstrumentation().waitForIdleSync();
958        }
959
960        public String getBoundsLog() {
961            StringBuilder sb = new StringBuilder();
962            sb.append("view bounds:[start:").append(mOrientationHelper.getStartAfterPadding())
963                    .append(",").append(" end").append(mOrientationHelper.getEndAfterPadding());
964            sb.append("\nchildren bounds\n");
965            final int childCount = getChildCount();
966            for (int i = 0; i < childCount; i++) {
967                View child = getChildAt(i);
968                sb.append("child (ind:").append(i).append(", pos:").append(getPosition(child))
969                        .append("[").append("start:").append(
970                        mOrientationHelper.getDecoratedStart(child)).append(", end:")
971                        .append(mOrientationHelper.getDecoratedEnd(child)).append("]\n");
972            }
973            return sb.toString();
974        }
975
976        public void waitForAnimationsToEnd(int timeoutInSeconds) throws InterruptedException {
977            RecyclerView.ItemAnimator itemAnimator = mRecyclerView.getItemAnimator();
978            if (itemAnimator == null) {
979                return;
980            }
981            final CountDownLatch latch = new CountDownLatch(1);
982            final boolean running = itemAnimator.isRunning(
983                    new RecyclerView.ItemAnimator.ItemAnimatorFinishedListener() {
984                        @Override
985                        public void onAnimationsFinished() {
986                            latch.countDown();
987                        }
988                    }
989            );
990            if (running) {
991                latch.await(timeoutInSeconds, TimeUnit.SECONDS);
992            }
993        }
994
995        public VisibleChildren traverseAndFindVisibleChildren() {
996            int childCount = getChildCount();
997            final VisibleChildren visibleChildren = new VisibleChildren();
998            final int start = mOrientationHelper.getStartAfterPadding();
999            final int end = mOrientationHelper.getEndAfterPadding();
1000            for (int i = 0; i < childCount; i++) {
1001                View child = getChildAt(i);
1002                final int childStart = mOrientationHelper.getDecoratedStart(child);
1003                final int childEnd = mOrientationHelper.getDecoratedEnd(child);
1004                final boolean fullyVisible = childStart >= start && childEnd <= end;
1005                final boolean hidden = childEnd <= start || childStart >= end;
1006                if (hidden) {
1007                    continue;
1008                }
1009                final int position = getPosition(child);
1010                if (fullyVisible) {
1011                    if (position < visibleChildren.firstFullyVisiblePosition ||
1012                            visibleChildren.firstFullyVisiblePosition == RecyclerView.NO_POSITION) {
1013                        visibleChildren.firstFullyVisiblePosition = position;
1014                    }
1015
1016                    if (position > visibleChildren.lastFullyVisiblePosition) {
1017                        visibleChildren.lastFullyVisiblePosition = position;
1018                    }
1019                }
1020
1021                if (position < visibleChildren.firstVisiblePosition ||
1022                        visibleChildren.firstVisiblePosition == RecyclerView.NO_POSITION) {
1023                    visibleChildren.firstVisiblePosition = position;
1024                }
1025
1026                if (position > visibleChildren.lastVisiblePosition) {
1027                    visibleChildren.lastVisiblePosition = position;
1028                }
1029
1030            }
1031            return visibleChildren;
1032        }
1033
1034        Rect getViewBounds(View view) {
1035            if (getOrientation() == HORIZONTAL) {
1036                return new Rect(
1037                        mOrientationHelper.getDecoratedStart(view),
1038                        mSecondaryOrientation.getDecoratedStart(view),
1039                        mOrientationHelper.getDecoratedEnd(view),
1040                        mSecondaryOrientation.getDecoratedEnd(view));
1041            } else {
1042                return new Rect(
1043                        mSecondaryOrientation.getDecoratedStart(view),
1044                        mOrientationHelper.getDecoratedStart(view),
1045                        mSecondaryOrientation.getDecoratedEnd(view),
1046                        mOrientationHelper.getDecoratedEnd(view));
1047            }
1048
1049        }
1050
1051        Map<Item, Rect> collectChildCoordinates() throws Throwable {
1052            final Map<Item, Rect> items = new LinkedHashMap<Item, Rect>();
1053            runTestOnUiThread(new Runnable() {
1054                @Override
1055                public void run() {
1056                    final int childCount = getChildCount();
1057                    for (int i = 0; i < childCount; i++) {
1058                        View child = getChildAt(i);
1059                        RecyclerView.LayoutParams lp = (RecyclerView.LayoutParams) child
1060                                .getLayoutParams();
1061                        TestViewHolder vh = (TestViewHolder) lp.mViewHolder;
1062                        items.put(vh.mBoundItem, getViewBounds(child));
1063                    }
1064                }
1065            });
1066            return items;
1067        }
1068
1069        @Override
1070        public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
1071            try {
1072                if (mOnLayoutListener != null) {
1073                    mOnLayoutListener.before(recycler, state);
1074                }
1075                super.onLayoutChildren(recycler, state);
1076                if (mOnLayoutListener != null) {
1077                    mOnLayoutListener.after(recycler, state);
1078                }
1079            } catch (Throwable t) {
1080                postExceptionToInstrumentation(t);
1081            }
1082            layoutLatch.countDown();
1083        }
1084
1085
1086    }
1087
1088    static class OnLayoutListener {
1089        void before(RecyclerView.Recycler recycler, RecyclerView.State state){}
1090        void after(RecyclerView.Recycler recycler, RecyclerView.State state){}
1091    }
1092
1093    static class Config implements Cloneable {
1094
1095        private static final int DEFAULT_ITEM_COUNT = 100;
1096
1097        private boolean mStackFromEnd;
1098
1099        int mOrientation = VERTICAL;
1100
1101        boolean mReverseLayout = false;
1102
1103        boolean mRecycleChildrenOnDetach = false;
1104
1105        int mItemCount = DEFAULT_ITEM_COUNT;
1106
1107        TestAdapter mTestAdapter;
1108
1109        Config(int orientation, boolean reverseLayout, boolean stackFromEnd) {
1110            mOrientation = orientation;
1111            mReverseLayout = reverseLayout;
1112            mStackFromEnd = stackFromEnd;
1113        }
1114
1115        public Config() {
1116
1117        }
1118
1119        Config adapter(TestAdapter adapter) {
1120            mTestAdapter = adapter;
1121            return this;
1122        }
1123
1124        Config recycleChildrenOnDetach(boolean recycleChildrenOnDetach) {
1125            mRecycleChildrenOnDetach = recycleChildrenOnDetach;
1126            return this;
1127        }
1128
1129        Config orientation(int orientation) {
1130            mOrientation = orientation;
1131            return this;
1132        }
1133
1134        Config stackFromBottom(boolean stackFromBottom) {
1135            mStackFromEnd = stackFromBottom;
1136            return this;
1137        }
1138
1139        Config reverseLayout(boolean reverseLayout) {
1140            mReverseLayout = reverseLayout;
1141            return this;
1142        }
1143
1144        public Config itemCount(int itemCount) {
1145            mItemCount = itemCount;
1146            return this;
1147        }
1148
1149        // required by convention
1150        @Override
1151        public Object clone() throws CloneNotSupportedException {
1152            return super.clone();
1153        }
1154
1155        @Override
1156        public String toString() {
1157            return "Config{" +
1158                    "mStackFromEnd=" + mStackFromEnd +
1159                    ", mOrientation=" + mOrientation +
1160                    ", mReverseLayout=" + mReverseLayout +
1161                    ", mRecycleChildrenOnDetach=" + mRecycleChildrenOnDetach +
1162                    ", mItemCount=" + mItemCount +
1163                    '}';
1164        }
1165    }
1166}
1167