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 17package android.support.v7.widget; 18 19import android.content.Context; 20import android.graphics.Rect; 21import android.os.Parcel; 22import android.os.Parcelable; 23import android.support.v4.view.AccessibilityDelegateCompat; 24import android.support.v4.view.accessibility.AccessibilityEventCompat; 25import android.support.v4.view.accessibility.AccessibilityRecordCompat; 26import android.util.Log; 27import android.view.View; 28import android.view.ViewGroup; 29import android.view.accessibility.AccessibilityEvent; 30import android.widget.FrameLayout; 31 32import static android.support.v7.widget.LayoutState.LAYOUT_END; 33import static android.support.v7.widget.LayoutState.LAYOUT_START; 34import static android.support.v7.widget.LinearLayoutManager.HORIZONTAL; 35import static android.support.v7.widget.LinearLayoutManager.VERTICAL; 36import java.lang.reflect.Field; 37import java.util.ArrayList; 38import java.util.LinkedHashMap; 39import java.util.List; 40import java.util.Map; 41import java.util.UUID; 42import java.util.concurrent.CountDownLatch; 43import java.util.concurrent.TimeUnit; 44import java.util.concurrent.atomic.AtomicInteger; 45 46/** 47 * Includes tests for {@link LinearLayoutManager}. 48 * <p> 49 * Since most UI tests are not practical, these tests are focused on internal data representation 50 * and stability of LinearLayoutManager in response to different events (state change, scrolling 51 * etc) where it is very hard to do manual testing. 52 */ 53public class LinearLayoutManagerTest extends BaseRecyclerViewInstrumentationTest { 54 55 private static final boolean DEBUG = false; 56 57 private static final String TAG = "LinearLayoutManagerTest"; 58 59 WrappedLinearLayoutManager mLayoutManager; 60 61 TestAdapter mTestAdapter; 62 63 final List<Config> mBaseVariations = new ArrayList<Config>(); 64 65 @Override 66 protected void setUp() throws Exception { 67 super.setUp(); 68 for (int orientation : new int[]{VERTICAL, HORIZONTAL}) { 69 for (boolean reverseLayout : new boolean[]{false, true}) { 70 for (boolean stackFromBottom : new boolean[]{false, true}) { 71 mBaseVariations.add(new Config(orientation, reverseLayout, stackFromBottom)); 72 } 73 } 74 } 75 } 76 77 protected List<Config> addConfigVariation(List<Config> base, String fieldName, 78 Object... variations) 79 throws CloneNotSupportedException, NoSuchFieldException, IllegalAccessException { 80 List<Config> newConfigs = new ArrayList<Config>(); 81 Field field = Config.class.getDeclaredField(fieldName); 82 for (Config config : base) { 83 for (Object variation : variations) { 84 Config newConfig = (Config) config.clone(); 85 field.set(newConfig, variation); 86 newConfigs.add(newConfig); 87 } 88 } 89 return newConfigs; 90 } 91 92 void setupByConfig(Config config, boolean waitForFirstLayout) throws Throwable { 93 mRecyclerView = new RecyclerView(getActivity()); 94 mRecyclerView.setHasFixedSize(true); 95 mTestAdapter = config.mTestAdapter == null ? new TestAdapter(config.mItemCount) 96 : config.mTestAdapter; 97 mRecyclerView.setAdapter(mTestAdapter); 98 mLayoutManager = new WrappedLinearLayoutManager(getActivity(), config.mOrientation, 99 config.mReverseLayout); 100 mLayoutManager.setStackFromEnd(config.mStackFromEnd); 101 mLayoutManager.setRecycleChildrenOnDetach(config.mRecycleChildrenOnDetach); 102 mRecyclerView.setLayoutManager(mLayoutManager); 103 if (waitForFirstLayout) { 104 waitForFirstLayout(); 105 } 106 } 107 108 public void testKeepFocusOnRelayout() throws Throwable { 109 setupByConfig(new Config(VERTICAL, false, false).itemCount(500), true); 110 int center = (mLayoutManager.findLastVisibleItemPosition() 111 - mLayoutManager.findFirstVisibleItemPosition()) / 2; 112 final RecyclerView.ViewHolder vh = mRecyclerView.findViewHolderForLayoutPosition(center); 113 final int top = mLayoutManager.mOrientationHelper.getDecoratedStart(vh.itemView); 114 runTestOnUiThread(new Runnable() { 115 @Override 116 public void run() { 117 vh.itemView.requestFocus(); 118 } 119 }); 120 assertTrue("view should have the focus", vh.itemView.hasFocus()); 121 // add a bunch of items right before that view, make sure it keeps its position 122 mLayoutManager.expectLayouts(2); 123 final int childCountToAdd = mRecyclerView.getChildCount() * 2; 124 mTestAdapter.addAndNotify(center, childCountToAdd); 125 center += childCountToAdd; // offset item 126 mLayoutManager.waitForLayout(2); 127 mLayoutManager.waitForAnimationsToEnd(20); 128 final RecyclerView.ViewHolder postVH = mRecyclerView.findViewHolderForLayoutPosition(center); 129 assertNotNull("focused child should stay in layout", postVH); 130 assertSame("same view holder should be kept for unchanged child", vh, postVH); 131 assertEquals("focused child's screen position should stay unchanged", top, 132 mLayoutManager.mOrientationHelper.getDecoratedStart(postVH.itemView)); 133 } 134 135 public void testResize() throws Throwable { 136 for(Config config : addConfigVariation(mBaseVariations, "mItemCount", 5 137 , Config.DEFAULT_ITEM_COUNT)) { 138 stackFromEndTest(config); 139 removeRecyclerView(); 140 } 141 } 142 143 public void testScrollToPositionWithOffset() throws Throwable { 144 for (Config config : mBaseVariations) { 145 scrollToPositionWithOffsetTest(config.itemCount(300)); 146 removeRecyclerView(); 147 } 148 } 149 150 public void scrollToPositionWithOffsetTest(Config config) throws Throwable { 151 setupByConfig(config, true); 152 OrientationHelper orientationHelper = OrientationHelper 153 .createOrientationHelper(mLayoutManager, config.mOrientation); 154 Rect layoutBounds = getDecoratedRecyclerViewBounds(); 155 // try scrolling towards head, should not affect anything 156 Map<Item, Rect> before = mLayoutManager.collectChildCoordinates(); 157 if (config.mStackFromEnd) { 158 scrollToPositionWithOffset(mTestAdapter.getItemCount() - 1, 159 mLayoutManager.mOrientationHelper.getEnd() - 500); 160 } else { 161 scrollToPositionWithOffset(0, 20); 162 } 163 assertRectSetsEqual(config + " trying to over scroll with offset should be no-op", 164 before, mLayoutManager.collectChildCoordinates()); 165 // try offsetting some visible children 166 int testCount = 10; 167 while (testCount-- > 0) { 168 // get middle child 169 final View child = mLayoutManager.getChildAt(mLayoutManager.getChildCount() / 2); 170 final int position = mRecyclerView.getChildLayoutPosition(child); 171 final int startOffset = config.mReverseLayout ? 172 orientationHelper.getEndAfterPadding() - orientationHelper 173 .getDecoratedEnd(child) 174 : orientationHelper.getDecoratedStart(child) - orientationHelper 175 .getStartAfterPadding(); 176 final int scrollOffset = config.mStackFromEnd ? startOffset + startOffset / 2 177 : startOffset / 2; 178 mLayoutManager.expectLayouts(1); 179 scrollToPositionWithOffset(position, scrollOffset); 180 mLayoutManager.waitForLayout(2); 181 final int finalOffset = config.mReverseLayout ? 182 orientationHelper.getEndAfterPadding() - orientationHelper 183 .getDecoratedEnd(child) 184 : orientationHelper.getDecoratedStart(child) - orientationHelper 185 .getStartAfterPadding(); 186 assertEquals(config + " scroll with offset on a visible child should work fine " + 187 " offset:" + finalOffset + " , existing offset:" + startOffset + ", " 188 + "child " + position, 189 scrollOffset, finalOffset); 190 } 191 192 // try scrolling to invisible children 193 testCount = 10; 194 // we test above and below, one by one 195 int offsetMultiplier = -1; 196 while (testCount-- > 0) { 197 final TargetTuple target = findInvisibleTarget(config); 198 final String logPrefix = config + " " + target; 199 mLayoutManager.expectLayouts(1); 200 final int offset = offsetMultiplier 201 * orientationHelper.getDecoratedMeasurement(mLayoutManager.getChildAt(0)) / 3; 202 scrollToPositionWithOffset(target.mPosition, offset); 203 mLayoutManager.waitForLayout(2); 204 final View child = mLayoutManager.findViewByPosition(target.mPosition); 205 assertNotNull(logPrefix + " scrolling to a mPosition with offset " + offset 206 + " should layout it", child); 207 final Rect bounds = mLayoutManager.getViewBounds(child); 208 if (DEBUG) { 209 Log.d(TAG, logPrefix + " post scroll to invisible mPosition " + bounds + " in " 210 + layoutBounds + " with offset " + offset); 211 } 212 213 if (config.mReverseLayout) { 214 assertEquals(logPrefix + " when scrolling with offset to an invisible in reverse " 215 + "layout, its end should align with recycler view's end - offset", 216 orientationHelper.getEndAfterPadding() - offset, 217 orientationHelper.getDecoratedEnd(child) 218 ); 219 } else { 220 assertEquals(logPrefix + " when scrolling with offset to an invisible child in normal" 221 + " layout its start should align with recycler view's start + " 222 + "offset", 223 orientationHelper.getStartAfterPadding() + offset, 224 orientationHelper.getDecoratedStart(child) 225 ); 226 } 227 offsetMultiplier *= -1; 228 } 229 } 230 231 private TargetTuple findInvisibleTarget(Config config) { 232 int minPosition = Integer.MAX_VALUE, maxPosition = Integer.MIN_VALUE; 233 for (int i = 0; i < mLayoutManager.getChildCount(); i++) { 234 View child = mLayoutManager.getChildAt(i); 235 int position = mRecyclerView.getChildLayoutPosition(child); 236 if (position < minPosition) { 237 minPosition = position; 238 } 239 if (position > maxPosition) { 240 maxPosition = position; 241 } 242 } 243 final int tailTarget = maxPosition + 244 (mRecyclerView.getAdapter().getItemCount() - maxPosition) / 2; 245 final int headTarget = minPosition / 2; 246 final int target; 247 // where will the child come from ? 248 final int itemLayoutDirection; 249 if (Math.abs(tailTarget - maxPosition) > Math.abs(headTarget - minPosition)) { 250 target = tailTarget; 251 itemLayoutDirection = config.mReverseLayout ? LAYOUT_START : LAYOUT_END; 252 } else { 253 target = headTarget; 254 itemLayoutDirection = config.mReverseLayout ? LAYOUT_END : LAYOUT_START; 255 } 256 if (DEBUG) { 257 Log.d(TAG, 258 config + " target:" + target + " min:" + minPosition + ", max:" + maxPosition); 259 } 260 return new TargetTuple(target, itemLayoutDirection); 261 } 262 263 public void stackFromEndTest(final Config config) throws Throwable { 264 final FrameLayout container = getRecyclerViewContainer(); 265 runTestOnUiThread(new Runnable() { 266 @Override 267 public void run() { 268 container.setPadding(0, 0, 0, 0); 269 } 270 }); 271 272 setupByConfig(config, true); 273 int lastVisibleItemPosition = mLayoutManager.findLastVisibleItemPosition(); 274 int firstVisibleItemPosition = mLayoutManager.findFirstVisibleItemPosition(); 275 int lastCompletelyVisibleItemPosition = mLayoutManager.findLastCompletelyVisibleItemPosition(); 276 int firstCompletelyVisibleItemPosition = mLayoutManager.findFirstCompletelyVisibleItemPosition(); 277 mLayoutManager.expectLayouts(1); 278 // resize the recycler view to half 279 runTestOnUiThread(new Runnable() { 280 @Override 281 public void run() { 282 if (config.mOrientation == HORIZONTAL) { 283 container.setPadding(0, 0, container.getWidth() / 2, 0); 284 } else { 285 container.setPadding(0, 0, 0, container.getWidth() / 2); 286 } 287 } 288 }); 289 mLayoutManager.waitForLayout(1); 290 if (config.mStackFromEnd) { 291 assertEquals("[" + config + "]: last visible position should not change.", 292 lastVisibleItemPosition, mLayoutManager.findLastVisibleItemPosition()); 293 assertEquals("[" + config + "]: last completely visible position should not change", 294 lastCompletelyVisibleItemPosition, 295 mLayoutManager.findLastCompletelyVisibleItemPosition()); 296 } else { 297 assertEquals("[" + config + "]: first visible position should not change.", 298 firstVisibleItemPosition, mLayoutManager.findFirstVisibleItemPosition()); 299 assertEquals("[" + config + "]: last completely visible position should not change", 300 firstCompletelyVisibleItemPosition, 301 mLayoutManager.findFirstCompletelyVisibleItemPosition()); 302 } 303 } 304 305 public void testScrollToPositionWithPredictive() throws Throwable { 306 scrollToPositionWithPredictive(0, LinearLayoutManager.INVALID_OFFSET); 307 removeRecyclerView(); 308 scrollToPositionWithPredictive(3, 20); 309 removeRecyclerView(); 310 scrollToPositionWithPredictive(Config.DEFAULT_ITEM_COUNT / 2, 311 LinearLayoutManager.INVALID_OFFSET); 312 removeRecyclerView(); 313 scrollToPositionWithPredictive(Config.DEFAULT_ITEM_COUNT / 2, 10); 314 } 315 316 public void scrollToPositionWithPredictive(final int scrollPosition, final int scrollOffset) 317 throws Throwable { 318 setupByConfig(new Config(VERTICAL, false, false), true); 319 320 mLayoutManager.mOnLayoutListener = new OnLayoutListener() { 321 @Override 322 void after(RecyclerView.Recycler recycler, RecyclerView.State state) { 323 if (state.isPreLayout()) { 324 assertEquals("pending scroll position should still be pending", 325 scrollPosition, mLayoutManager.mPendingScrollPosition); 326 if (scrollOffset != LinearLayoutManager.INVALID_OFFSET) { 327 assertEquals("pending scroll position offset should still be pending", 328 scrollOffset, mLayoutManager.mPendingScrollPositionOffset); 329 } 330 } else { 331 RecyclerView.ViewHolder vh = 332 mRecyclerView.findViewHolderForLayoutPosition(scrollPosition); 333 assertNotNull("scroll to position should work", vh); 334 if (scrollOffset != LinearLayoutManager.INVALID_OFFSET) { 335 assertEquals("scroll offset should be applied properly", 336 mLayoutManager.getPaddingTop() + scrollOffset + 337 ((RecyclerView.LayoutParams) vh.itemView 338 .getLayoutParams()).topMargin, 339 mLayoutManager.getDecoratedTop(vh.itemView)); 340 } 341 } 342 } 343 }; 344 mLayoutManager.expectLayouts(2); 345 runTestOnUiThread(new Runnable() { 346 @Override 347 public void run() { 348 try { 349 mTestAdapter.addAndNotify(0, 1); 350 if (scrollOffset == LinearLayoutManager.INVALID_OFFSET) { 351 mLayoutManager.scrollToPosition(scrollPosition); 352 } else { 353 mLayoutManager.scrollToPositionWithOffset(scrollPosition, 354 scrollOffset); 355 } 356 357 } catch (Throwable throwable) { 358 throwable.printStackTrace(); 359 } 360 361 } 362 }); 363 mLayoutManager.waitForLayout(2); 364 checkForMainThreadException(); 365 } 366 367 private void waitForFirstLayout() throws Throwable { 368 mLayoutManager.expectLayouts(1); 369 setRecyclerView(mRecyclerView); 370 mLayoutManager.waitForLayout(2); 371 } 372 373 public void testRecycleDuringAnimations() throws Throwable { 374 final AtomicInteger childCount = new AtomicInteger(0); 375 final TestAdapter adapter = new TestAdapter(300) { 376 @Override 377 public TestViewHolder onCreateViewHolder(ViewGroup parent, 378 int viewType) { 379 final int cnt = childCount.incrementAndGet(); 380 final TestViewHolder testViewHolder = super.onCreateViewHolder(parent, viewType); 381 if (DEBUG) { 382 Log.d(TAG, "CHILD_CNT(create):" + cnt + ", " + testViewHolder); 383 } 384 return testViewHolder; 385 } 386 }; 387 setupByConfig(new Config(VERTICAL, false, false).itemCount(300) 388 .adapter(adapter), true); 389 390 final RecyclerView.RecycledViewPool pool = new RecyclerView.RecycledViewPool() { 391 @Override 392 public void putRecycledView(RecyclerView.ViewHolder scrap) { 393 super.putRecycledView(scrap); 394 int cnt = childCount.decrementAndGet(); 395 if (DEBUG) { 396 Log.d(TAG, "CHILD_CNT(put):" + cnt + ", " + scrap); 397 } 398 } 399 400 @Override 401 public RecyclerView.ViewHolder getRecycledView(int viewType) { 402 final RecyclerView.ViewHolder recycledView = super.getRecycledView(viewType); 403 if (recycledView != null) { 404 final int cnt = childCount.incrementAndGet(); 405 if (DEBUG) { 406 Log.d(TAG, "CHILD_CNT(get):" + cnt + ", " + recycledView); 407 } 408 } 409 return recycledView; 410 } 411 }; 412 pool.setMaxRecycledViews(mTestAdapter.getItemViewType(0), 500); 413 mRecyclerView.setRecycledViewPool(pool); 414 415 416 // now keep adding children to trigger more children being created etc. 417 for (int i = 0; i < 100; i ++) { 418 adapter.addAndNotify(15, 1); 419 Thread.sleep(15); 420 } 421 getInstrumentation().waitForIdleSync(); 422 waitForAnimations(2); 423 assertEquals("Children count should add up", childCount.get(), 424 mRecyclerView.getChildCount() + mRecyclerView.mRecycler.mCachedViews.size()); 425 426 // now trigger lots of add again, followed by a scroll to position 427 for (int i = 0; i < 100; i ++) { 428 adapter.addAndNotify(5 + (i % 3) * 3, 1); 429 Thread.sleep(25); 430 } 431 smoothScrollToPosition(mLayoutManager.findLastVisibleItemPosition() + 20); 432 waitForAnimations(2); 433 getInstrumentation().waitForIdleSync(); 434 assertEquals("Children count should add up", childCount.get(), 435 mRecyclerView.getChildCount() + mRecyclerView.mRecycler.mCachedViews.size()); 436 } 437 438 439 public void testGetFirstLastChildrenTest() throws Throwable { 440 for (Config config : mBaseVariations) { 441 getFirstLastChildrenTest(config); 442 } 443 } 444 445 public void testDontRecycleChildrenOnDetach() throws Throwable { 446 setupByConfig(new Config().recycleChildrenOnDetach(false), true); 447 runTestOnUiThread(new Runnable() { 448 @Override 449 public void run() { 450 int recyclerSize = mRecyclerView.mRecycler.getRecycledViewPool().size(); 451 mRecyclerView.setLayoutManager(new TestLayoutManager()); 452 assertEquals("No views are recycled", recyclerSize, 453 mRecyclerView.mRecycler.getRecycledViewPool().size()); 454 } 455 }); 456 } 457 458 public void testRecycleChildrenOnDetach() throws Throwable { 459 setupByConfig(new Config().recycleChildrenOnDetach(true), true); 460 final int childCount = mLayoutManager.getChildCount(); 461 runTestOnUiThread(new Runnable() { 462 @Override 463 public void run() { 464 int recyclerSize = mRecyclerView.mRecycler.getRecycledViewPool().size(); 465 mRecyclerView.mRecycler.getRecycledViewPool().setMaxRecycledViews( 466 mTestAdapter.getItemViewType(0), recyclerSize + childCount); 467 mRecyclerView.setLayoutManager(new TestLayoutManager()); 468 assertEquals("All children should be recycled", childCount + recyclerSize, 469 mRecyclerView.mRecycler.getRecycledViewPool().size()); 470 } 471 }); 472 } 473 474 public void getFirstLastChildrenTest(final Config config) throws Throwable { 475 setupByConfig(config, true); 476 Runnable viewInBoundsTest = new Runnable() { 477 @Override 478 public void run() { 479 VisibleChildren visibleChildren = mLayoutManager.traverseAndFindVisibleChildren(); 480 final String boundsLog = mLayoutManager.getBoundsLog(); 481 assertEquals(config + ":\nfirst visible child should match traversal result\n" 482 + boundsLog, visibleChildren.firstVisiblePosition, 483 mLayoutManager.findFirstVisibleItemPosition() 484 ); 485 assertEquals( 486 config + ":\nfirst fully visible child should match traversal result\n" 487 + boundsLog, visibleChildren.firstFullyVisiblePosition, 488 mLayoutManager.findFirstCompletelyVisibleItemPosition() 489 ); 490 491 assertEquals(config + ":\nlast visible child should match traversal result\n" 492 + boundsLog, visibleChildren.lastVisiblePosition, 493 mLayoutManager.findLastVisibleItemPosition() 494 ); 495 assertEquals( 496 config + ":\nlast fully visible child should match traversal result\n" 497 + boundsLog, visibleChildren.lastFullyVisiblePosition, 498 mLayoutManager.findLastCompletelyVisibleItemPosition() 499 ); 500 } 501 }; 502 runTestOnUiThread(viewInBoundsTest); 503 // smooth scroll to end of the list and keep testing meanwhile. This will test pre-caching 504 // case 505 final int scrollPosition = config.mStackFromEnd ? 0 : mTestAdapter.getItemCount(); 506 runTestOnUiThread(new Runnable() { 507 @Override 508 public void run() { 509 mRecyclerView.smoothScrollToPosition(scrollPosition); 510 } 511 }); 512 while (mLayoutManager.isSmoothScrolling() || 513 mRecyclerView.getScrollState() != RecyclerView.SCROLL_STATE_IDLE) { 514 runTestOnUiThread(viewInBoundsTest); 515 Thread.sleep(400); 516 } 517 // delete all items 518 mLayoutManager.expectLayouts(2); 519 mTestAdapter.deleteAndNotify(0, mTestAdapter.getItemCount()); 520 mLayoutManager.waitForLayout(2); 521 // test empty case 522 runTestOnUiThread(viewInBoundsTest); 523 // set a new adapter with huge items to test full bounds check 524 mLayoutManager.expectLayouts(1); 525 final int totalSpace = mLayoutManager.mOrientationHelper.getTotalSpace(); 526 final TestAdapter newAdapter = new TestAdapter(100) { 527 @Override 528 public void onBindViewHolder(TestViewHolder holder, 529 int position) { 530 super.onBindViewHolder(holder, position); 531 if (config.mOrientation == HORIZONTAL) { 532 holder.itemView.setMinimumWidth(totalSpace + 5); 533 } else { 534 holder.itemView.setMinimumHeight(totalSpace + 5); 535 } 536 } 537 }; 538 runTestOnUiThread(new Runnable() { 539 @Override 540 public void run() { 541 mRecyclerView.setAdapter(newAdapter); 542 } 543 }); 544 mLayoutManager.waitForLayout(2); 545 runTestOnUiThread(viewInBoundsTest); 546 } 547 548 public void testSavedState() throws Throwable { 549 PostLayoutRunnable[] postLayoutOptions = new PostLayoutRunnable[]{ 550 new PostLayoutRunnable() { 551 @Override 552 public void run() throws Throwable { 553 // do nothing 554 } 555 556 @Override 557 public String describe() { 558 return "doing nothing"; 559 } 560 }, 561 new PostLayoutRunnable() { 562 @Override 563 public void run() throws Throwable { 564 mLayoutManager.expectLayouts(1); 565 scrollToPosition(mTestAdapter.getItemCount() * 3 / 4); 566 mLayoutManager.waitForLayout(2); 567 } 568 569 @Override 570 public String describe() { 571 return "scroll to position"; 572 } 573 }, 574 new PostLayoutRunnable() { 575 @Override 576 public void run() throws Throwable { 577 mLayoutManager.expectLayouts(1); 578 scrollToPositionWithOffset(mTestAdapter.getItemCount() * 1 / 3, 579 50); 580 mLayoutManager.waitForLayout(2); 581 } 582 583 @Override 584 public String describe() { 585 return "scroll to position with positive offset"; 586 } 587 }, 588 new PostLayoutRunnable() { 589 @Override 590 public void run() throws Throwable { 591 mLayoutManager.expectLayouts(1); 592 scrollToPositionWithOffset(mTestAdapter.getItemCount() * 2 / 3, 593 -50); 594 mLayoutManager.waitForLayout(2); 595 } 596 597 @Override 598 public String describe() { 599 return "scroll to position with negative offset"; 600 } 601 } 602 }; 603 604 PostRestoreRunnable[] postRestoreOptions = new PostRestoreRunnable[]{ 605 new PostRestoreRunnable() { 606 @Override 607 public String describe() { 608 return "Doing nothing"; 609 } 610 }, 611 new PostRestoreRunnable() { 612 @Override 613 void onAfterRestore(Config config) throws Throwable { 614 // update config as well so that restore assertions will work 615 config.mOrientation = 1 - config.mOrientation; 616 mLayoutManager.setOrientation(config.mOrientation); 617 } 618 619 @Override 620 boolean shouldLayoutMatch(Config config) { 621 return config.mItemCount == 0; 622 } 623 624 @Override 625 public String describe() { 626 return "Changing orientation"; 627 } 628 }, 629 new PostRestoreRunnable() { 630 @Override 631 void onAfterRestore(Config config) throws Throwable { 632 config.mStackFromEnd = !config.mStackFromEnd; 633 mLayoutManager.setStackFromEnd(config.mStackFromEnd); 634 } 635 636 @Override 637 boolean shouldLayoutMatch(Config config) { 638 return true; //stack from end should not move items on change 639 } 640 641 @Override 642 public String describe() { 643 return "Changing stack from end"; 644 } 645 }, 646 new PostRestoreRunnable() { 647 @Override 648 void onAfterRestore(Config config) throws Throwable { 649 config.mReverseLayout = !config.mReverseLayout; 650 mLayoutManager.setReverseLayout(config.mReverseLayout); 651 } 652 653 @Override 654 boolean shouldLayoutMatch(Config config) { 655 return config.mItemCount == 0; 656 } 657 658 @Override 659 public String describe() { 660 return "Changing reverse layout"; 661 } 662 }, 663 new PostRestoreRunnable() { 664 @Override 665 void onAfterRestore(Config config) throws Throwable { 666 config.mRecycleChildrenOnDetach = !config.mRecycleChildrenOnDetach; 667 mLayoutManager.setRecycleChildrenOnDetach(config.mRecycleChildrenOnDetach); 668 } 669 670 @Override 671 boolean shouldLayoutMatch(Config config) { 672 return true; 673 } 674 675 @Override 676 String describe() { 677 return "Change should recycle children"; 678 } 679 }, 680 new PostRestoreRunnable() { 681 int position; 682 @Override 683 void onAfterRestore(Config config) throws Throwable { 684 position = mTestAdapter.getItemCount() / 2; 685 mLayoutManager.scrollToPosition(position); 686 } 687 688 @Override 689 boolean shouldLayoutMatch(Config config) { 690 return mTestAdapter.getItemCount() == 0; 691 } 692 693 @Override 694 String describe() { 695 return "Scroll to position " + position ; 696 } 697 698 @Override 699 void onAfterReLayout(Config config) { 700 if (mTestAdapter.getItemCount() > 0) { 701 assertEquals(config + ":scrolled view should be last completely visible", 702 position, 703 config.mStackFromEnd ? 704 mLayoutManager.findLastCompletelyVisibleItemPosition() 705 : mLayoutManager.findFirstCompletelyVisibleItemPosition()); 706 } 707 } 708 } 709 }; 710 boolean[] waitForLayoutOptions = new boolean[]{true, false}; 711 List<Config> variations = addConfigVariation(mBaseVariations, "mItemCount", 0, 300); 712 variations = addConfigVariation(variations, "mRecycleChildrenOnDetach", true); 713 for (Config config : variations) { 714 for (PostLayoutRunnable postLayoutRunnable : postLayoutOptions) { 715 for (boolean waitForLayout : waitForLayoutOptions) { 716 for (PostRestoreRunnable postRestoreRunnable : postRestoreOptions) { 717 savedStateTest((Config) config.clone(), waitForLayout, postLayoutRunnable, 718 postRestoreRunnable); 719 removeRecyclerView(); 720 } 721 722 } 723 } 724 } 725 } 726 727 public void savedStateTest(Config config, boolean waitForLayout, 728 PostLayoutRunnable postLayoutOperation, PostRestoreRunnable postRestoreOperation) 729 throws Throwable { 730 if (DEBUG) { 731 Log.d(TAG, "testing saved state with wait for layout = " + waitForLayout + " config " + 732 config + " post layout action " + postLayoutOperation.describe() + 733 "post restore action " + postRestoreOperation.describe()); 734 } 735 setupByConfig(config, false); 736 if (waitForLayout) { 737 waitForFirstLayout(); 738 postLayoutOperation.run(); 739 } 740 Map<Item, Rect> before = mLayoutManager.collectChildCoordinates(); 741 Parcelable savedState = mRecyclerView.onSaveInstanceState(); 742 // we append a suffix to the parcelable to test out of bounds 743 String parcelSuffix = UUID.randomUUID().toString(); 744 Parcel parcel = Parcel.obtain(); 745 savedState.writeToParcel(parcel, 0); 746 parcel.writeString(parcelSuffix); 747 removeRecyclerView(); 748 // reset for reading 749 parcel.setDataPosition(0); 750 // re-create 751 savedState = RecyclerView.SavedState.CREATOR.createFromParcel(parcel); 752 removeRecyclerView(); 753 754 RecyclerView restored = new RecyclerView(getActivity()); 755 // this config should be no op. 756 mLayoutManager = new WrappedLinearLayoutManager(getActivity(), 757 config.mOrientation, config.mReverseLayout); 758 mLayoutManager.setStackFromEnd(config.mStackFromEnd); 759 restored.setLayoutManager(mLayoutManager); 760 // use the same adapter for Rect matching 761 restored.setAdapter(mTestAdapter); 762 restored.onRestoreInstanceState(savedState); 763 postRestoreOperation.onAfterRestore(config); 764 assertEquals("Parcel reading should not go out of bounds", parcelSuffix, 765 parcel.readString()); 766 mLayoutManager.expectLayouts(1); 767 setRecyclerView(restored); 768 mLayoutManager.waitForLayout(2); 769 // calculate prefix here instead of above to include post restore changes 770 final String logPrefix = config + "\npostLayout:" + postLayoutOperation.describe() + 771 "\npostRestore:" + postRestoreOperation.describe() + "\n"; 772 assertEquals(logPrefix + " on saved state, reverse layout should be preserved", 773 config.mReverseLayout, mLayoutManager.getReverseLayout()); 774 assertEquals(logPrefix + " on saved state, orientation should be preserved", 775 config.mOrientation, mLayoutManager.getOrientation()); 776 assertEquals(logPrefix + " on saved state, stack from end should be preserved", 777 config.mStackFromEnd, mLayoutManager.getStackFromEnd()); 778 if (waitForLayout) { 779 if (postRestoreOperation.shouldLayoutMatch(config)) { 780 assertRectSetsEqual( 781 logPrefix + ": on restore, previous view positions should be preserved", 782 before, mLayoutManager.collectChildCoordinates()); 783 } else { 784 assertRectSetsNotEqual( 785 logPrefix 786 + ": on restore with changes, previous view positions should NOT " 787 + "be preserved", 788 before, mLayoutManager.collectChildCoordinates()); 789 } 790 postRestoreOperation.onAfterReLayout(config); 791 } 792 } 793 794 void scrollToPositionWithOffset(final int position, final int offset) throws Throwable { 795 runTestOnUiThread(new Runnable() { 796 @Override 797 public void run() { 798 mLayoutManager.scrollToPositionWithOffset(position, offset); 799 } 800 }); 801 } 802 803 public void assertRectSetsNotEqual(String message, Map<Item, Rect> before, 804 Map<Item, Rect> after) { 805 Throwable throwable = null; 806 try { 807 assertRectSetsEqual("NOT " + message, before, after); 808 } catch (Throwable t) { 809 throwable = t; 810 } 811 assertNotNull(message + "\ntwo layout should be different", throwable); 812 } 813 814 public void assertRectSetsEqual(String message, Map<Item, Rect> before, Map<Item, Rect> after) { 815 StringBuilder sb = new StringBuilder(); 816 sb.append("checking rectangle equality."); 817 sb.append("before:\n"); 818 for (Map.Entry<Item, Rect> entry : before.entrySet()) { 819 sb.append(entry.getKey().mAdapterIndex + ":" + entry.getValue()).append("\n"); 820 } 821 sb.append("after:\n"); 822 for (Map.Entry<Item, Rect> entry : after.entrySet()) { 823 sb.append(entry.getKey().mAdapterIndex + ":" + entry.getValue()).append("\n"); 824 } 825 message = message + "\n" + sb.toString(); 826 assertEquals(message + ":\nitem counts should be equal", before.size() 827 , after.size()); 828 for (Map.Entry<Item, Rect> entry : before.entrySet()) { 829 Rect afterRect = after.get(entry.getKey()); 830 assertNotNull(message + ":\nSame item should be visible after simple re-layout", 831 afterRect); 832 assertEquals(message + ":\nItem should be laid out at the same coordinates", 833 entry.getValue(), afterRect); 834 } 835 } 836 837 public void testAccessibilityPositions() throws Throwable { 838 setupByConfig(new Config(VERTICAL, false, false), true); 839 final AccessibilityDelegateCompat delegateCompat = mRecyclerView 840 .getCompatAccessibilityDelegate(); 841 final AccessibilityEvent event = AccessibilityEvent.obtain(); 842 runTestOnUiThread(new Runnable() { 843 @Override 844 public void run() { 845 delegateCompat.onInitializeAccessibilityEvent(mRecyclerView, event); 846 } 847 }); 848 final AccessibilityRecordCompat record = AccessibilityEventCompat 849 .asRecord(event); 850 assertEquals("result should have first position", 851 record.getFromIndex(), 852 mLayoutManager.findFirstVisibleItemPosition()); 853 assertEquals("result should have last position", 854 record.getToIndex(), 855 mLayoutManager.findLastVisibleItemPosition()); 856 } 857 858 static class VisibleChildren { 859 860 int firstVisiblePosition = RecyclerView.NO_POSITION; 861 862 int firstFullyVisiblePosition = RecyclerView.NO_POSITION; 863 864 int lastVisiblePosition = RecyclerView.NO_POSITION; 865 866 int lastFullyVisiblePosition = RecyclerView.NO_POSITION; 867 868 @Override 869 public String toString() { 870 return "VisibleChildren{" + 871 "firstVisiblePosition=" + firstVisiblePosition + 872 ", firstFullyVisiblePosition=" + firstFullyVisiblePosition + 873 ", lastVisiblePosition=" + lastVisiblePosition + 874 ", lastFullyVisiblePosition=" + lastFullyVisiblePosition + 875 '}'; 876 } 877 } 878 879 abstract private class PostLayoutRunnable { 880 881 abstract void run() throws Throwable; 882 883 abstract String describe(); 884 } 885 886 abstract private class PostRestoreRunnable { 887 888 void onAfterRestore(Config config) throws Throwable { 889 } 890 891 abstract String describe(); 892 893 boolean shouldLayoutMatch(Config config) { 894 return true; 895 } 896 897 void onAfterReLayout(Config config) { 898 899 }; 900 } 901 902 class WrappedLinearLayoutManager extends LinearLayoutManager { 903 904 CountDownLatch layoutLatch; 905 906 OrientationHelper mSecondaryOrientation; 907 908 OnLayoutListener mOnLayoutListener; 909 910 public WrappedLinearLayoutManager(Context context, int orientation, boolean reverseLayout) { 911 super(context, orientation, reverseLayout); 912 } 913 914 public void expectLayouts(int count) { 915 layoutLatch = new CountDownLatch(count); 916 } 917 918 public void waitForLayout(long timeout) throws InterruptedException { 919 waitForLayout(timeout, TimeUnit.SECONDS); 920 } 921 922 @Override 923 public void setOrientation(int orientation) { 924 super.setOrientation(orientation); 925 mSecondaryOrientation = null; 926 } 927 928 @Override 929 public void removeAndRecycleView(View child, RecyclerView.Recycler recycler) { 930 if (DEBUG) { 931 Log.d(TAG, "recycling view " + mRecyclerView.getChildViewHolder(child)); 932 } 933 super.removeAndRecycleView(child, recycler); 934 } 935 936 @Override 937 public void removeAndRecycleViewAt(int index, RecyclerView.Recycler recycler) { 938 if (DEBUG) { 939 Log.d(TAG, "recycling view at" + mRecyclerView.getChildViewHolder(getChildAt(index))); 940 } 941 super.removeAndRecycleViewAt(index, recycler); 942 } 943 944 @Override 945 void ensureLayoutState() { 946 super.ensureLayoutState(); 947 if (mSecondaryOrientation == null) { 948 mSecondaryOrientation = OrientationHelper.createOrientationHelper(this, 949 1 - getOrientation()); 950 } 951 } 952 953 private void waitForLayout(long timeout, TimeUnit timeUnit) throws InterruptedException { 954 layoutLatch.await(timeout * (DEBUG ? 100 : 1), timeUnit); 955 assertEquals("all expected layouts should be executed at the expected time", 956 0, layoutLatch.getCount()); 957 getInstrumentation().waitForIdleSync(); 958 } 959 960 public String getBoundsLog() { 961 StringBuilder sb = new StringBuilder(); 962 sb.append("view bounds:[start:").append(mOrientationHelper.getStartAfterPadding()) 963 .append(",").append(" end").append(mOrientationHelper.getEndAfterPadding()); 964 sb.append("\nchildren bounds\n"); 965 final int childCount = getChildCount(); 966 for (int i = 0; i < childCount; i++) { 967 View child = getChildAt(i); 968 sb.append("child (ind:").append(i).append(", pos:").append(getPosition(child)) 969 .append("[").append("start:").append( 970 mOrientationHelper.getDecoratedStart(child)).append(", end:") 971 .append(mOrientationHelper.getDecoratedEnd(child)).append("]\n"); 972 } 973 return sb.toString(); 974 } 975 976 public void waitForAnimationsToEnd(int timeoutInSeconds) throws InterruptedException { 977 RecyclerView.ItemAnimator itemAnimator = mRecyclerView.getItemAnimator(); 978 if (itemAnimator == null) { 979 return; 980 } 981 final CountDownLatch latch = new CountDownLatch(1); 982 final boolean running = itemAnimator.isRunning( 983 new RecyclerView.ItemAnimator.ItemAnimatorFinishedListener() { 984 @Override 985 public void onAnimationsFinished() { 986 latch.countDown(); 987 } 988 } 989 ); 990 if (running) { 991 latch.await(timeoutInSeconds, TimeUnit.SECONDS); 992 } 993 } 994 995 public VisibleChildren traverseAndFindVisibleChildren() { 996 int childCount = getChildCount(); 997 final VisibleChildren visibleChildren = new VisibleChildren(); 998 final int start = mOrientationHelper.getStartAfterPadding(); 999 final int end = mOrientationHelper.getEndAfterPadding(); 1000 for (int i = 0; i < childCount; i++) { 1001 View child = getChildAt(i); 1002 final int childStart = mOrientationHelper.getDecoratedStart(child); 1003 final int childEnd = mOrientationHelper.getDecoratedEnd(child); 1004 final boolean fullyVisible = childStart >= start && childEnd <= end; 1005 final boolean hidden = childEnd <= start || childStart >= end; 1006 if (hidden) { 1007 continue; 1008 } 1009 final int position = getPosition(child); 1010 if (fullyVisible) { 1011 if (position < visibleChildren.firstFullyVisiblePosition || 1012 visibleChildren.firstFullyVisiblePosition == RecyclerView.NO_POSITION) { 1013 visibleChildren.firstFullyVisiblePosition = position; 1014 } 1015 1016 if (position > visibleChildren.lastFullyVisiblePosition) { 1017 visibleChildren.lastFullyVisiblePosition = position; 1018 } 1019 } 1020 1021 if (position < visibleChildren.firstVisiblePosition || 1022 visibleChildren.firstVisiblePosition == RecyclerView.NO_POSITION) { 1023 visibleChildren.firstVisiblePosition = position; 1024 } 1025 1026 if (position > visibleChildren.lastVisiblePosition) { 1027 visibleChildren.lastVisiblePosition = position; 1028 } 1029 1030 } 1031 return visibleChildren; 1032 } 1033 1034 Rect getViewBounds(View view) { 1035 if (getOrientation() == HORIZONTAL) { 1036 return new Rect( 1037 mOrientationHelper.getDecoratedStart(view), 1038 mSecondaryOrientation.getDecoratedStart(view), 1039 mOrientationHelper.getDecoratedEnd(view), 1040 mSecondaryOrientation.getDecoratedEnd(view)); 1041 } else { 1042 return new Rect( 1043 mSecondaryOrientation.getDecoratedStart(view), 1044 mOrientationHelper.getDecoratedStart(view), 1045 mSecondaryOrientation.getDecoratedEnd(view), 1046 mOrientationHelper.getDecoratedEnd(view)); 1047 } 1048 1049 } 1050 1051 Map<Item, Rect> collectChildCoordinates() throws Throwable { 1052 final Map<Item, Rect> items = new LinkedHashMap<Item, Rect>(); 1053 runTestOnUiThread(new Runnable() { 1054 @Override 1055 public void run() { 1056 final int childCount = getChildCount(); 1057 for (int i = 0; i < childCount; i++) { 1058 View child = getChildAt(i); 1059 RecyclerView.LayoutParams lp = (RecyclerView.LayoutParams) child 1060 .getLayoutParams(); 1061 TestViewHolder vh = (TestViewHolder) lp.mViewHolder; 1062 items.put(vh.mBoundItem, getViewBounds(child)); 1063 } 1064 } 1065 }); 1066 return items; 1067 } 1068 1069 @Override 1070 public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) { 1071 try { 1072 if (mOnLayoutListener != null) { 1073 mOnLayoutListener.before(recycler, state); 1074 } 1075 super.onLayoutChildren(recycler, state); 1076 if (mOnLayoutListener != null) { 1077 mOnLayoutListener.after(recycler, state); 1078 } 1079 } catch (Throwable t) { 1080 postExceptionToInstrumentation(t); 1081 } 1082 layoutLatch.countDown(); 1083 } 1084 1085 1086 } 1087 1088 static class OnLayoutListener { 1089 void before(RecyclerView.Recycler recycler, RecyclerView.State state){} 1090 void after(RecyclerView.Recycler recycler, RecyclerView.State state){} 1091 } 1092 1093 static class Config implements Cloneable { 1094 1095 private static final int DEFAULT_ITEM_COUNT = 100; 1096 1097 private boolean mStackFromEnd; 1098 1099 int mOrientation = VERTICAL; 1100 1101 boolean mReverseLayout = false; 1102 1103 boolean mRecycleChildrenOnDetach = false; 1104 1105 int mItemCount = DEFAULT_ITEM_COUNT; 1106 1107 TestAdapter mTestAdapter; 1108 1109 Config(int orientation, boolean reverseLayout, boolean stackFromEnd) { 1110 mOrientation = orientation; 1111 mReverseLayout = reverseLayout; 1112 mStackFromEnd = stackFromEnd; 1113 } 1114 1115 public Config() { 1116 1117 } 1118 1119 Config adapter(TestAdapter adapter) { 1120 mTestAdapter = adapter; 1121 return this; 1122 } 1123 1124 Config recycleChildrenOnDetach(boolean recycleChildrenOnDetach) { 1125 mRecycleChildrenOnDetach = recycleChildrenOnDetach; 1126 return this; 1127 } 1128 1129 Config orientation(int orientation) { 1130 mOrientation = orientation; 1131 return this; 1132 } 1133 1134 Config stackFromBottom(boolean stackFromBottom) { 1135 mStackFromEnd = stackFromBottom; 1136 return this; 1137 } 1138 1139 Config reverseLayout(boolean reverseLayout) { 1140 mReverseLayout = reverseLayout; 1141 return this; 1142 } 1143 1144 public Config itemCount(int itemCount) { 1145 mItemCount = itemCount; 1146 return this; 1147 } 1148 1149 // required by convention 1150 @Override 1151 public Object clone() throws CloneNotSupportedException { 1152 return super.clone(); 1153 } 1154 1155 @Override 1156 public String toString() { 1157 return "Config{" + 1158 "mStackFromEnd=" + mStackFromEnd + 1159 ", mOrientation=" + mOrientation + 1160 ", mReverseLayout=" + mReverseLayout + 1161 ", mRecycleChildrenOnDetach=" + mRecycleChildrenOnDetach + 1162 ", mItemCount=" + mItemCount + 1163 '}'; 1164 } 1165 } 1166} 1167