1/*
2 * Copyright (C) 2015 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *      http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17package android.support.v7.widget;
18
19import static android.support.v7.widget.LayoutState.LAYOUT_END;
20import static android.support.v7.widget.LayoutState.LAYOUT_START;
21import static android.support.v7.widget.LinearLayoutManager.HORIZONTAL;
22
23import static org.hamcrest.CoreMatchers.hasItem;
24import static org.hamcrest.CoreMatchers.is;
25import static org.hamcrest.CoreMatchers.not;
26import static org.hamcrest.CoreMatchers.sameInstance;
27import static org.junit.Assert.assertEquals;
28import static org.junit.Assert.assertNotNull;
29import static org.junit.Assert.assertThat;
30
31import android.graphics.Rect;
32import android.support.v4.view.ViewCompat;
33import android.test.suitebuilder.annotation.MediumTest;
34import android.util.Log;
35import android.view.View;
36import android.view.ViewParent;
37
38import org.junit.Test;
39import org.junit.runner.RunWith;
40import org.junit.runners.Parameterized;
41
42import java.util.ArrayList;
43import java.util.List;
44import java.util.Map;
45
46/**
47 * Tests that rely on the basic configuration and does not do any additions / removals
48 */
49@RunWith(Parameterized.class)
50@MediumTest
51public class LinearLayoutManagerBaseConfigSetTest extends BaseLinearLayoutManagerTest {
52
53    private final Config mConfig;
54
55    public LinearLayoutManagerBaseConfigSetTest(Config config) {
56        mConfig = config;
57    }
58
59
60    @Parameterized.Parameters(name = "{0}")
61    public static List<Config> configs() throws CloneNotSupportedException {
62        List<Config> result = new ArrayList<>();
63        for (Config config : createBaseVariations()) {
64            result.add(config);
65        }
66        return result;
67    }
68
69    @Test
70    public void scrollToPositionWithOffsetTest() throws Throwable {
71        Config config = ((Config) mConfig.clone()).itemCount(300);
72        setupByConfig(config, true);
73        OrientationHelper orientationHelper = OrientationHelper
74                .createOrientationHelper(mLayoutManager, config.mOrientation);
75        Rect layoutBounds = getDecoratedRecyclerViewBounds();
76        // try scrolling towards head, should not affect anything
77        Map<Item, Rect> before = mLayoutManager.collectChildCoordinates();
78        if (config.mStackFromEnd) {
79            scrollToPositionWithOffset(mTestAdapter.getItemCount() - 1,
80                    mLayoutManager.mOrientationHelper.getEnd() - 500);
81        } else {
82            scrollToPositionWithOffset(0, 20);
83        }
84        assertRectSetsEqual(config + " trying to over scroll with offset should be no-op",
85                before, mLayoutManager.collectChildCoordinates());
86        // try offsetting some visible children
87        int testCount = 10;
88        while (testCount-- > 0) {
89            // get middle child
90            final View child = mLayoutManager.getChildAt(mLayoutManager.getChildCount() / 2);
91            final int position = mRecyclerView.getChildLayoutPosition(child);
92            final int startOffset = config.mReverseLayout ?
93                    orientationHelper.getEndAfterPadding() - orientationHelper
94                            .getDecoratedEnd(child)
95                    : orientationHelper.getDecoratedStart(child) - orientationHelper
96                            .getStartAfterPadding();
97            final int scrollOffset = config.mStackFromEnd ? startOffset + startOffset / 2
98                    : startOffset / 2;
99            mLayoutManager.expectLayouts(1);
100            scrollToPositionWithOffset(position, scrollOffset);
101            mLayoutManager.waitForLayout(2);
102            final int finalOffset = config.mReverseLayout ?
103                    orientationHelper.getEndAfterPadding() - orientationHelper
104                            .getDecoratedEnd(child)
105                    : orientationHelper.getDecoratedStart(child) - orientationHelper
106                            .getStartAfterPadding();
107            assertEquals(config + " scroll with offset on a visible child should work fine " +
108                            " offset:" + finalOffset + " , existing offset:" + startOffset + ", "
109                            + "child " + position,
110                    scrollOffset, finalOffset);
111        }
112
113        // try scrolling to invisible children
114        testCount = 10;
115        // we test above and below, one by one
116        int offsetMultiplier = -1;
117        while (testCount-- > 0) {
118            final TargetTuple target = findInvisibleTarget(config);
119            final String logPrefix = config + " " + target;
120            mLayoutManager.expectLayouts(1);
121            final int offset = offsetMultiplier
122                    * orientationHelper.getDecoratedMeasurement(mLayoutManager.getChildAt(0)) / 3;
123            scrollToPositionWithOffset(target.mPosition, offset);
124            mLayoutManager.waitForLayout(2);
125            final View child = mLayoutManager.findViewByPosition(target.mPosition);
126            assertNotNull(logPrefix + " scrolling to a mPosition with offset " + offset
127                    + " should layout it", child);
128            final Rect bounds = mLayoutManager.getViewBounds(child);
129            if (DEBUG) {
130                Log.d(TAG, logPrefix + " post scroll to invisible mPosition " + bounds + " in "
131                        + layoutBounds + " with offset " + offset);
132            }
133
134            if (config.mReverseLayout) {
135                assertEquals(logPrefix + " when scrolling with offset to an invisible in reverse "
136                                + "layout, its end should align with recycler view's end - offset",
137                        orientationHelper.getEndAfterPadding() - offset,
138                        orientationHelper.getDecoratedEnd(child)
139                );
140            } else {
141                assertEquals(
142                        logPrefix + " when scrolling with offset to an invisible child in normal"
143                                + " layout its start should align with recycler view's start + "
144                                + "offset",
145                        orientationHelper.getStartAfterPadding() + offset,
146                        orientationHelper.getDecoratedStart(child)
147                );
148            }
149            offsetMultiplier *= -1;
150        }
151    }
152
153    @Test
154    public void getFirstLastChildrenTest() throws Throwable {
155        final Config config = ((Config) mConfig.clone()).itemCount(300);
156        setupByConfig(config, true);
157        Runnable viewInBoundsTest = new Runnable() {
158            @Override
159            public void run() {
160                VisibleChildren visibleChildren = mLayoutManager.traverseAndFindVisibleChildren();
161                final String boundsLog = mLayoutManager.getBoundsLog();
162                assertEquals(config + ":\nfirst visible child should match traversal result\n"
163                                + boundsLog, visibleChildren.firstVisiblePosition,
164                        mLayoutManager.findFirstVisibleItemPosition()
165                );
166                assertEquals(
167                        config + ":\nfirst fully visible child should match traversal result\n"
168                                + boundsLog, visibleChildren.firstFullyVisiblePosition,
169                        mLayoutManager.findFirstCompletelyVisibleItemPosition()
170                );
171
172                assertEquals(config + ":\nlast visible child should match traversal result\n"
173                                + boundsLog, visibleChildren.lastVisiblePosition,
174                        mLayoutManager.findLastVisibleItemPosition()
175                );
176                assertEquals(
177                        config + ":\nlast fully visible child should match traversal result\n"
178                                + boundsLog, visibleChildren.lastFullyVisiblePosition,
179                        mLayoutManager.findLastCompletelyVisibleItemPosition()
180                );
181            }
182        };
183        runTestOnUiThread(viewInBoundsTest);
184        // smooth scroll to end of the list and keep testing meanwhile. This will test pre-caching
185        // case
186        final int scrollPosition = config.mStackFromEnd ? 0 : mTestAdapter.getItemCount();
187        runTestOnUiThread(new Runnable() {
188            @Override
189            public void run() {
190                mRecyclerView.smoothScrollToPosition(scrollPosition);
191            }
192        });
193        while (mLayoutManager.isSmoothScrolling() ||
194                mRecyclerView.getScrollState() != RecyclerView.SCROLL_STATE_IDLE) {
195            runTestOnUiThread(viewInBoundsTest);
196            Thread.sleep(400);
197        }
198        // delete all items
199        mLayoutManager.expectLayouts(2);
200        mTestAdapter.deleteAndNotify(0, mTestAdapter.getItemCount());
201        mLayoutManager.waitForLayout(2);
202        // test empty case
203        runTestOnUiThread(viewInBoundsTest);
204        // set a new adapter with huge items to test full bounds check
205        mLayoutManager.expectLayouts(1);
206        final int totalSpace = mLayoutManager.mOrientationHelper.getTotalSpace();
207        final TestAdapter newAdapter = new TestAdapter(100) {
208            @Override
209            public void onBindViewHolder(TestViewHolder holder,
210                    int position) {
211                super.onBindViewHolder(holder, position);
212                if (config.mOrientation == HORIZONTAL) {
213                    holder.itemView.setMinimumWidth(totalSpace + 5);
214                } else {
215                    holder.itemView.setMinimumHeight(totalSpace + 5);
216                }
217            }
218        };
219        runTestOnUiThread(new Runnable() {
220            @Override
221            public void run() {
222                mRecyclerView.setAdapter(newAdapter);
223            }
224        });
225        mLayoutManager.waitForLayout(2);
226        runTestOnUiThread(viewInBoundsTest);
227    }
228
229    @Test
230    public void dontRecycleViewsTranslatedOutOfBoundsFromStart() throws Throwable {
231        final Config config = ((Config) mConfig.clone()).itemCount(1000);
232        setupByConfig(config, true);
233        mLayoutManager.expectLayouts(1);
234        scrollToPosition(500);
235        mLayoutManager.waitForLayout(2);
236        final RecyclerView.ViewHolder vh = mRecyclerView.findViewHolderForAdapterPosition(500);
237        OrientationHelper helper = mLayoutManager.mOrientationHelper;
238        int gap = helper.getDecoratedStart(vh.itemView);
239        scrollBy(gap);
240        gap = helper.getDecoratedStart(vh.itemView);
241        assertThat("test sanity", gap, is(0));
242
243        final int size = helper.getDecoratedMeasurement(vh.itemView);
244        AttachDetachCollector collector = new AttachDetachCollector(mRecyclerView);
245        runTestOnUiThread(new Runnable() {
246            @Override
247            public void run() {
248                if (mConfig.mOrientation == HORIZONTAL) {
249                    ViewCompat.setTranslationX(vh.itemView, size * 2);
250                } else {
251                    ViewCompat.setTranslationY(vh.itemView, size * 2);
252                }
253            }
254        });
255        scrollBy(size * 2);
256        assertThat(collector.getDetached(), not(hasItem(sameInstance(vh.itemView))));
257        assertThat(vh.itemView.getParent(), is((ViewParent) mRecyclerView));
258        assertThat(vh.getAdapterPosition(), is(500));
259        scrollBy(size * 2);
260        assertThat(collector.getDetached(), hasItem(sameInstance(vh.itemView)));
261    }
262
263    @Test
264    public void dontRecycleViewsTranslatedOutOfBoundsFromEnd() throws Throwable {
265        final Config config = ((Config) mConfig.clone()).itemCount(1000);
266        setupByConfig(config, true);
267        mLayoutManager.expectLayouts(1);
268        scrollToPosition(500);
269        mLayoutManager.waitForLayout(2);
270        final RecyclerView.ViewHolder vh = mRecyclerView.findViewHolderForAdapterPosition(500);
271        OrientationHelper helper = mLayoutManager.mOrientationHelper;
272        int gap = helper.getEnd() - helper.getDecoratedEnd(vh.itemView);
273        scrollBy(-gap);
274        gap = helper.getEnd() - helper.getDecoratedEnd(vh.itemView);
275        assertThat("test sanity", gap, is(0));
276
277        final int size = helper.getDecoratedMeasurement(vh.itemView);
278        AttachDetachCollector collector = new AttachDetachCollector(mRecyclerView);
279        runTestOnUiThread(new Runnable() {
280            @Override
281            public void run() {
282                if (mConfig.mOrientation == HORIZONTAL) {
283                    ViewCompat.setTranslationX(vh.itemView, -size * 2);
284                } else {
285                    ViewCompat.setTranslationY(vh.itemView, -size * 2);
286                }
287            }
288        });
289        scrollBy(-size * 2);
290        assertThat(collector.getDetached(), not(hasItem(sameInstance(vh.itemView))));
291        assertThat(vh.itemView.getParent(), is((ViewParent) mRecyclerView));
292        assertThat(vh.getAdapterPosition(), is(500));
293        scrollBy(-size * 2);
294        assertThat(collector.getDetached(), hasItem(sameInstance(vh.itemView)));
295    }
296
297    private TargetTuple findInvisibleTarget(Config config) {
298        int minPosition = Integer.MAX_VALUE, maxPosition = Integer.MIN_VALUE;
299        for (int i = 0; i < mLayoutManager.getChildCount(); i++) {
300            View child = mLayoutManager.getChildAt(i);
301            int position = mRecyclerView.getChildLayoutPosition(child);
302            if (position < minPosition) {
303                minPosition = position;
304            }
305            if (position > maxPosition) {
306                maxPosition = position;
307            }
308        }
309        final int tailTarget = maxPosition +
310                (mRecyclerView.getAdapter().getItemCount() - maxPosition) / 2;
311        final int headTarget = minPosition / 2;
312        final int target;
313        // where will the child come from ?
314        final int itemLayoutDirection;
315        if (Math.abs(tailTarget - maxPosition) > Math.abs(headTarget - minPosition)) {
316            target = tailTarget;
317            itemLayoutDirection = config.mReverseLayout ? LAYOUT_START : LAYOUT_END;
318        } else {
319            target = headTarget;
320            itemLayoutDirection = config.mReverseLayout ? LAYOUT_END : LAYOUT_START;
321        }
322        if (DEBUG) {
323            Log.d(TAG,
324                    config + " target:" + target + " min:" + minPosition + ", max:" + maxPosition);
325        }
326        return new TargetTuple(target, itemLayoutDirection);
327    }
328}
329