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