1 /*
2  * Copyright 2019 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 androidx.recyclerview.widget;
18 
19 import static android.view.ViewGroup.LayoutParams.MATCH_PARENT;
20 
21 import static androidx.recyclerview.widget.LinearLayoutManagerExtraLayoutSpaceTest.ScrollDirection.TOWARDS_END;
22 import static androidx.recyclerview.widget.LinearLayoutManagerExtraLayoutSpaceTest.ScrollDirection.TOWARDS_START;
23 import static androidx.recyclerview.widget.RecyclerView.HORIZONTAL;
24 import static androidx.recyclerview.widget.RecyclerView.VERTICAL;
25 
26 import static org.hamcrest.CoreMatchers.allOf;
27 import static org.hamcrest.CoreMatchers.equalTo;
28 import static org.hamcrest.Matchers.greaterThan;
29 import static org.hamcrest.Matchers.greaterThanOrEqualTo;
30 import static org.hamcrest.Matchers.lessThan;
31 import static org.hamcrest.Matchers.lessThanOrEqualTo;
32 import static org.junit.Assert.assertThat;
33 
34 import android.content.Context;
35 import android.view.View;
36 import android.view.ViewTreeObserver;
37 
38 import androidx.test.filters.MediumTest;
39 
40 import org.jspecify.annotations.NonNull;
41 import org.junit.Test;
42 import org.junit.runner.RunWith;
43 import org.junit.runners.Parameterized;
44 
45 import java.util.ArrayList;
46 import java.util.Arrays;
47 import java.util.Comparator;
48 import java.util.List;
49 
50 @RunWith(Parameterized.class)
51 public class LinearLayoutManagerExtraLayoutSpaceTest extends BaseLinearLayoutManagerTest {
52 
53     private static final int RECYCLERVIEW_SIZE = 400;
54     private static final int CHILD_SIZE = 140;
55 
56     enum ScrollDirection {
57         TOWARDS_START, // towards left/top
58         TOWARDS_END;   // towards right/bottom
59 
invert()60         public ScrollDirection invert() {
61             return this == TOWARDS_END ? TOWARDS_START : TOWARDS_END;
62         }
63     }
64 
65     private final Config mConfig;
66     private final int mExtraLayoutSpaceLegacy;
67     private final int[] mExtraLayoutSpace;
68     private ExtraLayoutSpaceLayoutManager mLayoutManager;
69 
70     private int mCurrPosition = 0;
71     private ScrollDirection mLastScrollDirection = TOWARDS_END;
72     private LastScrollDeltaTracker mLastScrollTracker = new LastScrollDeltaTracker();
73 
LinearLayoutManagerExtraLayoutSpaceTest(Config config, int extraLayoutSpaceLegacy, int extraLayoutSpace)74     public LinearLayoutManagerExtraLayoutSpaceTest(Config config, int extraLayoutSpaceLegacy,
75             int extraLayoutSpace) {
76         mConfig = config;
77         mExtraLayoutSpaceLegacy = extraLayoutSpaceLegacy;
78         if (extraLayoutSpace == -1) {
79             mExtraLayoutSpace = null;
80         } else {
81             mExtraLayoutSpace = new int[]{extraLayoutSpace, extraLayoutSpace};
82         }
83     }
84 
85     @Parameterized.Parameters(name = "config:{0},extraLegacySpace:{1},extraLayoutSpace:{2}")
getParams()86     public static List<Object[]> getParams() {
87         List<Object[]> result = new ArrayList<>();
88         List<Config> configs = createBaseVariations();
89         for (Config config : configs) {
90             // Ignore mWrap because we have our own RV layout params
91             if (!config.mWrap) {
92                 // Either the legacy, or the new mechanism is used,
93                 // so test at most one of them at a time
94                 result.add(new Object[]{config, -1, -1});
95                 for (int extraLegacySpace : new int[]{0, 200}) {
96                     result.add(new Object[]{config, extraLegacySpace, -1});
97                 }
98                 for (int extraLayoutSpace : new int[]{100, 400}) {
99                     result.add(new Object[]{config, -1, extraLayoutSpace});
100                 }
101             }
102         }
103         return result;
104     }
105 
isReversed()106     private boolean isReversed() {
107         return mConfig.mReverseLayout ^ mConfig.mStackFromEnd;
108     }
109 
110     @Test
111     @MediumTest
test()112     public void test() throws Throwable {
113         // Setup
114         mConfig.mTestLayoutManager = new ExtraLayoutSpaceLayoutManager(getActivity(),
115                 mConfig.mOrientation, mConfig.mReverseLayout);
116         setupByConfig(mConfig, false, getChildLayoutParams(), getParentLayoutParams());
117         mLayoutManager = (ExtraLayoutSpaceLayoutManager) super.mLayoutManager;
118         mLayoutManager.mExtraLayoutSpaceLegacy = mExtraLayoutSpaceLegacy;
119         mLayoutManager.mExtraLayoutSpace = mExtraLayoutSpace;
120         mRecyclerView.addOnScrollListener(mLastScrollTracker);
121 
122         // Verify start position
123         verifyStartPosition();
124         // Jump directly to a position, moving forward
125         scrollToPositionAndVerify(90, false);
126         // Smooth scroll to a position, moving forward
127         scrollToPositionAndVerify(100, true);
128         // Jump directly to a position, moving backward
129         scrollToPositionAndVerify(60, false);
130         // Smooth scroll to a position, moving backward
131         scrollToPositionAndVerify(50, true);
132     }
133 
verifyStartPosition()134     private void verifyStartPosition() throws Throwable {
135         mLayoutManager.recordNextExtraLayoutSpace();
136         waitForFirstLayout();
137         waitForDraw(2);
138         verify(getExpectedExtraSpace(false), getAvailableSpace(TOWARDS_START));
139     }
140 
scrollToPositionAndVerify(int target, boolean smoothScroll)141     private void scrollToPositionAndVerify(int target, boolean smoothScroll) throws Throwable {
142         int prevPosition = mCurrPosition;
143         mCurrPosition = target;
144 
145         // Perform the scroll
146         scrollToPosition(mCurrPosition, smoothScroll);
147         int direction = Integer.signum(mCurrPosition - prevPosition);
148         if (smoothScroll) {
149             // TODO(b/139350295): fix the overshoot instead of detecting it
150             while (!isLastScrollDirectionCorrect(direction)) {
151                 correctLastScrollDirection();
152             }
153         }
154 
155         // Update expected results
156         // Alignment means the side of the viewport to which mCurrPosition is aligned
157         // This is necessary to calculate the available space before and after the viewport
158         ScrollDirection alignment = mCurrPosition > prevPosition ? TOWARDS_END : TOWARDS_START;
159         if (smoothScroll) {
160             mLastScrollDirection = isReversed() ? alignment.invert() : alignment;
161         }
162 
163         // Verify actual results
164         verify(getExpectedExtraSpace(smoothScroll), getAvailableSpace(alignment));
165     }
166 
isLastScrollDirectionCorrect(int expectedDirection)167     private boolean isLastScrollDirectionCorrect(int expectedDirection) {
168         int lastDirection = mLastScrollTracker.get(mConfig.mOrientation);
169         int reversedModifier = isReversed() ? -1 : 1;
170         return lastDirection * reversedModifier * expectedDirection >= 0;
171     }
172 
correctLastScrollDirection()173     private void correctLastScrollDirection() throws Throwable {
174         final int dx = Integer.signum(mLastScrollTracker.getX());
175         final int dy = Integer.signum(mLastScrollTracker.getY());
176 
177         mLayoutManager.expectIdleState(1);
178         mRecyclerView.smoothScrollBy(dx, dy);
179         mLayoutManager.waitForSnap(10);
180 
181         mLayoutManager.expectIdleState(1);
182         mRecyclerView.smoothScrollBy(-dx, -dy);
183         mLayoutManager.waitForSnap(10);
184     }
185 
scrollToPosition(final int position, final boolean smoothScroll)186     private void scrollToPosition(final int position, final boolean smoothScroll) throws Throwable {
187         mActivityRule.runOnUiThread(new Runnable() {
188             @Override
189             public void run() {
190                 // When mStackFromEnd, RV starts at the last item, so mirror positions in that case
191                 int resolvedTarget = mConfig.mStackFromEnd
192                         ? mConfig.mItemCount - position - 1
193                         : position;
194                 mLayoutManager.recordNextExtraLayoutSpace();
195                 if (smoothScroll) {
196                     mRecyclerView.smoothScrollToPosition(resolvedTarget);
197                 } else {
198                     mRecyclerView.scrollToPosition(resolvedTarget);
199                 }
200             }
201         });
202         if (smoothScroll) {
203             mLayoutManager.expectIdleState(1);
204             mLayoutManager.waitForSnap(10);
205         } else {
206             mLayoutManager.waitForLayout(2);
207             waitForDraw(2);
208         }
209     }
210 
getExpectedExtraSpace(boolean didScroll)211     private int[] getExpectedExtraSpace(boolean didScroll) {
212         int[] space = new int[2];
213 
214         if (mExtraLayoutSpace != null) {
215             // If calculateExtraLayoutSpace is overridden, expect those values
216             space[0] = mExtraLayoutSpace[0];
217             space[1] = mExtraLayoutSpace[1];
218         } else {
219             // Otherwise, expect mExtraLayoutSpaceLegacy or the default
220             // of one page when scrolling in the scroll direction
221             int defaultScrollSpace = didScroll ? RECYCLERVIEW_SIZE : 0;
222             int potentialScrollSpace = mExtraLayoutSpaceLegacy == -1
223                     ? defaultScrollSpace : mExtraLayoutSpaceLegacy;
224             if (mLastScrollDirection == TOWARDS_START) {
225                 space[0] = potentialScrollSpace;
226             } else {
227                 space[1] = potentialScrollSpace;
228             }
229         }
230 
231         return space;
232     }
233 
getAvailableSpace(ScrollDirection alignment)234     private int[] getAvailableSpace(ScrollDirection alignment) {
235         int[] availableSpace = new int[2];
236         int fullListSize = mConfig.mItemCount * CHILD_SIZE;
237         int positionFromStart = mCurrPosition;
238         if (isReversed()) {
239             // If RV starts at the end, mirror both the alignment and
240             // position to make the available space calculation uniform
241             alignment = alignment.invert();
242             positionFromStart = mConfig.mItemCount - mCurrPosition - 1;
243         }
244         if (alignment == TOWARDS_START) {
245             availableSpace[0] = positionFromStart * CHILD_SIZE;
246         } else {
247             availableSpace[0] = (positionFromStart + 1) * CHILD_SIZE - RECYCLERVIEW_SIZE;
248         }
249         availableSpace[1] = fullListSize - availableSpace[0] - RECYCLERVIEW_SIZE;
250         return availableSpace;
251     }
252 
verify(int[] expectedExtraSpace, int[] availableSpace)253     private void verify(int[] expectedExtraSpace, int[] availableSpace) {
254         // Verify that the recorded extra layout space matches what we expected
255         assertThat(mLayoutManager.mRecordedExtraLayoutSpace, equalTo(expectedExtraSpace));
256         // Verify that the extra layout space was actually used
257         expectedExtraSpace[0] = Math.min(availableSpace[0], expectedExtraSpace[0]);
258         expectedExtraSpace[1] = Math.min(availableSpace[1], expectedExtraSpace[1]);
259         OrientationHelper orientationHelper = mLayoutManager.mOrientationHelper;
260         int[] startBandwidth = new int[] {
261                 orientationHelper.getStartAfterPadding() - expectedExtraSpace[0] - CHILD_SIZE,
262                 orientationHelper.getStartAfterPadding() - expectedExtraSpace[0]
263         };
264         int[] endBandwidth = new int[] {
265                 orientationHelper.getEndAfterPadding() + expectedExtraSpace[1],
266                 orientationHelper.getEndAfterPadding() + expectedExtraSpace[1] + CHILD_SIZE
267         };
268         assertThat(mLayoutManager.mLayoutRecorder.mIsContiguous,
269                 equalTo(true));
270         assertThat(mLayoutManager.mLayoutRecorder.mObservedStart,
271                 allOf(
272                         greaterThan(startBandwidth[0]),
273                         lessThanOrEqualTo(startBandwidth[1])
274                 ));
275         assertThat(mLayoutManager.mLayoutRecorder.mObservedEnd,
276                 allOf(
277                         greaterThanOrEqualTo(endBandwidth[0]),
278                         lessThan(endBandwidth[1])
279                 ));
280     }
281 
getViewTreeObserver()282     private ViewTreeObserver getViewTreeObserver() {
283         return mRecyclerView.getViewTreeObserver();
284     }
285 
getParentLayoutParams()286     private RecyclerView.LayoutParams getParentLayoutParams() {
287         return new RecyclerView.LayoutParams(
288                 mConfig.mOrientation == HORIZONTAL ? RECYCLERVIEW_SIZE : CHILD_SIZE,
289                 mConfig.mOrientation == VERTICAL ? RECYCLERVIEW_SIZE : CHILD_SIZE
290         );
291     }
292 
getChildLayoutParams()293     private RecyclerView.LayoutParams getChildLayoutParams() {
294         return new RecyclerView.LayoutParams(
295                 mConfig.mOrientation == HORIZONTAL ? CHILD_SIZE : MATCH_PARENT,
296                 mConfig.mOrientation == VERTICAL ? CHILD_SIZE : MATCH_PARENT
297         );
298     }
299 
300 
301     private class LastScrollDeltaTracker extends RecyclerView.OnScrollListener {
302         public final int[] mLastScrollDelta = new int[2];
303 
304         @Override
onScrolled(@onNull RecyclerView recyclerView, int dx, int dy)305         public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) {
306             mLastScrollDelta[0] = dx;
307             mLastScrollDelta[1] = dy;
308         }
309 
getX()310         public int getX() {
311             return mLastScrollDelta[0];
312         }
313 
getY()314         public int getY() {
315             return mLastScrollDelta[1];
316         }
317 
get(int orientation)318         public int get(int orientation) {
319             return mLastScrollDelta[orientation];
320         }
321     }
322 
323     class ExtraLayoutSpaceLayoutManager extends WrappedLinearLayoutManager {
324         int mExtraLayoutSpaceLegacy = -1;
325         int[] mExtraLayoutSpace = null;
326 
327         private boolean mRecordExtraLayoutSpace = false;
328         int[] mRecordedExtraLayoutSpace = new int[2];
329         LayoutBoundsRecorder mLayoutRecorder;
330 
ExtraLayoutSpaceLayoutManager(Context context, int orientation, boolean reverseLayout)331         ExtraLayoutSpaceLayoutManager(Context context, int orientation, boolean reverseLayout) {
332             super(context, orientation, reverseLayout);
333         }
334 
335         @SuppressWarnings("deprecation")
336         @Override
getExtraLayoutSpace(RecyclerView.State state)337         protected int getExtraLayoutSpace(RecyclerView.State state) {
338             if (mExtraLayoutSpaceLegacy == -1) {
339                 return super.getExtraLayoutSpace(state);
340             } else {
341                 return mExtraLayoutSpaceLegacy;
342             }
343         }
344 
345         @Override
calculateExtraLayoutSpace(RecyclerView.@onNull State state, int @NonNull [] extraLayoutSpace)346         protected void calculateExtraLayoutSpace(RecyclerView.@NonNull State state,
347                 int @NonNull [] extraLayoutSpace) {
348             if (mExtraLayoutSpace == null) {
349                 super.calculateExtraLayoutSpace(state, extraLayoutSpace);
350             } else {
351                 extraLayoutSpace[0] = mExtraLayoutSpace[0];
352                 extraLayoutSpace[1] = mExtraLayoutSpace[1];
353             }
354             if (mRecordExtraLayoutSpace) {
355                 mRecordExtraLayoutSpace = false;
356                 mRecordedExtraLayoutSpace[0] = extraLayoutSpace[0];
357                 mRecordedExtraLayoutSpace[1] = extraLayoutSpace[1];
358                 getViewTreeObserver().addOnPreDrawListener(mLayoutRecorder);
359             }
360         }
361 
recordNextExtraLayoutSpace()362         public void recordNextExtraLayoutSpace() {
363             mRecordedExtraLayoutSpace[0] = -1;
364             mRecordedExtraLayoutSpace[1] = -1;
365             mRecordExtraLayoutSpace = true;
366             mLayoutRecorder = new LayoutBoundsRecorder();
367         }
368     }
369 
370     class LayoutBoundsRecorder implements ViewTreeObserver.OnPreDrawListener {
371         private final OrientationHelper mHelper;
372         private final int[][] mBounds;
373 
374         private boolean mHasRecorded = false;
375         public boolean mIsContiguous;
376         public int mObservedStart;
377         public int mObservedEnd;
378 
LayoutBoundsRecorder()379         LayoutBoundsRecorder() {
380             mHelper = mLayoutManager.mOrientationHelper;
381             mBounds = new int[mTestAdapter.getItemCount()][2];
382         }
383 
384         @Override
onPreDraw()385         public boolean onPreDraw() {
386             if (!mHasRecorded) {
387                 recordBounds();
388                 mRecyclerView.post(new Runnable() {
389                     @Override
390                     public void run() {
391                         getViewTreeObserver().removeOnPreDrawListener(LayoutBoundsRecorder.this);
392                     }
393                 });
394             }
395             return true;
396         }
397 
recordBounds()398         private void recordBounds() {
399             int childCount = mLayoutManager.getChildCount();
400             for (int i = 0; i < childCount; i++) {
401                 View child = mLayoutManager.getChildAt(i);
402                 mBounds[i][0] = mHelper.getDecoratedStart(child);
403                 mBounds[i][1] = mHelper.getDecoratedEnd(child);
404             }
405             Arrays.sort(mBounds, 0, childCount, BOUNDS_COMPARATOR);
406             mObservedStart = childCount == 0 ? 0 : mBounds[0][0];
407             mObservedEnd = childCount == 0 ? 0 : mBounds[childCount - 1][1];
408             mIsContiguous = true;
409             for (int i = 1; mIsContiguous && i < childCount; i++) {
410                 mIsContiguous = mBounds[i - 1][1] == mBounds[i][0];
411             }
412             mHasRecorded = true;
413         }
414     }
415 
416     private static final Comparator<int[]> BOUNDS_COMPARATOR = new Comparator<int[]>() {
417         @Override
418         public int compare(int[] lhs, int[] rhs) {
419             return lhs[0] - rhs[0];
420         }
421     };
422 }
423