1/*
2 * Copyright 2018 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 androidx.recyclerview.widget;
18
19import static org.junit.Assert.assertEquals;
20import static org.junit.Assert.assertNotSame;
21import static org.junit.Assert.assertSame;
22import static org.junit.Assert.assertTrue;
23
24import android.support.test.filters.LargeTest;
25import android.view.View;
26
27import androidx.annotation.Nullable;
28
29import org.junit.Test;
30import org.junit.runner.RunWith;
31import org.junit.runners.Parameterized;
32
33import java.util.ArrayList;
34import java.util.List;
35import java.util.concurrent.atomic.AtomicBoolean;
36
37@LargeTest
38@RunWith(Parameterized.class)
39public class LinearLayoutManagerSnappingTest extends BaseLinearLayoutManagerTest {
40
41    final Config mConfig;
42    final boolean mReverseScroll;
43
44    public LinearLayoutManagerSnappingTest(Config config, boolean reverseScroll) {
45        mConfig = config;
46        mReverseScroll = reverseScroll;
47    }
48
49    @Parameterized.Parameters(name = "config:{0},reverseScroll:{1}")
50    public static List<Object[]> getParams() {
51        List<Object[]> result = new ArrayList<>();
52        List<Config> configs = createBaseVariations();
53        for (Config config : configs) {
54            for (boolean reverseScroll : new boolean[] {true, false}) {
55                result.add(new Object[]{config, reverseScroll});
56            }
57        }
58        return result;
59    }
60
61    @Test
62    public void snapOnScrollSameViewEdge() throws Throwable {
63        final Config config = (Config) mConfig.clone();
64        // Ensure that the views are big enough to reach the pathological case when the view closest
65        // to the center is an edge view, but it cannot scroll further in order to snap.
66        setupByConfig(config, true, new RecyclerView.LayoutParams(1000, 1000),
67            new RecyclerView.LayoutParams(1500, 1500));
68        SnapHelper snapHelper = new LinearSnapHelper();
69        mLayoutManager.expectIdleState(1);
70        snapHelper.attachToRecyclerView(mRecyclerView);
71        mLayoutManager.waitForSnap(10);
72
73        // Record the current center view.
74        View view = findCenterView(mLayoutManager);
75
76        int scrollDistance = (getViewDimension(view) / 2) - 1;
77        int scrollDist = config.mStackFromEnd == config.mReverseLayout
78            ? -scrollDistance : scrollDistance;
79        mLayoutManager.expectIdleState(1);
80        smoothScrollBy(scrollDist);
81        mLayoutManager.waitForSnap(10);
82        mLayoutManager.expectCallbacks(5);
83        mLayoutManager.assertNoCallbacks("There should be no callbacks after some time", 3);
84    }
85
86    @Test
87    public void snapOnScrollSameView() throws Throwable {
88        final Config config = (Config) mConfig.clone();
89        setupByConfig(config, true);
90        setupSnapHelper();
91
92        // Record the current center view.
93        View view = findCenterView(mLayoutManager);
94        assertCenterAligned(view);
95
96        int scrollDistance = (getViewDimension(view) / 2) - 1;
97        int scrollDist = mReverseScroll ? -scrollDistance : scrollDistance;
98        mLayoutManager.expectIdleState(2);
99        smoothScrollBy(scrollDist);
100        mLayoutManager.waitForSnap(10);
101
102        // Views have not changed
103        View viewAfterFling = findCenterView(mLayoutManager);
104        assertSame("The view should NOT have scrolled", view, viewAfterFling);
105        assertCenterAligned(viewAfterFling);
106    }
107
108    @Test
109    public void snapOnScrollNextView() throws Throwable {
110        final Config config = (Config) mConfig.clone();
111        setupByConfig(config, true);
112        setupSnapHelper();
113
114        // Record the current center view.
115        View view = findCenterView(mLayoutManager);
116        assertCenterAligned(view);
117
118        int scrollDistance = (getViewDimension(view) / 2) + 1;
119        int scrollDist = mReverseScroll ? -scrollDistance : scrollDistance;
120        mLayoutManager.expectIdleState(2);
121        smoothScrollBy(scrollDist);
122        mLayoutManager.waitForSnap(10);
123
124        // Views have not changed
125        View viewAfterFling = findCenterView(mLayoutManager);
126        assertNotSame("The view should have scrolled", view, viewAfterFling);
127        assertCenterAligned(viewAfterFling);
128    }
129
130    @Test
131    public void snapOnFlingSameView() throws Throwable {
132        final Config config = (Config) mConfig.clone();
133        setupByConfig(config, true);
134        setupSnapHelper();
135
136        // Record the current center view.
137        View view = findCenterView(mLayoutManager);
138        assertCenterAligned(view);
139
140        // Velocity small enough to not scroll to the next view.
141        int velocity = (int) (1.000001 * mRecyclerView.getMinFlingVelocity());
142        int velocityDir = mReverseScroll ? -velocity : velocity;
143        mLayoutManager.expectIdleState(2);
144        assertTrue(fling(velocityDir, velocityDir));
145        // Wait for two settling scrolls: the initial one and the corrective one.
146        waitForIdleScroll(mRecyclerView);
147        mLayoutManager.waitForSnap(100);
148
149        View viewAfterFling = findCenterView(mLayoutManager);
150
151        assertSame("The view should NOT have scrolled", view, viewAfterFling);
152        assertCenterAligned(viewAfterFling);
153    }
154
155    @Test
156    public void snapOnFlingNextView() throws Throwable {
157        final Config config = (Config) mConfig.clone();
158        setupByConfig(config, true);
159        setupSnapHelper();
160
161        // Record the current center view.
162        View view = findCenterView(mLayoutManager);
163        assertCenterAligned(view);
164
165        // Velocity high enough to scroll beyond the current view.
166        int velocity = (int) (0.2 * mRecyclerView.getMaxFlingVelocity());
167        int velocityDir = mReverseScroll ? -velocity : velocity;
168        mLayoutManager.expectIdleState(1);
169        assertTrue(fling(velocityDir, velocityDir));
170        mLayoutManager.waitForSnap(100);
171        getInstrumentation().waitForIdleSync();
172
173        View viewAfterFling = findCenterView(mLayoutManager);
174
175        assertNotSame("The view should have scrolled", view, viewAfterFling);
176        assertCenterAligned(viewAfterFling);
177    }
178
179    private void setupSnapHelper() throws Throwable {
180        SnapHelper snapHelper = new LinearSnapHelper();
181        mLayoutManager.expectIdleState(1);
182        snapHelper.attachToRecyclerView(mRecyclerView);
183        mLayoutManager.waitForSnap(10);
184
185        mLayoutManager.expectLayouts(1);
186        scrollToPosition(mConfig.mItemCount / 2);
187        mLayoutManager.waitForLayout(2);
188
189        View view = findCenterView(mLayoutManager);
190        int scrollDistance = distFromCenter(view) / 2;
191        if (scrollDistance == 0) {
192            return;
193        }
194
195        int scrollDist = mReverseScroll ? -scrollDistance : scrollDistance;
196
197        mLayoutManager.expectIdleState(2);
198        smoothScrollBy(scrollDist);
199        mLayoutManager.waitForSnap(10);
200    }
201
202    @Nullable private View findCenterView(RecyclerView.LayoutManager layoutManager) {
203        if (layoutManager.canScrollHorizontally()) {
204            return mRecyclerView.findChildViewUnder(mRecyclerView.getWidth() / 2, 0);
205        } else {
206            return mRecyclerView.findChildViewUnder(0, mRecyclerView.getHeight() / 2);
207        }
208    }
209
210    private int getViewDimension(View view) {
211        OrientationHelper helper;
212        if (mLayoutManager.canScrollHorizontally()) {
213            helper = OrientationHelper.createHorizontalHelper(mLayoutManager);
214        } else {
215            helper = OrientationHelper.createVerticalHelper(mLayoutManager);
216        }
217        return helper.getDecoratedMeasurement(view);
218    }
219
220    private void assertCenterAligned(View view) {
221        if (mLayoutManager.canScrollHorizontally()) {
222            assertEquals(mRecyclerView.getWidth() / 2,
223                    mLayoutManager.getViewBounds(view).centerX());
224        } else {
225            assertEquals(mRecyclerView.getHeight() / 2,
226                    mLayoutManager.getViewBounds(view).centerY());
227        }
228    }
229
230    private int distFromCenter(View view) {
231        if (mLayoutManager.canScrollHorizontally()) {
232            return Math.abs(mRecyclerView.getWidth() / 2 -
233                mLayoutManager.getViewBounds(view).centerX());
234        } else {
235            return Math.abs(mRecyclerView.getHeight() / 2 -
236                mLayoutManager.getViewBounds(view).centerY());
237        }
238    }
239
240    private boolean fling(final int velocityX, final int velocityY) throws Throwable {
241        final AtomicBoolean didStart = new AtomicBoolean(false);
242        mActivityRule.runOnUiThread(new Runnable() {
243            @Override
244            public void run() {
245                boolean result = mRecyclerView.fling(velocityX, velocityY);
246                didStart.set(result);
247            }
248        });
249        if (!didStart.get()) {
250            return false;
251        }
252        waitForIdleScroll(mRecyclerView);
253        return true;
254    }
255}
256