1/*
2 * Copyright (C) 2015 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 */
16package android.support.v7.widget;
17
18import static android.support.v7.widget.LinearLayoutManager.HORIZONTAL;
19import static android.support.v7.widget.LinearLayoutManager.VERTICAL;
20import static android.view.ViewGroup.LayoutParams.MATCH_PARENT;
21import static android.view.ViewGroup.LayoutParams.WRAP_CONTENT;
22
23import static org.junit.Assert.assertEquals;
24import static org.junit.Assert.assertFalse;
25import static org.junit.Assert.assertNotNull;
26
27import static java.util.concurrent.TimeUnit.SECONDS;
28
29import android.content.Context;
30import android.graphics.Rect;
31import android.support.annotation.Nullable;
32import android.util.Log;
33import android.view.View;
34import android.view.ViewGroup;
35
36import org.hamcrest.CoreMatchers;
37import org.hamcrest.MatcherAssert;
38
39import java.lang.reflect.Field;
40import java.util.ArrayList;
41import java.util.LinkedHashMap;
42import java.util.List;
43import java.util.Map;
44import java.util.concurrent.CountDownLatch;
45import java.util.concurrent.TimeUnit;
46
47public class BaseLinearLayoutManagerTest extends BaseRecyclerViewInstrumentationTest {
48
49    protected static final boolean DEBUG = false;
50    protected static final String TAG = "LinearLayoutManagerTest";
51
52    protected static List<Config> createBaseVariations() {
53        List<Config> variations = new ArrayList<>();
54        for (int orientation : new int[]{VERTICAL, HORIZONTAL}) {
55            for (boolean reverseLayout : new boolean[]{false, true}) {
56                for (boolean stackFromBottom : new boolean[]{false, true}) {
57                    for (boolean wrap : new boolean[]{false, true}) {
58                        variations.add(
59                                new Config(orientation, reverseLayout, stackFromBottom).wrap(wrap));
60                    }
61
62                }
63            }
64        }
65        return variations;
66    }
67
68    WrappedLinearLayoutManager mLayoutManager;
69    TestAdapter mTestAdapter;
70
71    protected static List<Config> addConfigVariation(List<Config> base, String fieldName,
72            Object... variations)
73            throws CloneNotSupportedException, NoSuchFieldException, IllegalAccessException {
74        List<Config> newConfigs = new ArrayList<Config>();
75        Field field = Config.class.getDeclaredField(fieldName);
76        for (Config config : base) {
77            for (Object variation : variations) {
78                Config newConfig = (Config) config.clone();
79                field.set(newConfig, variation);
80                newConfigs.add(newConfig);
81            }
82        }
83        return newConfigs;
84    }
85
86    void setupByConfig(Config config, boolean waitForFirstLayout) throws Throwable {
87        setupByConfig(config, waitForFirstLayout, null, null);
88    }
89
90    void setupByConfig(Config config, boolean waitForFirstLayout,
91        @Nullable RecyclerView.LayoutParams childLayoutParams,
92        @Nullable RecyclerView.LayoutParams parentLayoutParams) throws Throwable {
93        mRecyclerView = inflateWrappedRV();
94
95        mRecyclerView.setHasFixedSize(true);
96        mTestAdapter = config.mTestAdapter == null
97                ? new TestAdapter(config.mItemCount, childLayoutParams)
98                : config.mTestAdapter;
99        mRecyclerView.setAdapter(mTestAdapter);
100        mLayoutManager = new WrappedLinearLayoutManager(getActivity(), config.mOrientation,
101            config.mReverseLayout);
102        mLayoutManager.setStackFromEnd(config.mStackFromEnd);
103        mLayoutManager.setRecycleChildrenOnDetach(config.mRecycleChildrenOnDetach);
104        mRecyclerView.setLayoutManager(mLayoutManager);
105        if (config.mWrap) {
106            mRecyclerView.setLayoutParams(
107                    new ViewGroup.LayoutParams(
108                            config.mOrientation == HORIZONTAL ? WRAP_CONTENT : MATCH_PARENT,
109                            config.mOrientation == VERTICAL ? WRAP_CONTENT : MATCH_PARENT
110                    )
111            );
112        }
113        if (parentLayoutParams != null) {
114            mRecyclerView.setLayoutParams(parentLayoutParams);
115        }
116
117        if (waitForFirstLayout) {
118            waitForFirstLayout();
119        }
120    }
121
122    public void scrollToPositionWithPredictive(final int scrollPosition, final int scrollOffset)
123            throws Throwable {
124        setupByConfig(new Config(VERTICAL, false, false), true);
125
126        mLayoutManager.mOnLayoutListener = new OnLayoutListener() {
127            @Override
128            void after(RecyclerView.Recycler recycler, RecyclerView.State state) {
129                if (state.isPreLayout()) {
130                    assertEquals("pending scroll position should still be pending",
131                            scrollPosition, mLayoutManager.mPendingScrollPosition);
132                    if (scrollOffset != LinearLayoutManager.INVALID_OFFSET) {
133                        assertEquals("pending scroll position offset should still be pending",
134                                scrollOffset, mLayoutManager.mPendingScrollPositionOffset);
135                    }
136                } else {
137                    RecyclerView.ViewHolder vh =
138                            mRecyclerView.findViewHolderForLayoutPosition(scrollPosition);
139                    assertNotNull("scroll to position should work", vh);
140                    if (scrollOffset != LinearLayoutManager.INVALID_OFFSET) {
141                        assertEquals("scroll offset should be applied properly",
142                                mLayoutManager.getPaddingTop() + scrollOffset +
143                                        ((RecyclerView.LayoutParams) vh.itemView
144                                                .getLayoutParams()).topMargin,
145                                mLayoutManager.getDecoratedTop(vh.itemView));
146                    }
147                }
148            }
149        };
150        mLayoutManager.expectLayouts(2);
151        mActivityRule.runOnUiThread(new Runnable() {
152            @Override
153            public void run() {
154                try {
155                    mTestAdapter.addAndNotify(0, 1);
156                    if (scrollOffset == LinearLayoutManager.INVALID_OFFSET) {
157                        mLayoutManager.scrollToPosition(scrollPosition);
158                    } else {
159                        mLayoutManager.scrollToPositionWithOffset(scrollPosition,
160                                scrollOffset);
161                    }
162
163                } catch (Throwable throwable) {
164                    throwable.printStackTrace();
165                }
166
167            }
168        });
169        mLayoutManager.waitForLayout(2);
170        checkForMainThreadException();
171    }
172
173    protected void waitForFirstLayout() throws Throwable {
174        mLayoutManager.expectLayouts(1);
175        setRecyclerView(mRecyclerView);
176        mLayoutManager.waitForLayout(2);
177    }
178
179    void scrollToPositionWithOffset(final int position, final int offset) throws Throwable {
180        mActivityRule.runOnUiThread(new Runnable() {
181            @Override
182            public void run() {
183                mLayoutManager.scrollToPositionWithOffset(position, offset);
184            }
185        });
186    }
187
188    public void assertRectSetsNotEqual(String message, Map<Item, Rect> before,
189            Map<Item, Rect> after, boolean strictItemEquality) {
190        Throwable throwable = null;
191        try {
192            assertRectSetsEqual("NOT " + message, before, after, strictItemEquality);
193        } catch (Throwable t) {
194            throwable = t;
195        }
196        assertNotNull(message + "\ntwo layout should be different", throwable);
197    }
198
199    public void assertRectSetsEqual(String message, Map<Item, Rect> before, Map<Item, Rect> after) {
200        assertRectSetsEqual(message, before, after, true);
201    }
202
203    public void assertRectSetsEqual(String message, Map<Item, Rect> before, Map<Item, Rect> after,
204            boolean strictItemEquality) {
205        StringBuilder sb = new StringBuilder();
206        sb.append("checking rectangle equality.\n");
207        sb.append("before:\n");
208        for (Map.Entry<Item, Rect> entry : before.entrySet()) {
209            sb.append(entry.getKey().mAdapterIndex + ":" + entry.getValue()).append("\n");
210        }
211        sb.append("after:\n");
212        for (Map.Entry<Item, Rect> entry : after.entrySet()) {
213            sb.append(entry.getKey().mAdapterIndex + ":" + entry.getValue()).append("\n");
214        }
215        message = message + "\n" + sb.toString();
216        assertEquals(message + ":\nitem counts should be equal", before.size()
217                , after.size());
218        for (Map.Entry<Item, Rect> entry : before.entrySet()) {
219            final Item beforeItem = entry.getKey();
220            Rect afterRect = null;
221            if (strictItemEquality) {
222                afterRect = after.get(beforeItem);
223                assertNotNull(message + ":\nSame item should be visible after simple re-layout",
224                        afterRect);
225            } else {
226                for (Map.Entry<Item, Rect> afterEntry : after.entrySet()) {
227                    final Item afterItem = afterEntry.getKey();
228                    if (afterItem.mAdapterIndex == beforeItem.mAdapterIndex) {
229                        afterRect = afterEntry.getValue();
230                        break;
231                    }
232                }
233                assertNotNull(message + ":\nItem with same adapter index should be visible " +
234                                "after simple re-layout",
235                        afterRect);
236            }
237            assertEquals(message + ":\nItem should be laid out at the same coordinates",
238                    entry.getValue(), afterRect);
239        }
240    }
241
242    static class VisibleChildren {
243
244        int firstVisiblePosition = RecyclerView.NO_POSITION;
245
246        int firstFullyVisiblePosition = RecyclerView.NO_POSITION;
247
248        int lastVisiblePosition = RecyclerView.NO_POSITION;
249
250        int lastFullyVisiblePosition = RecyclerView.NO_POSITION;
251
252        @Override
253        public String toString() {
254            return "VisibleChildren{" +
255                    "firstVisiblePosition=" + firstVisiblePosition +
256                    ", firstFullyVisiblePosition=" + firstFullyVisiblePosition +
257                    ", lastVisiblePosition=" + lastVisiblePosition +
258                    ", lastFullyVisiblePosition=" + lastFullyVisiblePosition +
259                    '}';
260        }
261    }
262
263    static class OnLayoutListener {
264
265        void before(RecyclerView.Recycler recycler, RecyclerView.State state) {
266        }
267
268        void after(RecyclerView.Recycler recycler, RecyclerView.State state) {
269        }
270    }
271
272    static class Config implements Cloneable {
273
274        static final int DEFAULT_ITEM_COUNT = 250;
275
276        boolean mStackFromEnd;
277
278        int mOrientation = VERTICAL;
279
280        boolean mReverseLayout = false;
281
282        boolean mRecycleChildrenOnDetach = false;
283
284        int mItemCount = DEFAULT_ITEM_COUNT;
285
286        boolean mWrap = false;
287
288        TestAdapter mTestAdapter;
289
290        Config(int orientation, boolean reverseLayout, boolean stackFromEnd) {
291            mOrientation = orientation;
292            mReverseLayout = reverseLayout;
293            mStackFromEnd = stackFromEnd;
294        }
295
296        public Config() {
297
298        }
299
300        Config adapter(TestAdapter adapter) {
301            mTestAdapter = adapter;
302            return this;
303        }
304
305        Config recycleChildrenOnDetach(boolean recycleChildrenOnDetach) {
306            mRecycleChildrenOnDetach = recycleChildrenOnDetach;
307            return this;
308        }
309
310        Config orientation(int orientation) {
311            mOrientation = orientation;
312            return this;
313        }
314
315        Config stackFromBottom(boolean stackFromBottom) {
316            mStackFromEnd = stackFromBottom;
317            return this;
318        }
319
320        Config reverseLayout(boolean reverseLayout) {
321            mReverseLayout = reverseLayout;
322            return this;
323        }
324
325        public Config itemCount(int itemCount) {
326            mItemCount = itemCount;
327            return this;
328        }
329
330        // required by convention
331        @Override
332        public Object clone() throws CloneNotSupportedException {
333            return super.clone();
334        }
335
336        @Override
337        public String toString() {
338            return "Config{"
339                    + "mStackFromEnd=" + mStackFromEnd
340                    + ",mOrientation=" + mOrientation
341                    + ",mReverseLayout=" + mReverseLayout
342                    + ",mRecycleChildrenOnDetach=" + mRecycleChildrenOnDetach
343                    + ",mItemCount=" + mItemCount
344                    + ",wrap=" + mWrap
345                    + '}';
346        }
347
348        public Config wrap(boolean wrap) {
349            mWrap = wrap;
350            return this;
351        }
352    }
353
354    class WrappedLinearLayoutManager extends LinearLayoutManager {
355
356        CountDownLatch layoutLatch;
357        CountDownLatch snapLatch;
358        CountDownLatch prefetchLatch;
359        CountDownLatch callbackLatch;
360
361        OrientationHelper mSecondaryOrientation;
362
363        OnLayoutListener mOnLayoutListener;
364
365        RecyclerView.OnScrollListener mCallbackListener = new RecyclerView.OnScrollListener() {
366
367            @Override
368            public void onScrollStateChanged(RecyclerView recyclerView, int newState) {
369                super.onScrollStateChanged(recyclerView, newState);
370                callbackLatch.countDown();
371                if (callbackLatch.getCount() == 0L) {
372                    removeOnScrollListener(this);
373                }
374            }
375        };
376
377        public WrappedLinearLayoutManager(Context context, int orientation, boolean reverseLayout) {
378            super(context, orientation, reverseLayout);
379        }
380
381        public void expectLayouts(int count) {
382            layoutLatch = new CountDownLatch(count);
383        }
384
385        public void expectCallbacks(int count) throws Throwable {
386            callbackLatch = new CountDownLatch(count);
387            mRecyclerView.addOnScrollListener(mCallbackListener);
388        }
389
390        private void removeOnScrollListener(RecyclerView.OnScrollListener listener) {
391            mRecyclerView.removeOnScrollListener(listener);
392        }
393
394        public void waitForLayout(int seconds) throws Throwable {
395            layoutLatch.await(seconds * (DEBUG ? 100 : 1), SECONDS);
396            checkForMainThreadException();
397            MatcherAssert.assertThat("all layouts should complete on time",
398                    layoutLatch.getCount(), CoreMatchers.is(0L));
399            // use a runnable to ensure RV layout is finished
400            getInstrumentation().runOnMainSync(new Runnable() {
401                @Override
402                public void run() {
403                }
404            });
405        }
406
407        public void assertNoCallbacks(String msg, long timeout) throws Throwable {
408            callbackLatch.await(timeout, TimeUnit.SECONDS);
409            long latchCount = callbackLatch.getCount();
410            assertFalse(msg + " :" + latchCount, latchCount == 0);
411            removeOnScrollListener(mCallbackListener);
412        }
413
414        public void expectPrefetch(int count) {
415            prefetchLatch = new CountDownLatch(count);
416        }
417
418        public void waitForPrefetch(int seconds) throws Throwable {
419            prefetchLatch.await(seconds * (DEBUG ? 100 : 1), SECONDS);
420            checkForMainThreadException();
421            MatcherAssert.assertThat("all prefetches should complete on time",
422                    prefetchLatch.getCount(), CoreMatchers.is(0L));
423            // use a runnable to ensure RV layout is finished
424            getInstrumentation().runOnMainSync(new Runnable() {
425                @Override
426                public void run() {
427                }
428            });
429        }
430
431        public void expectIdleState(int count) {
432            snapLatch = new CountDownLatch(count);
433            mRecyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() {
434                @Override
435                public void onScrollStateChanged(RecyclerView recyclerView, int newState) {
436                    super.onScrollStateChanged(recyclerView, newState);
437                    if (newState == RecyclerView.SCROLL_STATE_IDLE) {
438                        snapLatch.countDown();
439                        if (snapLatch.getCount() == 0L) {
440                            mRecyclerView.removeOnScrollListener(this);
441                        }
442                    }
443                }
444            });
445        }
446
447        public void waitForSnap(int seconds) throws Throwable {
448            snapLatch.await(seconds * (DEBUG ? 100 : 1), SECONDS);
449            checkForMainThreadException();
450            MatcherAssert.assertThat("all scrolling should complete on time",
451                    snapLatch.getCount(), CoreMatchers.is(0L));
452            // use a runnable to ensure RV layout is finished
453            getInstrumentation().runOnMainSync(new Runnable() {
454                @Override
455                public void run() {}
456            });
457        }
458
459        @Override
460        public void setOrientation(int orientation) {
461            super.setOrientation(orientation);
462            mSecondaryOrientation = null;
463        }
464
465        @Override
466        public void removeAndRecycleView(View child, RecyclerView.Recycler recycler) {
467            if (DEBUG) {
468                Log.d(TAG, "recycling view " + mRecyclerView.getChildViewHolder(child));
469            }
470            super.removeAndRecycleView(child, recycler);
471        }
472
473        @Override
474        public void removeAndRecycleViewAt(int index, RecyclerView.Recycler recycler) {
475            if (DEBUG) {
476                Log.d(TAG,
477                        "recycling view at" + mRecyclerView.getChildViewHolder(getChildAt(index)));
478            }
479            super.removeAndRecycleViewAt(index, recycler);
480        }
481
482        @Override
483        void ensureLayoutState() {
484            super.ensureLayoutState();
485            if (mSecondaryOrientation == null) {
486                mSecondaryOrientation = OrientationHelper.createOrientationHelper(this,
487                        1 - getOrientation());
488            }
489        }
490
491        @Override
492        LayoutState createLayoutState() {
493            return new LayoutState() {
494                @Override
495                View next(RecyclerView.Recycler recycler) {
496                    final boolean hadMore = hasMore(mRecyclerView.mState);
497                    final int position = mCurrentPosition;
498                    View next = super.next(recycler);
499                    assertEquals("if has more, should return a view", hadMore, next != null);
500                    assertEquals("position of the returned view must match current position",
501                            position, RecyclerView.getChildViewHolderInt(next).getLayoutPosition());
502                    return next;
503                }
504            };
505        }
506
507        public String getBoundsLog() {
508            StringBuilder sb = new StringBuilder();
509            sb.append("view bounds:[start:").append(mOrientationHelper.getStartAfterPadding())
510                    .append(",").append(" end").append(mOrientationHelper.getEndAfterPadding());
511            sb.append("\nchildren bounds\n");
512            final int childCount = getChildCount();
513            for (int i = 0; i < childCount; i++) {
514                View child = getChildAt(i);
515                sb.append("child (ind:").append(i).append(", pos:").append(getPosition(child))
516                        .append("[").append("start:").append(
517                        mOrientationHelper.getDecoratedStart(child)).append(", end:")
518                        .append(mOrientationHelper.getDecoratedEnd(child)).append("]\n");
519            }
520            return sb.toString();
521        }
522
523        public void waitForAnimationsToEnd(int timeoutInSeconds) throws InterruptedException {
524            RecyclerView.ItemAnimator itemAnimator = mRecyclerView.getItemAnimator();
525            if (itemAnimator == null) {
526                return;
527            }
528            final CountDownLatch latch = new CountDownLatch(1);
529            final boolean running = itemAnimator.isRunning(
530                    new RecyclerView.ItemAnimator.ItemAnimatorFinishedListener() {
531                        @Override
532                        public void onAnimationsFinished() {
533                            latch.countDown();
534                        }
535                    }
536            );
537            if (running) {
538                latch.await(timeoutInSeconds, TimeUnit.SECONDS);
539            }
540        }
541
542        public VisibleChildren traverseAndFindVisibleChildren() {
543            int childCount = getChildCount();
544            final VisibleChildren visibleChildren = new VisibleChildren();
545            final int start = mOrientationHelper.getStartAfterPadding();
546            final int end = mOrientationHelper.getEndAfterPadding();
547            for (int i = 0; i < childCount; i++) {
548                View child = getChildAt(i);
549                final int childStart = mOrientationHelper.getDecoratedStart(child);
550                final int childEnd = mOrientationHelper.getDecoratedEnd(child);
551                final boolean fullyVisible = childStart >= start && childEnd <= end;
552                final boolean hidden = childEnd <= start || childStart >= end;
553                if (hidden) {
554                    continue;
555                }
556                final int position = getPosition(child);
557                if (fullyVisible) {
558                    if (position < visibleChildren.firstFullyVisiblePosition ||
559                            visibleChildren.firstFullyVisiblePosition == RecyclerView.NO_POSITION) {
560                        visibleChildren.firstFullyVisiblePosition = position;
561                    }
562
563                    if (position > visibleChildren.lastFullyVisiblePosition) {
564                        visibleChildren.lastFullyVisiblePosition = position;
565                    }
566                }
567
568                if (position < visibleChildren.firstVisiblePosition ||
569                        visibleChildren.firstVisiblePosition == RecyclerView.NO_POSITION) {
570                    visibleChildren.firstVisiblePosition = position;
571                }
572
573                if (position > visibleChildren.lastVisiblePosition) {
574                    visibleChildren.lastVisiblePosition = position;
575                }
576
577            }
578            return visibleChildren;
579        }
580
581        Rect getViewBounds(View view) {
582            if (getOrientation() == HORIZONTAL) {
583                return new Rect(
584                        mOrientationHelper.getDecoratedStart(view),
585                        mSecondaryOrientation.getDecoratedStart(view),
586                        mOrientationHelper.getDecoratedEnd(view),
587                        mSecondaryOrientation.getDecoratedEnd(view));
588            } else {
589                return new Rect(
590                        mSecondaryOrientation.getDecoratedStart(view),
591                        mOrientationHelper.getDecoratedStart(view),
592                        mSecondaryOrientation.getDecoratedEnd(view),
593                        mOrientationHelper.getDecoratedEnd(view));
594            }
595
596        }
597
598        Map<Item, Rect> collectChildCoordinates() throws Throwable {
599            final Map<Item, Rect> items = new LinkedHashMap<Item, Rect>();
600            mActivityRule.runOnUiThread(new Runnable() {
601                @Override
602                public void run() {
603                    final int childCount = getChildCount();
604                    Rect layoutBounds = new Rect(0, 0,
605                            mLayoutManager.getWidth(), mLayoutManager.getHeight());
606                    for (int i = 0; i < childCount; i++) {
607                        View child = getChildAt(i);
608                        RecyclerView.LayoutParams lp = (RecyclerView.LayoutParams) child
609                                .getLayoutParams();
610                        TestViewHolder vh = (TestViewHolder) lp.mViewHolder;
611                        Rect childBounds = getViewBounds(child);
612                        if (new Rect(childBounds).intersect(layoutBounds)) {
613                            items.put(vh.mBoundItem, childBounds);
614                        }
615                    }
616                }
617            });
618            return items;
619        }
620
621        @Override
622        public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
623            try {
624                if (mOnLayoutListener != null) {
625                    mOnLayoutListener.before(recycler, state);
626                }
627                super.onLayoutChildren(recycler, state);
628                if (mOnLayoutListener != null) {
629                    mOnLayoutListener.after(recycler, state);
630                }
631            } catch (Throwable t) {
632                postExceptionToInstrumentation(t);
633            }
634            layoutLatch.countDown();
635        }
636
637        @Override
638        public void collectAdjacentPrefetchPositions(int dx, int dy, RecyclerView.State state,
639                LayoutPrefetchRegistry layoutPrefetchRegistry) {
640            if (prefetchLatch != null) prefetchLatch.countDown();
641            super.collectAdjacentPrefetchPositions(dx, dy, state, layoutPrefetchRegistry);
642        }
643    }
644}
645