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 package android.support.v7.widget;
18 
19 import android.content.Context;
20 import android.graphics.Rect;
21 import android.os.Parcel;
22 import android.os.Parcelable;
23 import android.support.v4.view.AccessibilityDelegateCompat;
24 import android.support.v4.view.accessibility.AccessibilityEventCompat;
25 import android.support.v4.view.accessibility.AccessibilityRecordCompat;
26 import android.util.Log;
27 import android.view.View;
28 import android.view.ViewGroup;
29 import android.view.accessibility.AccessibilityEvent;
30 import android.widget.FrameLayout;
31 
32 import static android.support.v7.widget.LayoutState.LAYOUT_END;
33 import static android.support.v7.widget.LayoutState.LAYOUT_START;
34 import static android.support.v7.widget.LinearLayoutManager.HORIZONTAL;
35 import static android.support.v7.widget.LinearLayoutManager.VERTICAL;
36 import java.lang.reflect.Field;
37 import java.util.ArrayList;
38 import java.util.LinkedHashMap;
39 import java.util.List;
40 import java.util.Map;
41 import java.util.UUID;
42 import java.util.concurrent.CountDownLatch;
43 import java.util.concurrent.TimeUnit;
44 import 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  */
53 public 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
setUp()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 
addConfigVariation(List<Config> base, String fieldName, Object... variations)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 
setupByConfig(Config config, boolean waitForFirstLayout)92     void setupByConfig(Config config, boolean waitForFirstLayout) throws Throwable {
93         mRecyclerView = inflateWrappedRV();
94 
95         mRecyclerView.setHasFixedSize(true);
96         mTestAdapter = config.mTestAdapter == null ? new TestAdapter(config.mItemCount)
97                 : config.mTestAdapter;
98         mRecyclerView.setAdapter(mTestAdapter);
99         mLayoutManager = new WrappedLinearLayoutManager(getActivity(), config.mOrientation,
100                 config.mReverseLayout);
101         mLayoutManager.setStackFromEnd(config.mStackFromEnd);
102         mLayoutManager.setRecycleChildrenOnDetach(config.mRecycleChildrenOnDetach);
103         mRecyclerView.setLayoutManager(mLayoutManager);
104         if (waitForFirstLayout) {
105             waitForFirstLayout();
106         }
107     }
108 
testRemoveAnchorItem()109     public void testRemoveAnchorItem() throws Throwable {
110         removeAnchorItemTest(
111                 new Config().orientation(VERTICAL).stackFromBottom(false).reverseLayout(
112                         false), 100, 0);
113     }
114 
testRemoveAnchorItemReverse()115     public void testRemoveAnchorItemReverse() throws Throwable {
116         removeAnchorItemTest(
117                 new Config().orientation(VERTICAL).stackFromBottom(false).reverseLayout(true), 100,
118                 0);
119     }
120 
testRemoveAnchorItemStackFromEnd()121     public void testRemoveAnchorItemStackFromEnd() throws Throwable {
122         removeAnchorItemTest(
123                 new Config().orientation(VERTICAL).stackFromBottom(true).reverseLayout(false), 100,
124                 99);
125     }
126 
testRemoveAnchorItemStackFromEndAndReverse()127     public void testRemoveAnchorItemStackFromEndAndReverse() throws Throwable {
128         removeAnchorItemTest(
129                 new Config().orientation(VERTICAL).stackFromBottom(true).reverseLayout(true), 100,
130                 99);
131     }
132 
testRemoveAnchorItemHorizontal()133     public void testRemoveAnchorItemHorizontal() throws Throwable {
134         removeAnchorItemTest(
135                 new Config().orientation(HORIZONTAL).stackFromBottom(false).reverseLayout(
136                         false), 100, 0);
137     }
138 
testRemoveAnchorItemReverseHorizontal()139     public void testRemoveAnchorItemReverseHorizontal() throws Throwable {
140         removeAnchorItemTest(
141                 new Config().orientation(HORIZONTAL).stackFromBottom(false).reverseLayout(true),
142                 100, 0);
143     }
144 
testRemoveAnchorItemStackFromEndHorizontal()145     public void testRemoveAnchorItemStackFromEndHorizontal() throws Throwable {
146         removeAnchorItemTest(
147                 new Config().orientation(HORIZONTAL).stackFromBottom(true).reverseLayout(false),
148                 100, 99);
149     }
150 
testRemoveAnchorItemStackFromEndAndReverseHorizontal()151     public void testRemoveAnchorItemStackFromEndAndReverseHorizontal() throws Throwable {
152         removeAnchorItemTest(
153                 new Config().orientation(HORIZONTAL).stackFromBottom(true).reverseLayout(true), 100,
154                 99);
155     }
156 
157     /**
158      * This tests a regression where predictive animations were not working as expected when the
159      * first item is removed and there aren't any more items to add from that direction.
160      * First item refers to the default anchor item.
161      */
removeAnchorItemTest(final Config config, int adapterSize, final int removePos)162     public void removeAnchorItemTest(final Config config, int adapterSize,
163             final int removePos) throws Throwable {
164         config.adapter(new TestAdapter(adapterSize) {
165             @Override
166             public void onBindViewHolder(TestViewHolder holder,
167                     int position) {
168                 super.onBindViewHolder(holder, position);
169                 ViewGroup.LayoutParams lp = holder.itemView.getLayoutParams();
170                 if (!(lp instanceof ViewGroup.MarginLayoutParams)) {
171                     lp = new ViewGroup.MarginLayoutParams(0, 0);
172                     holder.itemView.setLayoutParams(lp);
173                 }
174                 ViewGroup.MarginLayoutParams mlp = (ViewGroup.MarginLayoutParams) lp;
175                 final int maxSize;
176                 if (config.mOrientation == HORIZONTAL) {
177                     maxSize = mRecyclerView.getWidth();
178                     mlp.height = ViewGroup.MarginLayoutParams.FILL_PARENT;
179                 } else {
180                     maxSize = mRecyclerView.getHeight();
181                     mlp.width = ViewGroup.MarginLayoutParams.FILL_PARENT;
182                 }
183 
184                 final int desiredSize;
185                 if (position == removePos) {
186                     // make it large
187                     desiredSize = maxSize / 4;
188                 } else {
189                     // make it small
190                     desiredSize = maxSize / 8;
191                 }
192                 if (config.mOrientation == HORIZONTAL) {
193                     mlp.width = desiredSize;
194                 } else {
195                     mlp.height = desiredSize;
196                 }
197             }
198         });
199         setupByConfig(config, true);
200         final int childCount = mLayoutManager.getChildCount();
201         RecyclerView.ViewHolder toBeRemoved = null;
202         List<RecyclerView.ViewHolder> toBeMoved = new ArrayList<RecyclerView.ViewHolder>();
203         for (int i = 0; i < childCount; i++) {
204             View child = mLayoutManager.getChildAt(i);
205             RecyclerView.ViewHolder holder = mRecyclerView.getChildViewHolder(child);
206             if (holder.getAdapterPosition() == removePos) {
207                 toBeRemoved = holder;
208             } else {
209                 toBeMoved.add(holder);
210             }
211         }
212         assertNotNull("test sanity", toBeRemoved);
213         assertEquals("test sanity", childCount - 1, toBeMoved.size());
214         LoggingItemAnimator loggingItemAnimator = new LoggingItemAnimator();
215         mRecyclerView.setItemAnimator(loggingItemAnimator);
216         loggingItemAnimator.reset();
217         loggingItemAnimator.expectRunPendingAnimationsCall(1);
218         mLayoutManager.expectLayouts(2);
219         mTestAdapter.deleteAndNotify(removePos, 1);
220         mLayoutManager.waitForLayout(1);
221         loggingItemAnimator.waitForPendingAnimationsCall(2);
222         assertTrue("removed child should receive remove animation",
223                 loggingItemAnimator.mRemoveVHs.contains(toBeRemoved));
224         for (RecyclerView.ViewHolder vh : toBeMoved) {
225             assertTrue("view holder should be in moved list",
226                     loggingItemAnimator.mMoveVHs.contains(vh));
227         }
228         List<RecyclerView.ViewHolder> newHolders = new ArrayList<RecyclerView.ViewHolder>();
229         for (int i = 0; i < mLayoutManager.getChildCount(); i++) {
230             View child = mLayoutManager.getChildAt(i);
231             RecyclerView.ViewHolder holder = mRecyclerView.getChildViewHolder(child);
232             if (toBeRemoved != holder && !toBeMoved.contains(holder)) {
233                 newHolders.add(holder);
234             }
235         }
236         assertTrue("some new children should show up for the new space", newHolders.size() > 0);
237         assertEquals("no items should receive animate add since they are not new", 0,
238                 loggingItemAnimator.mAddVHs.size());
239         for (RecyclerView.ViewHolder holder : newHolders) {
240             assertTrue("new holder should receive a move animation",
241                     loggingItemAnimator.mMoveVHs.contains(holder));
242         }
243         assertTrue("control against adding too many children due to bad layout state preparation."
244                         + " initial:" + childCount + ", current:" + mRecyclerView.getChildCount(),
245                 mRecyclerView.getChildCount() <= childCount + 3 /*1 for removed view, 2 for its size*/);
246     }
247 
testKeepFocusOnRelayout()248     public void testKeepFocusOnRelayout() throws Throwable {
249         setupByConfig(new Config(VERTICAL, false, false).itemCount(500), true);
250         int center = (mLayoutManager.findLastVisibleItemPosition()
251                 - mLayoutManager.findFirstVisibleItemPosition()) / 2;
252         final RecyclerView.ViewHolder vh = mRecyclerView.findViewHolderForLayoutPosition(center);
253         final int top = mLayoutManager.mOrientationHelper.getDecoratedStart(vh.itemView);
254         runTestOnUiThread(new Runnable() {
255             @Override
256             public void run() {
257                 vh.itemView.requestFocus();
258             }
259         });
260         assertTrue("view should have the focus", vh.itemView.hasFocus());
261         // add a bunch of items right before that view, make sure it keeps its position
262         mLayoutManager.expectLayouts(2);
263         final int childCountToAdd = mRecyclerView.getChildCount() * 2;
264         mTestAdapter.addAndNotify(center, childCountToAdd);
265         center += childCountToAdd; // offset item
266         mLayoutManager.waitForLayout(2);
267         mLayoutManager.waitForAnimationsToEnd(20);
268         final RecyclerView.ViewHolder postVH = mRecyclerView.findViewHolderForLayoutPosition(center);
269         assertNotNull("focused child should stay in layout", postVH);
270         assertSame("same view holder should be kept for unchanged child", vh, postVH);
271         assertEquals("focused child's screen position should stay unchanged", top,
272                 mLayoutManager.mOrientationHelper.getDecoratedStart(postVH.itemView));
273     }
274 
testKeepFullFocusOnResize()275     public void testKeepFullFocusOnResize() throws Throwable {
276         keepFocusOnResizeTest(new Config(VERTICAL, false, false).itemCount(500), true);
277     }
278 
testKeepPartialFocusOnResize()279     public void testKeepPartialFocusOnResize() throws Throwable {
280         keepFocusOnResizeTest(new Config(VERTICAL, false, false).itemCount(500), false);
281     }
282 
testKeepReverseFullFocusOnResize()283     public void testKeepReverseFullFocusOnResize() throws Throwable {
284         keepFocusOnResizeTest(new Config(VERTICAL, true, false).itemCount(500), true);
285     }
286 
testKeepReversePartialFocusOnResize()287     public void testKeepReversePartialFocusOnResize() throws Throwable {
288         keepFocusOnResizeTest(new Config(VERTICAL, true, false).itemCount(500), false);
289     }
290 
testKeepStackFromEndFullFocusOnResize()291     public void testKeepStackFromEndFullFocusOnResize() throws Throwable {
292         keepFocusOnResizeTest(new Config(VERTICAL, false, true).itemCount(500), true);
293     }
294 
testKeepStackFromEndPartialFocusOnResize()295     public void testKeepStackFromEndPartialFocusOnResize() throws Throwable {
296         keepFocusOnResizeTest(new Config(VERTICAL, false, true).itemCount(500), false);
297     }
298 
keepFocusOnResizeTest(final Config config, boolean fullyVisible)299     public void keepFocusOnResizeTest(final Config config, boolean fullyVisible) throws Throwable {
300         setupByConfig(config, true);
301         final int targetPosition;
302         if (config.mStackFromEnd) {
303             targetPosition = mLayoutManager.findFirstVisibleItemPosition();
304         } else {
305             targetPosition = mLayoutManager.findLastVisibleItemPosition();
306         }
307         final OrientationHelper helper = mLayoutManager.mOrientationHelper;
308         final RecyclerView.ViewHolder vh = mRecyclerView
309                 .findViewHolderForLayoutPosition(targetPosition);
310 
311         // scroll enough to offset the child
312         int startMargin = helper.getDecoratedStart(vh.itemView) -
313                 helper.getStartAfterPadding();
314         int endMargin = helper.getEndAfterPadding() -
315                 helper.getDecoratedEnd(vh.itemView);
316         Log.d(TAG, "initial start margin " + startMargin + " , end margin:" + endMargin);
317         requestFocus(vh.itemView);
318         runTestOnUiThread(new Runnable() {
319             @Override
320             public void run() {
321                 assertTrue("view should gain the focus", vh.itemView.hasFocus());
322             }
323         });
324         do {
325             Thread.sleep(100);
326         } while (mRecyclerView.getScrollState() != RecyclerView.SCROLL_STATE_IDLE);
327         // scroll enough to offset the child
328         startMargin = helper.getDecoratedStart(vh.itemView) -
329                 helper.getStartAfterPadding();
330         endMargin = helper.getEndAfterPadding() -
331                 helper.getDecoratedEnd(vh.itemView);
332 
333         Log.d(TAG, "start margin " + startMargin + " , end margin:" + endMargin);
334         assertTrue("View should become fully visible", startMargin >= 0 && endMargin >= 0);
335 
336         int expectedOffset = 0;
337         boolean offsetAtStart = false;
338         if (!fullyVisible) {
339             // move it a bit such that it is no more fully visible
340             final int childSize = helper
341                     .getDecoratedMeasurement(vh.itemView);
342             expectedOffset = childSize / 3;
343             if (startMargin < endMargin) {
344                 scrollBy(expectedOffset);
345                 offsetAtStart = true;
346             } else {
347                 scrollBy(-expectedOffset);
348                 offsetAtStart = false;
349             }
350             startMargin = helper.getDecoratedStart(vh.itemView) -
351                     helper.getStartAfterPadding();
352             endMargin = helper.getEndAfterPadding() -
353                     helper.getDecoratedEnd(vh.itemView);
354             assertTrue("test sanity, view should not be fully visible", startMargin < 0
355                     || endMargin < 0);
356         }
357 
358         mLayoutManager.expectLayouts(1);
359         runTestOnUiThread(new Runnable() {
360             @Override
361             public void run() {
362                 final ViewGroup.LayoutParams layoutParams = mRecyclerView.getLayoutParams();
363                 if (config.mOrientation == HORIZONTAL) {
364                     layoutParams.width = mRecyclerView.getWidth() / 2;
365                 } else {
366                     layoutParams.height = mRecyclerView.getHeight() / 2;
367                 }
368                 mRecyclerView.setLayoutParams(layoutParams);
369             }
370         });
371         Thread.sleep(100);
372         // add a bunch of items right before that view, make sure it keeps its position
373         mLayoutManager.waitForLayout(2);
374         mLayoutManager.waitForAnimationsToEnd(20);
375         assertTrue("view should preserve the focus", vh.itemView.hasFocus());
376         final RecyclerView.ViewHolder postVH = mRecyclerView
377                 .findViewHolderForLayoutPosition(targetPosition);
378         assertNotNull("focused child should stay in layout", postVH);
379         assertSame("same view holder should be kept for unchanged child", vh, postVH);
380         View focused = postVH.itemView;
381 
382         startMargin = helper.getDecoratedStart(focused) - helper.getStartAfterPadding();
383         endMargin = helper.getEndAfterPadding() - helper.getDecoratedEnd(focused);
384 
385         assertTrue("focused child should be somewhat visible",
386                 helper.getDecoratedStart(focused) < helper.getEndAfterPadding()
387                         && helper.getDecoratedEnd(focused) > helper.getStartAfterPadding());
388         if (fullyVisible) {
389             assertTrue("focused child end should stay fully visible",
390                     endMargin >= 0);
391             assertTrue("focused child start should stay fully visible",
392                     startMargin >= 0);
393         } else {
394             if (offsetAtStart) {
395                 assertTrue("start should preserve its offset", startMargin < 0);
396                 assertTrue("end should be visible", endMargin >= 0);
397             } else {
398                 assertTrue("end should preserve its offset", endMargin < 0);
399                 assertTrue("start should be visible", startMargin >= 0);
400             }
401         }
402     }
403 
testResize()404     public void testResize() throws Throwable {
405         for(Config config : addConfigVariation(mBaseVariations, "mItemCount", 5
406                 , Config.DEFAULT_ITEM_COUNT)) {
407             stackFromEndTest(config);
408             removeRecyclerView();
409         }
410     }
411 
testScrollToPositionWithOffset()412     public void testScrollToPositionWithOffset() throws Throwable {
413         for (Config config : mBaseVariations) {
414             scrollToPositionWithOffsetTest(config.itemCount(300));
415             removeRecyclerView();
416         }
417     }
418 
scrollToPositionWithOffsetTest(Config config)419     public void scrollToPositionWithOffsetTest(Config config) throws Throwable {
420         setupByConfig(config, true);
421         OrientationHelper orientationHelper = OrientationHelper
422                 .createOrientationHelper(mLayoutManager, config.mOrientation);
423         Rect layoutBounds = getDecoratedRecyclerViewBounds();
424         // try scrolling towards head, should not affect anything
425         Map<Item, Rect> before = mLayoutManager.collectChildCoordinates();
426         if (config.mStackFromEnd) {
427             scrollToPositionWithOffset(mTestAdapter.getItemCount() - 1,
428                     mLayoutManager.mOrientationHelper.getEnd() - 500);
429         } else {
430             scrollToPositionWithOffset(0, 20);
431         }
432         assertRectSetsEqual(config + " trying to over scroll with offset should be no-op",
433                 before, mLayoutManager.collectChildCoordinates());
434         // try offsetting some visible children
435         int testCount = 10;
436         while (testCount-- > 0) {
437             // get middle child
438             final View child = mLayoutManager.getChildAt(mLayoutManager.getChildCount() / 2);
439             final int position = mRecyclerView.getChildLayoutPosition(child);
440             final int startOffset = config.mReverseLayout ?
441                     orientationHelper.getEndAfterPadding() - orientationHelper
442                             .getDecoratedEnd(child)
443                     : orientationHelper.getDecoratedStart(child) - orientationHelper
444                             .getStartAfterPadding();
445             final int scrollOffset = config.mStackFromEnd ? startOffset + startOffset / 2
446                     : startOffset / 2;
447             mLayoutManager.expectLayouts(1);
448             scrollToPositionWithOffset(position, scrollOffset);
449             mLayoutManager.waitForLayout(2);
450             final int finalOffset = config.mReverseLayout ?
451                     orientationHelper.getEndAfterPadding() - orientationHelper
452                             .getDecoratedEnd(child)
453                     : orientationHelper.getDecoratedStart(child) - orientationHelper
454                             .getStartAfterPadding();
455             assertEquals(config + " scroll with offset on a visible child should work fine " +
456                     " offset:" + finalOffset + " , existing offset:" + startOffset + ", "
457                             + "child " + position,
458                     scrollOffset, finalOffset);
459         }
460 
461         // try scrolling to invisible children
462         testCount = 10;
463         // we test above and below, one by one
464         int offsetMultiplier = -1;
465         while (testCount-- > 0) {
466             final TargetTuple target = findInvisibleTarget(config);
467             final String logPrefix = config + " " + target;
468             mLayoutManager.expectLayouts(1);
469             final int offset = offsetMultiplier
470                     * orientationHelper.getDecoratedMeasurement(mLayoutManager.getChildAt(0)) / 3;
471             scrollToPositionWithOffset(target.mPosition, offset);
472             mLayoutManager.waitForLayout(2);
473             final View child = mLayoutManager.findViewByPosition(target.mPosition);
474             assertNotNull(logPrefix + " scrolling to a mPosition with offset " + offset
475                     + " should layout it", child);
476             final Rect bounds = mLayoutManager.getViewBounds(child);
477             if (DEBUG) {
478                 Log.d(TAG, logPrefix + " post scroll to invisible mPosition " + bounds + " in "
479                         + layoutBounds + " with offset " + offset);
480             }
481 
482             if (config.mReverseLayout) {
483                 assertEquals(logPrefix + " when scrolling with offset to an invisible in reverse "
484                                 + "layout, its end should align with recycler view's end - offset",
485                         orientationHelper.getEndAfterPadding() - offset,
486                         orientationHelper.getDecoratedEnd(child)
487                 );
488             } else {
489                 assertEquals(logPrefix + " when scrolling with offset to an invisible child in normal"
490                                 + " layout its start should align with recycler view's start + "
491                                 + "offset",
492                         orientationHelper.getStartAfterPadding() + offset,
493                         orientationHelper.getDecoratedStart(child)
494                 );
495             }
496             offsetMultiplier *= -1;
497         }
498     }
499 
findInvisibleTarget(Config config)500     private TargetTuple findInvisibleTarget(Config config) {
501         int minPosition = Integer.MAX_VALUE, maxPosition = Integer.MIN_VALUE;
502         for (int i = 0; i < mLayoutManager.getChildCount(); i++) {
503             View child = mLayoutManager.getChildAt(i);
504             int position = mRecyclerView.getChildLayoutPosition(child);
505             if (position < minPosition) {
506                 minPosition = position;
507             }
508             if (position > maxPosition) {
509                 maxPosition = position;
510             }
511         }
512         final int tailTarget = maxPosition +
513                 (mRecyclerView.getAdapter().getItemCount() - maxPosition) / 2;
514         final int headTarget = minPosition / 2;
515         final int target;
516         // where will the child come from ?
517         final int itemLayoutDirection;
518         if (Math.abs(tailTarget - maxPosition) > Math.abs(headTarget - minPosition)) {
519             target = tailTarget;
520             itemLayoutDirection = config.mReverseLayout ? LAYOUT_START : LAYOUT_END;
521         } else {
522             target = headTarget;
523             itemLayoutDirection = config.mReverseLayout ? LAYOUT_END : LAYOUT_START;
524         }
525         if (DEBUG) {
526             Log.d(TAG,
527                     config + " target:" + target + " min:" + minPosition + ", max:" + maxPosition);
528         }
529         return new TargetTuple(target, itemLayoutDirection);
530     }
531 
stackFromEndTest(final Config config)532     public void stackFromEndTest(final Config config) throws Throwable {
533         final FrameLayout container = getRecyclerViewContainer();
534         runTestOnUiThread(new Runnable() {
535             @Override
536             public void run() {
537                 container.setPadding(0, 0, 0, 0);
538             }
539         });
540 
541         setupByConfig(config, true);
542         int lastVisibleItemPosition = mLayoutManager.findLastVisibleItemPosition();
543         int firstVisibleItemPosition = mLayoutManager.findFirstVisibleItemPosition();
544         int lastCompletelyVisibleItemPosition = mLayoutManager.findLastCompletelyVisibleItemPosition();
545         int firstCompletelyVisibleItemPosition = mLayoutManager.findFirstCompletelyVisibleItemPosition();
546         mLayoutManager.expectLayouts(1);
547         // resize the recycler view to half
548         runTestOnUiThread(new Runnable() {
549             @Override
550             public void run() {
551                 if (config.mOrientation == HORIZONTAL) {
552                     container.setPadding(0, 0, container.getWidth() / 2, 0);
553                 } else {
554                     container.setPadding(0, 0, 0, container.getWidth() / 2);
555                 }
556             }
557         });
558         mLayoutManager.waitForLayout(1);
559         if (config.mStackFromEnd) {
560             assertEquals("[" + config + "]: last visible position should not change.",
561                     lastVisibleItemPosition, mLayoutManager.findLastVisibleItemPosition());
562             assertEquals("[" + config + "]: last completely visible position should not change",
563                     lastCompletelyVisibleItemPosition,
564                     mLayoutManager.findLastCompletelyVisibleItemPosition());
565         } else {
566             assertEquals("[" + config + "]: first visible position should not change.",
567                     firstVisibleItemPosition, mLayoutManager.findFirstVisibleItemPosition());
568             assertEquals("[" + config + "]: last completely visible position should not change",
569                     firstCompletelyVisibleItemPosition,
570                     mLayoutManager.findFirstCompletelyVisibleItemPosition());
571         }
572     }
573 
testScrollToPositionWithPredictive()574     public void testScrollToPositionWithPredictive() throws Throwable {
575         scrollToPositionWithPredictive(0, LinearLayoutManager.INVALID_OFFSET);
576         removeRecyclerView();
577         scrollToPositionWithPredictive(3, 20);
578         removeRecyclerView();
579         scrollToPositionWithPredictive(Config.DEFAULT_ITEM_COUNT / 2,
580                 LinearLayoutManager.INVALID_OFFSET);
581         removeRecyclerView();
582         scrollToPositionWithPredictive(Config.DEFAULT_ITEM_COUNT / 2, 10);
583     }
584 
scrollToPositionWithPredictive(final int scrollPosition, final int scrollOffset)585     public void scrollToPositionWithPredictive(final int scrollPosition, final int scrollOffset)
586             throws Throwable {
587         setupByConfig(new Config(VERTICAL, false, false), true);
588 
589         mLayoutManager.mOnLayoutListener = new OnLayoutListener() {
590             @Override
591             void after(RecyclerView.Recycler recycler, RecyclerView.State state) {
592                 if (state.isPreLayout()) {
593                     assertEquals("pending scroll position should still be pending",
594                             scrollPosition, mLayoutManager.mPendingScrollPosition);
595                     if (scrollOffset != LinearLayoutManager.INVALID_OFFSET) {
596                         assertEquals("pending scroll position offset should still be pending",
597                                 scrollOffset, mLayoutManager.mPendingScrollPositionOffset);
598                     }
599                 } else {
600                     RecyclerView.ViewHolder vh =
601                             mRecyclerView.findViewHolderForLayoutPosition(scrollPosition);
602                     assertNotNull("scroll to position should work", vh);
603                     if (scrollOffset != LinearLayoutManager.INVALID_OFFSET) {
604                         assertEquals("scroll offset should be applied properly",
605                                 mLayoutManager.getPaddingTop() + scrollOffset +
606                                         ((RecyclerView.LayoutParams) vh.itemView
607                                                 .getLayoutParams()).topMargin,
608                                 mLayoutManager.getDecoratedTop(vh.itemView));
609                     }
610                 }
611             }
612         };
613         mLayoutManager.expectLayouts(2);
614         runTestOnUiThread(new Runnable() {
615             @Override
616             public void run() {
617                 try {
618                     mTestAdapter.addAndNotify(0, 1);
619                     if (scrollOffset == LinearLayoutManager.INVALID_OFFSET) {
620                         mLayoutManager.scrollToPosition(scrollPosition);
621                     } else {
622                         mLayoutManager.scrollToPositionWithOffset(scrollPosition,
623                                 scrollOffset);
624                     }
625 
626                 } catch (Throwable throwable) {
627                     throwable.printStackTrace();
628                 }
629 
630             }
631         });
632         mLayoutManager.waitForLayout(2);
633         checkForMainThreadException();
634     }
635 
waitForFirstLayout()636     private void waitForFirstLayout() throws Throwable {
637         mLayoutManager.expectLayouts(1);
638         setRecyclerView(mRecyclerView);
639         mLayoutManager.waitForLayout(2);
640     }
641 
testRecycleDuringAnimations()642     public void testRecycleDuringAnimations() throws Throwable {
643         final AtomicInteger childCount = new AtomicInteger(0);
644         final TestAdapter adapter = new TestAdapter(300) {
645             @Override
646             public TestViewHolder onCreateViewHolder(ViewGroup parent,
647                     int viewType) {
648                 final int cnt = childCount.incrementAndGet();
649                 final TestViewHolder testViewHolder = super.onCreateViewHolder(parent, viewType);
650                 if (DEBUG) {
651                     Log.d(TAG, "CHILD_CNT(create):" + cnt + ", " + testViewHolder);
652                 }
653                 return testViewHolder;
654             }
655         };
656         setupByConfig(new Config(VERTICAL, false, false).itemCount(300)
657                 .adapter(adapter), true);
658 
659         final RecyclerView.RecycledViewPool pool = new RecyclerView.RecycledViewPool() {
660             @Override
661             public void putRecycledView(RecyclerView.ViewHolder scrap) {
662                 super.putRecycledView(scrap);
663                 int cnt = childCount.decrementAndGet();
664                 if (DEBUG) {
665                     Log.d(TAG, "CHILD_CNT(put):" + cnt + ", " + scrap);
666                 }
667             }
668 
669             @Override
670             public RecyclerView.ViewHolder getRecycledView(int viewType) {
671                 final RecyclerView.ViewHolder recycledView = super.getRecycledView(viewType);
672                 if (recycledView != null) {
673                     final int cnt = childCount.incrementAndGet();
674                     if (DEBUG) {
675                         Log.d(TAG, "CHILD_CNT(get):" + cnt + ", " + recycledView);
676                     }
677                 }
678                 return recycledView;
679             }
680         };
681         pool.setMaxRecycledViews(mTestAdapter.getItemViewType(0), 500);
682         mRecyclerView.setRecycledViewPool(pool);
683 
684 
685         // now keep adding children to trigger more children being created etc.
686         for (int i = 0; i < 100; i ++) {
687             adapter.addAndNotify(15, 1);
688             Thread.sleep(15);
689         }
690         getInstrumentation().waitForIdleSync();
691         waitForAnimations(2);
692         assertEquals("Children count should add up", childCount.get(),
693                 mRecyclerView.getChildCount() + mRecyclerView.mRecycler.mCachedViews.size());
694 
695         // now trigger lots of add again, followed by a scroll to position
696         for (int i = 0; i < 100; i ++) {
697             adapter.addAndNotify(5 + (i % 3) * 3, 1);
698             Thread.sleep(25);
699         }
700         smoothScrollToPosition(mLayoutManager.findLastVisibleItemPosition() + 20);
701         waitForAnimations(2);
702         getInstrumentation().waitForIdleSync();
703         assertEquals("Children count should add up", childCount.get(),
704                 mRecyclerView.getChildCount() + mRecyclerView.mRecycler.mCachedViews.size());
705     }
706 
707 
testGetFirstLastChildrenTest()708     public void testGetFirstLastChildrenTest() throws Throwable {
709         for (Config config : mBaseVariations) {
710             getFirstLastChildrenTest(config);
711         }
712     }
713 
testDontRecycleChildrenOnDetach()714     public void testDontRecycleChildrenOnDetach() throws Throwable {
715         setupByConfig(new Config().recycleChildrenOnDetach(false), true);
716         runTestOnUiThread(new Runnable() {
717             @Override
718             public void run() {
719                 int recyclerSize = mRecyclerView.mRecycler.getRecycledViewPool().size();
720                 mRecyclerView.setLayoutManager(new TestLayoutManager());
721                 assertEquals("No views are recycled", recyclerSize,
722                         mRecyclerView.mRecycler.getRecycledViewPool().size());
723             }
724         });
725     }
726 
testRecycleChildrenOnDetach()727     public void testRecycleChildrenOnDetach() throws Throwable {
728         setupByConfig(new Config().recycleChildrenOnDetach(true), true);
729         final int childCount = mLayoutManager.getChildCount();
730         runTestOnUiThread(new Runnable() {
731             @Override
732             public void run() {
733                 int recyclerSize = mRecyclerView.mRecycler.getRecycledViewPool().size();
734                 mRecyclerView.mRecycler.getRecycledViewPool().setMaxRecycledViews(
735                         mTestAdapter.getItemViewType(0), recyclerSize + childCount);
736                 mRecyclerView.setLayoutManager(new TestLayoutManager());
737                 assertEquals("All children should be recycled", childCount + recyclerSize,
738                         mRecyclerView.mRecycler.getRecycledViewPool().size());
739             }
740         });
741     }
742 
getFirstLastChildrenTest(final Config config)743     public void getFirstLastChildrenTest(final Config config) throws Throwable {
744         setupByConfig(config, true);
745         Runnable viewInBoundsTest = new Runnable() {
746             @Override
747             public void run() {
748                 VisibleChildren visibleChildren = mLayoutManager.traverseAndFindVisibleChildren();
749                 final String boundsLog = mLayoutManager.getBoundsLog();
750                 assertEquals(config + ":\nfirst visible child should match traversal result\n"
751                                 + boundsLog, visibleChildren.firstVisiblePosition,
752                         mLayoutManager.findFirstVisibleItemPosition()
753                 );
754                 assertEquals(
755                         config + ":\nfirst fully visible child should match traversal result\n"
756                                 + boundsLog, visibleChildren.firstFullyVisiblePosition,
757                         mLayoutManager.findFirstCompletelyVisibleItemPosition()
758                 );
759 
760                 assertEquals(config + ":\nlast visible child should match traversal result\n"
761                                 + boundsLog, visibleChildren.lastVisiblePosition,
762                         mLayoutManager.findLastVisibleItemPosition()
763                 );
764                 assertEquals(
765                         config + ":\nlast fully visible child should match traversal result\n"
766                                 + boundsLog, visibleChildren.lastFullyVisiblePosition,
767                         mLayoutManager.findLastCompletelyVisibleItemPosition()
768                 );
769             }
770         };
771         runTestOnUiThread(viewInBoundsTest);
772         // smooth scroll to end of the list and keep testing meanwhile. This will test pre-caching
773         // case
774         final int scrollPosition = config.mStackFromEnd ? 0 : mTestAdapter.getItemCount();
775         runTestOnUiThread(new Runnable() {
776             @Override
777             public void run() {
778                 mRecyclerView.smoothScrollToPosition(scrollPosition);
779             }
780         });
781         while (mLayoutManager.isSmoothScrolling() ||
782                 mRecyclerView.getScrollState() != RecyclerView.SCROLL_STATE_IDLE) {
783             runTestOnUiThread(viewInBoundsTest);
784             Thread.sleep(400);
785         }
786         // delete all items
787         mLayoutManager.expectLayouts(2);
788         mTestAdapter.deleteAndNotify(0, mTestAdapter.getItemCount());
789         mLayoutManager.waitForLayout(2);
790         // test empty case
791         runTestOnUiThread(viewInBoundsTest);
792         // set a new adapter with huge items to test full bounds check
793         mLayoutManager.expectLayouts(1);
794         final int totalSpace = mLayoutManager.mOrientationHelper.getTotalSpace();
795         final TestAdapter newAdapter = new TestAdapter(100) {
796             @Override
797             public void onBindViewHolder(TestViewHolder holder,
798                     int position) {
799                 super.onBindViewHolder(holder, position);
800                 if (config.mOrientation == HORIZONTAL) {
801                     holder.itemView.setMinimumWidth(totalSpace + 5);
802                 } else {
803                     holder.itemView.setMinimumHeight(totalSpace + 5);
804                 }
805             }
806         };
807         runTestOnUiThread(new Runnable() {
808             @Override
809             public void run() {
810                 mRecyclerView.setAdapter(newAdapter);
811             }
812         });
813         mLayoutManager.waitForLayout(2);
814         runTestOnUiThread(viewInBoundsTest);
815     }
816 
testSavedState()817     public void testSavedState() throws Throwable {
818         PostLayoutRunnable[] postLayoutOptions = new PostLayoutRunnable[]{
819                 new PostLayoutRunnable() {
820                     @Override
821                     public void run() throws Throwable {
822                         // do nothing
823                     }
824 
825                     @Override
826                     public String describe() {
827                         return "doing nothing";
828                     }
829                 },
830                 new PostLayoutRunnable() {
831                     @Override
832                     public void run() throws Throwable {
833                         mLayoutManager.expectLayouts(1);
834                         scrollToPosition(mTestAdapter.getItemCount() * 3 / 4);
835                         mLayoutManager.waitForLayout(2);
836                     }
837 
838                     @Override
839                     public String describe() {
840                         return "scroll to position";
841                     }
842                 },
843                 new PostLayoutRunnable() {
844                     @Override
845                     public void run() throws Throwable {
846                         mLayoutManager.expectLayouts(1);
847                         scrollToPositionWithOffset(mTestAdapter.getItemCount() * 1 / 3,
848                                 50);
849                         mLayoutManager.waitForLayout(2);
850                     }
851 
852                     @Override
853                     public String describe() {
854                         return "scroll to position with positive offset";
855                     }
856                 },
857                 new PostLayoutRunnable() {
858                     @Override
859                     public void run() throws Throwable {
860                         mLayoutManager.expectLayouts(1);
861                         scrollToPositionWithOffset(mTestAdapter.getItemCount() * 2 / 3,
862                                 -10);  // Some tests break if this value is below the item height.
863                         mLayoutManager.waitForLayout(2);
864                     }
865 
866                     @Override
867                     public String describe() {
868                         return "scroll to position with negative offset";
869                     }
870                 }
871         };
872 
873         PostRestoreRunnable[] postRestoreOptions = new PostRestoreRunnable[]{
874                 new PostRestoreRunnable() {
875                     @Override
876                     public String describe() {
877                         return "Doing nothing";
878                     }
879                 },
880                 new PostRestoreRunnable() {
881                     @Override
882                     void onAfterRestore(Config config) throws Throwable {
883                         // update config as well so that restore assertions will work
884                         config.mOrientation = 1 - config.mOrientation;
885                         mLayoutManager.setOrientation(config.mOrientation);
886                     }
887 
888                     @Override
889                     boolean shouldLayoutMatch(Config config) {
890                         return config.mItemCount == 0;
891                     }
892 
893                     @Override
894                     public String describe() {
895                         return "Changing orientation";
896                     }
897                 },
898                 new PostRestoreRunnable() {
899                     @Override
900                     void onAfterRestore(Config config) throws Throwable {
901                         config.mStackFromEnd = !config.mStackFromEnd;
902                         mLayoutManager.setStackFromEnd(config.mStackFromEnd);
903                     }
904 
905                     @Override
906                     boolean shouldLayoutMatch(Config config) {
907                         return true; //stack from end should not move items on change
908                     }
909 
910                     @Override
911                     public String describe() {
912                         return "Changing stack from end";
913                     }
914                 },
915                 new PostRestoreRunnable() {
916                     @Override
917                     void onAfterRestore(Config config) throws Throwable {
918                         config.mReverseLayout = !config.mReverseLayout;
919                         mLayoutManager.setReverseLayout(config.mReverseLayout);
920                     }
921 
922                     @Override
923                     boolean shouldLayoutMatch(Config config) {
924                         return config.mItemCount == 0;
925                     }
926 
927                     @Override
928                     public String describe() {
929                         return "Changing reverse layout";
930                     }
931                 },
932                 new PostRestoreRunnable() {
933                     @Override
934                     void onAfterRestore(Config config) throws Throwable {
935                         config.mRecycleChildrenOnDetach = !config.mRecycleChildrenOnDetach;
936                         mLayoutManager.setRecycleChildrenOnDetach(config.mRecycleChildrenOnDetach);
937                     }
938 
939                     @Override
940                     boolean shouldLayoutMatch(Config config) {
941                         return true;
942                     }
943 
944                     @Override
945                     String describe() {
946                         return "Change should recycle children";
947                     }
948                 },
949                 new PostRestoreRunnable() {
950                     int position;
951                     @Override
952                     void onAfterRestore(Config config) throws Throwable {
953                         position = mTestAdapter.getItemCount() / 2;
954                         mLayoutManager.scrollToPosition(position);
955                     }
956 
957                     @Override
958                     boolean shouldLayoutMatch(Config config) {
959                         return mTestAdapter.getItemCount() == 0;
960                     }
961 
962                     @Override
963                     String describe() {
964                         return "Scroll to position " + position ;
965                     }
966 
967                     @Override
968                     void onAfterReLayout(Config config) {
969                         if (mTestAdapter.getItemCount() > 0) {
970                             assertEquals(config + ":scrolled view should be last completely visible",
971                                     position,
972                                     config.mStackFromEnd ?
973                                             mLayoutManager.findLastCompletelyVisibleItemPosition()
974                                         : mLayoutManager.findFirstCompletelyVisibleItemPosition());
975                         }
976                     }
977                 }
978         };
979         boolean[] waitForLayoutOptions = new boolean[]{true, false};
980         boolean[] loadDataAfterRestoreOptions = new boolean[]{true, false};
981         List<Config> variations = addConfigVariation(mBaseVariations, "mItemCount", 0, 300);
982         variations = addConfigVariation(variations, "mRecycleChildrenOnDetach", true);
983         for (Config config : variations) {
984             for (PostLayoutRunnable postLayoutRunnable : postLayoutOptions) {
985                 for (boolean waitForLayout : waitForLayoutOptions) {
986                     for (PostRestoreRunnable postRestoreRunnable : postRestoreOptions) {
987                         for (boolean loadDataAfterRestore : loadDataAfterRestoreOptions) {
988                             savedStateTest((Config) config.clone(), waitForLayout,
989                                     loadDataAfterRestore, postLayoutRunnable, postRestoreRunnable);
990                             removeRecyclerView();
991                         }
992                     }
993 
994                 }
995             }
996         }
997     }
998 
savedStateTest(Config config, boolean waitForLayout, boolean loadDataAfterRestore, PostLayoutRunnable postLayoutOperation, PostRestoreRunnable postRestoreOperation)999     public void savedStateTest(Config config, boolean waitForLayout, boolean loadDataAfterRestore,
1000             PostLayoutRunnable postLayoutOperation, PostRestoreRunnable postRestoreOperation)
1001             throws Throwable {
1002         if (DEBUG) {
1003             Log.d(TAG, "testing saved state with wait for layout = " + waitForLayout + " config " +
1004                     config + " post layout action " + postLayoutOperation.describe() +
1005                     "post restore action " + postRestoreOperation.describe());
1006         }
1007         setupByConfig(config, false);
1008         if (waitForLayout) {
1009             waitForFirstLayout();
1010             postLayoutOperation.run();
1011         }
1012         Map<Item, Rect> before = mLayoutManager.collectChildCoordinates();
1013         Parcelable savedState = mRecyclerView.onSaveInstanceState();
1014         // we append a suffix to the parcelable to test out of bounds
1015         String parcelSuffix = UUID.randomUUID().toString();
1016         Parcel parcel = Parcel.obtain();
1017         savedState.writeToParcel(parcel, 0);
1018         parcel.writeString(parcelSuffix);
1019         removeRecyclerView();
1020         // reset for reading
1021         parcel.setDataPosition(0);
1022         // re-create
1023         savedState = RecyclerView.SavedState.CREATOR.createFromParcel(parcel);
1024         removeRecyclerView();
1025 
1026         final int itemCount = mTestAdapter.getItemCount();
1027         if (loadDataAfterRestore) {
1028             mTestAdapter.deleteAndNotify(0, itemCount);
1029         }
1030 
1031         RecyclerView restored = new RecyclerView(getActivity());
1032         // this config should be no op.
1033         mLayoutManager = new WrappedLinearLayoutManager(getActivity(),
1034                 config.mOrientation, config.mReverseLayout);
1035         mLayoutManager.setStackFromEnd(config.mStackFromEnd);
1036         restored.setLayoutManager(mLayoutManager);
1037         // use the same adapter for Rect matching
1038         restored.setAdapter(mTestAdapter);
1039         restored.onRestoreInstanceState(savedState);
1040 
1041         if (loadDataAfterRestore) {
1042             mTestAdapter.addAndNotify(itemCount);
1043         }
1044 
1045         postRestoreOperation.onAfterRestore(config);
1046         assertEquals("Parcel reading should not go out of bounds", parcelSuffix,
1047                 parcel.readString());
1048         mLayoutManager.expectLayouts(1);
1049         setRecyclerView(restored);
1050         mLayoutManager.waitForLayout(2);
1051         // calculate prefix here instead of above to include post restore changes
1052         final String logPrefix = config + "\npostLayout:" + postLayoutOperation.describe() +
1053                 "\npostRestore:" + postRestoreOperation.describe() + "\n";
1054         assertEquals(logPrefix + " on saved state, reverse layout should be preserved",
1055                 config.mReverseLayout, mLayoutManager.getReverseLayout());
1056         assertEquals(logPrefix + " on saved state, orientation should be preserved",
1057                 config.mOrientation, mLayoutManager.getOrientation());
1058         assertEquals(logPrefix + " on saved state, stack from end should be preserved",
1059                 config.mStackFromEnd, mLayoutManager.getStackFromEnd());
1060         if (waitForLayout) {
1061             final boolean strictItemEquality = !loadDataAfterRestore;
1062             if (postRestoreOperation.shouldLayoutMatch(config)) {
1063                 assertRectSetsEqual(
1064                         logPrefix + ": on restore, previous view positions should be preserved",
1065                         before, mLayoutManager.collectChildCoordinates(), strictItemEquality);
1066             } else {
1067                 assertRectSetsNotEqual(
1068                         logPrefix
1069                                 + ": on restore with changes, previous view positions should NOT "
1070                                 + "be preserved",
1071                         before, mLayoutManager.collectChildCoordinates(), strictItemEquality);
1072             }
1073             postRestoreOperation.onAfterReLayout(config);
1074         }
1075     }
1076 
testScrollAndClear()1077     public void testScrollAndClear() throws Throwable {
1078         setupByConfig(new Config(), true);
1079 
1080         assertTrue("Children not laid out", mLayoutManager.collectChildCoordinates().size() > 0);
1081 
1082         mLayoutManager.expectLayouts(1);
1083         runTestOnUiThread(new Runnable() {
1084             @Override
1085             public void run() {
1086                 mLayoutManager.scrollToPositionWithOffset(1, 0);
1087                 mTestAdapter.clearOnUIThread();
1088             }
1089         });
1090         mLayoutManager.waitForLayout(2);
1091 
1092         assertEquals("Remaining children", 0, mLayoutManager.collectChildCoordinates().size());
1093     }
1094 
1095 
scrollToPositionWithOffset(final int position, final int offset)1096     void scrollToPositionWithOffset(final int position, final int offset) throws Throwable {
1097         runTestOnUiThread(new Runnable() {
1098             @Override
1099             public void run() {
1100                 mLayoutManager.scrollToPositionWithOffset(position, offset);
1101             }
1102         });
1103     }
1104 
assertRectSetsNotEqual(String message, Map<Item, Rect> before, Map<Item, Rect> after, boolean strictItemEquality)1105     public void assertRectSetsNotEqual(String message, Map<Item, Rect> before,
1106             Map<Item, Rect> after, boolean strictItemEquality) {
1107         Throwable throwable = null;
1108         try {
1109             assertRectSetsEqual("NOT " + message, before, after, strictItemEquality);
1110         } catch (Throwable t) {
1111             throwable = t;
1112         }
1113         assertNotNull(message + "\ntwo layout should be different", throwable);
1114     }
1115 
assertRectSetsEqual(String message, Map<Item, Rect> before, Map<Item, Rect> after)1116     public void assertRectSetsEqual(String message, Map<Item, Rect> before, Map<Item, Rect> after) {
1117         assertRectSetsEqual(message, before, after, true);
1118     }
1119 
assertRectSetsEqual(String message, Map<Item, Rect> before, Map<Item, Rect> after, boolean strictItemEquality)1120     public void assertRectSetsEqual(String message, Map<Item, Rect> before, Map<Item, Rect> after,
1121             boolean strictItemEquality) {
1122         StringBuilder sb = new StringBuilder();
1123         sb.append("checking rectangle equality.\n");
1124         sb.append("before:\n");
1125         for (Map.Entry<Item, Rect> entry : before.entrySet()) {
1126             sb.append(entry.getKey().mAdapterIndex + ":" + entry.getValue()).append("\n");
1127         }
1128         sb.append("after:\n");
1129         for (Map.Entry<Item, Rect> entry : after.entrySet()) {
1130             sb.append(entry.getKey().mAdapterIndex + ":" + entry.getValue()).append("\n");
1131         }
1132         message = message + "\n" + sb.toString();
1133         assertEquals(message + ":\nitem counts should be equal", before.size()
1134                 , after.size());
1135         for (Map.Entry<Item, Rect> entry : before.entrySet()) {
1136             final Item beforeItem = entry.getKey();
1137             Rect afterRect = null;
1138             if (strictItemEquality) {
1139                 afterRect = after.get(beforeItem);
1140                 assertNotNull(message + ":\nSame item should be visible after simple re-layout",
1141                         afterRect);
1142             } else {
1143                 for (Map.Entry<Item, Rect> afterEntry : after.entrySet()) {
1144                     final Item afterItem = afterEntry.getKey();
1145                     if (afterItem.mAdapterIndex == beforeItem.mAdapterIndex) {
1146                         afterRect = afterEntry.getValue();
1147                         break;
1148                     }
1149                 }
1150                 assertNotNull(message + ":\nItem with same adapter index should be visible " +
1151                                 "after simple re-layout",
1152                         afterRect);
1153             }
1154             assertEquals(message + ":\nItem should be laid out at the same coordinates",
1155                     entry.getValue(), afterRect);
1156         }
1157     }
1158 
testAccessibilityPositions()1159     public void testAccessibilityPositions() throws Throwable {
1160         setupByConfig(new Config(VERTICAL, false, false), true);
1161         final AccessibilityDelegateCompat delegateCompat = mRecyclerView
1162                 .getCompatAccessibilityDelegate();
1163         final AccessibilityEvent event = AccessibilityEvent.obtain();
1164         runTestOnUiThread(new Runnable() {
1165             @Override
1166             public void run() {
1167                 delegateCompat.onInitializeAccessibilityEvent(mRecyclerView, event);
1168             }
1169         });
1170         final AccessibilityRecordCompat record = AccessibilityEventCompat
1171                 .asRecord(event);
1172         assertEquals("result should have first position",
1173                 record.getFromIndex(),
1174                 mLayoutManager.findFirstVisibleItemPosition());
1175         assertEquals("result should have last position",
1176                 record.getToIndex(),
1177                 mLayoutManager.findLastVisibleItemPosition());
1178     }
1179 
testPrepareForDrop()1180     public void testPrepareForDrop() throws Throwable {
1181         SelectTargetChildren[] selectors = new SelectTargetChildren[] {
1182                 new SelectTargetChildren() {
1183                     @Override
1184                     public int[] selectTargetChildren(int childCount) {
1185                         return new int[]{1, 0};
1186                     }
1187                 },
1188                 new SelectTargetChildren() {
1189                     @Override
1190                     public int[] selectTargetChildren(int childCount) {
1191                         return new int[]{0, 1};
1192                     }
1193                 },
1194                 new SelectTargetChildren() {
1195                     @Override
1196                     public int[] selectTargetChildren(int childCount) {
1197                         return new int[]{childCount - 1, childCount - 2};
1198                     }
1199                 },
1200                 new SelectTargetChildren() {
1201                     @Override
1202                     public int[] selectTargetChildren(int childCount) {
1203                         return new int[]{childCount - 2, childCount - 1};
1204                     }
1205                 },
1206                 new SelectTargetChildren() {
1207                     @Override
1208                     public int[] selectTargetChildren(int childCount) {
1209                         return new int[]{childCount / 2, childCount / 2 + 1};
1210                     }
1211                 },
1212                 new SelectTargetChildren() {
1213                     @Override
1214                     public int[] selectTargetChildren(int childCount) {
1215                         return new int[]{childCount / 2 + 1, childCount / 2};
1216                     }
1217                 }
1218         };
1219         for (SelectTargetChildren selector : selectors) {
1220             for (Config config : mBaseVariations) {
1221                 prepareForDropTest(config, selector);
1222                 removeRecyclerView();
1223             }
1224         }
1225     }
1226 
prepareForDropTest(final Config config, SelectTargetChildren selectTargetChildren)1227     public void prepareForDropTest(final Config config, SelectTargetChildren selectTargetChildren)
1228             throws Throwable {
1229         config.mTestAdapter = new TestAdapter(100) {
1230             @Override
1231             public void onBindViewHolder(TestViewHolder holder,
1232                     int position) {
1233                 super.onBindViewHolder(holder, position);
1234                 if (config.mOrientation == HORIZONTAL) {
1235                     final int base = mRecyclerView.getWidth() / 5;
1236                     final int itemRand = holder.mBoundItem.mText.hashCode() % base;
1237                     holder.itemView.setMinimumWidth(base + itemRand);
1238                 } else {
1239                     final int base = mRecyclerView.getHeight() / 5;
1240                     final int itemRand = holder.mBoundItem.mText.hashCode() % base;
1241                     holder.itemView.setMinimumHeight(base + itemRand);
1242                 }
1243             }
1244         };
1245         setupByConfig(config, true);
1246         mLayoutManager.expectLayouts(1);
1247         scrollToPosition(mTestAdapter.getItemCount() / 2);
1248         mLayoutManager.waitForLayout(1);
1249         int[] positions = selectTargetChildren.selectTargetChildren(mRecyclerView.getChildCount());
1250         final View fromChild = mLayoutManager.getChildAt(positions[0]);
1251         final int fromPos = mLayoutManager.getPosition(fromChild);
1252         final View onChild = mLayoutManager.getChildAt(positions[1]);
1253         final int toPos = mLayoutManager.getPosition(onChild);
1254         final OrientationHelper helper = mLayoutManager.mOrientationHelper;
1255         final int dragCoordinate;
1256         final boolean towardsHead = toPos < fromPos;
1257         final int referenceLine;
1258         if (config.mReverseLayout == towardsHead) {
1259             referenceLine = helper.getDecoratedEnd(onChild);
1260             dragCoordinate = referenceLine + 3 -
1261                     helper.getDecoratedMeasurement(fromChild);
1262         } else {
1263             referenceLine = helper.getDecoratedStart(onChild);
1264             dragCoordinate = referenceLine - 3;
1265         }
1266         mLayoutManager.expectLayouts(2);
1267 
1268         final int x,y;
1269         if (config.mOrientation == HORIZONTAL) {
1270             x = dragCoordinate;
1271             y = fromChild.getTop();
1272         } else {
1273             y = dragCoordinate;
1274             x = fromChild.getLeft();
1275         }
1276         runTestOnUiThread(new Runnable() {
1277             @Override
1278             public void run() {
1279                 mTestAdapter.moveInUIThread(fromPos, toPos);
1280                 mTestAdapter.notifyItemMoved(fromPos, toPos);
1281                 mLayoutManager.prepareForDrop(fromChild, onChild, x, y);
1282             }
1283         });
1284         mLayoutManager.waitForLayout(2);
1285 
1286         assertSame(fromChild, mRecyclerView.findViewHolderForAdapterPosition(toPos).itemView);
1287         // make sure it has the position we wanted
1288         if (config.mReverseLayout == towardsHead) {
1289             assertEquals(referenceLine, helper.getDecoratedEnd(fromChild));
1290         } else {
1291             assertEquals(referenceLine, helper.getDecoratedStart(fromChild));
1292         }
1293     }
1294 
1295     static class VisibleChildren {
1296 
1297         int firstVisiblePosition = RecyclerView.NO_POSITION;
1298 
1299         int firstFullyVisiblePosition = RecyclerView.NO_POSITION;
1300 
1301         int lastVisiblePosition = RecyclerView.NO_POSITION;
1302 
1303         int lastFullyVisiblePosition = RecyclerView.NO_POSITION;
1304 
1305         @Override
1306         public String toString() {
1307             return "VisibleChildren{" +
1308                     "firstVisiblePosition=" + firstVisiblePosition +
1309                     ", firstFullyVisiblePosition=" + firstFullyVisiblePosition +
1310                     ", lastVisiblePosition=" + lastVisiblePosition +
1311                     ", lastFullyVisiblePosition=" + lastFullyVisiblePosition +
1312                     '}';
1313         }
1314     }
1315 
1316     abstract private class PostLayoutRunnable {
1317 
1318         abstract void run() throws Throwable;
1319 
1320         abstract String describe();
1321     }
1322 
1323     abstract private class PostRestoreRunnable {
1324 
1325         void onAfterRestore(Config config) throws Throwable {
1326         }
1327 
1328         abstract String describe();
1329 
1330         boolean shouldLayoutMatch(Config config) {
1331             return true;
1332         }
1333 
1334         void onAfterReLayout(Config config) {
1335 
1336         };
1337     }
1338 
1339     class WrappedLinearLayoutManager extends LinearLayoutManager {
1340 
1341         CountDownLatch layoutLatch;
1342 
1343         OrientationHelper mSecondaryOrientation;
1344 
1345         OnLayoutListener mOnLayoutListener;
1346 
1347         public WrappedLinearLayoutManager(Context context, int orientation, boolean reverseLayout) {
1348             super(context, orientation, reverseLayout);
1349         }
1350 
1351         public void expectLayouts(int count) {
1352             layoutLatch = new CountDownLatch(count);
1353         }
1354 
1355         public void waitForLayout(long timeout) throws InterruptedException {
1356             waitForLayout(timeout, TimeUnit.SECONDS);
1357         }
1358 
1359         @Override
1360         public void setOrientation(int orientation) {
1361             super.setOrientation(orientation);
1362             mSecondaryOrientation = null;
1363         }
1364 
1365         @Override
1366         public void removeAndRecycleView(View child, RecyclerView.Recycler recycler) {
1367             if (DEBUG) {
1368                 Log.d(TAG, "recycling view " + mRecyclerView.getChildViewHolder(child));
1369             }
1370             super.removeAndRecycleView(child, recycler);
1371         }
1372 
1373         @Override
1374         public void removeAndRecycleViewAt(int index, RecyclerView.Recycler recycler) {
1375             if (DEBUG) {
1376                 Log.d(TAG, "recycling view at" + mRecyclerView.getChildViewHolder(getChildAt(index)));
1377             }
1378             super.removeAndRecycleViewAt(index, recycler);
1379         }
1380 
1381         @Override
1382         void ensureLayoutState() {
1383             super.ensureLayoutState();
1384             if (mSecondaryOrientation == null) {
1385                 mSecondaryOrientation = OrientationHelper.createOrientationHelper(this,
1386                         1 - getOrientation());
1387             }
1388         }
1389 
1390         private void waitForLayout(long timeout, TimeUnit timeUnit) throws InterruptedException {
1391             layoutLatch.await(timeout * (DEBUG ? 100 : 1), timeUnit);
1392             assertEquals("all expected layouts should be executed at the expected time",
1393                     0, layoutLatch.getCount());
1394             getInstrumentation().waitForIdleSync();
1395         }
1396 
1397         @Override
1398         LayoutState createLayoutState() {
1399             return new LayoutState() {
1400                 @Override
1401                 View next(RecyclerView.Recycler recycler) {
1402                     final boolean hadMore = hasMore(mRecyclerView.mState);
1403                     final int position = mCurrentPosition;
1404                     View next = super.next(recycler);
1405                     assertEquals("if has more, should return a view", hadMore, next != null);
1406                     assertEquals("position of the returned view must match current position",
1407                             position, RecyclerView.getChildViewHolderInt(next).getLayoutPosition());
1408                     return next;
1409                 }
1410             };
1411         }
1412 
1413         public String getBoundsLog() {
1414             StringBuilder sb = new StringBuilder();
1415             sb.append("view bounds:[start:").append(mOrientationHelper.getStartAfterPadding())
1416                     .append(",").append(" end").append(mOrientationHelper.getEndAfterPadding());
1417             sb.append("\nchildren bounds\n");
1418             final int childCount = getChildCount();
1419             for (int i = 0; i < childCount; i++) {
1420                 View child = getChildAt(i);
1421                 sb.append("child (ind:").append(i).append(", pos:").append(getPosition(child))
1422                         .append("[").append("start:").append(
1423                         mOrientationHelper.getDecoratedStart(child)).append(", end:")
1424                         .append(mOrientationHelper.getDecoratedEnd(child)).append("]\n");
1425             }
1426             return sb.toString();
1427         }
1428 
1429         public void waitForAnimationsToEnd(int timeoutInSeconds) throws InterruptedException {
1430             RecyclerView.ItemAnimator itemAnimator = mRecyclerView.getItemAnimator();
1431             if (itemAnimator == null) {
1432                 return;
1433             }
1434             final CountDownLatch latch = new CountDownLatch(1);
1435             final boolean running = itemAnimator.isRunning(
1436                     new RecyclerView.ItemAnimator.ItemAnimatorFinishedListener() {
1437                         @Override
1438                         public void onAnimationsFinished() {
1439                             latch.countDown();
1440                         }
1441                     }
1442             );
1443             if (running) {
1444                 latch.await(timeoutInSeconds, TimeUnit.SECONDS);
1445             }
1446         }
1447 
1448         public VisibleChildren traverseAndFindVisibleChildren() {
1449             int childCount = getChildCount();
1450             final VisibleChildren visibleChildren = new VisibleChildren();
1451             final int start = mOrientationHelper.getStartAfterPadding();
1452             final int end = mOrientationHelper.getEndAfterPadding();
1453             for (int i = 0; i < childCount; i++) {
1454                 View child = getChildAt(i);
1455                 final int childStart = mOrientationHelper.getDecoratedStart(child);
1456                 final int childEnd = mOrientationHelper.getDecoratedEnd(child);
1457                 final boolean fullyVisible = childStart >= start && childEnd <= end;
1458                 final boolean hidden = childEnd <= start || childStart >= end;
1459                 if (hidden) {
1460                     continue;
1461                 }
1462                 final int position = getPosition(child);
1463                 if (fullyVisible) {
1464                     if (position < visibleChildren.firstFullyVisiblePosition ||
1465                             visibleChildren.firstFullyVisiblePosition == RecyclerView.NO_POSITION) {
1466                         visibleChildren.firstFullyVisiblePosition = position;
1467                     }
1468 
1469                     if (position > visibleChildren.lastFullyVisiblePosition) {
1470                         visibleChildren.lastFullyVisiblePosition = position;
1471                     }
1472                 }
1473 
1474                 if (position < visibleChildren.firstVisiblePosition ||
1475                         visibleChildren.firstVisiblePosition == RecyclerView.NO_POSITION) {
1476                     visibleChildren.firstVisiblePosition = position;
1477                 }
1478 
1479                 if (position > visibleChildren.lastVisiblePosition) {
1480                     visibleChildren.lastVisiblePosition = position;
1481                 }
1482 
1483             }
1484             return visibleChildren;
1485         }
1486 
1487         Rect getViewBounds(View view) {
1488             if (getOrientation() == HORIZONTAL) {
1489                 return new Rect(
1490                         mOrientationHelper.getDecoratedStart(view),
1491                         mSecondaryOrientation.getDecoratedStart(view),
1492                         mOrientationHelper.getDecoratedEnd(view),
1493                         mSecondaryOrientation.getDecoratedEnd(view));
1494             } else {
1495                 return new Rect(
1496                         mSecondaryOrientation.getDecoratedStart(view),
1497                         mOrientationHelper.getDecoratedStart(view),
1498                         mSecondaryOrientation.getDecoratedEnd(view),
1499                         mOrientationHelper.getDecoratedEnd(view));
1500             }
1501 
1502         }
1503 
1504         Map<Item, Rect> collectChildCoordinates() throws Throwable {
1505             final Map<Item, Rect> items = new LinkedHashMap<Item, Rect>();
1506             runTestOnUiThread(new Runnable() {
1507                 @Override
1508                 public void run() {
1509                     final int childCount = getChildCount();
1510                     Rect layoutBounds = new Rect(0, 0,
1511                             mLayoutManager.getWidth(), mLayoutManager.getHeight());
1512                     for (int i = 0; i < childCount; i++) {
1513                         View child = getChildAt(i);
1514                         RecyclerView.LayoutParams lp = (RecyclerView.LayoutParams) child
1515                                 .getLayoutParams();
1516                         TestViewHolder vh = (TestViewHolder) lp.mViewHolder;
1517                         Rect childBounds = getViewBounds(child);
1518                         if (new Rect(childBounds).intersect(layoutBounds)) {
1519                             items.put(vh.mBoundItem, childBounds);
1520                         }
1521                     }
1522                 }
1523             });
1524             return items;
1525         }
1526 
1527         @Override
1528         public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
1529             try {
1530                 if (mOnLayoutListener != null) {
1531                     mOnLayoutListener.before(recycler, state);
1532                 }
1533                 super.onLayoutChildren(recycler, state);
1534                 if (mOnLayoutListener != null) {
1535                     mOnLayoutListener.after(recycler, state);
1536                 }
1537             } catch (Throwable t) {
1538                 postExceptionToInstrumentation(t);
1539             }
1540             layoutLatch.countDown();
1541         }
1542 
1543 
1544     }
1545 
1546     static class OnLayoutListener {
1547         void before(RecyclerView.Recycler recycler, RecyclerView.State state){}
1548         void after(RecyclerView.Recycler recycler, RecyclerView.State state){}
1549     }
1550 
1551     static class Config implements Cloneable {
1552 
1553         private static final int DEFAULT_ITEM_COUNT = 100;
1554 
1555         private boolean mStackFromEnd;
1556 
1557         int mOrientation = VERTICAL;
1558 
1559         boolean mReverseLayout = false;
1560 
1561         boolean mRecycleChildrenOnDetach = false;
1562 
1563         int mItemCount = DEFAULT_ITEM_COUNT;
1564 
1565         TestAdapter mTestAdapter;
1566 
1567         Config(int orientation, boolean reverseLayout, boolean stackFromEnd) {
1568             mOrientation = orientation;
1569             mReverseLayout = reverseLayout;
1570             mStackFromEnd = stackFromEnd;
1571         }
1572 
1573         public Config() {
1574 
1575         }
1576 
1577         Config adapter(TestAdapter adapter) {
1578             mTestAdapter = adapter;
1579             return this;
1580         }
1581 
1582         Config recycleChildrenOnDetach(boolean recycleChildrenOnDetach) {
1583             mRecycleChildrenOnDetach = recycleChildrenOnDetach;
1584             return this;
1585         }
1586 
1587         Config orientation(int orientation) {
1588             mOrientation = orientation;
1589             return this;
1590         }
1591 
1592         Config stackFromBottom(boolean stackFromBottom) {
1593             mStackFromEnd = stackFromBottom;
1594             return this;
1595         }
1596 
1597         Config reverseLayout(boolean reverseLayout) {
1598             mReverseLayout = reverseLayout;
1599             return this;
1600         }
1601 
1602         public Config itemCount(int itemCount) {
1603             mItemCount = itemCount;
1604             return this;
1605         }
1606 
1607         // required by convention
1608         @Override
1609         public Object clone() throws CloneNotSupportedException {
1610             return super.clone();
1611         }
1612 
1613         @Override
1614         public String toString() {
1615             return "Config{" +
1616                     "mStackFromEnd=" + mStackFromEnd +
1617                     ", mOrientation=" + mOrientation +
1618                     ", mReverseLayout=" + mReverseLayout +
1619                     ", mRecycleChildrenOnDetach=" + mRecycleChildrenOnDetach +
1620                     ", mItemCount=" + mItemCount +
1621                     '}';
1622         }
1623     }
1624 
1625     private interface SelectTargetChildren {
1626         int[] selectTargetChildren(int childCount);
1627     }
1628 }
1629