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.LayoutState.LAYOUT_END; 20 import static androidx.recyclerview.widget.LayoutState.LAYOUT_START; 21 import static androidx.recyclerview.widget.LinearLayoutManager.VERTICAL; 22 import static androidx.recyclerview.widget.StaggeredGridLayoutManager 23 .GAP_HANDLING_MOVE_ITEMS_BETWEEN_SPANS; 24 import static androidx.recyclerview.widget.StaggeredGridLayoutManager.GAP_HANDLING_NONE; 25 import static androidx.recyclerview.widget.StaggeredGridLayoutManager.HORIZONTAL; 26 27 import static org.junit.Assert.assertEquals; 28 import static org.junit.Assert.assertFalse; 29 import static org.junit.Assert.assertNotNull; 30 import static org.junit.Assert.assertTrue; 31 32 import static java.util.concurrent.TimeUnit.SECONDS; 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.util.Log; 39 import android.util.StateSet; 40 import android.view.View; 41 import android.view.ViewGroup; 42 43 import org.hamcrest.CoreMatchers; 44 import org.hamcrest.MatcherAssert; 45 import org.jspecify.annotations.NonNull; 46 import org.jspecify.annotations.Nullable; 47 48 import java.lang.reflect.Field; 49 import java.util.ArrayList; 50 import java.util.Arrays; 51 import java.util.HashSet; 52 import java.util.LinkedHashMap; 53 import java.util.List; 54 import java.util.Map; 55 import java.util.concurrent.CountDownLatch; 56 import java.util.concurrent.TimeUnit; 57 import java.util.concurrent.atomic.AtomicInteger; 58 59 abstract class BaseStaggeredGridLayoutManagerTest extends BaseRecyclerViewInstrumentationTest { 60 61 protected static final boolean DEBUG = false; 62 protected static final int AVG_ITEM_PER_VIEW = 3; 63 protected static final String TAG = "SGLM_TEST"; 64 volatile WrappedLayoutManager mLayoutManager; 65 GridTestAdapter mAdapter; 66 createBaseVariations()67 protected static List<Config> createBaseVariations() { 68 List<Config> variations = new ArrayList<>(); 69 for (int orientation : new int[]{VERTICAL, HORIZONTAL}) { 70 for (boolean reverseLayout : new boolean[]{false, true}) { 71 for (int spanCount : new int[]{1, 3}) { 72 for (int gapStrategy : new int[]{GAP_HANDLING_NONE, 73 GAP_HANDLING_MOVE_ITEMS_BETWEEN_SPANS}) { 74 variations.add(new Config(orientation, reverseLayout, spanCount, 75 gapStrategy)); 76 } 77 } 78 } 79 } 80 return variations; 81 } 82 addConfigVariation(List<Config> base, String fieldName, Object... variations)83 protected static List<Config> addConfigVariation(List<Config> base, String fieldName, 84 Object... variations) 85 throws CloneNotSupportedException, NoSuchFieldException, IllegalAccessException { 86 List<Config> newConfigs = new ArrayList<Config>(); 87 Field field = Config.class.getDeclaredField(fieldName); 88 for (Config config : base) { 89 for (Object variation : variations) { 90 Config newConfig = (Config) config.clone(); 91 field.set(newConfig, variation); 92 newConfigs.add(newConfig); 93 } 94 } 95 return newConfigs; 96 } 97 setupByConfig(Config config)98 void setupByConfig(Config config) throws Throwable { 99 setupByConfig(config, new GridTestAdapter(config.mItemCount, config.mOrientation)); 100 } 101 setupByConfig(Config config, GridTestAdapter adapter)102 void setupByConfig(Config config, GridTestAdapter adapter) throws Throwable { 103 mAdapter = adapter; 104 mRecyclerView = new WrappedRecyclerView(getActivity()); 105 mRecyclerView.setAdapter(mAdapter); 106 mRecyclerView.setHasFixedSize(true); 107 mLayoutManager = new WrappedLayoutManager(config.mSpanCount, config.mOrientation); 108 mLayoutManager.setGapStrategy(config.mGapStrategy); 109 mLayoutManager.setReverseLayout(config.mReverseLayout); 110 mRecyclerView.setLayoutParams(new ViewGroup.LayoutParams( 111 config.mRecyclerViewLayoutWidth, config.mRecyclerViewLayoutHeight)); 112 mRecyclerView.setLayoutManager(mLayoutManager); 113 mRecyclerView.addItemDecoration(new RecyclerView.ItemDecoration() { 114 @Override 115 public void getItemOffsets(@NonNull Rect outRect, @NonNull View view, 116 @NonNull RecyclerView parent, RecyclerView.@NonNull State state) { 117 try { 118 StaggeredGridLayoutManager.LayoutParams 119 lp = (StaggeredGridLayoutManager.LayoutParams) view.getLayoutParams(); 120 assertNotNull("view should have layout params assigned", lp); 121 assertNotNull("when item offsets are requested, view should have a valid span", 122 lp.mSpan); 123 } catch (Throwable t) { 124 postExceptionToInstrumentation(t); 125 } 126 } 127 }); 128 } 129 getLp(View view)130 StaggeredGridLayoutManager.LayoutParams getLp(View view) { 131 return (StaggeredGridLayoutManager.LayoutParams) view.getLayoutParams(); 132 } 133 waitFirstLayout()134 void waitFirstLayout() throws Throwable { 135 mLayoutManager.expectLayouts(1); 136 setRecyclerView(mRecyclerView); 137 mLayoutManager.waitForLayout(3); 138 getInstrumentation().waitForIdleSync(); 139 } 140 141 /** 142 * enqueues an empty runnable to main thread so that we can be assured it did run 143 * 144 * @param count Number of times to run 145 */ waitForMainThread(int count)146 protected void waitForMainThread(int count) throws Throwable { 147 final AtomicInteger i = new AtomicInteger(count); 148 while (i.get() > 0) { 149 mActivityRule.runOnUiThread(new Runnable() { 150 @Override 151 public void run() { 152 i.decrementAndGet(); 153 } 154 }); 155 } 156 } 157 assertRectSetsNotEqual(String message, Map<Item, Rect> before, Map<Item, Rect> after)158 public void assertRectSetsNotEqual(String message, Map<Item, Rect> before, 159 Map<Item, Rect> after) { 160 Throwable throwable = null; 161 try { 162 assertRectSetsEqual("NOT " + message, before, after); 163 } catch (Throwable t) { 164 throwable = t; 165 } 166 assertNotNull(message + " two layout should be different", throwable); 167 } 168 assertRectSetsEqual(String message, Map<Item, Rect> before, Map<Item, Rect> after)169 public void assertRectSetsEqual(String message, Map<Item, Rect> before, Map<Item, Rect> after) { 170 assertRectSetsEqual(message, before, after, true); 171 } 172 assertRectSetsEqual(String message, Map<Item, Rect> before, Map<Item, Rect> after, boolean strictItemEquality)173 public void assertRectSetsEqual(String message, Map<Item, Rect> before, Map<Item, Rect> after, 174 boolean strictItemEquality) { 175 StringBuilder log = new StringBuilder(); 176 if (DEBUG) { 177 log.append("checking rectangle equality.\n"); 178 log.append("total space:" + mLayoutManager.mPrimaryOrientation.getTotalSpace()); 179 log.append("before:"); 180 for (Map.Entry<Item, Rect> entry : before.entrySet()) { 181 log.append("\n").append(entry.getKey().mAdapterIndex).append(":") 182 .append(entry.getValue()); 183 } 184 log.append("\nafter:"); 185 for (Map.Entry<Item, Rect> entry : after.entrySet()) { 186 log.append("\n").append(entry.getKey().mAdapterIndex).append(":") 187 .append(entry.getValue()); 188 } 189 message += "\n\n" + log.toString(); 190 } 191 assertEquals(message + ": item counts should be equal", before.size() 192 , after.size()); 193 for (Map.Entry<Item, Rect> entry : before.entrySet()) { 194 final Item beforeItem = entry.getKey(); 195 Rect afterRect = null; 196 if (strictItemEquality) { 197 afterRect = after.get(beforeItem); 198 assertNotNull(message + ": Same item should be visible after simple re-layout", 199 afterRect); 200 } else { 201 for (Map.Entry<Item, Rect> afterEntry : after.entrySet()) { 202 final Item afterItem = afterEntry.getKey(); 203 if (afterItem.mAdapterIndex == beforeItem.mAdapterIndex) { 204 afterRect = afterEntry.getValue(); 205 break; 206 } 207 } 208 assertNotNull(message + ": Item with same adapter index should be visible " + 209 "after simple re-layout", 210 afterRect); 211 } 212 assertEquals(message + ": Item should be laid out at the same coordinates", 213 entry.getValue(), 214 afterRect); 215 } 216 } 217 assertViewPositions(Config config)218 protected void assertViewPositions(Config config) { 219 ArrayList<ArrayList<View>> viewsBySpan = mLayoutManager.collectChildrenBySpan(); 220 OrientationHelper orientationHelper = OrientationHelper 221 .createOrientationHelper(mLayoutManager, config.mOrientation); 222 for (ArrayList<View> span : viewsBySpan) { 223 // validate all children's order. first child should have min start mPosition 224 final int count = span.size(); 225 for (int i = 0, j = 1; j < count; i++, j++) { 226 View prev = span.get(i); 227 View next = span.get(j); 228 assertTrue(config + " prev item should be above next item", 229 orientationHelper.getDecoratedEnd(prev) <= orientationHelper 230 .getDecoratedStart(next) 231 ); 232 233 } 234 } 235 } 236 findInvisibleTarget(Config config)237 protected TargetTuple findInvisibleTarget(Config config) { 238 int minPosition = Integer.MAX_VALUE, maxPosition = Integer.MIN_VALUE; 239 for (int i = 0; i < mLayoutManager.getChildCount(); i++) { 240 View child = mLayoutManager.getChildAt(i); 241 int position = mRecyclerView.getChildLayoutPosition(child); 242 if (position < minPosition) { 243 minPosition = position; 244 } 245 if (position > maxPosition) { 246 maxPosition = position; 247 } 248 } 249 final int tailTarget = maxPosition + (mAdapter.getItemCount() - maxPosition) / 2; 250 final int headTarget = minPosition / 2; 251 final int target; 252 // where will the child come from ? 253 final int itemLayoutDirection; 254 if (Math.abs(tailTarget - maxPosition) > Math.abs(headTarget - minPosition)) { 255 target = tailTarget; 256 itemLayoutDirection = config.mReverseLayout ? LAYOUT_START : LAYOUT_END; 257 } else { 258 target = headTarget; 259 itemLayoutDirection = config.mReverseLayout ? LAYOUT_END : LAYOUT_START; 260 } 261 if (DEBUG) { 262 Log.d(TAG, 263 config + " target:" + target + " min:" + minPosition + ", max:" + maxPosition); 264 } 265 return new TargetTuple(target, itemLayoutDirection); 266 } 267 scrollToPositionWithOffset(final int position, final int offset)268 protected void scrollToPositionWithOffset(final int position, final int offset) 269 throws Throwable { 270 mActivityRule.runOnUiThread(new Runnable() { 271 @Override 272 public void run() { 273 mLayoutManager.scrollToPositionWithOffset(position, offset); 274 } 275 }); 276 } 277 278 static class OnLayoutListener { 279 before(RecyclerView.Recycler recycler, RecyclerView.State state)280 void before(RecyclerView.Recycler recycler, RecyclerView.State state) { 281 } 282 after(RecyclerView.Recycler recycler, RecyclerView.State state)283 void after(RecyclerView.Recycler recycler, RecyclerView.State state) { 284 } 285 } 286 287 static class VisibleChildren { 288 289 int[] firstVisiblePositions; 290 291 int[] firstFullyVisiblePositions; 292 293 int[] lastVisiblePositions; 294 295 int[] lastFullyVisiblePositions; 296 297 View findFirstPartialVisibleClosestToStart; 298 View findFirstPartialVisibleClosestToEnd; 299 VisibleChildren(int spanCount)300 VisibleChildren(int spanCount) { 301 firstFullyVisiblePositions = new int[spanCount]; 302 firstVisiblePositions = new int[spanCount]; 303 lastVisiblePositions = new int[spanCount]; 304 lastFullyVisiblePositions = new int[spanCount]; 305 for (int i = 0; i < spanCount; i++) { 306 firstFullyVisiblePositions[i] = RecyclerView.NO_POSITION; 307 firstVisiblePositions[i] = RecyclerView.NO_POSITION; 308 lastVisiblePositions[i] = RecyclerView.NO_POSITION; 309 lastFullyVisiblePositions[i] = RecyclerView.NO_POSITION; 310 } 311 } 312 313 @Override equals(Object o)314 public boolean equals(Object o) { 315 if (this == o) { 316 return true; 317 } 318 if (o == null || getClass() != o.getClass()) { 319 return false; 320 } 321 322 VisibleChildren that = (VisibleChildren) o; 323 324 if (!Arrays.equals(firstFullyVisiblePositions, that.firstFullyVisiblePositions)) { 325 return false; 326 } 327 if (findFirstPartialVisibleClosestToStart 328 != null ? !findFirstPartialVisibleClosestToStart 329 .equals(that.findFirstPartialVisibleClosestToStart) 330 : that.findFirstPartialVisibleClosestToStart != null) { 331 return false; 332 } 333 if (!Arrays.equals(firstVisiblePositions, that.firstVisiblePositions)) { 334 return false; 335 } 336 if (!Arrays.equals(lastFullyVisiblePositions, that.lastFullyVisiblePositions)) { 337 return false; 338 } 339 if (findFirstPartialVisibleClosestToEnd != null ? !findFirstPartialVisibleClosestToEnd 340 .equals(that.findFirstPartialVisibleClosestToEnd) 341 : that.findFirstPartialVisibleClosestToEnd 342 != null) { 343 return false; 344 } 345 if (!Arrays.equals(lastVisiblePositions, that.lastVisiblePositions)) { 346 return false; 347 } 348 349 return true; 350 } 351 352 @Override hashCode()353 public int hashCode() { 354 int result = Arrays.hashCode(firstVisiblePositions); 355 result = 31 * result + Arrays.hashCode(firstFullyVisiblePositions); 356 result = 31 * result + Arrays.hashCode(lastVisiblePositions); 357 result = 31 * result + Arrays.hashCode(lastFullyVisiblePositions); 358 result = 31 * result + (findFirstPartialVisibleClosestToStart != null 359 ? findFirstPartialVisibleClosestToStart 360 .hashCode() : 0); 361 result = 31 * result + (findFirstPartialVisibleClosestToEnd != null 362 ? findFirstPartialVisibleClosestToEnd 363 .hashCode() 364 : 0); 365 return result; 366 } 367 368 @Override toString()369 public String toString() { 370 return "VisibleChildren{" + 371 "firstVisiblePositions=" + Arrays.toString(firstVisiblePositions) + 372 ", firstFullyVisiblePositions=" + Arrays.toString(firstFullyVisiblePositions) + 373 ", lastVisiblePositions=" + Arrays.toString(lastVisiblePositions) + 374 ", lastFullyVisiblePositions=" + Arrays.toString(lastFullyVisiblePositions) + 375 ", findFirstPartialVisibleClosestToStart=" + 376 viewToString(findFirstPartialVisibleClosestToStart) + 377 ", findFirstPartialVisibleClosestToEnd=" + 378 viewToString(findFirstPartialVisibleClosestToEnd) + 379 '}'; 380 } 381 viewToString(View view)382 private String viewToString(View view) { 383 if (view == null) { 384 return null; 385 } 386 ViewGroup.LayoutParams lp = view.getLayoutParams(); 387 if (lp instanceof RecyclerView.LayoutParams == false) { 388 return System.identityHashCode(view) + "(?)"; 389 } 390 RecyclerView.LayoutParams rvlp = (RecyclerView.LayoutParams) lp; 391 return System.identityHashCode(view) + "(" + rvlp.getViewAdapterPosition() + ")"; 392 } 393 } 394 395 abstract static class OnBindCallback { 396 onBoundItem(TestViewHolder vh, int position)397 abstract void onBoundItem(TestViewHolder vh, int position); 398 assignRandomSize()399 boolean assignRandomSize() { 400 return true; 401 } 402 onCreatedViewHolder(TestViewHolder vh)403 void onCreatedViewHolder(TestViewHolder vh) { 404 } 405 } 406 407 static class Config implements Cloneable { 408 409 static final int DEFAULT_ITEM_COUNT = 300; 410 411 int mOrientation = OrientationHelper.VERTICAL; 412 boolean mReverseLayout = false; 413 int mSpanCount = 3; 414 int mGapStrategy = GAP_HANDLING_MOVE_ITEMS_BETWEEN_SPANS; 415 int mItemCount = DEFAULT_ITEM_COUNT; 416 int mRecyclerViewLayoutHeight = ViewGroup.LayoutParams.MATCH_PARENT; 417 int mRecyclerViewLayoutWidth = ViewGroup.LayoutParams.MATCH_PARENT; 418 Config(int orientation, boolean reverseLayout, int spanCount, int gapStrategy)419 Config(int orientation, boolean reverseLayout, int spanCount, int gapStrategy) { 420 mOrientation = orientation; 421 mReverseLayout = reverseLayout; 422 mSpanCount = spanCount; 423 mGapStrategy = gapStrategy; 424 } 425 Config()426 public Config() { 427 428 } 429 orientation(int orientation)430 Config orientation(int orientation) { 431 mOrientation = orientation; 432 return this; 433 } 434 reverseLayout(boolean reverseLayout)435 Config reverseLayout(boolean reverseLayout) { 436 mReverseLayout = reverseLayout; 437 return this; 438 } 439 spanCount(int spanCount)440 Config spanCount(int spanCount) { 441 mSpanCount = spanCount; 442 return this; 443 } 444 gapStrategy(int gapStrategy)445 Config gapStrategy(int gapStrategy) { 446 mGapStrategy = gapStrategy; 447 return this; 448 } 449 recyclerViewLayoutWidth(int recyclerViewLayoutWidth)450 Config recyclerViewLayoutWidth(int recyclerViewLayoutWidth) { 451 mRecyclerViewLayoutWidth = recyclerViewLayoutWidth; 452 return this; 453 } 454 recyclerViewLayoutHeight(int recyclerViewLayoutHeight)455 Config recyclerViewLayoutHeight(int recyclerViewLayoutHeight) { 456 mRecyclerViewLayoutHeight = recyclerViewLayoutHeight; 457 return this; 458 } 459 itemCount(int itemCount)460 public Config itemCount(int itemCount) { 461 mItemCount = itemCount; 462 return this; 463 } 464 465 @Override toString()466 public String toString() { 467 return "[CONFIG:" 468 + "span:" + mSpanCount 469 + ",orientation:" + (mOrientation == HORIZONTAL ? "horz," : "vert,") 470 + ",reverse:" + (mReverseLayout ? "T" : "F") 471 + ",itemCount:" + mItemCount 472 + ",gap_strategy:" + gapStrategyName(mGapStrategy); 473 } 474 gapStrategyName(int gapStrategy)475 protected static String gapStrategyName(int gapStrategy) { 476 switch (gapStrategy) { 477 case GAP_HANDLING_NONE: 478 return "none"; 479 case GAP_HANDLING_MOVE_ITEMS_BETWEEN_SPANS: 480 return "move_spans"; 481 } 482 return "gap_strategy:unknown"; 483 } 484 485 @Override clone()486 public Object clone() throws CloneNotSupportedException { 487 return super.clone(); 488 } 489 } 490 491 class WrappedLayoutManager extends StaggeredGridLayoutManager { 492 493 CountDownLatch layoutLatch; 494 CountDownLatch prefetchLatch; 495 OnLayoutListener mOnLayoutListener; 496 // gradle does not yet let us customize manifest for tests which is necessary to test RTL. 497 // until bug is fixed, we'll fake it. 498 // public issue id: 57819 499 Boolean mFakeRTL; 500 CountDownLatch mSnapLatch; 501 502 @Override isLayoutRTL()503 boolean isLayoutRTL() { 504 return mFakeRTL == null ? super.isLayoutRTL() : mFakeRTL; 505 } 506 expectLayouts(int count)507 public void expectLayouts(int count) { 508 layoutLatch = new CountDownLatch(count); 509 } 510 waitForLayout(int seconds)511 public void waitForLayout(int seconds) throws Throwable { 512 layoutLatch.await(seconds * (DEBUG ? 100 : 1), SECONDS); 513 checkForMainThreadException(); 514 MatcherAssert.assertThat("all layouts should complete on time", 515 layoutLatch.getCount(), CoreMatchers.is(0L)); 516 // use a runnable to ensure RV layout is finished 517 getInstrumentation().runOnMainSync(new Runnable() { 518 @Override 519 public void run() { 520 } 521 }); 522 } 523 expectPrefetch(int count)524 public void expectPrefetch(int count) { 525 prefetchLatch = new CountDownLatch(count); 526 } 527 waitForPrefetch(int seconds)528 public void waitForPrefetch(int seconds) throws Throwable { 529 prefetchLatch.await(seconds * (DEBUG ? 100 : 1), SECONDS); 530 checkForMainThreadException(); 531 MatcherAssert.assertThat("all prefetches should complete on time", 532 prefetchLatch.getCount(), CoreMatchers.is(0L)); 533 // use a runnable to ensure RV layout is finished 534 getInstrumentation().runOnMainSync(new Runnable() { 535 @Override 536 public void run() { 537 } 538 }); 539 } 540 expectIdleState(int count)541 public void expectIdleState(int count) { 542 mSnapLatch = new CountDownLatch(count); 543 mRecyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() { 544 @Override 545 public void onScrollStateChanged(RecyclerView recyclerView, int newState) { 546 super.onScrollStateChanged(recyclerView, newState); 547 if (newState == RecyclerView.SCROLL_STATE_IDLE) { 548 mSnapLatch.countDown(); 549 if (mSnapLatch.getCount() == 0L) { 550 mRecyclerView.removeOnScrollListener(this); 551 } 552 } 553 } 554 }); 555 } 556 waitForSnap(int seconds)557 public void waitForSnap(int seconds) throws Throwable { 558 mSnapLatch.await(seconds * (DEBUG ? 100 : 1), SECONDS); 559 checkForMainThreadException(); 560 MatcherAssert.assertThat("all scrolling should complete on time", 561 mSnapLatch.getCount(), CoreMatchers.is(0L)); 562 // use a runnable to ensure RV layout is finished 563 getInstrumentation().runOnMainSync(new Runnable() { 564 @Override 565 public void run() { 566 } 567 }); 568 } 569 assertNoLayout(String msg, long timeout)570 public void assertNoLayout(String msg, long timeout) throws Throwable { 571 layoutLatch.await(timeout, TimeUnit.SECONDS); 572 assertFalse(msg, layoutLatch.getCount() == 0); 573 } 574 575 @Override onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state)576 public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) { 577 String before; 578 if (DEBUG) { 579 before = layoutToString("before"); 580 } else { 581 before = "enable DEBUG"; 582 } 583 try { 584 if (mOnLayoutListener != null) { 585 mOnLayoutListener.before(recycler, state); 586 } 587 super.onLayoutChildren(recycler, state); 588 if (mOnLayoutListener != null) { 589 mOnLayoutListener.after(recycler, state); 590 } 591 validateChildren(before); 592 } catch (Throwable t) { 593 postExceptionToInstrumentation(t); 594 } 595 596 layoutLatch.countDown(); 597 } 598 599 @Override scrollBy(int dt, RecyclerView.Recycler recycler, RecyclerView.State state)600 int scrollBy(int dt, RecyclerView.Recycler recycler, RecyclerView.State state) { 601 try { 602 int result = super.scrollBy(dt, recycler, state); 603 validateChildren(); 604 return result; 605 } catch (Throwable t) { 606 postExceptionToInstrumentation(t); 607 } 608 609 return 0; 610 } 611 findFirstVisibleItemClosestToCenter()612 View findFirstVisibleItemClosestToCenter() { 613 final int boundsStart = mPrimaryOrientation.getStartAfterPadding(); 614 final int boundsEnd = mPrimaryOrientation.getEndAfterPadding(); 615 final int boundsCenter = (boundsStart + boundsEnd) / 2; 616 final Rect childBounds = new Rect(); 617 int minDist = Integer.MAX_VALUE; 618 View closestChild = null; 619 for (int i = getChildCount() - 1; i >= 0; i--) { 620 final View child = getChildAt(i); 621 childBounds.setEmpty(); 622 getDecoratedBoundsWithMargins(child, childBounds); 623 int childCenter = canScrollHorizontally() 624 ? childBounds.centerX() : childBounds.centerY(); 625 int dist = Math.abs(boundsCenter - childCenter); 626 if (dist < minDist) { 627 minDist = dist; 628 closestChild = child; 629 } 630 } 631 return closestChild; 632 } 633 WrappedLayoutManager(int spanCount, int orientation)634 public WrappedLayoutManager(int spanCount, int orientation) { 635 super(spanCount, orientation); 636 } 637 collectChildrenBySpan()638 ArrayList<ArrayList<View>> collectChildrenBySpan() { 639 ArrayList<ArrayList<View>> viewsBySpan = new ArrayList<ArrayList<View>>(); 640 for (int i = 0; i < getSpanCount(); i++) { 641 viewsBySpan.add(new ArrayList<View>()); 642 } 643 for (int i = 0; i < getChildCount(); i++) { 644 View view = getChildAt(i); 645 LayoutParams lp 646 = (LayoutParams) view 647 .getLayoutParams(); 648 viewsBySpan.get(lp.mSpan.mIndex).add(view); 649 } 650 return viewsBySpan; 651 } 652 653 @Override onFocusSearchFailed(View focused, int direction, RecyclerView.Recycler recycler, RecyclerView.State state)654 public @Nullable View onFocusSearchFailed(View focused, int direction, 655 RecyclerView.Recycler recycler, RecyclerView.State state) { 656 View result = null; 657 try { 658 result = super.onFocusSearchFailed(focused, direction, recycler, state); 659 validateChildren(); 660 } catch (Throwable t) { 661 postExceptionToInstrumentation(t); 662 } 663 return result; 664 } 665 getViewBounds(View view)666 Rect getViewBounds(View view) { 667 if (getOrientation() == HORIZONTAL) { 668 return new Rect( 669 mPrimaryOrientation.getDecoratedStart(view), 670 mSecondaryOrientation.getDecoratedStart(view), 671 mPrimaryOrientation.getDecoratedEnd(view), 672 mSecondaryOrientation.getDecoratedEnd(view)); 673 } else { 674 return new Rect( 675 mSecondaryOrientation.getDecoratedStart(view), 676 mPrimaryOrientation.getDecoratedStart(view), 677 mSecondaryOrientation.getDecoratedEnd(view), 678 mPrimaryOrientation.getDecoratedEnd(view)); 679 } 680 } 681 getBoundsLog()682 public String getBoundsLog() { 683 StringBuilder sb = new StringBuilder(); 684 sb.append("view bounds:[start:").append(mPrimaryOrientation.getStartAfterPadding()) 685 .append(",").append(" end").append(mPrimaryOrientation.getEndAfterPadding()); 686 sb.append("\nchildren bounds\n"); 687 final int childCount = getChildCount(); 688 for (int i = 0; i < childCount; i++) { 689 View child = getChildAt(i); 690 sb.append("child (ind:").append(i).append(", pos:").append(getPosition(child)) 691 .append("[").append("start:").append( 692 mPrimaryOrientation.getDecoratedStart(child)).append(", end:") 693 .append(mPrimaryOrientation.getDecoratedEnd(child)).append("]\n"); 694 } 695 return sb.toString(); 696 } 697 traverseAndFindVisibleChildren()698 public VisibleChildren traverseAndFindVisibleChildren() { 699 int childCount = getChildCount(); 700 final VisibleChildren visibleChildren = new VisibleChildren(getSpanCount()); 701 final int start = mPrimaryOrientation.getStartAfterPadding(); 702 final int end = mPrimaryOrientation.getEndAfterPadding(); 703 for (int i = 0; i < childCount; i++) { 704 View child = getChildAt(i); 705 final int childStart = mPrimaryOrientation.getDecoratedStart(child); 706 final int childEnd = mPrimaryOrientation.getDecoratedEnd(child); 707 final boolean fullyVisible = childStart >= start && childEnd <= end; 708 final boolean hidden = childEnd <= start || childStart >= end; 709 if (hidden) { 710 continue; 711 } 712 final int position = getPosition(child); 713 final int span = getLp(child).getSpanIndex(); 714 if (fullyVisible) { 715 if (position < visibleChildren.firstFullyVisiblePositions[span] || 716 visibleChildren.firstFullyVisiblePositions[span] 717 == RecyclerView.NO_POSITION) { 718 visibleChildren.firstFullyVisiblePositions[span] = position; 719 } 720 721 if (position > visibleChildren.lastFullyVisiblePositions[span]) { 722 visibleChildren.lastFullyVisiblePositions[span] = position; 723 } 724 } 725 726 if (position < visibleChildren.firstVisiblePositions[span] || 727 visibleChildren.firstVisiblePositions[span] == RecyclerView.NO_POSITION) { 728 visibleChildren.firstVisiblePositions[span] = position; 729 } 730 731 if (position > visibleChildren.lastVisiblePositions[span]) { 732 visibleChildren.lastVisiblePositions[span] = position; 733 } 734 if (visibleChildren.findFirstPartialVisibleClosestToStart == null) { 735 visibleChildren.findFirstPartialVisibleClosestToStart = child; 736 } 737 visibleChildren.findFirstPartialVisibleClosestToEnd = child; 738 } 739 return visibleChildren; 740 } 741 collectChildCoordinates()742 Map<Item, Rect> collectChildCoordinates() throws Throwable { 743 final Map<Item, Rect> items = new LinkedHashMap<Item, Rect>(); 744 mActivityRule.runOnUiThread(new Runnable() { 745 @Override 746 public void run() { 747 final int start = mPrimaryOrientation.getStartAfterPadding(); 748 final int end = mPrimaryOrientation.getEndAfterPadding(); 749 final int childCount = getChildCount(); 750 for (int i = 0; i < childCount; i++) { 751 View child = getChildAt(i); 752 // ignore child if it fits the recycling constraints 753 if (mPrimaryOrientation.getDecoratedStart(child) >= end 754 || mPrimaryOrientation.getDecoratedEnd(child) < start) { 755 continue; 756 } 757 LayoutParams lp = (LayoutParams) child.getLayoutParams(); 758 TestViewHolder vh = (TestViewHolder) lp.mViewHolder; 759 items.put(vh.mBoundItem, getViewBounds(child)); 760 } 761 } 762 }); 763 return items; 764 } 765 766 setFakeRtl(Boolean fakeRtl)767 public void setFakeRtl(Boolean fakeRtl) { 768 mFakeRTL = fakeRtl; 769 try { 770 requestLayoutOnUIThread(mRecyclerView); 771 } catch (Throwable throwable) { 772 postExceptionToInstrumentation(throwable); 773 } 774 } 775 layoutToString(String hint)776 String layoutToString(String hint) { 777 StringBuilder sb = new StringBuilder(); 778 sb.append("LAYOUT POSITIONS AND INDICES ").append(hint).append("\n"); 779 for (int i = 0; i < getChildCount(); i++) { 780 final View view = getChildAt(i); 781 final LayoutParams layoutParams = (LayoutParams) view.getLayoutParams(); 782 sb.append(String.format("index: %d pos: %d top: %d bottom: %d span: %d isFull:%s", 783 i, getPosition(view), 784 mPrimaryOrientation.getDecoratedStart(view), 785 mPrimaryOrientation.getDecoratedEnd(view), 786 layoutParams.getSpanIndex(), layoutParams.isFullSpan())).append("\n"); 787 } 788 return sb.toString(); 789 } 790 validateChildren()791 protected void validateChildren() { 792 validateChildren(null); 793 } 794 validateChildren(String msg)795 private void validateChildren(String msg) { 796 if (getChildCount() == 0 || mRecyclerView.mState.isPreLayout()) { 797 return; 798 } 799 final int dir = mShouldReverseLayout ? -1 : 1; 800 int i = 0; 801 int pos = -1; 802 while (i < getChildCount()) { 803 LayoutParams lp = (LayoutParams) getChildAt(i).getLayoutParams(); 804 if (lp.isItemRemoved()) { 805 i++; 806 continue; 807 } 808 pos = getPosition(getChildAt(i)); 809 break; 810 } 811 if (pos == -1) { 812 return; 813 } 814 while (++i < getChildCount()) { 815 LayoutParams lp = (LayoutParams) getChildAt(i).getLayoutParams(); 816 if (lp.isItemRemoved()) { 817 continue; 818 } 819 pos += dir; 820 if (getPosition(getChildAt(i)) != pos) { 821 throw new RuntimeException("INVALID POSITION FOR CHILD " + i + "\n" + 822 layoutToString("ERROR") + "\n msg:" + msg); 823 } 824 } 825 } 826 827 @Override collectAdjacentPrefetchPositions(int dx, int dy, RecyclerView.State state, LayoutPrefetchRegistry layoutPrefetchRegistry)828 public void collectAdjacentPrefetchPositions(int dx, int dy, RecyclerView.State state, 829 LayoutPrefetchRegistry layoutPrefetchRegistry) { 830 if (prefetchLatch != null) prefetchLatch.countDown(); 831 super.collectAdjacentPrefetchPositions(dx, dy, state, layoutPrefetchRegistry); 832 } 833 } 834 835 class GridTestAdapter extends TestAdapter { 836 837 int mOrientation; 838 int mRecyclerViewWidth; 839 int mRecyclerViewHeight; 840 Integer mSizeReference = null; 841 842 // original ids of items that should be full span 843 HashSet<Integer> mFullSpanItems = new HashSet<Integer>(); 844 845 protected boolean mViewsHaveEqualSize = false; // size in the scrollable direction 846 847 protected OnBindCallback mOnBindCallback; 848 GridTestAdapter(int count, int orientation)849 GridTestAdapter(int count, int orientation) { 850 super(count); 851 mOrientation = orientation; 852 } 853 854 @Override onCreateViewHolder(@onNull ViewGroup parent, int viewType)855 public @NonNull TestViewHolder onCreateViewHolder(@NonNull ViewGroup parent, 856 int viewType) { 857 mRecyclerViewWidth = parent.getWidth(); 858 mRecyclerViewHeight = parent.getHeight(); 859 TestViewHolder vh = super.onCreateViewHolder(parent, viewType); 860 if (mOnBindCallback != null) { 861 mOnBindCallback.onCreatedViewHolder(vh); 862 } 863 return vh; 864 } 865 866 @Override offsetOriginalIndices(int start, int offset)867 public void offsetOriginalIndices(int start, int offset) { 868 if (mFullSpanItems.size() > 0) { 869 HashSet<Integer> old = mFullSpanItems; 870 mFullSpanItems = new HashSet<Integer>(); 871 for (Integer i : old) { 872 if (i < start) { 873 mFullSpanItems.add(i); 874 } else if (offset > 0 || (start + Math.abs(offset)) <= i) { 875 mFullSpanItems.add(i + offset); 876 } else if (DEBUG) { 877 Log.d(TAG, "removed full span item " + i); 878 } 879 } 880 } 881 super.offsetOriginalIndices(start, offset); 882 } 883 884 @Override moveInUIThread(int from, int to)885 protected void moveInUIThread(int from, int to) { 886 boolean setAsFullSpanAgain = mFullSpanItems.contains(from); 887 super.moveInUIThread(from, to); 888 if (setAsFullSpanAgain) { 889 mFullSpanItems.add(to); 890 } 891 } 892 893 @Override onBindViewHolder(@onNull TestViewHolder holder, int position)894 public void onBindViewHolder(@NonNull TestViewHolder holder, 895 int position) { 896 if (mSizeReference == null) { 897 mSizeReference = mOrientation == OrientationHelper.HORIZONTAL ? mRecyclerViewWidth 898 / AVG_ITEM_PER_VIEW : mRecyclerViewHeight / AVG_ITEM_PER_VIEW; 899 } 900 super.onBindViewHolder(holder, position); 901 902 Item item = mItems.get(position); 903 RecyclerView.LayoutParams lp = (RecyclerView.LayoutParams) holder.itemView 904 .getLayoutParams(); 905 if (lp instanceof StaggeredGridLayoutManager.LayoutParams) { 906 ((StaggeredGridLayoutManager.LayoutParams) lp) 907 .setFullSpan(mFullSpanItems.contains(item.mAdapterIndex)); 908 } else { 909 StaggeredGridLayoutManager.LayoutParams slp 910 = (StaggeredGridLayoutManager.LayoutParams) mLayoutManager 911 .generateDefaultLayoutParams(); 912 holder.itemView.setLayoutParams(slp); 913 slp.setFullSpan(mFullSpanItems.contains(item.mAdapterIndex)); 914 lp = slp; 915 } 916 917 if (mOnBindCallback == null || mOnBindCallback.assignRandomSize()) { 918 final int minSize = mViewsHaveEqualSize ? mSizeReference : 919 mSizeReference + 20 * (item.mId % 10); 920 if (mOrientation == OrientationHelper.HORIZONTAL) { 921 lp.width = minSize; 922 } else { 923 lp.height = minSize; 924 } 925 lp.topMargin = 3; 926 lp.leftMargin = 5; 927 lp.rightMargin = 7; 928 lp.bottomMargin = 9; 929 } 930 // Good to have colors for debugging 931 StateListDrawable stl = new StateListDrawable(); 932 stl.addState(new int[]{android.R.attr.state_focused}, 933 new ColorDrawable(Color.RED)); 934 stl.addState(StateSet.WILD_CARD, new ColorDrawable(Color.BLUE)); 935 holder.itemView.setBackgroundDrawable(stl); 936 if (mOnBindCallback != null) { 937 mOnBindCallback.onBoundItem(holder, position); 938 } 939 } 940 } 941 } 942