LinearLayoutManagerTest.java revision 668e774379c036a5d53d07ec69ed9ebee13a1fd9
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.util.Log; 24import android.util.StringBuilderPrinter; 25import android.view.View; 26 27import java.lang.reflect.Field; 28import java.util.ArrayList; 29import java.util.LinkedHashMap; 30import java.util.List; 31import java.util.Map; 32import java.util.UUID; 33import java.util.concurrent.CountDownLatch; 34import java.util.concurrent.TimeUnit; 35 36/** 37 * Includes tests for {@link LinearLayoutManager}. 38 * <p> 39 * Since most UI tests are not practical, these tests are focused on internal data representation 40 * and stability of LinearLayoutManager in response to different events (state change, scrolling 41 * etc) where it is very hard to do manual testing. 42 */ 43public class LinearLayoutManagerTest extends BaseRecyclerViewInstrumentationTest { 44 45 private static final boolean DEBUG = false; 46 47 private static final String TAG = "LinearLayoutManagerTest"; 48 49 WrappedLinearLayoutManager mLayoutManager; 50 51 TestAdapter mTestAdapter; 52 53 final List<Config> mBaseVariations = new ArrayList<Config>(); 54 55 @Override 56 protected void setUp() throws Exception { 57 super.setUp(); 58 for (int orientation : new int[]{LinearLayoutManager.VERTICAL, 59 LinearLayoutManager.HORIZONTAL}) { 60 for (boolean reverseLayout : new boolean[]{false, true}) { 61 for (boolean stackFromBottom : new boolean[]{false, true}) { 62 mBaseVariations.add(new Config(orientation, reverseLayout, stackFromBottom)); 63 } 64 } 65 } 66 } 67 68 protected List<Config> addConfigVariation(List<Config> base, String fieldName, 69 Object... variations) 70 throws CloneNotSupportedException, NoSuchFieldException, IllegalAccessException { 71 List<Config> newConfigs = new ArrayList<Config>(); 72 Field field = Config.class.getDeclaredField(fieldName); 73 for (Config config : base) { 74 for (Object variation : variations) { 75 Config newConfig = (Config) config.clone(); 76 field.set(newConfig, variation); 77 newConfigs.add(newConfig); 78 } 79 } 80 return newConfigs; 81 } 82 83 void setupByConfig(Config config, boolean waitForFirstLayout) throws Throwable { 84 mRecyclerView = new RecyclerView(getActivity()); 85 mRecyclerView.setHasFixedSize(true); 86 mTestAdapter = new TestAdapter(config.mItemCount); 87 mRecyclerView.setAdapter(mTestAdapter); 88 mLayoutManager = new WrappedLinearLayoutManager(getActivity(), config.mOrientation, 89 config.mReverseLayout); 90 mLayoutManager.setStackFromEnd(config.mStackFromEnd); 91 mRecyclerView.setLayoutManager(mLayoutManager); 92 if (waitForFirstLayout) { 93 waitForFirstLayout(); 94 } 95 } 96 97 private void waitForFirstLayout() throws Throwable { 98 mLayoutManager.expectLayouts(1); 99 setRecyclerView(mRecyclerView); 100 mLayoutManager.waitForLayout(2); 101 } 102 103 104 public void testGetFirstLastChildrenTest() throws Throwable { 105 for (Config config : mBaseVariations) { 106 getFirstLastChildrenTest(config); 107 } 108 } 109 110 public void getFirstLastChildrenTest(final Config config) throws Throwable { 111 setupByConfig(config, true); 112 Runnable viewInBoundsTest = new Runnable() { 113 @Override 114 public void run() { 115 VisibleChildren visibleChildren = mLayoutManager.traverseAndFindVisibleChildren(); 116 final String boundsLog = mLayoutManager.getBoundsLog(); 117 assertEquals(config + ":\nfirst visible child should match traversal result\n" 118 + boundsLog, visibleChildren.firstVisiblePosition, 119 mLayoutManager.findFirstVisibleItemPosition() 120 ); 121 assertEquals( 122 config + ":\nfirst fully visible child should match traversal result\n" 123 + boundsLog, visibleChildren.firstFullyVisiblePosition, 124 mLayoutManager.findFirstCompletelyVisibleItemPosition() 125 ); 126 127 assertEquals(config + ":\nlast visible child should match traversal result\n" 128 + boundsLog, visibleChildren.lastVisiblePosition, 129 mLayoutManager.findLastVisibleItemPosition() 130 ); 131 assertEquals( 132 config + ":\nlast fully visible child should match traversal result\n" 133 + boundsLog, visibleChildren.lastFullyVisiblePosition, 134 mLayoutManager.findLastCompletelyVisibleItemPosition() 135 ); 136 } 137 }; 138 runTestOnUiThread(viewInBoundsTest); 139 // smooth scroll to end of the list and keep testing meanwhile. This will test pre-caching 140 // case 141 final int scrollPosition = config.mStackFromEnd ? 0 : mTestAdapter.getItemCount(); 142 runTestOnUiThread(new Runnable() { 143 @Override 144 public void run() { 145 mRecyclerView.smoothScrollToPosition(scrollPosition); 146 } 147 }); 148 while (mLayoutManager.isSmoothScrolling() || 149 mRecyclerView.getScrollState() != RecyclerView.SCROLL_STATE_IDLE) { 150 runTestOnUiThread(viewInBoundsTest); 151 Thread.sleep(400); 152 } 153 // delete all items 154 mLayoutManager.expectLayouts(2); 155 mTestAdapter.deleteAndNotify(0, mTestAdapter.getItemCount()); 156 mLayoutManager.waitForLayout(2); 157 // test empty case 158 runTestOnUiThread(viewInBoundsTest); 159 // set a new adapter with huge items to test full bounds check 160 mLayoutManager.expectLayouts(1); 161 final int totalSpace = mLayoutManager.mOrientationHelper.getTotalSpace(); 162 final TestAdapter newAdapter = new TestAdapter(100) { 163 @Override 164 public void onBindViewHolder(TestViewHolder holder, 165 int position) { 166 super.onBindViewHolder(holder, position); 167 if (config.mOrientation == LinearLayoutManager.HORIZONTAL) { 168 holder.itemView.setMinimumWidth(totalSpace + 5); 169 } else { 170 holder.itemView.setMinimumHeight(totalSpace + 5); 171 } 172 } 173 }; 174 runTestOnUiThread(new Runnable() { 175 @Override 176 public void run() { 177 mRecyclerView.setAdapter(newAdapter); 178 } 179 }); 180 mLayoutManager.waitForLayout(2); 181 runTestOnUiThread(viewInBoundsTest); 182 } 183 184 public void testSavedState() throws Throwable { 185 PostLayoutRunnable[] postLayoutOptions = new PostLayoutRunnable[]{ 186 new PostLayoutRunnable() { 187 @Override 188 public void run() throws Throwable { 189 // do nothing 190 } 191 192 @Override 193 public String describe() { 194 return "doing nothing"; 195 } 196 }, 197 new PostLayoutRunnable() { 198 @Override 199 public void run() throws Throwable { 200 mLayoutManager.expectLayouts(1); 201 scrollToPosition(mTestAdapter.getItemCount() * 3 / 4); 202 mLayoutManager.waitForLayout(2); 203 } 204 205 @Override 206 public String describe() { 207 return "scroll to position"; 208 } 209 }, 210 new PostLayoutRunnable() { 211 @Override 212 public void run() throws Throwable { 213 mLayoutManager.expectLayouts(1); 214 scrollToPositionWithOffset(mTestAdapter.getItemCount() * 1 / 3, 215 50); 216 mLayoutManager.waitForLayout(2); 217 } 218 219 @Override 220 public String describe() { 221 return "scroll to position with positive offset"; 222 } 223 }, 224 new PostLayoutRunnable() { 225 @Override 226 public void run() throws Throwable { 227 mLayoutManager.expectLayouts(1); 228 scrollToPositionWithOffset(mTestAdapter.getItemCount() * 2 / 3, 229 -50); 230 mLayoutManager.waitForLayout(2); 231 } 232 233 @Override 234 public String describe() { 235 return "scroll to position with negative offset"; 236 } 237 } 238 }; 239 240 PostRestoreRunnable[] postRestoreOptions = new PostRestoreRunnable[]{ 241 new PostRestoreRunnable() { 242 @Override 243 public String describe() { 244 return "Doing nothing"; 245 } 246 }, 247 new PostRestoreRunnable() { 248 @Override 249 void onAfterRestore(Config config) throws Throwable { 250 // update config as well so that restore assertions will work 251 config.mOrientation = 1 - config.mOrientation; 252 mLayoutManager.setOrientation(config.mOrientation); 253 } 254 255 @Override 256 boolean shouldLayoutMatch(Config config) { 257 return config.mItemCount == 0; 258 } 259 260 @Override 261 public String describe() { 262 return "Changing orientation"; 263 } 264 }, 265 new PostRestoreRunnable() { 266 @Override 267 void onAfterRestore(Config config) throws Throwable { 268 config.mStackFromEnd = !config.mStackFromEnd; 269 mLayoutManager.setStackFromEnd(config.mStackFromEnd); 270 } 271 272 @Override 273 boolean shouldLayoutMatch(Config config) { 274 return true; //stack from end should not move items on change 275 } 276 277 @Override 278 public String describe() { 279 return "Changing stack from end"; 280 } 281 }, 282 new PostRestoreRunnable() { 283 @Override 284 void onAfterRestore(Config config) throws Throwable { 285 config.mReverseLayout = !config.mReverseLayout; 286 mLayoutManager.setReverseLayout(config.mReverseLayout); 287 } 288 289 @Override 290 boolean shouldLayoutMatch(Config config) { 291 return config.mItemCount == 0; 292 } 293 294 @Override 295 public String describe() { 296 return "Changing reverse layout"; 297 } 298 }, 299 new PostRestoreRunnable() { 300 int position; 301 @Override 302 void onAfterRestore(Config config) throws Throwable { 303 position = mTestAdapter.getItemCount() / 2; 304 mLayoutManager.scrollToPosition(position); 305 } 306 307 @Override 308 boolean shouldLayoutMatch(Config config) { 309 return mTestAdapter.getItemCount() == 0; 310 } 311 312 @Override 313 String describe() { 314 return "Scroll to position " + position ; 315 } 316 317 @Override 318 void onAfterReLayout(Config config) { 319 if (mTestAdapter.getItemCount() > 0) { 320 assertEquals(config + ":scrolled view should be last completely visible", 321 position, 322 config.mStackFromEnd ? 323 mLayoutManager.findLastCompletelyVisibleItemPosition() 324 : mLayoutManager.findFirstCompletelyVisibleItemPosition()); 325 } 326 } 327 } 328 }; 329 boolean[] waitForLayoutOptions = new boolean[]{true, false}; 330 for (Config config : addConfigVariation(mBaseVariations, "mItemCount", 0, 300)) { 331 for (PostLayoutRunnable postLayoutRunnable : postLayoutOptions) { 332 for (boolean waitForLayout : waitForLayoutOptions) { 333 for (PostRestoreRunnable postRestoreRunnable : postRestoreOptions) { 334 savedStateTest((Config) config.clone(), waitForLayout, postLayoutRunnable, 335 postRestoreRunnable); 336 removeRecyclerView(); 337 } 338 339 } 340 } 341 } 342 } 343 344 public void savedStateTest(Config config, boolean waitForLayout, 345 PostLayoutRunnable postLayoutOperation, PostRestoreRunnable postRestoreOperation) 346 throws Throwable { 347 if (DEBUG) { 348 Log.d(TAG, "testing saved state with wait for layout = " + waitForLayout + " config " + 349 config + " post layout action " + postLayoutOperation.describe() + 350 "post restore action " + postRestoreOperation.describe()); 351 } 352 setupByConfig(config, false); 353 if (waitForLayout) { 354 waitForFirstLayout(); 355 postLayoutOperation.run(); 356 } 357 Map<Item, Rect> before = mLayoutManager.collectChildCoordinates(); 358 Parcelable savedState = mRecyclerView.onSaveInstanceState(); 359 // we append a suffix to the parcelable to test out of bounds 360 String parcelSuffix = UUID.randomUUID().toString(); 361 Parcel parcel = Parcel.obtain(); 362 savedState.writeToParcel(parcel, 0); 363 parcel.writeString(parcelSuffix); 364 removeRecyclerView(); 365 // reset for reading 366 parcel.setDataPosition(0); 367 // re-create 368 savedState = RecyclerView.SavedState.CREATOR.createFromParcel(parcel); 369 removeRecyclerView(); 370 371 RecyclerView restored = new RecyclerView(getActivity()); 372 // this config should be no op. 373 mLayoutManager = new WrappedLinearLayoutManager(getActivity(), 374 1 - config.mOrientation, !config.mReverseLayout); 375 mLayoutManager.setStackFromEnd(!config.mStackFromEnd); 376 restored.setLayoutManager(mLayoutManager); 377 // use the same adapter for Rect matching 378 restored.setAdapter(mTestAdapter); 379 restored.onRestoreInstanceState(savedState); 380 postRestoreOperation.onAfterRestore(config); 381 assertEquals("Parcel reading should not go out of bounds", parcelSuffix, 382 parcel.readString()); 383 mLayoutManager.expectLayouts(1); 384 setRecyclerView(restored); 385 mLayoutManager.waitForLayout(2); 386 // calculate prefix here instead of above to include post restore changes 387 final String logPrefix = config + "\npostLayout:" + postLayoutOperation.describe() + 388 "\npostRestore:" + postRestoreOperation.describe() + "\n"; 389 assertEquals(logPrefix + " on saved state, reverse layout should be preserved", 390 config.mReverseLayout, mLayoutManager.getReverseLayout()); 391 assertEquals(logPrefix + " on saved state, orientation should be preserved", 392 config.mOrientation, mLayoutManager.getOrientation()); 393 assertEquals(logPrefix + " on saved state, stack from end should be preserved", 394 config.mStackFromEnd, mLayoutManager.getStackFromEnd()); 395 if (waitForLayout) { 396 if (postRestoreOperation.shouldLayoutMatch(config)) { 397 assertRectSetsEqual( 398 logPrefix + ": on restore, previous view positions should be preserved", 399 before, mLayoutManager.collectChildCoordinates()); 400 } else { 401 assertRectSetsNotEqual( 402 logPrefix 403 + ": on restore with changes, previous view positions should NOT be preserved", 404 before, mLayoutManager.collectChildCoordinates()); 405 } 406 postRestoreOperation.onAfterReLayout(config); 407 } 408 } 409 410 void scrollToPositionWithOffset(final int position, final int offset) throws Throwable { 411 runTestOnUiThread(new Runnable() { 412 @Override 413 public void run() { 414 mLayoutManager.scrollToPositionWithOffset(position, offset); 415 } 416 }); 417 } 418 419 public void assertRectSetsNotEqual(String message, Map<Item, Rect> before, 420 Map<Item, Rect> after) { 421 Throwable throwable = null; 422 try { 423 assertRectSetsEqual("NOT " + message, before, after); 424 } catch (Throwable t) { 425 throwable = t; 426 } 427 assertNotNull(message + "\ntwo layout should be different", throwable); 428 } 429 430 public void assertRectSetsEqual(String message, Map<Item, Rect> before, Map<Item, Rect> after) { 431 StringBuilder sb = new StringBuilder(); 432 sb.append("checking rectangle equality."); 433 sb.append("before:\n"); 434 for (Map.Entry<Item, Rect> entry : before.entrySet()) { 435 sb.append(entry.getKey().mAdapterIndex + ":" + entry.getValue()).append("\n"); 436 } 437 sb.append("after:\n"); 438 for (Map.Entry<Item, Rect> entry : after.entrySet()) { 439 sb.append(entry.getKey().mAdapterIndex + ":" + entry.getValue()).append("\n"); 440 } 441 message = message + "\n" + sb.toString(); 442 assertEquals(message + ":\nitem counts should be equal", before.size() 443 , after.size()); 444 for (Map.Entry<Item, Rect> entry : before.entrySet()) { 445 Rect afterRect = after.get(entry.getKey()); 446 assertNotNull(message + ":\nSame item should be visible after simple re-layout", 447 afterRect); 448 assertEquals(message + ":\nItem should be laid out at the same coordinates", 449 entry.getValue(), afterRect); 450 } 451 } 452 453 static class VisibleChildren { 454 455 int firstVisiblePosition = RecyclerView.NO_POSITION; 456 457 int firstFullyVisiblePosition = RecyclerView.NO_POSITION; 458 459 int lastVisiblePosition = RecyclerView.NO_POSITION; 460 461 int lastFullyVisiblePosition = RecyclerView.NO_POSITION; 462 463 @Override 464 public String toString() { 465 return "VisibleChildren{" + 466 "firstVisiblePosition=" + firstVisiblePosition + 467 ", firstFullyVisiblePosition=" + firstFullyVisiblePosition + 468 ", lastVisiblePosition=" + lastVisiblePosition + 469 ", lastFullyVisiblePosition=" + lastFullyVisiblePosition + 470 '}'; 471 } 472 } 473 474 abstract private class PostLayoutRunnable { 475 476 abstract void run() throws Throwable; 477 478 abstract String describe(); 479 } 480 481 abstract private class PostRestoreRunnable { 482 483 void onAfterRestore(Config config) throws Throwable { 484 } 485 486 abstract String describe(); 487 488 boolean shouldLayoutMatch(Config config) { 489 return true; 490 } 491 492 void onAfterReLayout(Config config) { 493 494 }; 495 } 496 497 class WrappedLinearLayoutManager extends LinearLayoutManager { 498 499 CountDownLatch layoutLatch; 500 501 OrientationHelper mSecondaryOrientation; 502 503 public WrappedLinearLayoutManager(Context context, int orientation, boolean reverseLayout) { 504 super(context, orientation, reverseLayout); 505 } 506 507 public void expectLayouts(int count) { 508 layoutLatch = new CountDownLatch(count); 509 } 510 511 public void waitForLayout(long timeout) throws InterruptedException { 512 waitForLayout(timeout, TimeUnit.SECONDS); 513 } 514 515 @Override 516 public void setOrientation(int orientation) { 517 super.setOrientation(orientation); 518 mSecondaryOrientation = null; 519 } 520 521 @Override 522 void ensureLayoutState() { 523 super.ensureLayoutState(); 524 if (mSecondaryOrientation == null) { 525 mSecondaryOrientation = OrientationHelper.createOrientationHelper(this, 526 1 - getOrientation()); 527 } 528 } 529 530 private void waitForLayout(long timeout, TimeUnit timeUnit) throws InterruptedException { 531 layoutLatch.await(timeout, timeUnit); 532 assertEquals("all expected layouts should be executed at the expected time", 533 0, layoutLatch.getCount()); 534 } 535 536 public String getBoundsLog() { 537 StringBuilder sb = new StringBuilder(); 538 sb.append("view bounds:[start:").append(mOrientationHelper.getStartAfterPadding()) 539 .append(",").append(" end").append(mOrientationHelper.getEndAfterPadding()); 540 sb.append("\nchildren bounds\n"); 541 final int childCount = getChildCount(); 542 for (int i = 0; i < childCount; i++) { 543 View child = getChildAt(i); 544 sb.append("child (ind:").append(i).append(", pos:").append(getPosition(child)) 545 .append("[").append("start:").append( 546 mOrientationHelper.getDecoratedStart(child)).append(", end:") 547 .append(mOrientationHelper.getDecoratedEnd(child)).append("]\n"); 548 } 549 return sb.toString(); 550 } 551 552 public VisibleChildren traverseAndFindVisibleChildren() { 553 int childCount = getChildCount(); 554 final VisibleChildren visibleChildren = new VisibleChildren(); 555 final int start = mOrientationHelper.getStartAfterPadding(); 556 final int end = mOrientationHelper.getEndAfterPadding(); 557 for (int i = 0; i < childCount; i++) { 558 View child = getChildAt(i); 559 final int childStart = mOrientationHelper.getDecoratedStart(child); 560 final int childEnd = mOrientationHelper.getDecoratedEnd(child); 561 final boolean fullyVisible = childStart >= start && childEnd <= end; 562 final boolean hidden = childEnd <= start || childStart >= end; 563 if (hidden) { 564 continue; 565 } 566 final int position = getPosition(child); 567 if (fullyVisible) { 568 if (position < visibleChildren.firstFullyVisiblePosition || 569 visibleChildren.firstFullyVisiblePosition == RecyclerView.NO_POSITION) { 570 visibleChildren.firstFullyVisiblePosition = position; 571 } 572 573 if (position > visibleChildren.lastFullyVisiblePosition) { 574 visibleChildren.lastFullyVisiblePosition = position; 575 } 576 } 577 578 if (position < visibleChildren.firstVisiblePosition || 579 visibleChildren.firstVisiblePosition == RecyclerView.NO_POSITION) { 580 visibleChildren.firstVisiblePosition = position; 581 } 582 583 if (position > visibleChildren.lastVisiblePosition) { 584 visibleChildren.lastVisiblePosition = position; 585 } 586 587 } 588 return visibleChildren; 589 } 590 591 Rect getViewBounds(View view) { 592 if (getOrientation() == HORIZONTAL) { 593 return new Rect( 594 mOrientationHelper.getDecoratedStart(view), 595 mSecondaryOrientation.getDecoratedStart(view), 596 mOrientationHelper.getDecoratedEnd(view), 597 mSecondaryOrientation.getDecoratedEnd(view)); 598 } else { 599 return new Rect( 600 mSecondaryOrientation.getDecoratedStart(view), 601 mOrientationHelper.getDecoratedStart(view), 602 mSecondaryOrientation.getDecoratedEnd(view), 603 mOrientationHelper.getDecoratedEnd(view)); 604 } 605 606 } 607 608 Map<Item, Rect> collectChildCoordinates() throws Throwable { 609 final Map<Item, Rect> items = new LinkedHashMap<Item, Rect>(); 610 runTestOnUiThread(new Runnable() { 611 @Override 612 public void run() { 613 final int childCount = getChildCount(); 614 for (int i = 0; i < childCount; i++) { 615 View child = getChildAt(i); 616 RecyclerView.LayoutParams lp = (RecyclerView.LayoutParams) child 617 .getLayoutParams(); 618 TestViewHolder vh = (TestViewHolder) lp.mViewHolder; 619 items.put(vh.mBindedItem, getViewBounds(child)); 620 } 621 } 622 }); 623 return items; 624 } 625 626 @Override 627 public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) { 628 super.onLayoutChildren(recycler, state); 629 layoutLatch.countDown(); 630 } 631 } 632 633 static class Config implements Cloneable { 634 635 private static final int DEFAULT_ITEM_COUNT = 100; 636 637 private boolean mStackFromEnd; 638 639 int mOrientation = LinearLayoutManager.VERTICAL; 640 641 boolean mReverseLayout = false; 642 643 int mItemCount = DEFAULT_ITEM_COUNT; 644 645 Config(int orientation, boolean reverseLayout, boolean stackFromEnd) { 646 mOrientation = orientation; 647 mReverseLayout = reverseLayout; 648 mStackFromEnd = stackFromEnd; 649 } 650 651 public Config() { 652 653 } 654 655 Config orientation(int orientation) { 656 mOrientation = orientation; 657 return this; 658 } 659 660 Config stackFromBottom(boolean stackFromBottom) { 661 mStackFromEnd = stackFromBottom; 662 return this; 663 } 664 665 Config reverseLayout(boolean reverseLayout) { 666 mReverseLayout = reverseLayout; 667 return this; 668 } 669 670 public Config itemCount(int itemCount) { 671 mItemCount = itemCount; 672 return this; 673 } 674 675 // required by convention 676 @Override 677 public Object clone() throws CloneNotSupportedException { 678 return super.clone(); 679 } 680 681 @Override 682 public String toString() { 683 return "Config{" + 684 "mStackFromEnd=" + mStackFromEnd + 685 ", mOrientation=" + mOrientation + 686 ", mReverseLayout=" + mReverseLayout + 687 ", mItemCount=" + mItemCount + 688 '}'; 689 } 690 } 691} 692