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