1/*
2 * Copyright (C) 2014 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *      http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17package android.support.v7.widget;
18
19import org.junit.Test;
20
21import android.content.Context;
22import android.graphics.Rect;
23import android.os.Parcel;
24import android.os.Parcelable;
25import android.support.v4.view.AccessibilityDelegateCompat;
26import android.support.v4.view.accessibility.AccessibilityEventCompat;
27import android.support.v4.view.accessibility.AccessibilityRecordCompat;
28import android.test.suitebuilder.annotation.MediumTest;
29import android.util.Log;
30import android.view.View;
31import android.view.ViewGroup;
32import android.view.accessibility.AccessibilityEvent;
33import android.widget.FrameLayout;
34
35import static android.support.v7.widget.LayoutState.LAYOUT_END;
36import static android.support.v7.widget.LayoutState.LAYOUT_START;
37import static android.support.v7.widget.LinearLayoutManager.HORIZONTAL;
38import static android.support.v7.widget.LinearLayoutManager.VERTICAL;
39import java.lang.reflect.Field;
40import java.util.ArrayList;
41import java.util.LinkedHashMap;
42import java.util.List;
43import java.util.Map;
44import java.util.UUID;
45import java.util.concurrent.CountDownLatch;
46import java.util.concurrent.TimeUnit;
47import java.util.concurrent.atomic.AtomicInteger;
48import static org.junit.Assert.*;
49
50/**
51 * Includes tests for {@link LinearLayoutManager}.
52 * <p>
53 * Since most UI tests are not practical, these tests are focused on internal data representation
54 * and stability of LinearLayoutManager in response to different events (state change, scrolling
55 * etc) where it is very hard to do manual testing.
56 */
57@MediumTest
58public class LinearLayoutManagerTest extends BaseLinearLayoutManagerTest {
59
60    @Test
61    public void removeAnchorItem() throws Throwable {
62        removeAnchorItemTest(
63                new Config().orientation(VERTICAL).stackFromBottom(false).reverseLayout(
64                        false), 100, 0);
65    }
66
67    @Test
68    public void removeAnchorItemReverse() throws Throwable {
69        removeAnchorItemTest(
70                new Config().orientation(VERTICAL).stackFromBottom(false).reverseLayout(true), 100,
71                0);
72    }
73
74    @Test
75    public void removeAnchorItemStackFromEnd() throws Throwable {
76        removeAnchorItemTest(
77                new Config().orientation(VERTICAL).stackFromBottom(true).reverseLayout(false), 100,
78                99);
79    }
80
81    @Test
82    public void removeAnchorItemStackFromEndAndReverse() throws Throwable {
83        removeAnchorItemTest(
84                new Config().orientation(VERTICAL).stackFromBottom(true).reverseLayout(true), 100,
85                99);
86    }
87
88    @Test
89    public void removeAnchorItemHorizontal() throws Throwable {
90        removeAnchorItemTest(
91                new Config().orientation(HORIZONTAL).stackFromBottom(false).reverseLayout(
92                        false), 100, 0);
93    }
94
95    @Test
96    public void removeAnchorItemReverseHorizontal() throws Throwable {
97        removeAnchorItemTest(
98                new Config().orientation(HORIZONTAL).stackFromBottom(false).reverseLayout(true),
99                100, 0);
100    }
101
102    @Test
103    public void removeAnchorItemStackFromEndHorizontal() throws Throwable {
104        removeAnchorItemTest(
105                new Config().orientation(HORIZONTAL).stackFromBottom(true).reverseLayout(false),
106                100, 99);
107    }
108
109    @Test
110    public void removeAnchorItemStackFromEndAndReverseHorizontal() throws Throwable {
111        removeAnchorItemTest(
112                new Config().orientation(HORIZONTAL).stackFromBottom(true).reverseLayout(true), 100,
113                99);
114    }
115
116    /**
117     * This tests a regression where predictive animations were not working as expected when the
118     * first item is removed and there aren't any more items to add from that direction.
119     * First item refers to the default anchor item.
120     */
121    public void removeAnchorItemTest(final Config config, int adapterSize,
122            final int removePos) throws Throwable {
123        config.adapter(new TestAdapter(adapterSize) {
124            @Override
125            public void onBindViewHolder(TestViewHolder holder,
126                    int position) {
127                super.onBindViewHolder(holder, position);
128                ViewGroup.LayoutParams lp = holder.itemView.getLayoutParams();
129                if (!(lp instanceof ViewGroup.MarginLayoutParams)) {
130                    lp = new ViewGroup.MarginLayoutParams(0, 0);
131                    holder.itemView.setLayoutParams(lp);
132                }
133                ViewGroup.MarginLayoutParams mlp = (ViewGroup.MarginLayoutParams) lp;
134                final int maxSize;
135                if (config.mOrientation == HORIZONTAL) {
136                    maxSize = mRecyclerView.getWidth();
137                    mlp.height = ViewGroup.MarginLayoutParams.FILL_PARENT;
138                } else {
139                    maxSize = mRecyclerView.getHeight();
140                    mlp.width = ViewGroup.MarginLayoutParams.FILL_PARENT;
141                }
142
143                final int desiredSize;
144                if (position == removePos) {
145                    // make it large
146                    desiredSize = maxSize / 4;
147                } else {
148                    // make it small
149                    desiredSize = maxSize / 8;
150                }
151                if (config.mOrientation == HORIZONTAL) {
152                    mlp.width = desiredSize;
153                } else {
154                    mlp.height = desiredSize;
155                }
156            }
157        });
158        setupByConfig(config, true);
159        final int childCount = mLayoutManager.getChildCount();
160        RecyclerView.ViewHolder toBeRemoved = null;
161        List<RecyclerView.ViewHolder> toBeMoved = new ArrayList<RecyclerView.ViewHolder>();
162        for (int i = 0; i < childCount; i++) {
163            View child = mLayoutManager.getChildAt(i);
164            RecyclerView.ViewHolder holder = mRecyclerView.getChildViewHolder(child);
165            if (holder.getAdapterPosition() == removePos) {
166                toBeRemoved = holder;
167            } else {
168                toBeMoved.add(holder);
169            }
170        }
171        assertNotNull("test sanity", toBeRemoved);
172        assertEquals("test sanity", childCount - 1, toBeMoved.size());
173        LoggingItemAnimator loggingItemAnimator = new LoggingItemAnimator();
174        mRecyclerView.setItemAnimator(loggingItemAnimator);
175        loggingItemAnimator.reset();
176        loggingItemAnimator.expectRunPendingAnimationsCall(1);
177        mLayoutManager.expectLayouts(2);
178        mTestAdapter.deleteAndNotify(removePos, 1);
179        mLayoutManager.waitForLayout(1);
180        loggingItemAnimator.waitForPendingAnimationsCall(2);
181        assertTrue("removed child should receive remove animation",
182                loggingItemAnimator.mRemoveVHs.contains(toBeRemoved));
183        for (RecyclerView.ViewHolder vh : toBeMoved) {
184            assertTrue("view holder should be in moved list",
185                    loggingItemAnimator.mMoveVHs.contains(vh));
186        }
187        List<RecyclerView.ViewHolder> newHolders = new ArrayList<RecyclerView.ViewHolder>();
188        for (int i = 0; i < mLayoutManager.getChildCount(); i++) {
189            View child = mLayoutManager.getChildAt(i);
190            RecyclerView.ViewHolder holder = mRecyclerView.getChildViewHolder(child);
191            if (toBeRemoved != holder && !toBeMoved.contains(holder)) {
192                newHolders.add(holder);
193            }
194        }
195        assertTrue("some new children should show up for the new space", newHolders.size() > 0);
196        assertEquals("no items should receive animate add since they are not new", 0,
197                loggingItemAnimator.mAddVHs.size());
198        for (RecyclerView.ViewHolder holder : newHolders) {
199            assertTrue("new holder should receive a move animation",
200                    loggingItemAnimator.mMoveVHs.contains(holder));
201        }
202        assertTrue("control against adding too many children due to bad layout state preparation."
203                        + " initial:" + childCount + ", current:" + mRecyclerView.getChildCount(),
204                mRecyclerView.getChildCount() <= childCount + 3 /*1 for removed view, 2 for its size*/);
205    }
206
207    @Test
208    public void keepFocusOnRelayout() throws Throwable {
209        setupByConfig(new Config(VERTICAL, false, false).itemCount(500), true);
210        int center = (mLayoutManager.findLastVisibleItemPosition()
211                - mLayoutManager.findFirstVisibleItemPosition()) / 2;
212        final RecyclerView.ViewHolder vh = mRecyclerView.findViewHolderForLayoutPosition(center);
213        final int top = mLayoutManager.mOrientationHelper.getDecoratedStart(vh.itemView);
214        requestFocus(vh.itemView, true);
215        assertTrue("view should have the focus", vh.itemView.hasFocus());
216        // add a bunch of items right before that view, make sure it keeps its position
217        mLayoutManager.expectLayouts(2);
218        final int childCountToAdd = mRecyclerView.getChildCount() * 2;
219        mTestAdapter.addAndNotify(center, childCountToAdd);
220        center += childCountToAdd; // offset item
221        mLayoutManager.waitForLayout(2);
222        mLayoutManager.waitForAnimationsToEnd(20);
223        final RecyclerView.ViewHolder postVH = mRecyclerView.findViewHolderForLayoutPosition(center);
224        assertNotNull("focused child should stay in layout", postVH);
225        assertSame("same view holder should be kept for unchanged child", vh, postVH);
226        assertEquals("focused child's screen position should stay unchanged", top,
227                mLayoutManager.mOrientationHelper.getDecoratedStart(postVH.itemView));
228    }
229
230    @Test
231    public void keepFullFocusOnResize() throws Throwable {
232        keepFocusOnResizeTest(new Config(VERTICAL, false, false).itemCount(500), true);
233    }
234
235    @Test
236    public void keepPartialFocusOnResize() throws Throwable {
237        keepFocusOnResizeTest(new Config(VERTICAL, false, false).itemCount(500), false);
238    }
239
240    @Test
241    public void keepReverseFullFocusOnResize() throws Throwable {
242        keepFocusOnResizeTest(new Config(VERTICAL, true, false).itemCount(500), true);
243    }
244
245    @Test
246    public void keepReversePartialFocusOnResize() throws Throwable {
247        keepFocusOnResizeTest(new Config(VERTICAL, true, false).itemCount(500), false);
248    }
249
250    @Test
251    public void keepStackFromEndFullFocusOnResize() throws Throwable {
252        keepFocusOnResizeTest(new Config(VERTICAL, false, true).itemCount(500), true);
253    }
254
255    @Test
256    public void keepStackFromEndPartialFocusOnResize() throws Throwable {
257        keepFocusOnResizeTest(new Config(VERTICAL, false, true).itemCount(500), false);
258    }
259
260    public void keepFocusOnResizeTest(final Config config, boolean fullyVisible) throws Throwable {
261        setupByConfig(config, true);
262        final int targetPosition;
263        if (config.mStackFromEnd) {
264            targetPosition = mLayoutManager.findFirstVisibleItemPosition();
265        } else {
266            targetPosition = mLayoutManager.findLastVisibleItemPosition();
267        }
268        final OrientationHelper helper = mLayoutManager.mOrientationHelper;
269        final RecyclerView.ViewHolder vh = mRecyclerView
270                .findViewHolderForLayoutPosition(targetPosition);
271
272        // scroll enough to offset the child
273        int startMargin = helper.getDecoratedStart(vh.itemView) -
274                helper.getStartAfterPadding();
275        int endMargin = helper.getEndAfterPadding() -
276                helper.getDecoratedEnd(vh.itemView);
277        Log.d(TAG, "initial start margin " + startMargin + " , end margin:" + endMargin);
278        requestFocus(vh.itemView, true);
279        assertTrue("view should gain the focus", vh.itemView.hasFocus());
280        // scroll enough to offset the child
281        startMargin = helper.getDecoratedStart(vh.itemView) -
282                helper.getStartAfterPadding();
283        endMargin = helper.getEndAfterPadding() -
284                helper.getDecoratedEnd(vh.itemView);
285
286        Log.d(TAG, "start margin " + startMargin + " , end margin:" + endMargin);
287        assertTrue("View should become fully visible", startMargin >= 0 && endMargin >= 0);
288
289        int expectedOffset = 0;
290        boolean offsetAtStart = false;
291        if (!fullyVisible) {
292            // move it a bit such that it is no more fully visible
293            final int childSize = helper
294                    .getDecoratedMeasurement(vh.itemView);
295            expectedOffset = childSize / 3;
296            if (startMargin < endMargin) {
297                scrollBy(expectedOffset);
298                offsetAtStart = true;
299            } else {
300                scrollBy(-expectedOffset);
301                offsetAtStart = false;
302            }
303            startMargin = helper.getDecoratedStart(vh.itemView) -
304                    helper.getStartAfterPadding();
305            endMargin = helper.getEndAfterPadding() -
306                    helper.getDecoratedEnd(vh.itemView);
307            assertTrue("test sanity, view should not be fully visible", startMargin < 0
308                    || endMargin < 0);
309        }
310
311        mLayoutManager.expectLayouts(1);
312        runTestOnUiThread(new Runnable() {
313            @Override
314            public void run() {
315                final ViewGroup.LayoutParams layoutParams = mRecyclerView.getLayoutParams();
316                if (config.mOrientation == HORIZONTAL) {
317                    layoutParams.width = mRecyclerView.getWidth() / 2;
318                } else {
319                    layoutParams.height = mRecyclerView.getHeight() / 2;
320                }
321                mRecyclerView.setLayoutParams(layoutParams);
322            }
323        });
324        Thread.sleep(100);
325        // add a bunch of items right before that view, make sure it keeps its position
326        mLayoutManager.waitForLayout(2);
327        mLayoutManager.waitForAnimationsToEnd(20);
328        assertTrue("view should preserve the focus", vh.itemView.hasFocus());
329        final RecyclerView.ViewHolder postVH = mRecyclerView
330                .findViewHolderForLayoutPosition(targetPosition);
331        assertNotNull("focused child should stay in layout", postVH);
332        assertSame("same view holder should be kept for unchanged child", vh, postVH);
333        View focused = postVH.itemView;
334
335        startMargin = helper.getDecoratedStart(focused) - helper.getStartAfterPadding();
336        endMargin = helper.getEndAfterPadding() - helper.getDecoratedEnd(focused);
337
338        assertTrue("focused child should be somewhat visible",
339                helper.getDecoratedStart(focused) < helper.getEndAfterPadding()
340                        && helper.getDecoratedEnd(focused) > helper.getStartAfterPadding());
341        if (fullyVisible) {
342            assertTrue("focused child end should stay fully visible",
343                    endMargin >= 0);
344            assertTrue("focused child start should stay fully visible",
345                    startMargin >= 0);
346        } else {
347            if (offsetAtStart) {
348                assertTrue("start should preserve its offset", startMargin < 0);
349                assertTrue("end should be visible", endMargin >= 0);
350            } else {
351                assertTrue("end should preserve its offset", endMargin < 0);
352                assertTrue("start should be visible", startMargin >= 0);
353            }
354        }
355    }
356
357    @Test
358    public void scrollToPositionWithPredictive() throws Throwable {
359        scrollToPositionWithPredictive(0, LinearLayoutManager.INVALID_OFFSET);
360        removeRecyclerView();
361        scrollToPositionWithPredictive(3, 20);
362        removeRecyclerView();
363        scrollToPositionWithPredictive(Config.DEFAULT_ITEM_COUNT / 2,
364                LinearLayoutManager.INVALID_OFFSET);
365        removeRecyclerView();
366        scrollToPositionWithPredictive(Config.DEFAULT_ITEM_COUNT / 2, 10);
367    }
368
369    @Test
370    public void recycleDuringAnimations() throws Throwable {
371        final AtomicInteger childCount = new AtomicInteger(0);
372        final TestAdapter adapter = new TestAdapter(300) {
373            @Override
374            public TestViewHolder onCreateViewHolder(ViewGroup parent,
375                    int viewType) {
376                final int cnt = childCount.incrementAndGet();
377                final TestViewHolder testViewHolder = super.onCreateViewHolder(parent, viewType);
378                if (DEBUG) {
379                    Log.d(TAG, "CHILD_CNT(create):" + cnt + ", " + testViewHolder);
380                }
381                return testViewHolder;
382            }
383        };
384        setupByConfig(new Config(VERTICAL, false, false).itemCount(300)
385                .adapter(adapter), true);
386
387        final RecyclerView.RecycledViewPool pool = new RecyclerView.RecycledViewPool() {
388            @Override
389            public void putRecycledView(RecyclerView.ViewHolder scrap) {
390                super.putRecycledView(scrap);
391                int cnt = childCount.decrementAndGet();
392                if (DEBUG) {
393                    Log.d(TAG, "CHILD_CNT(put):" + cnt + ", " + scrap);
394                }
395            }
396
397            @Override
398            public RecyclerView.ViewHolder getRecycledView(int viewType) {
399                final RecyclerView.ViewHolder recycledView = super.getRecycledView(viewType);
400                if (recycledView != null) {
401                    final int cnt = childCount.incrementAndGet();
402                    if (DEBUG) {
403                        Log.d(TAG, "CHILD_CNT(get):" + cnt + ", " + recycledView);
404                    }
405                }
406                return recycledView;
407            }
408        };
409        pool.setMaxRecycledViews(mTestAdapter.getItemViewType(0), 500);
410        mRecyclerView.setRecycledViewPool(pool);
411
412
413        // now keep adding children to trigger more children being created etc.
414        for (int i = 0; i < 100; i ++) {
415            adapter.addAndNotify(15, 1);
416            Thread.sleep(15);
417        }
418        getInstrumentation().waitForIdleSync();
419        waitForAnimations(2);
420        assertEquals("Children count should add up", childCount.get(),
421                mRecyclerView.getChildCount() + mRecyclerView.mRecycler.mCachedViews.size());
422
423        // now trigger lots of add again, followed by a scroll to position
424        for (int i = 0; i < 100; i ++) {
425            adapter.addAndNotify(5 + (i % 3) * 3, 1);
426            Thread.sleep(25);
427        }
428        smoothScrollToPosition(mLayoutManager.findLastVisibleItemPosition() + 20);
429        waitForAnimations(2);
430        getInstrumentation().waitForIdleSync();
431        assertEquals("Children count should add up", childCount.get(),
432                mRecyclerView.getChildCount() + mRecyclerView.mRecycler.mCachedViews.size());
433    }
434
435
436    @Test
437    public void dontRecycleChildrenOnDetach() throws Throwable {
438        setupByConfig(new Config().recycleChildrenOnDetach(false), true);
439        runTestOnUiThread(new Runnable() {
440            @Override
441            public void run() {
442                int recyclerSize = mRecyclerView.mRecycler.getRecycledViewPool().size();
443                mRecyclerView.setLayoutManager(new TestLayoutManager());
444                assertEquals("No views are recycled", recyclerSize,
445                        mRecyclerView.mRecycler.getRecycledViewPool().size());
446            }
447        });
448    }
449
450    @Test
451    public void recycleChildrenOnDetach() throws Throwable {
452        setupByConfig(new Config().recycleChildrenOnDetach(true), true);
453        final int childCount = mLayoutManager.getChildCount();
454        runTestOnUiThread(new Runnable() {
455            @Override
456            public void run() {
457                int recyclerSize = mRecyclerView.mRecycler.getRecycledViewPool().size();
458                mRecyclerView.mRecycler.getRecycledViewPool().setMaxRecycledViews(
459                        mTestAdapter.getItemViewType(0), recyclerSize + childCount);
460                mRecyclerView.setLayoutManager(new TestLayoutManager());
461                assertEquals("All children should be recycled", childCount + recyclerSize,
462                        mRecyclerView.mRecycler.getRecycledViewPool().size());
463            }
464        });
465    }
466
467    @Test
468    public void scrollAndClear() throws Throwable {
469        setupByConfig(new Config(), true);
470
471        assertTrue("Children not laid out", mLayoutManager.collectChildCoordinates().size() > 0);
472
473        mLayoutManager.expectLayouts(1);
474        runTestOnUiThread(new Runnable() {
475            @Override
476            public void run() {
477                mLayoutManager.scrollToPositionWithOffset(1, 0);
478                mTestAdapter.clearOnUIThread();
479            }
480        });
481        mLayoutManager.waitForLayout(2);
482
483        assertEquals("Remaining children", 0, mLayoutManager.collectChildCoordinates().size());
484    }
485
486
487    @Test
488    public void accessibilityPositions() throws Throwable {
489        setupByConfig(new Config(VERTICAL, false, false), true);
490        final AccessibilityDelegateCompat delegateCompat = mRecyclerView
491                .getCompatAccessibilityDelegate();
492        final AccessibilityEvent event = AccessibilityEvent.obtain();
493        runTestOnUiThread(new Runnable() {
494            @Override
495            public void run() {
496                delegateCompat.onInitializeAccessibilityEvent(mRecyclerView, event);
497            }
498        });
499        final AccessibilityRecordCompat record = AccessibilityEventCompat
500                .asRecord(event);
501        assertEquals("result should have first position",
502                record.getFromIndex(),
503                mLayoutManager.findFirstVisibleItemPosition());
504        assertEquals("result should have last position",
505                record.getToIndex(),
506                mLayoutManager.findLastVisibleItemPosition());
507    }
508}
509