1 /* 2 * Copyright (C) 2014 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 18 package android.support.v7.widget; 19 20 21 import android.graphics.Rect; 22 import android.os.Looper; 23 import android.os.Parcel; 24 import android.os.Parcelable; 25 import android.support.v4.view.AccessibilityDelegateCompat; 26 import android.support.v4.view.accessibility.AccessibilityEventCompat; 27 import android.support.v4.view.accessibility.AccessibilityRecordCompat; 28 import android.util.Log; 29 import android.view.View; 30 import android.view.ViewGroup; 31 import android.view.accessibility.AccessibilityEvent; 32 33 import java.util.ArrayList; 34 import java.util.Arrays; 35 import java.util.BitSet; 36 import java.util.HashSet; 37 import java.util.LinkedHashMap; 38 import java.util.List; 39 import java.util.Map; 40 import java.util.UUID; 41 import java.util.concurrent.CountDownLatch; 42 import java.util.concurrent.TimeUnit; 43 import java.util.concurrent.atomic.AtomicInteger; 44 45 import static android.support.v7.widget.LayoutState.*; 46 import static android.support.v7.widget.LinearLayoutManager.VERTICAL; 47 import static android.support.v7.widget.StaggeredGridLayoutManager.*; 48 49 public class StaggeredGridLayoutManagerTest extends BaseRecyclerViewInstrumentationTest { 50 51 private static final boolean DEBUG = false; 52 53 private static final String TAG = "StaggeredGridLayoutManagerTest"; 54 55 volatile WrappedLayoutManager mLayoutManager; 56 57 GridTestAdapter mAdapter; 58 59 final List<Config> mBaseVariations = new ArrayList<Config>(); 60 61 @Override 62 protected void setUp() throws Exception { 63 super.setUp(); 64 for (int orientation : new int[]{VERTICAL, HORIZONTAL}) { 65 for (boolean reverseLayout : new boolean[]{false, true}) { 66 for (int spanCount : new int[]{1, 3}) { 67 for (int gapStrategy : new int[]{GAP_HANDLING_NONE, 68 GAP_HANDLING_MOVE_ITEMS_BETWEEN_SPANS}) { 69 mBaseVariations.add(new Config(orientation, reverseLayout, spanCount, 70 gapStrategy)); 71 } 72 } 73 } 74 } 75 } 76 77 void setupByConfig(Config config) throws Throwable { 78 mAdapter = new GridTestAdapter(config.mItemCount, config.mOrientation); 79 mRecyclerView = new RecyclerView(getActivity()); 80 mRecyclerView.setAdapter(mAdapter); 81 mRecyclerView.setHasFixedSize(true); 82 mLayoutManager = new WrappedLayoutManager(config.mSpanCount, 83 config.mOrientation); 84 mLayoutManager.setGapStrategy(config.mGapStrategy); 85 mLayoutManager.setReverseLayout(config.mReverseLayout); 86 mRecyclerView.setLayoutManager(mLayoutManager); 87 } 88 89 public void testRTL() throws Throwable { 90 for (boolean changeRtlAfter : new boolean[]{false, true}) { 91 for (Config config : mBaseVariations) { 92 rtlTest(config, changeRtlAfter); 93 removeRecyclerView(); 94 } 95 } 96 } 97 98 void rtlTest(Config config, boolean changeRtlAfter) throws Throwable { 99 if (config.mSpanCount == 1) { 100 config.mSpanCount = 2; 101 } 102 String logPrefix = config + ", changeRtlAfterLayout:" + changeRtlAfter; 103 setupByConfig(config.itemCount(5)); 104 if (changeRtlAfter) { 105 waitFirstLayout(); 106 mLayoutManager.expectLayouts(1); 107 mLayoutManager.setFakeRtl(true); 108 mLayoutManager.waitForLayout(2); 109 } else { 110 mLayoutManager.mFakeRTL = true; 111 waitFirstLayout(); 112 } 113 114 assertEquals("view should become rtl", true, mLayoutManager.isLayoutRTL()); 115 OrientationHelper helper = OrientationHelper.createHorizontalHelper(mLayoutManager); 116 View child0 = mLayoutManager.findViewByPosition(0); 117 View child1 = mLayoutManager.findViewByPosition(config.mOrientation == VERTICAL ? 1 118 : config.mSpanCount); 119 assertNotNull(logPrefix + " child position 0 should be laid out", child0); 120 assertNotNull(logPrefix + " child position 0 should be laid out", child1); 121 if (config.mOrientation == VERTICAL || !config.mReverseLayout) { 122 assertTrue(logPrefix + " second child should be to the left of first child", 123 helper.getDecoratedStart(child0) >= helper.getDecoratedEnd(child1)); 124 assertEquals(logPrefix + " first child should be right aligned", 125 helper.getDecoratedEnd(child0), helper.getEndAfterPadding()); 126 } else { 127 assertTrue(logPrefix + " first child should be to the left of second child", 128 helper.getDecoratedStart(child1) >= helper.getDecoratedEnd(child0)); 129 assertEquals(logPrefix + " first child should be left aligned", 130 helper.getDecoratedStart(child0), helper.getStartAfterPadding()); 131 } 132 checkForMainThreadException(); 133 } 134 135 public void testScrollBackAndPreservePositions() throws Throwable { 136 for (boolean saveRestore : new boolean[]{false, true}) { 137 for (Config config : mBaseVariations) { 138 scrollBackAndPreservePositionsTest(config, saveRestore); 139 removeRecyclerView(); 140 } 141 } 142 } 143 144 public void scrollBackAndPreservePositionsTest(final Config config, final boolean saveRestoreInBetween) 145 throws Throwable { 146 setupByConfig(config); 147 mAdapter.mOnBindHandler = new OnBindHandler() { 148 @Override 149 public void onBoundItem(TestViewHolder vh, int postion) { 150 LayoutParams lp = (LayoutParams) vh.itemView.getLayoutParams(); 151 lp.setFullSpan((postion * 7) % (config.mSpanCount + 1) == 0); 152 } 153 }; 154 waitFirstLayout(); 155 final int[] globalPositions = new int[mAdapter.getItemCount()]; 156 Arrays.fill(globalPositions, Integer.MIN_VALUE); 157 final int scrollStep = (mLayoutManager.mPrimaryOrientation.getTotalSpace() / 10) 158 * (config.mReverseLayout ? -1 : 1); 159 160 161 final int[] globalPos = new int[1]; 162 runTestOnUiThread(new Runnable() { 163 @Override 164 public void run() { 165 int globalScrollPosition = 0; 166 while (globalPositions[mAdapter.getItemCount() - 1] == Integer.MIN_VALUE) { 167 for (int i = 0; i < mRecyclerView.getChildCount(); i++) { 168 View child = mRecyclerView.getChildAt(i); 169 final int pos = mRecyclerView.getChildPosition(child); 170 if (globalPositions[pos] != Integer.MIN_VALUE) { 171 continue; 172 } 173 if (config.mReverseLayout) { 174 globalPositions[pos] = globalScrollPosition + 175 mLayoutManager.mPrimaryOrientation.getDecoratedEnd(child); 176 } else { 177 globalPositions[pos] = globalScrollPosition + 178 mLayoutManager.mPrimaryOrientation.getDecoratedStart(child); 179 } 180 } 181 globalScrollPosition += mLayoutManager.scrollBy(scrollStep, 182 mRecyclerView.mRecycler, mRecyclerView.mState); 183 } 184 if (DEBUG) { 185 Log.d(TAG, "done recording positions " + Arrays.toString(globalPositions)); 186 } 187 globalPos[0] = globalScrollPosition; 188 } 189 }); 190 checkForMainThreadException(); 191 192 if (saveRestoreInBetween) { 193 saveRestore(config); 194 } 195 196 checkForMainThreadException(); 197 runTestOnUiThread(new Runnable() { 198 @Override 199 public void run() { 200 int globalScrollPosition = globalPos[0]; 201 // now scroll back and make sure global positions match 202 BitSet shouldTest = new BitSet(mAdapter.getItemCount()); 203 shouldTest.set(0, mAdapter.getItemCount() - 1, true); 204 String assertPrefix = config + ", restored in between:" + saveRestoreInBetween 205 + " global pos must match when scrolling in reverse for position "; 206 int scrollAmount = Integer.MAX_VALUE; 207 while (!shouldTest.isEmpty() && scrollAmount != 0) { 208 for (int i = 0; i < mRecyclerView.getChildCount(); i++) { 209 View child = mRecyclerView.getChildAt(i); 210 int pos = mRecyclerView.getChildPosition(child); 211 if (!shouldTest.get(pos)) { 212 continue; 213 } 214 shouldTest.clear(pos); 215 int globalPos; 216 if (config.mReverseLayout) { 217 globalPos = globalScrollPosition + 218 mLayoutManager.mPrimaryOrientation.getDecoratedEnd(child); 219 } else { 220 globalPos = globalScrollPosition + 221 mLayoutManager.mPrimaryOrientation.getDecoratedStart(child); 222 } 223 assertEquals(assertPrefix + pos, 224 globalPositions[pos], globalPos); 225 } 226 scrollAmount = mLayoutManager.scrollBy(-scrollStep, 227 mRecyclerView.mRecycler, mRecyclerView.mState); 228 globalScrollPosition += scrollAmount; 229 } 230 assertTrue("all views should be seen", shouldTest.isEmpty()); 231 } 232 }); 233 checkForMainThreadException(); 234 } 235 236 public void testScrollToPositionWithPredictive() throws Throwable { 237 scrollToPositionWithPredictive(0, LinearLayoutManager.INVALID_OFFSET); 238 removeRecyclerView(); 239 scrollToPositionWithPredictive(Config.DEFAULT_ITEM_COUNT / 2, 240 LinearLayoutManager.INVALID_OFFSET); 241 removeRecyclerView(); 242 scrollToPositionWithPredictive(9, 20); 243 removeRecyclerView(); 244 scrollToPositionWithPredictive(Config.DEFAULT_ITEM_COUNT / 2, 10); 245 246 } 247 248 public void scrollToPositionWithPredictive(final int scrollPosition, final int scrollOffset) 249 throws Throwable { 250 setupByConfig(new Config(StaggeredGridLayoutManager.VERTICAL, 251 false, 3, StaggeredGridLayoutManager.GAP_HANDLING_NONE)); 252 waitFirstLayout(); 253 mLayoutManager.mOnLayoutListener = new OnLayoutListener() { 254 @Override 255 void after(RecyclerView.Recycler recycler, RecyclerView.State state) { 256 RecyclerView rv = mLayoutManager.mRecyclerView; 257 if (state.isPreLayout()) { 258 assertEquals("pending scroll position should still be pending", 259 scrollPosition, mLayoutManager.mPendingScrollPosition); 260 if (scrollOffset != LinearLayoutManager.INVALID_OFFSET) { 261 assertEquals("pending scroll position offset should still be pending", 262 scrollOffset, mLayoutManager.mPendingScrollPositionOffset); 263 } 264 } else { 265 RecyclerView.ViewHolder vh = rv.findViewHolderForPosition(scrollPosition); 266 assertNotNull("scroll to position should work", vh); 267 if (scrollOffset != LinearLayoutManager.INVALID_OFFSET) { 268 assertEquals("scroll offset should be applied properly", 269 mLayoutManager.getPaddingTop() + scrollOffset 270 + ((RecyclerView.LayoutParams) vh.itemView 271 .getLayoutParams()).topMargin, 272 mLayoutManager.getDecoratedTop(vh.itemView)); 273 } 274 } 275 } 276 }; 277 mLayoutManager.expectLayouts(2); 278 runTestOnUiThread(new Runnable() { 279 @Override 280 public void run() { 281 try { 282 mAdapter.addAndNotify(0, 1); 283 if (scrollOffset == LinearLayoutManager.INVALID_OFFSET) { 284 mLayoutManager.scrollToPosition(scrollPosition); 285 } else { 286 mLayoutManager.scrollToPositionWithOffset(scrollPosition, 287 scrollOffset); 288 } 289 290 } catch (Throwable throwable) { 291 throwable.printStackTrace(); 292 } 293 294 } 295 }); 296 mLayoutManager.waitForLayout(2); 297 checkForMainThreadException(); 298 } 299 300 LayoutParams getLp(View view) { 301 return (LayoutParams) view.getLayoutParams(); 302 } 303 304 public void testGetFirstLastChildrenTest() throws Throwable { 305 for (boolean provideArr : new boolean[]{true, false}) { 306 for (Config config : mBaseVariations) { 307 getFirstLastChildrenTest(config, provideArr); 308 removeRecyclerView(); 309 } 310 } 311 } 312 313 public void getFirstLastChildrenTest(final Config config, final boolean provideArr) 314 throws Throwable { 315 setupByConfig(config); 316 waitFirstLayout(); 317 Runnable viewInBoundsTest = new Runnable() { 318 @Override 319 public void run() { 320 VisibleChildren visibleChildren = mLayoutManager.traverseAndFindVisibleChildren(); 321 final String boundsLog = mLayoutManager.getBoundsLog(); 322 VisibleChildren queryResult = new VisibleChildren(mLayoutManager.getSpanCount()); 323 queryResult.firstFullyVisiblePositions = mLayoutManager 324 .findFirstCompletelyVisibleItemPositions( 325 provideArr ? new int[mLayoutManager.getSpanCount()] : null); 326 queryResult.firstVisiblePositions = mLayoutManager 327 .findFirstVisibleItemPositions( 328 provideArr ? new int[mLayoutManager.getSpanCount()] : null); 329 queryResult.lastFullyVisiblePositions = mLayoutManager 330 .findLastCompletelyVisibleItemPositions( 331 provideArr ? new int[mLayoutManager.getSpanCount()] : null); 332 queryResult.lastVisiblePositions = mLayoutManager 333 .findLastVisibleItemPositions( 334 provideArr ? new int[mLayoutManager.getSpanCount()] : null); 335 assertEquals(config + ":\nfirst visible child should match traversal result\n" 336 + "traversed:" + visibleChildren + "\n" 337 + "queried:" + queryResult + "\n" 338 + boundsLog, visibleChildren, queryResult 339 ); 340 } 341 }; 342 runTestOnUiThread(viewInBoundsTest); 343 // smooth scroll to end of the list and keep testing meanwhile. This will test pre-caching 344 // case 345 final int scrollPosition = mAdapter.getItemCount(); 346 runTestOnUiThread(new Runnable() { 347 @Override 348 public void run() { 349 mRecyclerView.smoothScrollToPosition(scrollPosition); 350 } 351 }); 352 while (mLayoutManager.isSmoothScrolling() || 353 mRecyclerView.getScrollState() != RecyclerView.SCROLL_STATE_IDLE) { 354 runTestOnUiThread(viewInBoundsTest); 355 Thread.sleep(400); 356 } 357 // delete all items 358 mLayoutManager.expectLayouts(2); 359 mAdapter.deleteAndNotify(0, mAdapter.getItemCount()); 360 mLayoutManager.waitForLayout(2); 361 // test empty case 362 runTestOnUiThread(viewInBoundsTest); 363 // set a new adapter with huge items to test full bounds check 364 mLayoutManager.expectLayouts(1); 365 final int totalSpace = mLayoutManager.mPrimaryOrientation.getTotalSpace(); 366 final TestAdapter newAdapter = new TestAdapter(100) { 367 @Override 368 public void onBindViewHolder(TestViewHolder holder, 369 int position) { 370 super.onBindViewHolder(holder, position); 371 if (config.mOrientation == LinearLayoutManager.HORIZONTAL) { 372 holder.itemView.setMinimumWidth(totalSpace + 5); 373 } else { 374 holder.itemView.setMinimumHeight(totalSpace + 5); 375 } 376 } 377 }; 378 runTestOnUiThread(new Runnable() { 379 @Override 380 public void run() { 381 mRecyclerView.setAdapter(newAdapter); 382 } 383 }); 384 mLayoutManager.waitForLayout(2); 385 runTestOnUiThread(viewInBoundsTest); 386 } 387 388 public void testInnerGapHandling() throws Throwable { 389 innerGapHandlingTest(StaggeredGridLayoutManager.GAP_HANDLING_NONE); 390 innerGapHandlingTest(StaggeredGridLayoutManager.GAP_HANDLING_MOVE_ITEMS_BETWEEN_SPANS); 391 } 392 393 public void innerGapHandlingTest(int strategy) throws Throwable { 394 Config config = new Config().spanCount(3).itemCount(500); 395 setupByConfig(config); 396 mLayoutManager.setGapStrategy(strategy); 397 mAdapter.mFullSpanItems.add(100); 398 mAdapter.mFullSpanItems.add(104); 399 mAdapter.mViewsHaveEqualSize = true; 400 waitFirstLayout(); 401 mLayoutManager.expectLayouts(1); 402 scrollToPosition(400); 403 mLayoutManager.waitForLayout(2); 404 mLayoutManager.expectLayouts(2); 405 mAdapter.addAndNotify(101, 1); 406 mLayoutManager.waitForLayout(2); 407 if (strategy == GAP_HANDLING_MOVE_ITEMS_BETWEEN_SPANS) { 408 mLayoutManager.expectLayouts(1); 409 } 410 // state 411 // now smooth scroll to 99 to trigger a layout around 100 412 smoothScrollToPosition(99); 413 switch (strategy) { 414 case GAP_HANDLING_NONE: 415 assertSpans("gap handling:" + Config.gapStrategyName(strategy), new int[]{100, 0}, 416 new int[]{101, 2}, new int[]{102, 0}, new int[]{103, 1}, new int[]{104, 2}, 417 new int[]{105, 0}); 418 break; 419 case GAP_HANDLING_MOVE_ITEMS_BETWEEN_SPANS: 420 mLayoutManager.waitForLayout(2); 421 assertSpans("swap items between spans", new int[]{100, 0}, new int[]{101, 0}, 422 new int[]{102, 1}, new int[]{103, 2}, new int[]{104, 0}, new int[]{105, 0}); 423 break; 424 } 425 426 } 427 428 public void testFullSizeSpans() throws Throwable { 429 Config config = new Config().spanCount(5).itemCount(30); 430 setupByConfig(config); 431 mAdapter.mFullSpanItems.add(3); 432 waitFirstLayout(); 433 assertSpans("Testing full size span", new int[]{0, 0}, new int[]{1, 1}, new int[]{2, 2}, 434 new int[]{3, 0}, new int[]{4, 0}, new int[]{5, 1}, new int[]{6, 2}, 435 new int[]{7, 3}, new int[]{8, 4}); 436 } 437 438 void assertSpans(String msg, int[]... childSpanTuples) { 439 for (int i = 0; i < childSpanTuples.length; i++) { 440 assertSpan(msg, childSpanTuples[i][0], childSpanTuples[i][1]); 441 } 442 } 443 444 void assertSpan(String msg, int childPosition, int expectedSpan) { 445 View view = mLayoutManager.findViewByPosition(childPosition); 446 assertNotNull(msg + "view at position " + childPosition + " should exists", view); 447 assertEquals(msg + "[child:" + childPosition + "]", expectedSpan, 448 getLp(view).mSpan.mIndex); 449 } 450 451 public void gapInTheMiddle(Config config) throws Throwable { 452 453 } 454 455 public void testGapAtTheBeginning() throws Throwable { 456 for (Config config : mBaseVariations) { 457 for (int deleteCount = 1; deleteCount < config.mSpanCount * 2; deleteCount ++) { 458 for (int deletePosition = config.mSpanCount - 1; 459 deletePosition < config.mSpanCount + 2; deletePosition ++) { 460 gapAtTheBeginningOfTheListTest(config, deletePosition, deleteCount); 461 removeRecyclerView(); 462 } 463 } 464 } 465 } 466 467 public void gapAtTheBeginningOfTheListTest(final Config config, int deletePosition, 468 int deleteCount) throws Throwable { 469 if (config.mSpanCount < 2 || config.mGapStrategy == GAP_HANDLING_NONE) { 470 return; 471 } 472 if (config.mItemCount < 100) { 473 config.itemCount(100); 474 } 475 final String logPrefix = config + ", deletePos:" + deletePosition + ", deleteCount:" 476 + deleteCount; 477 setupByConfig(config); 478 final RecyclerView.Adapter adapter = mAdapter; 479 waitFirstLayout(); 480 // scroll far away 481 smoothScrollToPosition(config.mItemCount / 2); 482 // assert to be deleted child is not visible 483 assertNull(logPrefix + " test sanity, to be deleted child should be invisible", 484 mRecyclerView.findViewHolderForPosition(deletePosition)); 485 // delete the child and notify 486 mAdapter.deleteAndNotify(deletePosition, deleteCount); 487 getInstrumentation().waitForIdleSync(); 488 mLayoutManager.expectLayouts(1); 489 smoothScrollToPosition(0); 490 mLayoutManager.waitForLayout(2); 491 // due to data changes, first item may become visible before others which will cause 492 // smooth scrolling to stop. Triggering it twice more is a naive hack. 493 // Until we have time to consider it as a bug, this is the only workaround. 494 smoothScrollToPosition(0); 495 Thread.sleep(300); 496 smoothScrollToPosition(0); 497 Thread.sleep(500); 498 // some animations should happen and we should recover layout 499 final Map<Item, Rect> actualCoords = mLayoutManager.collectChildCoordinates(); 500 // now layout another RV with same adapter 501 removeRecyclerView(); 502 setupByConfig(config); 503 mRecyclerView.setAdapter(adapter);// use same adapter so that items can be matched 504 waitFirstLayout(); 505 final Map<Item, Rect> desiredCoords = mLayoutManager.collectChildCoordinates(); 506 assertRectSetsEqual(logPrefix + " when an item from the start of the list is deleted, " 507 + "layout should recover the state once scrolling is stopped", 508 desiredCoords, actualCoords); 509 } 510 511 public void testPartialSpanInvalidation() throws Throwable { 512 Config config = new Config().spanCount(5).itemCount(100); 513 setupByConfig(config); 514 for (int i = 20; i < mAdapter.getItemCount(); i += 20) { 515 mAdapter.mFullSpanItems.add(i); 516 } 517 waitFirstLayout(); 518 smoothScrollToPosition(50); 519 int prevSpanId = mLayoutManager.mLazySpanLookup.mData[30]; 520 mAdapter.changeAndNotify(15, 2); 521 Thread.sleep(200); 522 assertEquals("Invalidation should happen within full span item boundaries", prevSpanId, 523 mLayoutManager.mLazySpanLookup.mData[30]); 524 assertEquals("item in invalidated range should have clear span id", 525 LayoutParams.INVALID_SPAN_ID, mLayoutManager.mLazySpanLookup.mData[16]); 526 smoothScrollToPosition(85); 527 int[] prevSpans = copyOfRange(mLayoutManager.mLazySpanLookup.mData, 62, 85); 528 mAdapter.deleteAndNotify(55, 2); 529 Thread.sleep(200); 530 assertEquals("item in invalidated range should have clear span id", 531 LayoutParams.INVALID_SPAN_ID, mLayoutManager.mLazySpanLookup.mData[16]); 532 int[] newSpans = copyOfRange(mLayoutManager.mLazySpanLookup.mData, 60, 83); 533 assertSpanAssignmentEquality("valid spans should be shifted for deleted item", prevSpans, 534 newSpans, 0, 0, newSpans.length); 535 } 536 537 // Same as Arrays.copyOfRange but for API 7 538 private int[] copyOfRange(int[] original, int from, int to) { 539 int newLength = to - from; 540 if (newLength < 0) 541 throw new IllegalArgumentException(from + " > " + to); 542 int[] copy = new int[newLength]; 543 System.arraycopy(original, from, copy, 0, 544 Math.min(original.length - from, newLength)); 545 return copy; 546 } 547 548 public void testSpanReassignmentsOnItemChange() throws Throwable { 549 Config config = new Config().spanCount(5); 550 setupByConfig(config); 551 waitFirstLayout(); 552 smoothScrollToPosition(mAdapter.getItemCount() / 2); 553 final int changePosition = mAdapter.getItemCount() / 4; 554 mLayoutManager.expectLayouts(1); 555 mAdapter.changeAndNotify(changePosition, 1); 556 mLayoutManager.assertNoLayout("no layout should happen when an invisible child is updated", 557 1); 558 // delete an item before visible area 559 int deletedPosition = mLayoutManager.getPosition(mLayoutManager.getChildAt(0)) - 2; 560 Map<Item, Rect> before = mLayoutManager.collectChildCoordinates(); 561 if (DEBUG) { 562 Log.d(TAG, "before:"); 563 for (Map.Entry<Item, Rect> entry : before.entrySet()) { 564 Log.d(TAG, entry.getKey().mAdapterIndex + ":" + entry.getValue()); 565 } 566 } 567 mLayoutManager.expectLayouts(1); 568 mAdapter.deleteAndNotify(deletedPosition, 1); 569 mLayoutManager.waitForLayout(2); 570 assertRectSetsEqual(config + " when an item towards the head of the list is deleted, it " 571 + "should not affect the layout if it is not visible", before, 572 mLayoutManager.collectChildCoordinates() 573 ); 574 deletedPosition = mLayoutManager.getPosition(mLayoutManager.getChildAt(2)); 575 mLayoutManager.expectLayouts(1); 576 mAdapter.deleteAndNotify(deletedPosition, 1); 577 mLayoutManager.waitForLayout(2); 578 assertRectSetsNotEqual(config + " when a visible item is deleted, it should affect the " 579 + "layout", before, mLayoutManager.collectChildCoordinates()); 580 } 581 582 void assertSpanAssignmentEquality(String msg, int[] set1, int[] set2, int start, int end) { 583 for (int i = start; i < end; i++) { 584 assertEquals(msg + " ind:" + i, set1[i], set2[i]); 585 } 586 } 587 588 void assertSpanAssignmentEquality(String msg, int[] set1, int[] set2, int start1, int start2, 589 int length) { 590 for (int i = 0; i < length; i++) { 591 assertEquals(msg + " ind1:" + (start1 + i) + ", ind2:" + (start2 + i), set1[start1 + i], 592 set2[start2 + i]); 593 } 594 } 595 596 public void testViewSnapping() throws Throwable { 597 for (Config config : mBaseVariations) { 598 viewSnapTest(config.itemCount(config.mSpanCount + 1)); 599 removeRecyclerView(); 600 } 601 } 602 603 public void viewSnapTest(Config config) throws Throwable { 604 setupByConfig(config); 605 waitFirstLayout(); 606 // run these tests twice. once initial layout, once after scroll 607 String logSuffix = ""; 608 for (int i = 0; i < 2; i++) { 609 Map<Item, Rect> itemRectMap = mLayoutManager.collectChildCoordinates(); 610 Rect recyclerViewBounds = getDecoratedRecyclerViewBounds(); 611 Rect usedLayoutBounds = new Rect(); 612 for (Rect rect : itemRectMap.values()) { 613 usedLayoutBounds.union(rect); 614 } 615 if (DEBUG) { 616 Log.d(TAG, "testing view snapping (" + logSuffix + ") for config " + config); 617 } 618 if (config.mOrientation == VERTICAL) { 619 assertEquals(config + " there should be no gap on left" + logSuffix, 620 usedLayoutBounds.left, recyclerViewBounds.left); 621 assertEquals(config + " there should be no gap on right" + logSuffix, 622 usedLayoutBounds.right, recyclerViewBounds.right); 623 if (config.mReverseLayout) { 624 assertEquals(config + " there should be no gap on bottom" + logSuffix, 625 usedLayoutBounds.bottom, recyclerViewBounds.bottom); 626 assertTrue(config + " there should be some gap on top" + logSuffix, 627 usedLayoutBounds.top > recyclerViewBounds.top); 628 } else { 629 assertEquals(config + " there should be no gap on top" + logSuffix, 630 usedLayoutBounds.top, recyclerViewBounds.top); 631 assertTrue(config + " there should be some gap at the bottom" + logSuffix, 632 usedLayoutBounds.bottom < recyclerViewBounds.bottom); 633 } 634 } else { 635 assertEquals(config + " there should be no gap on top" + logSuffix, 636 usedLayoutBounds.top, recyclerViewBounds.top); 637 assertEquals(config + " there should be no gap at the bottom" + logSuffix, 638 usedLayoutBounds.bottom, recyclerViewBounds.bottom); 639 if (config.mReverseLayout) { 640 assertEquals(config + " there should be no on right" + logSuffix, 641 usedLayoutBounds.right, recyclerViewBounds.right); 642 assertTrue(config + " there should be some gap on left" + logSuffix, 643 usedLayoutBounds.left > recyclerViewBounds.left); 644 } else { 645 assertEquals(config + " there should be no gap on left" + logSuffix, 646 usedLayoutBounds.left, recyclerViewBounds.left); 647 assertTrue(config + " there should be some gap on right" + logSuffix, 648 usedLayoutBounds.right < recyclerViewBounds.right); 649 } 650 } 651 final int scroll = config.mReverseLayout ? -500 : 500; 652 scrollBy(scroll); 653 logSuffix = " scrolled " + scroll; 654 } 655 656 } 657 658 public void testSpanCountChangeOnRestoreSavedState() throws Throwable { 659 Config config = new Config(HORIZONTAL, true, 5, GAP_HANDLING_NONE); 660 setupByConfig(config); 661 waitFirstLayout(); 662 663 int beforeChildCount = mLayoutManager.getChildCount(); 664 Parcelable savedState = mRecyclerView.onSaveInstanceState(); 665 // we append a suffix to the parcelable to test out of bounds 666 String parcelSuffix = UUID.randomUUID().toString(); 667 Parcel parcel = Parcel.obtain(); 668 savedState.writeToParcel(parcel, 0); 669 parcel.writeString(parcelSuffix); 670 removeRecyclerView(); 671 // reset for reading 672 parcel.setDataPosition(0); 673 // re-create 674 savedState = RecyclerView.SavedState.CREATOR.createFromParcel(parcel); 675 removeRecyclerView(); 676 677 RecyclerView restored = new RecyclerView(getActivity()); 678 mLayoutManager = new WrappedLayoutManager(config.mSpanCount, config.mOrientation); 679 mLayoutManager.setReverseLayout(config.mReverseLayout); 680 mLayoutManager.setGapStrategy(config.mGapStrategy); 681 restored.setLayoutManager(mLayoutManager); 682 // use the same adapter for Rect matching 683 restored.setAdapter(mAdapter); 684 restored.onRestoreInstanceState(savedState); 685 mLayoutManager.setSpanCount(1); 686 mLayoutManager.expectLayouts(1); 687 setRecyclerView(restored); 688 mLayoutManager.waitForLayout(2); 689 assertEquals("on saved state, reverse layout should be preserved", 690 config.mReverseLayout, mLayoutManager.getReverseLayout()); 691 assertEquals("on saved state, orientation should be preserved", 692 config.mOrientation, mLayoutManager.getOrientation()); 693 assertEquals("after setting new span count, layout manager should keep new value", 694 1, mLayoutManager.getSpanCount()); 695 assertEquals("on saved state, gap strategy should be preserved", 696 config.mGapStrategy, mLayoutManager.getGapStrategy()); 697 assertTrue("when span count is dramatically changed after restore, # of child views " 698 + "should change", beforeChildCount > mLayoutManager.getChildCount()); 699 // make sure LLM can layout all children. is some span info is leaked, this would crash 700 smoothScrollToPosition(mAdapter.getItemCount() - 1); 701 } 702 703 public void testSavedState() throws Throwable { 704 PostLayoutRunnable[] postLayoutOptions = new PostLayoutRunnable[]{ 705 new PostLayoutRunnable() { 706 @Override 707 public void run() throws Throwable { 708 // do nothing 709 } 710 711 @Override 712 public String describe() { 713 return "doing nothing"; 714 } 715 }, 716 new PostLayoutRunnable() { 717 @Override 718 public void run() throws Throwable { 719 mLayoutManager.expectLayouts(1); 720 scrollToPosition(mAdapter.getItemCount() * 3 / 4); 721 mLayoutManager.waitForLayout(2); 722 } 723 724 @Override 725 public String describe() { 726 return "scroll to position " + (mAdapter == null ? "" : 727 mAdapter.getItemCount() * 3 / 4); 728 } 729 }, 730 new PostLayoutRunnable() { 731 @Override 732 public void run() throws Throwable { 733 mLayoutManager.expectLayouts(1); 734 scrollToPositionWithOffset(mAdapter.getItemCount() / 3, 735 50); 736 mLayoutManager.waitForLayout(2); 737 } 738 739 @Override 740 public String describe() { 741 return "scroll to position " + (mAdapter == null ? "" : 742 mAdapter.getItemCount() / 3) + "with positive offset"; 743 } 744 }, 745 new PostLayoutRunnable() { 746 @Override 747 public void run() throws Throwable { 748 mLayoutManager.expectLayouts(1); 749 scrollToPositionWithOffset(mAdapter.getItemCount() * 2 / 3, 750 -50); 751 mLayoutManager.waitForLayout(2); 752 } 753 754 @Override 755 public String describe() { 756 return "scroll to position with negative offset"; 757 } 758 } 759 }; 760 boolean[] waitForLayoutOptions = new boolean[]{false, true}; 761 List<Config> testVariations = new ArrayList<Config>(); 762 testVariations.addAll(mBaseVariations); 763 for (Config config : mBaseVariations) { 764 if (config.mSpanCount < 2) { 765 continue; 766 } 767 final Config clone = (Config) config.clone(); 768 clone.mItemCount = clone.mSpanCount - 1; 769 testVariations.add(clone); 770 } 771 772 for (Config config : testVariations) { 773 for (PostLayoutRunnable runnable : postLayoutOptions) { 774 for (boolean waitForLayout : waitForLayoutOptions) { 775 savedStateTest(config, waitForLayout, runnable); 776 removeRecyclerView(); 777 } 778 } 779 } 780 } 781 782 private void saveRestore(Config config) throws Throwable { 783 Parcelable savedState = mRecyclerView.onSaveInstanceState(); 784 // we append a suffix to the parcelable to test out of bounds 785 String parcelSuffix = UUID.randomUUID().toString(); 786 Parcel parcel = Parcel.obtain(); 787 savedState.writeToParcel(parcel, 0); 788 parcel.writeString(parcelSuffix); 789 removeRecyclerView(); 790 // reset for reading 791 parcel.setDataPosition(0); 792 // re-create 793 savedState = RecyclerView.SavedState.CREATOR.createFromParcel(parcel); 794 RecyclerView restored = new RecyclerView(getActivity()); 795 mLayoutManager = new WrappedLayoutManager(config.mSpanCount, config.mOrientation); 796 mLayoutManager.setGapStrategy(config.mGapStrategy); 797 restored.setLayoutManager(mLayoutManager); 798 // use the same adapter for Rect matching 799 restored.setAdapter(mAdapter); 800 restored.onRestoreInstanceState(savedState); 801 if (Looper.myLooper() == Looper.getMainLooper()) { 802 mLayoutManager.expectLayouts(1); 803 setRecyclerView(restored); 804 } else { 805 mLayoutManager.expectLayouts(1); 806 setRecyclerView(restored); 807 mLayoutManager.waitForLayout(2); 808 } 809 } 810 811 public void savedStateTest(Config config, boolean waitForLayout, 812 PostLayoutRunnable postLayoutOperations) 813 throws Throwable { 814 if (DEBUG) { 815 Log.d(TAG, "testing saved state with wait for layout = " + waitForLayout + " config " 816 + config + " post layout action " + postLayoutOperations.describe()); 817 } 818 setupByConfig(config); 819 waitFirstLayout(); 820 if (waitForLayout) { 821 postLayoutOperations.run(); 822 } 823 final int firstCompletelyVisiblePosition = mLayoutManager.findFirstVisibleItemPositionInt(); 824 Map<Item, Rect> before = mLayoutManager.collectChildCoordinates(); 825 Parcelable savedState = mRecyclerView.onSaveInstanceState(); 826 // we append a suffix to the parcelable to test out of bounds 827 String parcelSuffix = UUID.randomUUID().toString(); 828 Parcel parcel = Parcel.obtain(); 829 savedState.writeToParcel(parcel, 0); 830 parcel.writeString(parcelSuffix); 831 removeRecyclerView(); 832 // reset for reading 833 parcel.setDataPosition(0); 834 // re-create 835 savedState = RecyclerView.SavedState.CREATOR.createFromParcel(parcel); 836 removeRecyclerView(); 837 838 RecyclerView restored = new RecyclerView(getActivity()); 839 mLayoutManager = new WrappedLayoutManager(config.mSpanCount, config.mOrientation); 840 mLayoutManager.setGapStrategy(config.mGapStrategy); 841 restored.setLayoutManager(mLayoutManager); 842 // use the same adapter for Rect matching 843 restored.setAdapter(mAdapter); 844 restored.onRestoreInstanceState(savedState); 845 assertEquals("Parcel reading should not go out of bounds", parcelSuffix, 846 parcel.readString()); 847 mLayoutManager.expectLayouts(1); 848 setRecyclerView(restored); 849 mLayoutManager.waitForLayout(2); 850 assertEquals(config + " on saved state, reverse layout should be preserved", 851 config.mReverseLayout, mLayoutManager.getReverseLayout()); 852 assertEquals(config + " on saved state, orientation should be preserved", 853 config.mOrientation, mLayoutManager.getOrientation()); 854 assertEquals(config + " on saved state, span count should be preserved", 855 config.mSpanCount, mLayoutManager.getSpanCount()); 856 assertEquals(config + " on saved state, gap strategy should be preserved", 857 config.mGapStrategy, mLayoutManager.getGapStrategy()); 858 assertEquals(config + " on saved state, first completely visible child position should" 859 + " be preserved", firstCompletelyVisiblePosition, 860 mLayoutManager.findFirstVisibleItemPositionInt()); 861 if (waitForLayout) { 862 assertRectSetsEqual(config + "\npost layout op:" + postLayoutOperations.describe() 863 + ": on restore, previous view positions should be preserved", 864 before, mLayoutManager.collectChildCoordinates() 865 ); 866 } 867 // TODO add tests for changing values after restore before layout 868 } 869 870 public void testScrollToPositionWithOffset() throws Throwable { 871 for (Config config : mBaseVariations) { 872 scrollToPositionWithOffsetTest(config); 873 removeRecyclerView(); 874 } 875 } 876 877 public void scrollToPositionWithOffsetTest(Config config) throws Throwable { 878 setupByConfig(config); 879 waitFirstLayout(); 880 OrientationHelper orientationHelper = OrientationHelper 881 .createOrientationHelper(mLayoutManager, config.mOrientation); 882 Rect layoutBounds = getDecoratedRecyclerViewBounds(); 883 // try scrolling towards head, should not affect anything 884 Map<Item, Rect> before = mLayoutManager.collectChildCoordinates(); 885 scrollToPositionWithOffset(0, 20); 886 assertRectSetsEqual(config + " trying to over scroll with offset should be no-op", 887 before, mLayoutManager.collectChildCoordinates()); 888 // try offsetting some visible children 889 int testCount = 10; 890 while (testCount-- > 0) { 891 // get middle child 892 final View child = mLayoutManager.getChildAt(mLayoutManager.getChildCount() / 2); 893 final int position = mRecyclerView.getChildPosition(child); 894 final int startOffset = config.mReverseLayout ? 895 orientationHelper.getEndAfterPadding() - orientationHelper 896 .getDecoratedEnd(child) 897 : orientationHelper.getDecoratedStart(child) - orientationHelper 898 .getStartAfterPadding(); 899 final int scrollOffset = startOffset / 2; 900 mLayoutManager.expectLayouts(1); 901 scrollToPositionWithOffset(position, scrollOffset); 902 mLayoutManager.waitForLayout(2); 903 final int finalOffset = config.mReverseLayout ? 904 orientationHelper.getEndAfterPadding() - orientationHelper 905 .getDecoratedEnd(child) 906 : orientationHelper.getDecoratedStart(child) - orientationHelper 907 .getStartAfterPadding(); 908 assertEquals(config + " scroll with offset on a visible child should work fine", 909 scrollOffset, finalOffset); 910 } 911 912 // try scrolling to invisible children 913 testCount = 10; 914 // we test above and below, one by one 915 int offsetMultiplier = -1; 916 while (testCount-- > 0) { 917 final TargetTuple target = findInvisibleTarget(config); 918 mLayoutManager.expectLayouts(1); 919 final int offset = offsetMultiplier 920 * orientationHelper.getDecoratedMeasurement(mLayoutManager.getChildAt(0)) / 3; 921 scrollToPositionWithOffset(target.mPosition, offset); 922 mLayoutManager.waitForLayout(2); 923 final View child = mLayoutManager.findViewByPosition(target.mPosition); 924 assertNotNull(config + " scrolling to a mPosition with offset " + offset 925 + " should layout it", child); 926 final Rect bounds = mLayoutManager.getViewBounds(child); 927 if (DEBUG) { 928 Log.d(TAG, config + " post scroll to invisible mPosition " + bounds + " in " 929 + layoutBounds + " with offset " + offset); 930 } 931 932 if (config.mReverseLayout) { 933 assertEquals(config + " when scrolling with offset to an invisible in reverse " 934 + "layout, its end should align with recycler view's end - offset", 935 orientationHelper.getEndAfterPadding() - offset, 936 orientationHelper.getDecoratedEnd(child) 937 ); 938 } else { 939 assertEquals(config + " when scrolling with offset to an invisible child in normal" 940 + " layout its start should align with recycler view's start + " 941 + "offset", 942 orientationHelper.getStartAfterPadding() + offset, 943 orientationHelper.getDecoratedStart(child) 944 ); 945 } 946 offsetMultiplier *= -1; 947 } 948 } 949 950 public void testScrollToPosition() throws Throwable { 951 for (Config config : mBaseVariations) { 952 scrollToPositionTest(config); 953 removeRecyclerView(); 954 } 955 } 956 957 private TargetTuple findInvisibleTarget(Config config) { 958 int minPosition = Integer.MAX_VALUE, maxPosition = Integer.MIN_VALUE; 959 for (int i = 0; i < mLayoutManager.getChildCount(); i++) { 960 View child = mLayoutManager.getChildAt(i); 961 int position = mRecyclerView.getChildPosition(child); 962 if (position < minPosition) { 963 minPosition = position; 964 } 965 if (position > maxPosition) { 966 maxPosition = position; 967 } 968 } 969 final int tailTarget = maxPosition + (mAdapter.getItemCount() - maxPosition) / 2; 970 final int headTarget = minPosition / 2; 971 final int target; 972 // where will the child come from ? 973 final int itemLayoutDirection; 974 if (Math.abs(tailTarget - maxPosition) > Math.abs(headTarget - minPosition)) { 975 target = tailTarget; 976 itemLayoutDirection = config.mReverseLayout ? LAYOUT_START : LAYOUT_END; 977 } else { 978 target = headTarget; 979 itemLayoutDirection = config.mReverseLayout ? LAYOUT_END : LAYOUT_START; 980 } 981 if (DEBUG) { 982 Log.d(TAG, 983 config + " target:" + target + " min:" + minPosition + ", max:" + maxPosition); 984 } 985 return new TargetTuple(target, itemLayoutDirection); 986 } 987 988 public void scrollToPositionTest(Config config) throws Throwable { 989 setupByConfig(config); 990 waitFirstLayout(); 991 OrientationHelper orientationHelper = OrientationHelper 992 .createOrientationHelper(mLayoutManager, config.mOrientation); 993 Rect layoutBounds = getDecoratedRecyclerViewBounds(); 994 for (int i = 0; i < mLayoutManager.getChildCount(); i++) { 995 View view = mLayoutManager.getChildAt(i); 996 Rect bounds = mLayoutManager.getViewBounds(view); 997 if (layoutBounds.contains(bounds)) { 998 Map<Item, Rect> initialBounds = mLayoutManager.collectChildCoordinates(); 999 final int position = mRecyclerView.getChildPosition(view); 1000 LayoutParams layoutParams 1001 = (LayoutParams) (view.getLayoutParams()); 1002 TestViewHolder vh = (TestViewHolder) layoutParams.mViewHolder; 1003 assertEquals("recycler view mPosition should match adapter mPosition", position, 1004 vh.mBindedItem.mAdapterIndex); 1005 if (DEBUG) { 1006 Log.d(TAG, "testing scroll to visible mPosition at " + position 1007 + " " + bounds + " inside " + layoutBounds); 1008 } 1009 mLayoutManager.expectLayouts(1); 1010 scrollToPosition(position); 1011 mLayoutManager.waitForLayout(2); 1012 if (DEBUG) { 1013 view = mLayoutManager.findViewByPosition(position); 1014 Rect newBounds = mLayoutManager.getViewBounds(view); 1015 Log.d(TAG, "after scrolling to visible mPosition " + 1016 bounds + " equals " + newBounds); 1017 } 1018 1019 assertRectSetsEqual( 1020 config + "scroll to mPosition on fully visible child should be no-op", 1021 initialBounds, mLayoutManager.collectChildCoordinates()); 1022 } else { 1023 final int position = mRecyclerView.getChildPosition(view); 1024 if (DEBUG) { 1025 Log.d(TAG, 1026 "child(" + position + ") not fully visible " + bounds + " not inside " 1027 + layoutBounds 1028 + mRecyclerView.getChildPosition(view) 1029 ); 1030 } 1031 mLayoutManager.expectLayouts(1); 1032 runTestOnUiThread(new Runnable() { 1033 @Override 1034 public void run() { 1035 mLayoutManager.scrollToPosition(position); 1036 } 1037 }); 1038 mLayoutManager.waitForLayout(2); 1039 view = mLayoutManager.findViewByPosition(position); 1040 bounds = mLayoutManager.getViewBounds(view); 1041 if (DEBUG) { 1042 Log.d(TAG, "after scroll to partially visible child " + bounds + " in " 1043 + layoutBounds); 1044 } 1045 assertTrue(config 1046 + " after scrolling to a partially visible child, it should become fully " 1047 + " visible. " + bounds + " not inside " + layoutBounds, 1048 layoutBounds.contains(bounds) 1049 ); 1050 assertTrue(config + " when scrolling to a partially visible item, one of its edges " 1051 + "should be on the boundaries", orientationHelper.getStartAfterPadding() == 1052 orientationHelper.getDecoratedStart(view) 1053 || orientationHelper.getEndAfterPadding() == 1054 orientationHelper.getDecoratedEnd(view)); 1055 } 1056 } 1057 1058 // try scrolling to invisible children 1059 int testCount = 10; 1060 while (testCount-- > 0) { 1061 final TargetTuple target = findInvisibleTarget(config); 1062 mLayoutManager.expectLayouts(1); 1063 scrollToPosition(target.mPosition); 1064 mLayoutManager.waitForLayout(2); 1065 final View child = mLayoutManager.findViewByPosition(target.mPosition); 1066 assertNotNull(config + " scrolling to a mPosition should lay it out", child); 1067 final Rect bounds = mLayoutManager.getViewBounds(child); 1068 if (DEBUG) { 1069 Log.d(TAG, config + " post scroll to invisible mPosition " + bounds + " in " 1070 + layoutBounds); 1071 } 1072 assertTrue(config + " scrolling to a mPosition should make it fully visible", 1073 layoutBounds.contains(bounds)); 1074 if (target.mLayoutDirection == LAYOUT_START) { 1075 assertEquals( 1076 config + " when scrolling to an invisible child above, its start should" 1077 + " align with recycler view's start", 1078 orientationHelper.getStartAfterPadding(), 1079 orientationHelper.getDecoratedStart(child) 1080 ); 1081 } else { 1082 assertEquals(config + " when scrolling to an invisible child below, its end " 1083 + "should align with recycler view's end", 1084 orientationHelper.getEndAfterPadding(), 1085 orientationHelper.getDecoratedEnd(child) 1086 ); 1087 } 1088 } 1089 } 1090 1091 private void scrollToPositionWithOffset(final int position, final int offset) throws Throwable { 1092 runTestOnUiThread(new Runnable() { 1093 @Override 1094 public void run() { 1095 mLayoutManager.scrollToPositionWithOffset(position, offset); 1096 } 1097 }); 1098 } 1099 1100 public void testLayoutOrder() throws Throwable { 1101 for (Config config : mBaseVariations) { 1102 layoutOrderTest(config); 1103 removeRecyclerView(); 1104 } 1105 } 1106 1107 public void layoutOrderTest(Config config) throws Throwable { 1108 setupByConfig(config); 1109 assertViewPositions(config); 1110 } 1111 1112 void assertViewPositions(Config config) { 1113 ArrayList<ArrayList<View>> viewsBySpan = mLayoutManager.collectChildrenBySpan(); 1114 OrientationHelper orientationHelper = OrientationHelper 1115 .createOrientationHelper(mLayoutManager, config.mOrientation); 1116 for (ArrayList<View> span : viewsBySpan) { 1117 // validate all children's order. first child should have min start mPosition 1118 final int count = span.size(); 1119 for (int i = 0, j = 1; j < count; i++, j++) { 1120 View prev = span.get(i); 1121 View next = span.get(j); 1122 assertTrue(config + " prev item should be above next item", 1123 orientationHelper.getDecoratedEnd(prev) <= orientationHelper 1124 .getDecoratedStart(next) 1125 ); 1126 1127 } 1128 } 1129 } 1130 1131 public void testScrollBy() throws Throwable { 1132 for (Config config : mBaseVariations) { 1133 scrollByTest(config); 1134 removeRecyclerView(); 1135 } 1136 } 1137 1138 void waitFirstLayout() throws Throwable { 1139 mLayoutManager.expectLayouts(1); 1140 setRecyclerView(mRecyclerView); 1141 mLayoutManager.waitForLayout(2); 1142 getInstrumentation().waitForIdleSync(); 1143 } 1144 1145 public void scrollByTest(Config config) throws Throwable { 1146 setupByConfig(config); 1147 waitFirstLayout(); 1148 // try invalid scroll. should not happen 1149 final View first = mLayoutManager.getChildAt(0); 1150 OrientationHelper primaryOrientation = OrientationHelper 1151 .createOrientationHelper(mLayoutManager, config.mOrientation); 1152 int scrollDist; 1153 if (config.mReverseLayout) { 1154 scrollDist = primaryOrientation.getDecoratedMeasurement(first) / 2; 1155 } else { 1156 scrollDist = -primaryOrientation.getDecoratedMeasurement(first) / 2; 1157 } 1158 Map<Item, Rect> before = mLayoutManager.collectChildCoordinates(); 1159 scrollBy(scrollDist); 1160 Map<Item, Rect> after = mLayoutManager.collectChildCoordinates(); 1161 assertRectSetsEqual( 1162 config + " if there are no more items, scroll should not happen (dt:" + scrollDist 1163 + ")", 1164 before, after 1165 ); 1166 1167 scrollDist = -scrollDist * 3; 1168 before = mLayoutManager.collectChildCoordinates(); 1169 scrollBy(scrollDist); 1170 after = mLayoutManager.collectChildCoordinates(); 1171 int layoutStart = primaryOrientation.getStartAfterPadding(); 1172 int layoutEnd = primaryOrientation.getEndAfterPadding(); 1173 for (Map.Entry<Item, Rect> entry : before.entrySet()) { 1174 Rect afterRect = after.get(entry.getKey()); 1175 // offset rect 1176 if (config.mOrientation == VERTICAL) { 1177 entry.getValue().offset(0, -scrollDist); 1178 } else { 1179 entry.getValue().offset(-scrollDist, 0); 1180 } 1181 if (afterRect == null || afterRect.isEmpty()) { 1182 // assert item is out of bounds 1183 int start, end; 1184 if (config.mOrientation == VERTICAL) { 1185 start = entry.getValue().top; 1186 end = entry.getValue().bottom; 1187 } else { 1188 start = entry.getValue().left; 1189 end = entry.getValue().right; 1190 } 1191 assertTrue( 1192 config + " if item is missing after relayout, it should be out of bounds." 1193 + "item start: " + start + ", end:" + end + " layout start:" 1194 + layoutStart + 1195 ", layout end:" + layoutEnd, 1196 start <= layoutStart && end <= layoutEnd || 1197 start >= layoutEnd && end >= layoutEnd 1198 ); 1199 } else { 1200 assertEquals(config + " Item should be laid out at the scroll offset coordinates", 1201 entry.getValue(), 1202 afterRect); 1203 } 1204 } 1205 assertViewPositions(config); 1206 } 1207 1208 public void testAccessibilityPositions() throws Throwable { 1209 setupByConfig(new Config(VERTICAL, false, 3, GAP_HANDLING_NONE)); 1210 waitFirstLayout(); 1211 final AccessibilityDelegateCompat delegateCompat = mRecyclerView 1212 .getCompatAccessibilityDelegate(); 1213 final AccessibilityEvent event = AccessibilityEvent.obtain(); 1214 runTestOnUiThread(new Runnable() { 1215 @Override 1216 public void run() { 1217 delegateCompat.onInitializeAccessibilityEvent(mRecyclerView, event); 1218 } 1219 }); 1220 final AccessibilityRecordCompat record = AccessibilityEventCompat 1221 .asRecord(event); 1222 final int start = mRecyclerView 1223 .getChildPosition(mLayoutManager.findFirstVisibleItemClosestToStart(false)); 1224 final int end = mRecyclerView 1225 .getChildPosition(mLayoutManager.findFirstVisibleItemClosestToEnd(false)); 1226 assertEquals("first item position should match", 1227 Math.min(start, end), record.getFromIndex()); 1228 assertEquals("last item position should match", 1229 Math.max(start, end), record.getToIndex()); 1230 1231 } 1232 1233 public void testConsistentRelayout() throws Throwable { 1234 for (Config config : mBaseVariations) { 1235 for (boolean firstChildMultiSpan : new boolean[]{false, true}) { 1236 consistentRelayoutTest(config, firstChildMultiSpan); 1237 } 1238 removeRecyclerView(); 1239 } 1240 } 1241 1242 public void consistentRelayoutTest(Config config, boolean firstChildMultiSpan) 1243 throws Throwable { 1244 setupByConfig(config); 1245 if (firstChildMultiSpan) { 1246 mAdapter.mFullSpanItems.add(0); 1247 } 1248 waitFirstLayout(); 1249 // record all child positions 1250 Map<Item, Rect> before = mLayoutManager.collectChildCoordinates(); 1251 requestLayoutOnUIThread(mRecyclerView); 1252 Map<Item, Rect> after = mLayoutManager.collectChildCoordinates(); 1253 assertRectSetsEqual( 1254 config + " simple re-layout, firstChildMultiSpan:" + firstChildMultiSpan, before, 1255 after); 1256 // scroll some to create inconsistency 1257 View firstChild = mLayoutManager.getChildAt(0); 1258 final int firstChildStartBeforeScroll = mLayoutManager.mPrimaryOrientation 1259 .getDecoratedStart(firstChild); 1260 int distance = mLayoutManager.mPrimaryOrientation.getDecoratedMeasurement(firstChild) / 2; 1261 if (config.mReverseLayout) { 1262 distance *= -1; 1263 } 1264 scrollBy(distance); 1265 waitForMainThread(2); 1266 assertTrue("scroll by should move children", firstChildStartBeforeScroll != 1267 mLayoutManager.mPrimaryOrientation.getDecoratedStart(firstChild)); 1268 before = mLayoutManager.collectChildCoordinates(); 1269 mLayoutManager.expectLayouts(1); 1270 requestLayoutOnUIThread(mRecyclerView); 1271 mLayoutManager.waitForLayout(2); 1272 after = mLayoutManager.collectChildCoordinates(); 1273 assertRectSetsEqual(config + " simple re-layout after scroll", before, after); 1274 } 1275 1276 /** 1277 * enqueues an empty runnable to main thread so that we can be assured it did run 1278 * 1279 * @param count Number of times to run 1280 */ 1281 private void waitForMainThread(int count) throws Throwable { 1282 final AtomicInteger i = new AtomicInteger(count); 1283 while (i.get() > 0) { 1284 runTestOnUiThread(new Runnable() { 1285 @Override 1286 public void run() { 1287 i.decrementAndGet(); 1288 } 1289 }); 1290 } 1291 } 1292 1293 public void assertRectSetsNotEqual(String message, Map<Item, Rect> before, 1294 Map<Item, Rect> after) { 1295 Throwable throwable = null; 1296 try { 1297 assertRectSetsEqual("NOT " + message, before, after); 1298 } catch (Throwable t) { 1299 throwable = t; 1300 } 1301 assertNotNull(message + " two layout should be different", throwable); 1302 } 1303 1304 public void assertRectSetsEqual(String message, Map<Item, Rect> before, Map<Item, Rect> after) { 1305 StringBuilder log = new StringBuilder(); 1306 if (DEBUG) { 1307 log.append("checking rectangle equality.\n"); 1308 log.append("before:"); 1309 for (Map.Entry<Item, Rect> entry : before.entrySet()) { 1310 log.append("\n").append(entry.getKey().mAdapterIndex).append(":") 1311 .append(entry.getValue()); 1312 } 1313 log.append("\nafter:"); 1314 for (Map.Entry<Item, Rect> entry : after.entrySet()) { 1315 log.append("\n").append(entry.getKey().mAdapterIndex).append(":") 1316 .append(entry.getValue()); 1317 } 1318 message += "\n\n" + log.toString(); 1319 } 1320 assertEquals(message + ": item counts should be equal", before.size() 1321 , after.size()); 1322 for (Map.Entry<Item, Rect> entry : before.entrySet()) { 1323 Rect afterRect = after.get(entry.getKey()); 1324 assertNotNull(message + ": Same item should be visible after simple re-layout", 1325 afterRect); 1326 assertEquals(message + ": Item should be laid out at the same coordinates", 1327 entry.getValue(), 1328 afterRect); 1329 } 1330 } 1331 1332 // test layout params assignment 1333 1334 static class OnLayoutListener { 1335 void before(RecyclerView.Recycler recycler, RecyclerView.State state){} 1336 void after(RecyclerView.Recycler recycler, RecyclerView.State state){} 1337 } 1338 1339 class WrappedLayoutManager extends StaggeredGridLayoutManager { 1340 1341 CountDownLatch layoutLatch; 1342 OnLayoutListener mOnLayoutListener; 1343 // gradle does not yet let us customize manifest for tests which is necessary to test RTL. 1344 // until bug is fixed, we'll fake it. 1345 // public issue id: 57819 1346 Boolean mFakeRTL; 1347 1348 @Override 1349 boolean isLayoutRTL() { 1350 return mFakeRTL == null ? super.isLayoutRTL() : mFakeRTL; 1351 } 1352 1353 public void expectLayouts(int count) { 1354 layoutLatch = new CountDownLatch(count); 1355 } 1356 1357 public void waitForLayout(long timeout) throws InterruptedException { 1358 waitForLayout(timeout * (DEBUG ? 1000 : 1), TimeUnit.SECONDS); 1359 } 1360 1361 public void waitForLayout(long timeout, TimeUnit timeUnit) throws InterruptedException { 1362 layoutLatch.await(timeout, timeUnit); 1363 assertEquals("all expected layouts should be executed at the expected time", 1364 0, layoutLatch.getCount()); 1365 } 1366 1367 public void assertNoLayout(String msg, long timeout) throws Throwable { 1368 layoutLatch.await(timeout, TimeUnit.SECONDS); 1369 assertFalse(msg, layoutLatch.getCount() == 0); 1370 } 1371 1372 @Override 1373 public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) { 1374 try { 1375 if (mOnLayoutListener != null) { 1376 mOnLayoutListener.before(recycler, state); 1377 } 1378 super.onLayoutChildren(recycler, state); 1379 if (mOnLayoutListener != null) { 1380 mOnLayoutListener.after(recycler, state); 1381 } 1382 } catch (Throwable t) { 1383 postExceptionToInstrumentation(t); 1384 } 1385 layoutLatch.countDown(); 1386 } 1387 1388 public WrappedLayoutManager(int spanCount, int orientation) { 1389 super(spanCount, orientation); 1390 } 1391 1392 ArrayList<ArrayList<View>> collectChildrenBySpan() { 1393 ArrayList<ArrayList<View>> viewsBySpan = new ArrayList<ArrayList<View>>(); 1394 for (int i = 0; i < getSpanCount(); i++) { 1395 viewsBySpan.add(new ArrayList<View>()); 1396 } 1397 for (int i = 0; i < getChildCount(); i++) { 1398 View view = getChildAt(i); 1399 LayoutParams lp 1400 = (LayoutParams) view 1401 .getLayoutParams(); 1402 viewsBySpan.get(lp.mSpan.mIndex).add(view); 1403 } 1404 return viewsBySpan; 1405 } 1406 1407 Rect getViewBounds(View view) { 1408 if (getOrientation() == HORIZONTAL) { 1409 return new Rect( 1410 mPrimaryOrientation.getDecoratedStart(view), 1411 mSecondaryOrientation.getDecoratedStart(view), 1412 mPrimaryOrientation.getDecoratedEnd(view), 1413 mSecondaryOrientation.getDecoratedEnd(view)); 1414 } else { 1415 return new Rect( 1416 mSecondaryOrientation.getDecoratedStart(view), 1417 mPrimaryOrientation.getDecoratedStart(view), 1418 mSecondaryOrientation.getDecoratedEnd(view), 1419 mPrimaryOrientation.getDecoratedEnd(view)); 1420 } 1421 } 1422 1423 public String getBoundsLog() { 1424 StringBuilder sb = new StringBuilder(); 1425 sb.append("view bounds:[start:").append(mPrimaryOrientation.getStartAfterPadding()) 1426 .append(",").append(" end").append(mPrimaryOrientation.getEndAfterPadding()); 1427 sb.append("\nchildren bounds\n"); 1428 final int childCount = getChildCount(); 1429 for (int i = 0; i < childCount; i++) { 1430 View child = getChildAt(i); 1431 sb.append("child (ind:").append(i).append(", pos:").append(getPosition(child)) 1432 .append("[").append("start:").append( 1433 mPrimaryOrientation.getDecoratedStart(child)).append(", end:") 1434 .append(mPrimaryOrientation.getDecoratedEnd(child)).append("]\n"); 1435 } 1436 return sb.toString(); 1437 } 1438 1439 public VisibleChildren traverseAndFindVisibleChildren() { 1440 int childCount = getChildCount(); 1441 final VisibleChildren visibleChildren = new VisibleChildren(getSpanCount()); 1442 final int start = mPrimaryOrientation.getStartAfterPadding(); 1443 final int end = mPrimaryOrientation.getEndAfterPadding(); 1444 for (int i = 0; i < childCount; i++) { 1445 View child = getChildAt(i); 1446 final int childStart = mPrimaryOrientation.getDecoratedStart(child); 1447 final int childEnd = mPrimaryOrientation.getDecoratedEnd(child); 1448 final boolean fullyVisible = childStart >= start && childEnd <= end; 1449 final boolean hidden = childEnd <= start || childStart >= end; 1450 if (hidden) { 1451 continue; 1452 } 1453 final int position = getPosition(child); 1454 final int span = getLp(child).getSpanIndex(); 1455 if (fullyVisible) { 1456 if (position < visibleChildren.firstFullyVisiblePositions[span] || 1457 visibleChildren.firstFullyVisiblePositions[span] 1458 == RecyclerView.NO_POSITION) { 1459 visibleChildren.firstFullyVisiblePositions[span] = position; 1460 } 1461 1462 if (position > visibleChildren.lastFullyVisiblePositions[span]) { 1463 visibleChildren.lastFullyVisiblePositions[span] = position; 1464 } 1465 } 1466 1467 if (position < visibleChildren.firstVisiblePositions[span] || 1468 visibleChildren.firstVisiblePositions[span] == RecyclerView.NO_POSITION) { 1469 visibleChildren.firstVisiblePositions[span] = position; 1470 } 1471 1472 if (position > visibleChildren.lastVisiblePositions[span]) { 1473 visibleChildren.lastVisiblePositions[span] = position; 1474 } 1475 1476 } 1477 return visibleChildren; 1478 } 1479 1480 Map<Item, Rect> collectChildCoordinates() throws Throwable { 1481 final Map<Item, Rect> items = new LinkedHashMap<Item, Rect>(); 1482 runTestOnUiThread(new Runnable() { 1483 @Override 1484 public void run() { 1485 final int childCount = getChildCount(); 1486 for (int i = 0; i < childCount; i++) { 1487 View child = getChildAt(i); 1488 // do it if and only if child is visible 1489 if (child.getRight() < 0 || child.getBottom() < 0 || 1490 child.getLeft() >= getWidth() || child.getTop() >= getHeight()) { 1491 // invisible children may be drawn in cases like scrolling so we should 1492 // ignore them 1493 continue; 1494 } 1495 LayoutParams lp = (LayoutParams) child 1496 .getLayoutParams(); 1497 TestViewHolder vh = (TestViewHolder) lp.mViewHolder; 1498 items.put(vh.mBindedItem, getViewBounds(child)); 1499 } 1500 } 1501 }); 1502 return items; 1503 } 1504 1505 1506 public void setFakeRtl(Boolean fakeRtl) { 1507 mFakeRTL = fakeRtl; 1508 try { 1509 requestLayoutOnUIThread(mRecyclerView); 1510 } catch (Throwable throwable) { 1511 postExceptionToInstrumentation(throwable); 1512 } 1513 } 1514 } 1515 1516 static class VisibleChildren { 1517 1518 int[] firstVisiblePositions; 1519 1520 int[] firstFullyVisiblePositions; 1521 1522 int[] lastVisiblePositions; 1523 1524 int[] lastFullyVisiblePositions; 1525 1526 VisibleChildren(int spanCount) { 1527 firstFullyVisiblePositions = new int[spanCount]; 1528 firstVisiblePositions = new int[spanCount]; 1529 lastVisiblePositions = new int[spanCount]; 1530 lastFullyVisiblePositions = new int[spanCount]; 1531 for (int i = 0; i < spanCount; i++) { 1532 firstFullyVisiblePositions[i] = RecyclerView.NO_POSITION; 1533 firstVisiblePositions[i] = RecyclerView.NO_POSITION; 1534 lastVisiblePositions[i] = RecyclerView.NO_POSITION; 1535 lastFullyVisiblePositions[i] = RecyclerView.NO_POSITION; 1536 } 1537 } 1538 1539 @Override 1540 public boolean equals(Object o) { 1541 if (this == o) { 1542 return true; 1543 } 1544 if (o == null || getClass() != o.getClass()) { 1545 return false; 1546 } 1547 1548 VisibleChildren that = (VisibleChildren) o; 1549 1550 if (!Arrays.equals(firstFullyVisiblePositions, that.firstFullyVisiblePositions)) { 1551 return false; 1552 } 1553 if (!Arrays.equals(firstVisiblePositions, that.firstVisiblePositions)) { 1554 return false; 1555 } 1556 if (!Arrays.equals(lastFullyVisiblePositions, that.lastFullyVisiblePositions)) { 1557 return false; 1558 } 1559 if (!Arrays.equals(lastVisiblePositions, that.lastVisiblePositions)) { 1560 return false; 1561 } 1562 1563 return true; 1564 } 1565 1566 @Override 1567 public int hashCode() { 1568 int result = firstVisiblePositions != null ? Arrays.hashCode(firstVisiblePositions) : 0; 1569 result = 31 * result + (firstFullyVisiblePositions != null ? Arrays 1570 .hashCode(firstFullyVisiblePositions) : 0); 1571 result = 31 * result + (lastVisiblePositions != null ? Arrays 1572 .hashCode(lastVisiblePositions) 1573 : 0); 1574 result = 31 * result + (lastFullyVisiblePositions != null ? Arrays 1575 .hashCode(lastFullyVisiblePositions) : 0); 1576 return result; 1577 } 1578 1579 @Override 1580 public String toString() { 1581 return "VisibleChildren{" + 1582 "firstVisiblePositions=" + Arrays.toString(firstVisiblePositions) + 1583 ", firstFullyVisiblePositions=" + Arrays.toString(firstFullyVisiblePositions) + 1584 ", lastVisiblePositions=" + Arrays.toString(lastVisiblePositions) + 1585 ", lastFullyVisiblePositions=" + Arrays.toString(lastFullyVisiblePositions) + 1586 '}'; 1587 } 1588 } 1589 1590 class GridTestAdapter extends TestAdapter { 1591 1592 int mOrientation; 1593 1594 // original ids of items that should be full span 1595 HashSet<Integer> mFullSpanItems = new HashSet<Integer>(); 1596 1597 private boolean mViewsHaveEqualSize = false; // size in the scrollable direction 1598 1599 private OnBindHandler mOnBindHandler; 1600 1601 GridTestAdapter(int count, int orientation) { 1602 super(count); 1603 mOrientation = orientation; 1604 } 1605 1606 @Override 1607 public void offsetOriginalIndices(int start, int offset) { 1608 if (mFullSpanItems.size() > 0) { 1609 HashSet<Integer> old = mFullSpanItems; 1610 mFullSpanItems = new HashSet<Integer>(); 1611 for (Integer i : old) { 1612 if (i < start) { 1613 mFullSpanItems.add(i); 1614 } else if (offset > 0 || (start + Math.abs(offset)) <= i) { 1615 mFullSpanItems.add(i + offset); 1616 } else if (DEBUG) { 1617 Log.d(TAG, "removed full span item " + i); 1618 } 1619 } 1620 } 1621 super.offsetOriginalIndices(start, offset); 1622 } 1623 1624 @Override 1625 public void onBindViewHolder(TestViewHolder holder, 1626 int position) { 1627 super.onBindViewHolder(holder, position); 1628 Item item = mItems.get(position); 1629 final int minSize = mViewsHaveEqualSize ? 200 : 200 + 20 * (position % 10); 1630 if (mOrientation == OrientationHelper.HORIZONTAL) { 1631 holder.itemView.setMinimumWidth(minSize); 1632 } else { 1633 holder.itemView.setMinimumHeight(minSize); 1634 } 1635 RecyclerView.LayoutParams lp = (RecyclerView.LayoutParams) holder.itemView 1636 .getLayoutParams(); 1637 if (lp instanceof LayoutParams) { 1638 ((LayoutParams) lp).setFullSpan(mFullSpanItems.contains(item.mAdapterIndex)); 1639 } else { 1640 LayoutParams slp = new LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, 1641 ViewGroup.LayoutParams.WRAP_CONTENT); 1642 holder.itemView.setLayoutParams(slp); 1643 slp.setFullSpan(mFullSpanItems.contains(item.mAdapterIndex)); 1644 lp = slp; 1645 } 1646 lp.topMargin = 3; 1647 lp.leftMargin = 5; 1648 lp.rightMargin = 7; 1649 lp.bottomMargin = 9; 1650 1651 if (mOnBindHandler != null) { 1652 mOnBindHandler.onBoundItem(holder, position); 1653 } 1654 } 1655 } 1656 1657 static interface OnBindHandler { 1658 void onBoundItem(TestViewHolder vh, int postion); 1659 } 1660 1661 static class Config implements Cloneable { 1662 1663 private static final int DEFAULT_ITEM_COUNT = 300; 1664 1665 int mOrientation = OrientationHelper.VERTICAL; 1666 1667 boolean mReverseLayout = false; 1668 1669 int mSpanCount = 3; 1670 1671 int mGapStrategy = GAP_HANDLING_MOVE_ITEMS_BETWEEN_SPANS; 1672 1673 int mItemCount = DEFAULT_ITEM_COUNT; 1674 1675 Config(int orientation, boolean reverseLayout, int spanCount, int gapStrategy) { 1676 mOrientation = orientation; 1677 mReverseLayout = reverseLayout; 1678 mSpanCount = spanCount; 1679 mGapStrategy = gapStrategy; 1680 } 1681 1682 public Config() { 1683 1684 } 1685 1686 Config orientation(int orientation) { 1687 mOrientation = orientation; 1688 return this; 1689 } 1690 1691 Config reverseLayout(boolean reverseLayout) { 1692 mReverseLayout = reverseLayout; 1693 return this; 1694 } 1695 1696 Config spanCount(int spanCount) { 1697 mSpanCount = spanCount; 1698 return this; 1699 } 1700 1701 Config gapStrategy(int gapStrategy) { 1702 mGapStrategy = gapStrategy; 1703 return this; 1704 } 1705 1706 public Config itemCount(int itemCount) { 1707 mItemCount = itemCount; 1708 return this; 1709 } 1710 1711 @Override 1712 public String toString() { 1713 return "[CONFIG:" + 1714 " span:" + mSpanCount + "," + 1715 " orientation:" + (mOrientation == HORIZONTAL ? "horz," : "vert,") + 1716 " reverse:" + (mReverseLayout ? "T" : "F") + 1717 " itemCount:" + mItemCount + 1718 " gap strategy: " + gapStrategyName(mGapStrategy); 1719 } 1720 1721 private static String gapStrategyName(int gapStrategy) { 1722 switch (gapStrategy) { 1723 case GAP_HANDLING_NONE: 1724 return "none"; 1725 case GAP_HANDLING_MOVE_ITEMS_BETWEEN_SPANS: 1726 return "move spans"; 1727 } 1728 return "gap strategy: unknown"; 1729 } 1730 1731 @Override 1732 public Object clone() throws CloneNotSupportedException { 1733 return super.clone(); 1734 } 1735 } 1736 1737 private interface PostLayoutRunnable { 1738 1739 void run() throws Throwable; 1740 1741 String describe(); 1742 } 1743 1744 } 1745