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