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 17 package androidx.recyclerview.widget; 18 19 import static androidx.recyclerview.widget.LinearLayoutManager.VERTICAL; 20 import static androidx.recyclerview.widget.StaggeredGridLayoutManager.GAP_HANDLING_MOVE_ITEMS_BETWEEN_SPANS; 21 import static androidx.recyclerview.widget.StaggeredGridLayoutManager.GAP_HANDLING_NONE; 22 import static androidx.recyclerview.widget.StaggeredGridLayoutManager.HORIZONTAL; 23 import static androidx.recyclerview.widget.StaggeredGridLayoutManager.LayoutParams; 24 25 import static org.hamcrest.CoreMatchers.equalTo; 26 import static org.junit.Assert.assertEquals; 27 import static org.junit.Assert.assertFalse; 28 import static org.junit.Assert.assertNotNull; 29 import static org.junit.Assert.assertNull; 30 import static org.junit.Assert.assertSame; 31 import static org.junit.Assert.assertThat; 32 import static org.junit.Assert.assertTrue; 33 34 import android.graphics.Color; 35 import android.graphics.Rect; 36 import android.graphics.drawable.ColorDrawable; 37 import android.graphics.drawable.StateListDrawable; 38 import android.os.Parcel; 39 import android.os.Parcelable; 40 import android.text.TextUtils; 41 import android.util.Log; 42 import android.util.StateSet; 43 import android.view.View; 44 import android.view.ViewGroup; 45 import android.view.accessibility.AccessibilityEvent; 46 import android.widget.EditText; 47 import android.widget.FrameLayout; 48 49 import androidx.core.view.AccessibilityDelegateCompat; 50 import androidx.core.view.accessibility.AccessibilityNodeInfoCompat; 51 import androidx.test.filters.FlakyTest; 52 import androidx.test.filters.LargeTest; 53 54 import org.hamcrest.CoreMatchers; 55 import org.hamcrest.MatcherAssert; 56 import org.jspecify.annotations.NonNull; 57 import org.junit.Test; 58 59 import java.util.HashMap; 60 import java.util.Map; 61 import java.util.UUID; 62 63 @LargeTest 64 public class StaggeredGridLayoutManagerTest extends BaseStaggeredGridLayoutManagerTest { 65 66 @Test layout_rvHasPaddingChildIsMatchParentVertical_childrenAreInsideParent()67 public void layout_rvHasPaddingChildIsMatchParentVertical_childrenAreInsideParent() 68 throws Throwable { 69 layout_rvHasPaddingChildIsMatchParent_childrenAreInsideParent(VERTICAL, false); 70 } 71 72 @Test layout_rvHasPaddingChildIsMatchParentHorizontal_childrenAreInsideParent()73 public void layout_rvHasPaddingChildIsMatchParentHorizontal_childrenAreInsideParent() 74 throws Throwable { 75 layout_rvHasPaddingChildIsMatchParent_childrenAreInsideParent(HORIZONTAL, false); 76 } 77 78 @Test layout_rvHasPaddingChildIsMatchParentVerticalFullSpan_childrenAreInsideParent()79 public void layout_rvHasPaddingChildIsMatchParentVerticalFullSpan_childrenAreInsideParent() 80 throws Throwable { 81 layout_rvHasPaddingChildIsMatchParent_childrenAreInsideParent(VERTICAL, true); 82 } 83 84 @Test layout_rvHasPaddingChildIsMatchParentHorizontalFullSpan_childrenAreInsideParent()85 public void layout_rvHasPaddingChildIsMatchParentHorizontalFullSpan_childrenAreInsideParent() 86 throws Throwable { 87 layout_rvHasPaddingChildIsMatchParent_childrenAreInsideParent(HORIZONTAL, true); 88 } 89 layout_rvHasPaddingChildIsMatchParent_childrenAreInsideParent( final int orientation, final boolean fullSpan)90 private void layout_rvHasPaddingChildIsMatchParent_childrenAreInsideParent( 91 final int orientation, final boolean fullSpan) 92 throws Throwable { 93 94 setupByConfig(new Config(orientation, false, 1, GAP_HANDLING_MOVE_ITEMS_BETWEEN_SPANS), 95 new GridTestAdapter(10, orientation) { 96 97 @Override 98 public @NonNull TestViewHolder onCreateViewHolder( 99 @NonNull ViewGroup parent, int viewType) { 100 View view = new View(parent.getContext()); 101 StaggeredGridLayoutManager.LayoutParams layoutParams = 102 new StaggeredGridLayoutManager.LayoutParams( 103 ViewGroup.LayoutParams.MATCH_PARENT, 104 ViewGroup.LayoutParams.MATCH_PARENT); 105 layoutParams.setFullSpan(fullSpan); 106 view.setLayoutParams(layoutParams); 107 return new TestViewHolder(view); 108 } 109 110 @Override 111 public void onBindViewHolder(@NonNull TestViewHolder holder, int position) { 112 // No actual binding needed, but we need to override this to prevent default 113 // behavior of GridTestAdapter. 114 } 115 }); 116 mRecyclerView.setPadding(1, 2, 3, 4); 117 118 waitFirstLayout(); 119 120 mActivityRule.runOnUiThread(new Runnable() { 121 @Override 122 public void run() { 123 int childDimension; 124 int recyclerViewDimensionMinusPadding; 125 if (orientation == VERTICAL) { 126 childDimension = mRecyclerView.getChildAt(0).getHeight(); 127 recyclerViewDimensionMinusPadding = mRecyclerView.getHeight() 128 - mRecyclerView.getPaddingTop() 129 - mRecyclerView.getPaddingBottom(); 130 } else { 131 childDimension = mRecyclerView.getChildAt(0).getWidth(); 132 recyclerViewDimensionMinusPadding = mRecyclerView.getWidth() 133 - mRecyclerView.getPaddingLeft() 134 - mRecyclerView.getPaddingRight(); 135 } 136 assertThat(childDimension, equalTo(recyclerViewDimensionMinusPadding)); 137 } 138 }); 139 } 140 141 @Test forceLayoutOnDetach()142 public void forceLayoutOnDetach() throws Throwable { 143 setupByConfig(new Config(VERTICAL, false, 3, GAP_HANDLING_MOVE_ITEMS_BETWEEN_SPANS)); 144 waitFirstLayout(); 145 assertFalse("Assumption check", mRecyclerView.isLayoutRequested()); 146 mActivityRule.runOnUiThread(new Runnable() { 147 @Override 148 public void run() { 149 mLayoutManager.onDetachedFromWindow(mRecyclerView, mRecyclerView.mRecycler); 150 assertTrue(mRecyclerView.isLayoutRequested()); 151 } 152 }); 153 } 154 155 @Test areAllStartsTheSame()156 public void areAllStartsTheSame() throws Throwable { 157 setupByConfig(new Config(VERTICAL, false, 3, GAP_HANDLING_NONE).itemCount(300)); 158 waitFirstLayout(); 159 smoothScrollToPosition(100); 160 mLayoutManager.expectLayouts(1); 161 mAdapter.deleteAndNotify(0, 2); 162 mLayoutManager.waitForLayout(2000); 163 smoothScrollToPosition(0); 164 assertFalse("all starts should not be the same", mLayoutManager.areAllStartsEqual()); 165 } 166 167 @Test areAllEndsTheSame()168 public void areAllEndsTheSame() throws Throwable { 169 setupByConfig(new Config(VERTICAL, true, 3, GAP_HANDLING_NONE).itemCount(300)); 170 waitFirstLayout(); 171 smoothScrollToPosition(100); 172 mLayoutManager.expectLayouts(1); 173 mAdapter.deleteAndNotify(0, 2); 174 mLayoutManager.waitForLayout(2); 175 smoothScrollToPosition(0); 176 assertFalse("all ends should not be the same", mLayoutManager.areAllEndsEqual()); 177 } 178 179 @Test getPositionsBeforeInitialization()180 public void getPositionsBeforeInitialization() throws Throwable { 181 setupByConfig(new Config(VERTICAL, false, 3, GAP_HANDLING_MOVE_ITEMS_BETWEEN_SPANS)); 182 int[] positions = mLayoutManager.findFirstCompletelyVisibleItemPositions(null); 183 MatcherAssert.assertThat(positions, 184 CoreMatchers.is(new int[]{RecyclerView.NO_POSITION, RecyclerView.NO_POSITION, 185 RecyclerView.NO_POSITION})); 186 } 187 188 @Test findLastInUnevenDistribution()189 public void findLastInUnevenDistribution() throws Throwable { 190 setupByConfig(new Config(VERTICAL, false, 2, GAP_HANDLING_MOVE_ITEMS_BETWEEN_SPANS) 191 .itemCount(5)); 192 mAdapter.mOnBindCallback = new OnBindCallback() { 193 @Override 194 void onBoundItem(TestViewHolder vh, int position) { 195 LayoutParams lp = (LayoutParams) vh.itemView.getLayoutParams(); 196 if (position == 1) { 197 lp.height = mRecyclerView.getHeight() - 10; 198 } else { 199 lp.height = 5; 200 } 201 vh.itemView.setMinimumHeight(0); 202 } 203 }; 204 waitFirstLayout(); 205 int[] into = new int[2]; 206 mLayoutManager.findFirstCompletelyVisibleItemPositions(into); 207 assertEquals("first completely visible item from span 0 should be 0", 0, into[0]); 208 assertEquals("first completely visible item from span 1 should be 1", 1, into[1]); 209 mLayoutManager.findLastCompletelyVisibleItemPositions(into); 210 assertEquals("last completely visible item from span 0 should be 4", 4, into[0]); 211 assertEquals("last completely visible item from span 1 should be 1", 1, into[1]); 212 assertEquals("first fully visible child should be at position", 213 0, mRecyclerView.getChildViewHolder(mLayoutManager. 214 findFirstVisibleItemClosestToStart(true)).getPosition()); 215 assertEquals("last fully visible child should be at position", 216 4, mRecyclerView.getChildViewHolder(mLayoutManager. 217 findFirstVisibleItemClosestToEnd(true)).getPosition()); 218 219 assertEquals("first visible child should be at position", 220 0, mRecyclerView.getChildViewHolder(mLayoutManager. 221 findFirstVisibleItemClosestToStart(false)).getPosition()); 222 assertEquals("last visible child should be at position", 223 4, mRecyclerView.getChildViewHolder(mLayoutManager. 224 findFirstVisibleItemClosestToEnd(false)).getPosition()); 225 226 } 227 228 @Test customWidthInHorizontal()229 public void customWidthInHorizontal() throws Throwable { 230 customSizeInScrollDirectionTest( 231 new Config(HORIZONTAL, false, 3, GAP_HANDLING_MOVE_ITEMS_BETWEEN_SPANS)); 232 } 233 234 @Test customHeightInVertical()235 public void customHeightInVertical() throws Throwable { 236 customSizeInScrollDirectionTest( 237 new Config(VERTICAL, false, 3, GAP_HANDLING_MOVE_ITEMS_BETWEEN_SPANS)); 238 } 239 customSizeInScrollDirectionTest(final Config config)240 public void customSizeInScrollDirectionTest(final Config config) throws Throwable { 241 setupByConfig(config); 242 final Map<View, Integer> sizeMap = new HashMap<View, Integer>(); 243 mAdapter.mOnBindCallback = new OnBindCallback() { 244 @Override 245 void onBoundItem(TestViewHolder vh, int position) { 246 final ViewGroup.LayoutParams layoutParams = vh.itemView.getLayoutParams(); 247 final int size = 1 + position * 5; 248 if (config.mOrientation == HORIZONTAL) { 249 layoutParams.width = size; 250 } else { 251 layoutParams.height = size; 252 } 253 sizeMap.put(vh.itemView, size); 254 if (position == 3) { 255 getLp(vh.itemView).setFullSpan(true); 256 } 257 } 258 259 @Override 260 boolean assignRandomSize() { 261 return false; 262 } 263 }; 264 waitFirstLayout(); 265 assertTrue("[Assumption check] some views should be laid out", sizeMap.size() > 0); 266 for (int i = 0; i < mRecyclerView.getChildCount(); i++) { 267 View child = mRecyclerView.getChildAt(i); 268 final int size = config.mOrientation == HORIZONTAL ? child.getWidth() 269 : child.getHeight(); 270 assertEquals("child " + i + " should have the size specified in its layout params", 271 sizeMap.get(child).intValue(), size); 272 } 273 checkForMainThreadException(); 274 } 275 276 @Test gapHandlingWhenItemMovesToTop()277 public void gapHandlingWhenItemMovesToTop() throws Throwable { 278 gapHandlingWhenItemMovesToTopTest(); 279 } 280 281 @Test gapHandlingWhenItemMovesToTopWithFullSpan()282 public void gapHandlingWhenItemMovesToTopWithFullSpan() throws Throwable { 283 gapHandlingWhenItemMovesToTopTest(0); 284 } 285 286 @Test gapHandlingWhenItemMovesToTopWithFullSpan2()287 public void gapHandlingWhenItemMovesToTopWithFullSpan2() throws Throwable { 288 gapHandlingWhenItemMovesToTopTest(1); 289 } 290 gapHandlingWhenItemMovesToTopTest(int... fullSpanIndices)291 public void gapHandlingWhenItemMovesToTopTest(int... fullSpanIndices) throws Throwable { 292 Config config = new Config(VERTICAL, false, 2, GAP_HANDLING_MOVE_ITEMS_BETWEEN_SPANS); 293 config.itemCount(3); 294 setupByConfig(config); 295 mAdapter.mOnBindCallback = new OnBindCallback() { 296 @Override 297 void onBoundItem(TestViewHolder vh, int position) { 298 } 299 300 @Override 301 boolean assignRandomSize() { 302 return false; 303 } 304 }; 305 for (int i : fullSpanIndices) { 306 mAdapter.mFullSpanItems.add(i); 307 } 308 waitFirstLayout(); 309 mLayoutManager.expectLayouts(1); 310 mAdapter.moveItem(1, 0, true); 311 mLayoutManager.waitForLayout(2); 312 final Map<Item, Rect> desiredPositions = mLayoutManager.collectChildCoordinates(); 313 // move back. 314 mLayoutManager.expectLayouts(1); 315 mAdapter.moveItem(0, 1, true); 316 mLayoutManager.waitForLayout(2); 317 mLayoutManager.expectLayouts(2); 318 mAdapter.moveAndNotify(1, 0); 319 mLayoutManager.waitForLayout(2); 320 Thread.sleep(1000); 321 getInstrumentation().waitForIdleSync(); 322 checkForMainThreadException(); 323 // item should be positioned properly 324 assertRectSetsEqual("final position after a move", desiredPositions, 325 mLayoutManager.collectChildCoordinates()); 326 327 } 328 329 @Test focusSearchFailureUp()330 public void focusSearchFailureUp() throws Throwable { 331 focusSearchFailure(false); 332 } 333 334 @Test focusSearchFailureDown()335 public void focusSearchFailureDown() throws Throwable { 336 focusSearchFailure(true); 337 } 338 339 @Test focusSearchFailureFromSubChild()340 public void focusSearchFailureFromSubChild() throws Throwable { 341 setupByConfig(new Config(VERTICAL, false, 3, GAP_HANDLING_MOVE_ITEMS_BETWEEN_SPANS), 342 new GridTestAdapter(1000, VERTICAL) { 343 344 @Override 345 public @NonNull TestViewHolder onCreateViewHolder( 346 @NonNull ViewGroup parent, int viewType) { 347 FrameLayout fl = new FrameLayout(parent.getContext()); 348 EditText editText = new EditText(parent.getContext()); 349 fl.addView(editText); 350 editText.setEllipsize(TextUtils.TruncateAt.END); 351 return new TestViewHolder(fl); 352 } 353 354 @Override 355 @SuppressWarnings("deprecated") // using this for kitkat tests 356 public void onBindViewHolder(@NonNull TestViewHolder holder, int position) { 357 Item item = mItems.get(position); 358 holder.mBoundItem = item; 359 ((EditText) ((FrameLayout) holder.itemView).getChildAt(0)).setText( 360 item.getDisplayText()); 361 // Good to have colors for debugging 362 StateListDrawable stl = new StateListDrawable(); 363 stl.addState(new int[]{android.R.attr.state_focused}, 364 new ColorDrawable(Color.RED)); 365 stl.addState(StateSet.WILD_CARD, new ColorDrawable(Color.BLUE)); 366 holder.itemView.setBackgroundDrawable(stl); 367 if (mOnBindCallback != null) { 368 mOnBindCallback.onBoundItem(holder, position); 369 } 370 } 371 }); 372 mLayoutManager.expectLayouts(1); 373 setRecyclerView(mRecyclerView); 374 mLayoutManager.waitForLayout(10); 375 getInstrumentation().waitForIdleSync(); 376 ViewGroup lastChild = (ViewGroup) mRecyclerView.getChildAt( 377 mRecyclerView.getChildCount() - 1); 378 RecyclerView.ViewHolder lastViewHolder = mRecyclerView.getChildViewHolder(lastChild); 379 View subChildToFocus = lastChild.getChildAt(0); 380 requestFocus(subChildToFocus, true); 381 assertThat("Assumption check", subChildToFocus.isFocused(), CoreMatchers.is(true)); 382 focusSearch(subChildToFocus, View.FOCUS_FORWARD); 383 waitForIdleScroll(mRecyclerView); 384 checkForMainThreadException(); 385 View focusedChild = mRecyclerView.getFocusedChild(); 386 if (focusedChild == subChildToFocus.getParent()) { 387 focusSearch(focusedChild, View.FOCUS_FORWARD); 388 waitForIdleScroll(mRecyclerView); 389 focusedChild = mRecyclerView.getFocusedChild(); 390 } 391 RecyclerView.ViewHolder containingViewHolder = mRecyclerView.findContainingViewHolder( 392 focusedChild); 393 assertTrue("new focused view should have a larger position " 394 + lastViewHolder.getAbsoluteAdapterPosition() + " vs " 395 + containingViewHolder.getAbsoluteAdapterPosition(), 396 lastViewHolder.getAbsoluteAdapterPosition() 397 < containingViewHolder.getAbsoluteAdapterPosition()); 398 } 399 focusSearchFailure(boolean scrollDown)400 public void focusSearchFailure(boolean scrollDown) throws Throwable { 401 int focusDir = scrollDown ? View.FOCUS_DOWN : View.FOCUS_UP; 402 setupByConfig(new Config(VERTICAL, !scrollDown, 3, GAP_HANDLING_MOVE_ITEMS_BETWEEN_SPANS) 403 , new GridTestAdapter(31, 1) { 404 RecyclerView mAttachedRv; 405 406 @Override 407 @SuppressWarnings("deprecated") // using this for kitkat tests 408 public TestViewHolder onCreateViewHolder(@NonNull ViewGroup parent, 409 int viewType) { 410 TestViewHolder testViewHolder = super.onCreateViewHolder(parent, viewType); 411 testViewHolder.itemView.setFocusable(true); 412 testViewHolder.itemView.setFocusableInTouchMode(true); 413 // Good to have colors for debugging 414 StateListDrawable stl = new StateListDrawable(); 415 stl.addState(new int[]{android.R.attr.state_focused}, 416 new ColorDrawable(Color.RED)); 417 stl.addState(StateSet.WILD_CARD, new ColorDrawable(Color.BLUE)); 418 testViewHolder.itemView.setBackgroundDrawable(stl); 419 return testViewHolder; 420 } 421 422 @Override 423 public void onAttachedToRecyclerView(RecyclerView recyclerView) { 424 mAttachedRv = recyclerView; 425 } 426 427 @Override 428 public void onBindViewHolder(@NonNull TestViewHolder holder, 429 int position) { 430 super.onBindViewHolder(holder, position); 431 holder.itemView.getLayoutParams().height = mAttachedRv.getHeight() / 3; 432 } 433 }); 434 /** 435 * 0 1 2 436 * 3 4 5 437 * 6 7 8 438 * 9 10 11 439 * 12 13 14 440 * 15 16 17 441 * 18 18 18 442 * 19 443 * 20 20 20 444 * 21 22 445 * 23 23 23 446 * 24 25 26 447 * 27 28 29 448 * 30 449 */ 450 mAdapter.mFullSpanItems.add(18); 451 mAdapter.mFullSpanItems.add(20); 452 mAdapter.mFullSpanItems.add(23); 453 waitFirstLayout(); 454 View viewToFocus = mRecyclerView.findViewHolderForAdapterPosition(1).itemView; 455 assertTrue(requestFocus(viewToFocus, true)); 456 assertSame(viewToFocus, mRecyclerView.getFocusedChild()); 457 int pos = 1; 458 View focusedView = viewToFocus; 459 while (pos < 16) { 460 focusSearchAndWaitForScroll(focusedView, focusDir); 461 focusedView = mRecyclerView.getFocusedChild(); 462 assertEquals(pos + 3, 463 mRecyclerView.getChildViewHolder( 464 focusedView).getAbsoluteAdapterPosition()); 465 pos += 3; 466 } 467 for (int i : new int[]{18, 19, 20, 21, 23, 24}) { 468 focusSearchAndWaitForScroll(focusedView, focusDir); 469 focusedView = mRecyclerView.getFocusedChild(); 470 assertEquals(i, mRecyclerView.getChildViewHolder( 471 focusedView).getAbsoluteAdapterPosition()); 472 } 473 // now move right 474 focusSearch(focusedView, View.FOCUS_RIGHT); 475 waitForIdleScroll(mRecyclerView); 476 focusedView = mRecyclerView.getFocusedChild(); 477 assertEquals(25, 478 mRecyclerView.getChildViewHolder(focusedView).getAbsoluteAdapterPosition()); 479 for (int i : new int[]{28, 30}) { 480 focusSearchAndWaitForScroll(focusedView, focusDir); 481 focusedView = mRecyclerView.getFocusedChild(); 482 assertEquals(i, mRecyclerView.getChildViewHolder( 483 focusedView).getAbsoluteAdapterPosition()); 484 } 485 } 486 focusSearchAndWaitForScroll(View focused, int dir)487 private void focusSearchAndWaitForScroll(View focused, int dir) throws Throwable { 488 focusSearch(focused, dir); 489 waitForIdleScroll(mRecyclerView); 490 } 491 492 @Test topUnfocusableViewsVisibility()493 public void topUnfocusableViewsVisibility() throws Throwable { 494 // The maximum number of rows that can be fully in-bounds of RV. 495 final int visibleRowCount = 5; 496 final int spanCount = 3; 497 final int lastFocusableIndex = 6; 498 499 setupByConfig(new Config(VERTICAL, true, spanCount, GAP_HANDLING_MOVE_ITEMS_BETWEEN_SPANS), 500 new GridTestAdapter(18, 1) { 501 RecyclerView mAttachedRv; 502 503 @Override 504 @SuppressWarnings("deprecated") // using this for kitkat tests 505 public TestViewHolder onCreateViewHolder(@NonNull ViewGroup parent, 506 int viewType) { 507 TestViewHolder testViewHolder = super.onCreateViewHolder(parent, viewType); 508 testViewHolder.itemView.setFocusable(true); 509 testViewHolder.itemView.setFocusableInTouchMode(true); 510 // Good to have colors for debugging 511 StateListDrawable stl = new StateListDrawable(); 512 stl.addState(new int[]{android.R.attr.state_focused}, 513 new ColorDrawable(Color.RED)); 514 stl.addState(StateSet.WILD_CARD, new ColorDrawable(Color.BLUE)); 515 testViewHolder.itemView.setBackgroundDrawable(stl); 516 return testViewHolder; 517 } 518 519 @Override 520 public void onAttachedToRecyclerView(RecyclerView recyclerView) { 521 mAttachedRv = recyclerView; 522 } 523 524 @Override 525 public void onBindViewHolder(@NonNull TestViewHolder holder, 526 int position) { 527 super.onBindViewHolder(holder, position); 528 RecyclerView.LayoutParams lp = (RecyclerView.LayoutParams) holder.itemView 529 .getLayoutParams(); 530 if (position <= lastFocusableIndex) { 531 holder.itemView.setFocusable(true); 532 holder.itemView.setFocusableInTouchMode(true); 533 } else { 534 holder.itemView.setFocusable(false); 535 holder.itemView.setFocusableInTouchMode(false); 536 } 537 lp.height = mAttachedRv.getHeight() / visibleRowCount; 538 lp.topMargin = 0; 539 lp.leftMargin = 0; 540 lp.rightMargin = 0; 541 lp.bottomMargin = 0; 542 if (position == 11) { 543 lp.bottomMargin = 9; 544 } 545 } 546 }); 547 548 /** 549 * 550 * 15 16 17 551 * 12 13 14 552 * 11 11 11 553 * 9 10 554 * 8 8 8 555 * 7 556 * 6 6 6 557 * 3 4 5 558 * 0 1 2 559 */ 560 mAdapter.mFullSpanItems.add(6); 561 mAdapter.mFullSpanItems.add(8); 562 mAdapter.mFullSpanItems.add(11); 563 waitFirstLayout(); 564 565 566 // adapter position of the currently focused item. 567 int focusIndex = 1; 568 RecyclerView.ViewHolder toFocus = mRecyclerView.findViewHolderForAdapterPosition( 569 focusIndex); 570 View viewToFocus = toFocus.itemView; 571 assertTrue(requestFocus(viewToFocus, true)); 572 assertSame(viewToFocus, mRecyclerView.getFocusedChild()); 573 574 // The VH of the unfocusable item that just became fully visible after focusSearch. 575 RecyclerView.ViewHolder toVisible = null; 576 577 View focusedView = viewToFocus; 578 int actualFocusIndex = -1; 579 // First, scroll until the last focusable row. 580 for (int i : new int[]{4, 6}) { 581 focusSearchAndWaitForScroll(focusedView, View.FOCUS_UP); 582 focusedView = mRecyclerView.getFocusedChild(); 583 actualFocusIndex = mRecyclerView.getChildViewHolder( 584 focusedView).getAbsoluteAdapterPosition(); 585 assertEquals("Focused view should be at adapter position " + i + " whereas it's at " 586 + actualFocusIndex, i, actualFocusIndex); 587 } 588 589 // Further scroll up in order to make the unfocusable rows visible. This process should 590 // continue until the currently focused item is still visible. The focused item should not 591 // change in this loop. 592 for (int i : new int[]{9, 11, 11, 11}) { 593 focusSearchAndWaitForScroll(focusedView, View.FOCUS_UP); 594 focusedView = mRecyclerView.getFocusedChild(); 595 actualFocusIndex = 596 mRecyclerView.getChildViewHolder( 597 focusedView).getAbsoluteAdapterPosition(); 598 toVisible = mRecyclerView.findViewHolderForAdapterPosition(i); 599 600 assertEquals("Focused view should not be changed, whereas it's now at " 601 + actualFocusIndex, 6, actualFocusIndex); 602 assertTrue("Focused child should be at least partially visible.", 603 isViewPartiallyInBound(mRecyclerView, focusedView)); 604 assertTrue("Child view at adapter pos " + i + " should be fully visible.", 605 isViewFullyInBound(mRecyclerView, toVisible.itemView)); 606 } 607 } 608 609 @Test bottomUnfocusableViewsVisibility()610 public void bottomUnfocusableViewsVisibility() throws Throwable { 611 // The maximum number of rows that can be fully in-bounds of RV. 612 final int visibleRowCount = 5; 613 final int spanCount = 3; 614 final int lastFocusableIndex = 6; 615 616 setupByConfig(new Config(VERTICAL, false, spanCount, GAP_HANDLING_MOVE_ITEMS_BETWEEN_SPANS), 617 new GridTestAdapter(18, 1) { 618 RecyclerView mAttachedRv; 619 620 @Override 621 @SuppressWarnings("deprecated") // using this for kitkat tests 622 public TestViewHolder onCreateViewHolder(@NonNull ViewGroup parent, 623 int viewType) { 624 TestViewHolder testViewHolder = super.onCreateViewHolder(parent, viewType); 625 testViewHolder.itemView.setFocusable(true); 626 testViewHolder.itemView.setFocusableInTouchMode(true); 627 // Good to have colors for debugging 628 StateListDrawable stl = new StateListDrawable(); 629 stl.addState(new int[]{android.R.attr.state_focused}, 630 new ColorDrawable(Color.RED)); 631 stl.addState(StateSet.WILD_CARD, new ColorDrawable(Color.BLUE)); 632 testViewHolder.itemView.setBackgroundDrawable(stl); 633 return testViewHolder; 634 } 635 636 @Override 637 public void onAttachedToRecyclerView(RecyclerView recyclerView) { 638 mAttachedRv = recyclerView; 639 } 640 641 @Override 642 public void onBindViewHolder(@NonNull TestViewHolder holder, 643 int position) { 644 super.onBindViewHolder(holder, position); 645 RecyclerView.LayoutParams lp = (RecyclerView.LayoutParams) holder.itemView 646 .getLayoutParams(); 647 if (position <= lastFocusableIndex) { 648 holder.itemView.setFocusable(true); 649 holder.itemView.setFocusableInTouchMode(true); 650 } else { 651 holder.itemView.setFocusable(false); 652 holder.itemView.setFocusableInTouchMode(false); 653 } 654 lp.height = mAttachedRv.getHeight() / visibleRowCount; 655 lp.topMargin = 0; 656 lp.leftMargin = 0; 657 lp.rightMargin = 0; 658 lp.bottomMargin = 0; 659 if (position == 11) { 660 lp.topMargin = 9; 661 } 662 } 663 }); 664 665 /** 666 * 0 1 2 667 * 3 4 5 668 * 6 6 6 669 * 7 670 * 8 8 8 671 * 9 10 672 * 11 11 11 673 * 12 13 14 674 * 15 16 17 675 */ 676 mAdapter.mFullSpanItems.add(6); 677 mAdapter.mFullSpanItems.add(8); 678 mAdapter.mFullSpanItems.add(11); 679 waitFirstLayout(); 680 681 682 // adapter position of the currently focused item. 683 int focusIndex = 1; 684 RecyclerView.ViewHolder toFocus = mRecyclerView.findViewHolderForAdapterPosition( 685 focusIndex); 686 View viewToFocus = toFocus.itemView; 687 assertTrue(requestFocus(viewToFocus, true)); 688 assertSame(viewToFocus, mRecyclerView.getFocusedChild()); 689 690 // The VH of the unfocusable item that just became fully visible after focusSearch. 691 RecyclerView.ViewHolder toVisible = null; 692 693 View focusedView = viewToFocus; 694 int actualFocusIndex = -1; 695 // First, scroll until the last focusable row. 696 for (int i : new int[]{4, 6}) { 697 focusSearchAndWaitForScroll(focusedView, View.FOCUS_DOWN); 698 focusedView = mRecyclerView.getFocusedChild(); 699 actualFocusIndex = mRecyclerView.getChildViewHolder( 700 focusedView).getAbsoluteAdapterPosition(); 701 assertEquals("Focused view should be at adapter position " + i + " whereas it's at " 702 + actualFocusIndex, i, actualFocusIndex); 703 } 704 705 // Further scroll down in order to make the unfocusable rows visible. This process should 706 // continue until the currently focused item is still visible. The focused item should not 707 // change in this loop. 708 for (int i : new int[]{9, 11, 11, 11}) { 709 focusSearchAndWaitForScroll(focusedView, View.FOCUS_DOWN); 710 focusedView = mRecyclerView.getFocusedChild(); 711 actualFocusIndex = mRecyclerView.getChildViewHolder( 712 focusedView).getAbsoluteAdapterPosition(); 713 toVisible = mRecyclerView.findViewHolderForAdapterPosition(i); 714 715 assertEquals("Focused view should not be changed, whereas it's now at " 716 + actualFocusIndex, 6, actualFocusIndex); 717 assertTrue("Focused child should be at least partially visible.", 718 isViewPartiallyInBound(mRecyclerView, focusedView)); 719 assertTrue("Child view at adapter pos " + i + " should be fully visible.", 720 isViewFullyInBound(mRecyclerView, toVisible.itemView)); 721 } 722 } 723 724 @Test leftUnfocusableViewsVisibility()725 public void leftUnfocusableViewsVisibility() throws Throwable { 726 // The maximum number of columns that can be fully in-bounds of RV. 727 final int visibleColCount = 5; 728 final int spanCount = 3; 729 final int lastFocusableIndex = 6; 730 final int childWidth = 200; 731 final int childHeight = ViewGroup.LayoutParams.WRAP_CONTENT; 732 final int parentWidth = childWidth * visibleColCount; 733 final int parentHeight = 1000; 734 735 // Reverse layout so that views are placed from right to left. 736 setupByConfig(new Config(HORIZONTAL, true, spanCount, 737 GAP_HANDLING_MOVE_ITEMS_BETWEEN_SPANS), 738 new GridTestAdapter(18, 1) { 739 740 @Override 741 @SuppressWarnings("deprecated") // using this for kitkat tests 742 public TestViewHolder onCreateViewHolder(@NonNull ViewGroup parent, 743 int viewType) { 744 TestViewHolder testViewHolder = super.onCreateViewHolder(parent, viewType); 745 testViewHolder.itemView.setFocusable(true); 746 testViewHolder.itemView.setFocusableInTouchMode(true); 747 // Good to have colors for debugging 748 StateListDrawable stl = new StateListDrawable(); 749 stl.addState(new int[]{android.R.attr.state_focused}, 750 new ColorDrawable(Color.RED)); 751 stl.addState(StateSet.WILD_CARD, new ColorDrawable(Color.BLUE)); 752 testViewHolder.itemView.setBackgroundDrawable(stl); 753 return testViewHolder; 754 } 755 756 @Override 757 public void onBindViewHolder(@NonNull TestViewHolder holder, 758 int position) { 759 super.onBindViewHolder(holder, position); 760 if (position <= lastFocusableIndex) { 761 holder.itemView.setFocusable(true); 762 holder.itemView.setFocusableInTouchMode(true); 763 } else { 764 holder.itemView.setFocusable(false); 765 holder.itemView.setFocusableInTouchMode(false); 766 } 767 768 StaggeredGridLayoutManager.LayoutParams oldLp = 769 (StaggeredGridLayoutManager.LayoutParams) 770 holder.itemView.getLayoutParams(); 771 772 StaggeredGridLayoutManager.LayoutParams newLp = 773 new StaggeredGridLayoutManager.LayoutParams( 774 childWidth, 775 childHeight); 776 777 newLp.setFullSpan(oldLp.mFullSpan); 778 newLp.topMargin = 0; 779 newLp.leftMargin = 0; 780 newLp.rightMargin = 0; 781 newLp.bottomMargin = 0; 782 if (position == 11) { 783 newLp.leftMargin = 9; 784 } 785 786 holder.itemView.setLayoutParams(newLp); 787 } 788 }); 789 790 /** 791 * 15 12 11 9 8 7 6 3 0 792 * 16 13 11 10 8 6 4 1 793 * 17 14 11 8 6 5 2 794 */ 795 mAdapter.mFullSpanItems.add(6); 796 mAdapter.mFullSpanItems.add(8); 797 mAdapter.mFullSpanItems.add(11); 798 mRecyclerView.setLayoutParams(new ViewGroup.LayoutParams(parentWidth, parentHeight)); 799 waitFirstLayout(); 800 801 // adapter position of the currently focused item. 802 int focusIndex = 1; 803 RecyclerView.ViewHolder toFocus = mRecyclerView.findViewHolderForAdapterPosition( 804 focusIndex); 805 View viewToFocus = toFocus.itemView; 806 assertTrue(requestFocus(viewToFocus, true)); 807 assertSame(viewToFocus, mRecyclerView.getFocusedChild()); 808 809 // The VH of the unfocusable item that just became fully visible after focusSearch. 810 RecyclerView.ViewHolder toVisible = null; 811 812 View focusedView = viewToFocus; 813 int actualFocusIndex = -1; 814 // First, scroll until the last focusable column. 815 for (int i : new int[]{4, 6}) { 816 focusSearchAndWaitForScroll(focusedView, View.FOCUS_LEFT); 817 focusedView = mRecyclerView.getFocusedChild(); 818 actualFocusIndex = mRecyclerView.getChildViewHolder( 819 focusedView).getAbsoluteAdapterPosition(); 820 assertEquals("Focused view should be at adapter position " + i + " whereas it's at " 821 + actualFocusIndex, i, actualFocusIndex); 822 } 823 824 // Further scroll left in order to make the unfocusable columns visible. This process should 825 // continue until the currently focused item is still visible. The focused item should not 826 // change in this loop. 827 for (int i : new int[]{9, 11, 11, 11}) { 828 focusSearchAndWaitForScroll(focusedView, View.FOCUS_LEFT); 829 focusedView = mRecyclerView.getFocusedChild(); 830 actualFocusIndex = mRecyclerView.getChildViewHolder( 831 focusedView).getAbsoluteAdapterPosition(); 832 toVisible = mRecyclerView.findViewHolderForAdapterPosition(i); 833 834 assertEquals("Focused view should not be changed, whereas it's now at " 835 + actualFocusIndex, 6, actualFocusIndex); 836 assertTrue("Focused child should be at least partially visible.", 837 isViewPartiallyInBound(mRecyclerView, focusedView)); 838 assertTrue("Child view at adapter pos " + i + " should be fully visible.", 839 isViewFullyInBound(mRecyclerView, toVisible.itemView)); 840 } 841 } 842 843 @Test rightUnfocusableViewsVisibility()844 public void rightUnfocusableViewsVisibility() throws Throwable { 845 // The maximum number of columns that can be fully in-bounds of RV. 846 final int visibleColCount = 5; 847 final int spanCount = 3; 848 final int lastFocusableIndex = 6; 849 final int childWidth = 200; 850 final int childHeight = ViewGroup.LayoutParams.WRAP_CONTENT; 851 final int parentWidth = childWidth * visibleColCount; 852 final int parentHeight = 1000; 853 854 setupByConfig(new Config(HORIZONTAL, false, spanCount, 855 GAP_HANDLING_MOVE_ITEMS_BETWEEN_SPANS), 856 new GridTestAdapter(18, 1) { 857 858 @Override 859 @SuppressWarnings("deprecated") // using this for kitkat tests 860 public TestViewHolder onCreateViewHolder(@NonNull ViewGroup parent, 861 int viewType) { 862 TestViewHolder testViewHolder = super.onCreateViewHolder(parent, viewType); 863 testViewHolder.itemView.setFocusable(true); 864 testViewHolder.itemView.setFocusableInTouchMode(true); 865 // Good to have colors for debugging 866 StateListDrawable stl = new StateListDrawable(); 867 stl.addState(new int[]{android.R.attr.state_focused}, 868 new ColorDrawable(Color.RED)); 869 stl.addState(StateSet.WILD_CARD, new ColorDrawable(Color.BLUE)); 870 testViewHolder.itemView.setBackgroundDrawable(stl); 871 return testViewHolder; 872 } 873 874 @Override 875 public void onBindViewHolder(@NonNull TestViewHolder holder, 876 int position) { 877 super.onBindViewHolder(holder, position); 878 879 if (position <= lastFocusableIndex) { 880 holder.itemView.setFocusable(true); 881 holder.itemView.setFocusableInTouchMode(true); 882 } else { 883 holder.itemView.setFocusable(false); 884 holder.itemView.setFocusableInTouchMode(false); 885 } 886 887 StaggeredGridLayoutManager.LayoutParams oldLp = 888 (StaggeredGridLayoutManager.LayoutParams) 889 holder.itemView.getLayoutParams(); 890 891 StaggeredGridLayoutManager.LayoutParams newLp = 892 new StaggeredGridLayoutManager.LayoutParams( 893 childWidth, 894 childHeight); 895 896 newLp.setFullSpan(oldLp.mFullSpan); 897 newLp.topMargin = 0; 898 newLp.leftMargin = 0; 899 newLp.rightMargin = 0; 900 newLp.bottomMargin = 0; 901 if (position == 11) { 902 newLp.leftMargin = 9; 903 } 904 905 holder.itemView.setLayoutParams(newLp); 906 } 907 }); 908 909 /** 910 * 0 3 6 7 8 9 11 12 15 911 * 1 4 6 8 10 11 13 16 912 * 2 5 6 8 11 14 17 913 */ 914 mAdapter.mFullSpanItems.add(6); 915 mAdapter.mFullSpanItems.add(8); 916 mAdapter.mFullSpanItems.add(11); 917 mRecyclerView.setLayoutParams(new ViewGroup.LayoutParams(parentWidth, parentHeight)); 918 waitFirstLayout(); 919 920 // adapter position of the currently focused item. 921 int focusIndex = 1; 922 RecyclerView.ViewHolder toFocus = mRecyclerView.findViewHolderForAdapterPosition( 923 focusIndex); 924 View viewToFocus = toFocus.itemView; 925 assertTrue(requestFocus(viewToFocus, true)); 926 assertSame(viewToFocus, mRecyclerView.getFocusedChild()); 927 928 // The VH of the unfocusable item that just became fully visible after focusSearch. 929 RecyclerView.ViewHolder toVisible = null; 930 931 View focusedView = viewToFocus; 932 int actualFocusIndex = -1; 933 // First, scroll until the last focusable column. 934 for (int i : new int[]{4, 6}) { 935 focusSearchAndWaitForScroll(focusedView, View.FOCUS_RIGHT); 936 focusedView = mRecyclerView.getFocusedChild(); 937 actualFocusIndex = mRecyclerView.getChildViewHolder( 938 focusedView).getAbsoluteAdapterPosition(); 939 assertEquals("Focused view should be at adapter position " + i + " whereas it's at " 940 + actualFocusIndex, i, actualFocusIndex); 941 } 942 943 // Further scroll right in order to make the unfocusable rows visible. This process should 944 // continue until the currently focused item is still visible. The focused item should not 945 // change in this loop. 946 for (int i : new int[]{9, 11, 11, 11}) { 947 focusSearchAndWaitForScroll(focusedView, View.FOCUS_RIGHT); 948 focusedView = mRecyclerView.getFocusedChild(); 949 actualFocusIndex = mRecyclerView.getChildViewHolder( 950 focusedView).getAbsoluteAdapterPosition(); 951 toVisible = mRecyclerView.findViewHolderForAdapterPosition(i); 952 953 assertEquals("Focused view should not be changed, whereas it's now at " 954 + actualFocusIndex, 6, actualFocusIndex); 955 assertTrue("Focused child should be at least partially visible.", 956 isViewPartiallyInBound(mRecyclerView, focusedView)); 957 assertTrue("Child view at adapter pos " + i + " should be fully visible.", 958 isViewFullyInBound(mRecyclerView, toVisible.itemView)); 959 } 960 } 961 962 @Test scrollToPositionWithPredictive()963 public void scrollToPositionWithPredictive() throws Throwable { 964 scrollToPositionWithPredictive(0, LinearLayoutManager.INVALID_OFFSET); 965 removeRecyclerView(); 966 scrollToPositionWithPredictive(Config.DEFAULT_ITEM_COUNT / 2, 967 LinearLayoutManager.INVALID_OFFSET); 968 removeRecyclerView(); 969 scrollToPositionWithPredictive(9, 20); 970 removeRecyclerView(); 971 scrollToPositionWithPredictive(Config.DEFAULT_ITEM_COUNT / 2, 10); 972 973 } 974 scrollToPositionWithPredictive(final int scrollPosition, final int scrollOffset)975 public void scrollToPositionWithPredictive(final int scrollPosition, final int scrollOffset) 976 throws Throwable { 977 setupByConfig(new Config(StaggeredGridLayoutManager.VERTICAL, 978 false, 3, StaggeredGridLayoutManager.GAP_HANDLING_NONE)); 979 waitFirstLayout(); 980 mLayoutManager.mOnLayoutListener = new OnLayoutListener() { 981 @Override 982 void after(RecyclerView.Recycler recycler, RecyclerView.State state) { 983 RecyclerView rv = mLayoutManager.mRecyclerView; 984 if (state.isPreLayout()) { 985 assertEquals("pending scroll position should still be pending", 986 scrollPosition, mLayoutManager.mPendingScrollPosition); 987 if (scrollOffset != LinearLayoutManager.INVALID_OFFSET) { 988 assertEquals("pending scroll position offset should still be pending", 989 scrollOffset, mLayoutManager.mPendingScrollPositionOffset); 990 } 991 } else { 992 RecyclerView.ViewHolder vh = rv.findViewHolderForLayoutPosition(scrollPosition); 993 assertNotNull("scroll to position should work", vh); 994 if (scrollOffset != LinearLayoutManager.INVALID_OFFSET) { 995 assertEquals("scroll offset should be applied properly", 996 mLayoutManager.getPaddingTop() + scrollOffset 997 + ((RecyclerView.LayoutParams) vh.itemView 998 .getLayoutParams()).topMargin, 999 mLayoutManager.getDecoratedTop(vh.itemView)); 1000 } 1001 } 1002 } 1003 }; 1004 mLayoutManager.expectLayouts(2); 1005 mActivityRule.runOnUiThread(new Runnable() { 1006 @Override 1007 public void run() { 1008 try { 1009 mAdapter.addAndNotify(0, 1); 1010 if (scrollOffset == LinearLayoutManager.INVALID_OFFSET) { 1011 mLayoutManager.scrollToPosition(scrollPosition); 1012 } else { 1013 mLayoutManager.scrollToPositionWithOffset(scrollPosition, 1014 scrollOffset); 1015 } 1016 1017 } catch (Throwable throwable) { 1018 throwable.printStackTrace(); 1019 } 1020 1021 } 1022 }); 1023 mLayoutManager.waitForLayout(2); 1024 checkForMainThreadException(); 1025 } 1026 1027 @Test moveGapHandling()1028 public void moveGapHandling() throws Throwable { 1029 Config config = new Config().spanCount(2).itemCount(40); 1030 setupByConfig(config); 1031 waitFirstLayout(); 1032 mLayoutManager.expectLayouts(2); 1033 mAdapter.moveAndNotify(4, 1); 1034 mLayoutManager.waitForLayout(2); 1035 assertNull("moving item to upper should not cause gaps", mLayoutManager.hasGapsToFix()); 1036 } 1037 1038 @Test updateAfterFullSpan()1039 public void updateAfterFullSpan() throws Throwable { 1040 updateAfterFullSpanGapHandlingTest(0); 1041 } 1042 1043 @Test 1044 @FlakyTest(bugId = 187711357) updateAfterFullSpan2()1045 public void updateAfterFullSpan2() throws Throwable { 1046 updateAfterFullSpanGapHandlingTest(20); 1047 } 1048 1049 @Test testBatchInsertionsBetweenTailFullSpanItems()1050 public void testBatchInsertionsBetweenTailFullSpanItems() throws Throwable { 1051 // Magic numbers here aren't super specific to repro, but were the example test case that 1052 // led to the isolation of this bug. 1053 setupByConfig(new Config().spanCount(2).itemCount(22)); 1054 1055 // Last few items are full spans. Create a variable to reference later, even though it's 1056 // basically just a few repeated calls. 1057 mAdapter.mFullSpanItems.add(18); 1058 mAdapter.mFullSpanItems.add(19); 1059 mAdapter.mFullSpanItems.add(20); 1060 mAdapter.mFullSpanItems.add(21); 1061 1062 waitFirstLayout(); 1063 1064 // Scroll to the end to populate full span items. 1065 smoothScrollToPosition(mAdapter.mItems.size() - 1); 1066 1067 // Incrementally add a handful of items, mimicking some adapter usages. 1068 final int numberOfItemsToAdd = 12; 1069 final int fullSpanItemIndexToInsertFrom = 18 + 1; 1070 for (int i = 0; i < numberOfItemsToAdd; i++) { 1071 final int insertAt = fullSpanItemIndexToInsertFrom + i; 1072 mAdapter.addAndNotify(insertAt, 1); 1073 } 1074 1075 requestLayoutOnUIThread(mRecyclerView); 1076 mLayoutManager.waitForLayout(3); 1077 } 1078 1079 @Test temporaryGapHandling()1080 public void temporaryGapHandling() throws Throwable { 1081 int fullSpanIndex = 100; 1082 setupByConfig(new Config() 1083 .spanCount(2) 1084 .itemCount(250) 1085 .recyclerViewLayoutWidth(800) 1086 .recyclerViewLayoutHeight(1600)); 1087 mAdapter.mFullSpanItems.add(fullSpanIndex); 1088 waitFirstLayout(); 1089 smoothScrollToPosition(fullSpanIndex + 100); // go far away 1090 assertNull("Assumption check. full span item should not be visible", 1091 mRecyclerView.findViewHolderForAdapterPosition(fullSpanIndex)); 1092 mLayoutManager.expectLayouts(1); 1093 mAdapter.deleteAndNotify(fullSpanIndex + 1, 3); 1094 mLayoutManager.waitForLayout(1); 1095 smoothScrollToPosition(0); 1096 mLayoutManager.expectLayouts(1); 1097 smoothScrollToPosition(fullSpanIndex + 2 * (AVG_ITEM_PER_VIEW - 1)); 1098 String log = mLayoutManager.layoutToString("post gap"); 1099 mLayoutManager.assertNoLayout("if an interim gap is fixed, it should not cause a " 1100 + "relayout " + log, 2); 1101 View fullSpan = mLayoutManager.findViewByPosition(fullSpanIndex); 1102 assertNotNull("full span item should be there:\n" + log, fullSpan); 1103 View view1 = mLayoutManager.findViewByPosition(fullSpanIndex + 1); 1104 assertNotNull("next view should be there\n" + log, view1); 1105 View view2 = mLayoutManager.findViewByPosition(fullSpanIndex + 2); 1106 assertNotNull("+2 view should be there\n" + log, view2); 1107 1108 LayoutParams lp1 = (LayoutParams) view1.getLayoutParams(); 1109 LayoutParams lp2 = (LayoutParams) view2.getLayoutParams(); 1110 assertEquals("view 1 span index", 0, lp1.getSpanIndex()); 1111 assertEquals("view 2 span index", 1, lp2.getSpanIndex()); 1112 assertEquals("no gap between span and view 1", 1113 mLayoutManager.mPrimaryOrientation.getDecoratedEnd(fullSpan), 1114 mLayoutManager.mPrimaryOrientation.getDecoratedStart(view1)); 1115 assertEquals("no gap between span and view 2", 1116 mLayoutManager.mPrimaryOrientation.getDecoratedEnd(fullSpan), 1117 mLayoutManager.mPrimaryOrientation.getDecoratedStart(view2)); 1118 } 1119 updateAfterFullSpanGapHandlingTest(int fullSpanIndex)1120 public void updateAfterFullSpanGapHandlingTest(int fullSpanIndex) throws Throwable { 1121 setupByConfig(new Config().spanCount(2).itemCount(100)); 1122 mAdapter.mFullSpanItems.add(fullSpanIndex); 1123 waitFirstLayout(); 1124 smoothScrollToPosition(fullSpanIndex + 30); 1125 mLayoutManager.expectLayouts(1); 1126 mAdapter.deleteAndNotify(fullSpanIndex + 1, 3); 1127 mLayoutManager.waitForLayout(1); 1128 smoothScrollToPosition(fullSpanIndex); 1129 // give it some time to fix the gap 1130 Thread.sleep(500); 1131 View fullSpan = mLayoutManager.findViewByPosition(fullSpanIndex); 1132 1133 View view1 = mLayoutManager.findViewByPosition(fullSpanIndex + 1); 1134 View view2 = mLayoutManager.findViewByPosition(fullSpanIndex + 2); 1135 1136 LayoutParams lp1 = (LayoutParams) view1.getLayoutParams(); 1137 LayoutParams lp2 = (LayoutParams) view2.getLayoutParams(); 1138 assertEquals("view 1 span index", 0, lp1.getSpanIndex()); 1139 assertEquals("view 2 span index", 1, lp2.getSpanIndex()); 1140 assertEquals("no gap between span and view 1", 1141 mLayoutManager.mPrimaryOrientation.getDecoratedEnd(fullSpan), 1142 mLayoutManager.mPrimaryOrientation.getDecoratedStart(view1)); 1143 assertEquals("no gap between span and view 2", 1144 mLayoutManager.mPrimaryOrientation.getDecoratedEnd(fullSpan), 1145 mLayoutManager.mPrimaryOrientation.getDecoratedStart(view2)); 1146 } 1147 1148 @FlakyTest(bugId = 187526412) 1149 @Test innerGapHandling()1150 public void innerGapHandling() throws Throwable { 1151 innerGapHandlingTest(StaggeredGridLayoutManager.GAP_HANDLING_NONE); 1152 } 1153 1154 @FlakyTest(bugId = 187526412) 1155 @Test innerGapHandlingMoveItemsBetweenSpans()1156 public void innerGapHandlingMoveItemsBetweenSpans() throws Throwable { 1157 innerGapHandlingTest(StaggeredGridLayoutManager.GAP_HANDLING_MOVE_ITEMS_BETWEEN_SPANS); 1158 } 1159 innerGapHandlingTest(int strategy)1160 private void innerGapHandlingTest(int strategy) throws Throwable { 1161 Config config = new Config().spanCount(3).itemCount(500); 1162 setupByConfig(config); 1163 mLayoutManager.setGapStrategy(strategy); 1164 mAdapter.mFullSpanItems.add(100); 1165 mAdapter.mFullSpanItems.add(104); 1166 mAdapter.mViewsHaveEqualSize = true; 1167 mAdapter.mOnBindCallback = new OnBindCallback() { 1168 @Override 1169 void onBoundItem(TestViewHolder vh, int position) { 1170 1171 } 1172 1173 @Override 1174 void onCreatedViewHolder(TestViewHolder vh) { 1175 super.onCreatedViewHolder(vh); 1176 //make sure we have enough views 1177 mAdapter.mSizeReference = mRecyclerView.getHeight() / 5; 1178 } 1179 }; 1180 waitFirstLayout(); 1181 mLayoutManager.expectLayouts(1); 1182 scrollToPosition(400); 1183 mLayoutManager.waitForLayout(2); 1184 View view400 = mLayoutManager.findViewByPosition(400); 1185 assertNotNull("Assumption check, scrollToPos should succeed", view400); 1186 assertTrue("Assumption check, view should be visible top", 1187 mLayoutManager.mPrimaryOrientation.getDecoratedStart(view400) >= 1188 mLayoutManager.mPrimaryOrientation.getStartAfterPadding()); 1189 assertTrue("Assumption check, view should be visible bottom", 1190 mLayoutManager.mPrimaryOrientation.getDecoratedEnd(view400) <= 1191 mLayoutManager.mPrimaryOrientation.getEndAfterPadding()); 1192 mLayoutManager.expectLayouts(2); 1193 mAdapter.addAndNotify(101, 1); 1194 mLayoutManager.waitForLayout(2); 1195 checkForMainThreadException(); 1196 if (strategy == GAP_HANDLING_MOVE_ITEMS_BETWEEN_SPANS) { 1197 mLayoutManager.expectLayouts(1); 1198 } 1199 // state 1200 // now smooth scroll to 99 to trigger a layout around 100 1201 mLayoutManager.validateChildren(); 1202 smoothScrollToPosition(99); 1203 switch (strategy) { 1204 case GAP_HANDLING_NONE: 1205 assertSpans("gap handling:" + Config.gapStrategyName(strategy), new int[]{100, 0}, 1206 new int[]{101, 2}, new int[]{102, 0}, new int[]{103, 1}, new int[]{104, 2}, 1207 new int[]{105, 0}); 1208 break; 1209 case GAP_HANDLING_MOVE_ITEMS_BETWEEN_SPANS: 1210 // Wait time is 10 seconds because 2 seconds appeared to be flaky. If test still 1211 // flakes with this, there must be another problem and further investigation will be 1212 // needed. 1213 mLayoutManager.waitForLayout(10); 1214 assertSpans("swap items between spans", new int[]{100, 0}, new int[]{101, 0}, 1215 new int[]{102, 1}, new int[]{103, 2}, new int[]{104, 0}, new int[]{105, 0}); 1216 break; 1217 } 1218 1219 } 1220 1221 @Test fullSizeSpans()1222 public void fullSizeSpans() throws Throwable { 1223 Config config = new Config().spanCount(5).itemCount(30); 1224 setupByConfig(config); 1225 mAdapter.mFullSpanItems.add(3); 1226 waitFirstLayout(); 1227 assertSpans("Testing full size span", new int[]{0, 0}, new int[]{1, 1}, new int[]{2, 2}, 1228 new int[]{3, 0}, new int[]{4, 0}, new int[]{5, 1}, new int[]{6, 2}, 1229 new int[]{7, 3}, new int[]{8, 4}); 1230 } 1231 assertSpans(String msg, int[]... childSpanTuples)1232 void assertSpans(String msg, int[]... childSpanTuples) { 1233 msg = msg + mLayoutManager.layoutToString("\n\n"); 1234 for (int i = 0; i < childSpanTuples.length; i++) { 1235 assertSpan(msg, childSpanTuples[i][0], childSpanTuples[i][1]); 1236 } 1237 } 1238 assertSpan(String msg, int childPosition, int expectedSpan)1239 void assertSpan(String msg, int childPosition, int expectedSpan) { 1240 View view = mLayoutManager.findViewByPosition(childPosition); 1241 assertNotNull(msg + " view at position " + childPosition + " should exists", view); 1242 assertEquals(msg + "[child:" + childPosition + "]", expectedSpan, 1243 getLp(view).mSpan.mIndex); 1244 } 1245 1246 @Test partialSpanInvalidation()1247 public void partialSpanInvalidation() throws Throwable { 1248 Config config = new Config().spanCount(5).itemCount(100); 1249 setupByConfig(config); 1250 for (int i = 20; i < mAdapter.getItemCount(); i += 20) { 1251 mAdapter.mFullSpanItems.add(i); 1252 } 1253 waitFirstLayout(); 1254 smoothScrollToPosition(50); 1255 int prevSpanId = mLayoutManager.mLazySpanLookup.mData[30]; 1256 mAdapter.changeAndNotify(15, 2); 1257 Thread.sleep(200); 1258 assertEquals("Invalidation should happen within full span item boundaries", prevSpanId, 1259 mLayoutManager.mLazySpanLookup.mData[30]); 1260 assertEquals("item in invalidated range should have clear span id", 1261 LayoutParams.INVALID_SPAN_ID, mLayoutManager.mLazySpanLookup.mData[16]); 1262 smoothScrollToPosition(85); 1263 int[] prevSpans = copyOfRange(mLayoutManager.mLazySpanLookup.mData, 62, 85); 1264 mAdapter.deleteAndNotify(55, 2); 1265 Thread.sleep(200); 1266 assertEquals("item in invalidated range should have clear span id", 1267 LayoutParams.INVALID_SPAN_ID, mLayoutManager.mLazySpanLookup.mData[16]); 1268 int[] newSpans = copyOfRange(mLayoutManager.mLazySpanLookup.mData, 60, 83); 1269 assertSpanAssignmentEquality("valid spans should be shifted for deleted item", prevSpans, 1270 newSpans, 0, 0, newSpans.length); 1271 } 1272 1273 // Same as Arrays.copyOfRange but for API 7 copyOfRange(int[] original, int from, int to)1274 private int[] copyOfRange(int[] original, int from, int to) { 1275 int newLength = to - from; 1276 if (newLength < 0) { 1277 throw new IllegalArgumentException(from + " > " + to); 1278 } 1279 int[] copy = new int[newLength]; 1280 System.arraycopy(original, from, copy, 0, 1281 Math.min(original.length - from, newLength)); 1282 return copy; 1283 } 1284 1285 @Test spanReassignmentsOnItemChange()1286 public void spanReassignmentsOnItemChange() throws Throwable { 1287 Config config = new Config().spanCount(5); 1288 setupByConfig(config); 1289 waitFirstLayout(); 1290 smoothScrollToPosition(mAdapter.getItemCount() / 2); 1291 final int changePosition = mAdapter.getItemCount() / 4; 1292 mLayoutManager.expectLayouts(1); 1293 mAdapter.changeAndNotify(changePosition, 1); 1294 mLayoutManager.assertNoLayout("no layout should happen when an invisible child is " 1295 + "updated", 1); 1296 1297 // delete an item before visible area 1298 int deletedPosition = mLayoutManager.getPosition(mLayoutManager.getChildAt(0)) - 2; 1299 assertTrue("Assumption check", deletedPosition >= 0); 1300 Map<Item, Rect> before = mLayoutManager.collectChildCoordinates(); 1301 if (DEBUG) { 1302 Log.d(TAG, "before:"); 1303 for (Map.Entry<Item, Rect> entry : before.entrySet()) { 1304 Log.d(TAG, entry.getKey().mAdapterIndex + ":" + entry.getValue()); 1305 } 1306 } 1307 mLayoutManager.expectLayouts(1); 1308 mAdapter.deleteAndNotify(deletedPosition, 1); 1309 mLayoutManager.waitForLayout(2); 1310 assertRectSetsEqual(config + " when an item towards the head of the list is deleted, it " 1311 + "should not affect the layout if it is not visible", before, 1312 mLayoutManager.collectChildCoordinates() 1313 ); 1314 deletedPosition = mLayoutManager.getPosition(mLayoutManager.getChildAt(2)); 1315 mLayoutManager.expectLayouts(1); 1316 mAdapter.deleteAndNotify(deletedPosition, 1); 1317 mLayoutManager.waitForLayout(2); 1318 assertRectSetsNotEqual(config + " when a visible item is deleted, it should affect the " 1319 + "layout", before, mLayoutManager.collectChildCoordinates()); 1320 } 1321 assertSpanAssignmentEquality(String msg, int[] set1, int[] set2, int start1, int start2, int length)1322 void assertSpanAssignmentEquality(String msg, int[] set1, int[] set2, int start1, int start2, 1323 int length) { 1324 for (int i = 0; i < length; i++) { 1325 assertEquals(msg + " ind1:" + (start1 + i) + ", ind2:" + (start2 + i), set1[start1 + i], 1326 set2[start2 + i]); 1327 } 1328 } 1329 1330 @Test spanCountChangeOnRestoreSavedState()1331 public void spanCountChangeOnRestoreSavedState() throws Throwable { 1332 Config config = new Config(HORIZONTAL, true, 5, GAP_HANDLING_NONE).itemCount(50); 1333 setupByConfig(config); 1334 waitFirstLayout(); 1335 1336 int beforeChildCount = mLayoutManager.getChildCount(); 1337 Parcelable savedState = mRecyclerView.onSaveInstanceState(); 1338 // we append a suffix to the parcelable to test out of bounds 1339 String parcelSuffix = UUID.randomUUID().toString(); 1340 Parcel parcel = Parcel.obtain(); 1341 savedState.writeToParcel(parcel, 0); 1342 parcel.writeString(parcelSuffix); 1343 removeRecyclerView(); 1344 // reset for reading 1345 parcel.setDataPosition(0); 1346 // re-create 1347 savedState = RecyclerView.SavedState.CREATOR.createFromParcel(parcel); 1348 removeRecyclerView(); 1349 1350 RecyclerView restored = new RecyclerView(getActivity()); 1351 mLayoutManager = new WrappedLayoutManager(config.mSpanCount, config.mOrientation); 1352 mLayoutManager.setReverseLayout(config.mReverseLayout); 1353 mLayoutManager.setGapStrategy(config.mGapStrategy); 1354 restored.setLayoutManager(mLayoutManager); 1355 // use the same adapter for Rect matching 1356 restored.setAdapter(mAdapter); 1357 restored.onRestoreInstanceState(savedState); 1358 mLayoutManager.setSpanCount(1); 1359 mLayoutManager.expectLayouts(1); 1360 setRecyclerView(restored); 1361 mLayoutManager.waitForLayout(2); 1362 assertEquals("on saved state, reverse layout should be preserved", 1363 config.mReverseLayout, mLayoutManager.getReverseLayout()); 1364 assertEquals("on saved state, orientation should be preserved", 1365 config.mOrientation, mLayoutManager.getOrientation()); 1366 assertEquals("after setting new span count, layout manager should keep new value", 1367 1, mLayoutManager.getSpanCount()); 1368 assertEquals("on saved state, gap strategy should be preserved", 1369 config.mGapStrategy, mLayoutManager.getGapStrategy()); 1370 assertTrue("when span count is dramatically changed after restore, # of child views " 1371 + "should change", beforeChildCount > mLayoutManager.getChildCount()); 1372 // make sure SGLM can layout all children. is some span info is leaked, this would crash 1373 smoothScrollToPosition(mAdapter.getItemCount() - 1); 1374 } 1375 1376 @Test scrollAndClear()1377 public void scrollAndClear() throws Throwable { 1378 setupByConfig(new Config()); 1379 waitFirstLayout(); 1380 1381 assertTrue("Children not laid out", mLayoutManager.collectChildCoordinates().size() > 0); 1382 1383 mLayoutManager.expectLayouts(1); 1384 mActivityRule.runOnUiThread(new Runnable() { 1385 @Override 1386 public void run() { 1387 mLayoutManager.scrollToPositionWithOffset(1, 0); 1388 mAdapter.clearOnUIThread(); 1389 } 1390 }); 1391 mLayoutManager.waitForLayout(2); 1392 1393 assertEquals("Remaining children", 0, mLayoutManager.collectChildCoordinates().size()); 1394 } 1395 1396 @Test accessibilityPositions()1397 public void accessibilityPositions() throws Throwable { 1398 setupByConfig(new Config(VERTICAL, false, 3, GAP_HANDLING_NONE)); 1399 waitFirstLayout(); 1400 final AccessibilityDelegateCompat delegateCompat = mRecyclerView 1401 .getCompatAccessibilityDelegate(); 1402 final AccessibilityEvent event = AccessibilityEvent.obtain(); 1403 mActivityRule.runOnUiThread(new Runnable() { 1404 @Override 1405 public void run() { 1406 delegateCompat.onInitializeAccessibilityEvent(mRecyclerView, event); 1407 } 1408 }); 1409 final int start = mRecyclerView 1410 .getChildLayoutPosition( 1411 mLayoutManager.findFirstVisibleItemClosestToStart(false)); 1412 final int end = mRecyclerView 1413 .getChildLayoutPosition( 1414 mLayoutManager.findFirstVisibleItemClosestToEnd(false)); 1415 assertEquals("first item position should match", 1416 Math.min(start, end), event.getFromIndex()); 1417 assertEquals("last item position should match", 1418 Math.max(start, end), event.getToIndex()); 1419 1420 } 1421 1422 @Test rowCountForAccessibility_verticalOrientation()1423 public void rowCountForAccessibility_verticalOrientation() 1424 throws Throwable { 1425 Config config = new Config(VERTICAL, false, 3, GAP_HANDLING_NONE).itemCount(100); 1426 setupByConfig(config); 1427 waitFirstLayout(); 1428 1429 int count = mLayoutManager.getRowCountForAccessibility(mRecyclerView.mRecycler, 1430 mRecyclerView.mState); 1431 1432 assertEquals(-1, count); 1433 } 1434 1435 @Test rowCountForAccessibility_horizontalOrientation_fewerItemsThanSpanCount()1436 public void rowCountForAccessibility_horizontalOrientation_fewerItemsThanSpanCount() 1437 throws Throwable { 1438 final int itemCount = 2; 1439 Config config = new Config(HORIZONTAL, false, 3, GAP_HANDLING_NONE).itemCount(itemCount); 1440 setupByConfig(config); 1441 waitFirstLayout(); 1442 1443 int count = mLayoutManager.getRowCountForAccessibility(mRecyclerView.mRecycler, 1444 mRecyclerView.mState); 1445 1446 assertEquals(itemCount, count); 1447 } 1448 1449 @Test columnCountForAccessibility_horizontalOrientation()1450 public void columnCountForAccessibility_horizontalOrientation() 1451 throws Throwable { 1452 Config config = new Config(HORIZONTAL, false, 3, GAP_HANDLING_NONE).itemCount(100); 1453 setupByConfig(config); 1454 waitFirstLayout(); 1455 1456 int count = mLayoutManager.getColumnCountForAccessibility(mRecyclerView.mRecycler, 1457 mRecyclerView.mState); 1458 1459 assertEquals(-1, count); 1460 } 1461 1462 @Test columnCountForAccessibility_verticalOrientation_fewerItemsThanSpanCount()1463 public void columnCountForAccessibility_verticalOrientation_fewerItemsThanSpanCount() 1464 throws Throwable { 1465 final int itemCount = 2; 1466 Config config = new Config(VERTICAL, false, 3, GAP_HANDLING_NONE).itemCount(itemCount); 1467 setupByConfig(config); 1468 waitFirstLayout(); 1469 1470 int count = mLayoutManager.getColumnCountForAccessibility(mRecyclerView.mRecycler, 1471 mRecyclerView.mState); 1472 1473 assertEquals(itemCount, count); 1474 } 1475 1476 @Test onInitializeAccessibilityNodeInfo()1477 public void onInitializeAccessibilityNodeInfo() throws Throwable { 1478 setupByConfig(new Config(VERTICAL, false, 3, GAP_HANDLING_NONE)); 1479 waitFirstLayout(); 1480 final AccessibilityNodeInfoCompat info = AccessibilityNodeInfoCompat.obtain(); 1481 1482 mActivityRule.runOnUiThread( 1483 () -> mRecyclerView.getLayoutManager().onInitializeAccessibilityNodeInfo( 1484 mRecyclerView.mRecycler, mRecyclerView.mState, info)); 1485 assertEquals(info.getClassName(), 1486 "androidx.recyclerview.widget.StaggeredGridLayoutManager"); 1487 } 1488 } 1489