1/* 2 * Copyright (C) 2015 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 */ 16package android.support.v7.widget; 17 18import static android.support.v7.widget.LinearLayoutManager.HORIZONTAL; 19import static android.support.v7.widget.LinearLayoutManager.VERTICAL; 20import static android.view.ViewGroup.LayoutParams.MATCH_PARENT; 21import static android.view.ViewGroup.LayoutParams.WRAP_CONTENT; 22 23import static org.junit.Assert.assertEquals; 24import static org.junit.Assert.assertFalse; 25import static org.junit.Assert.assertNotNull; 26 27import static java.util.concurrent.TimeUnit.SECONDS; 28 29import android.content.Context; 30import android.graphics.Rect; 31import android.support.annotation.Nullable; 32import android.util.Log; 33import android.view.View; 34import android.view.ViewGroup; 35 36import org.hamcrest.CoreMatchers; 37import org.hamcrest.MatcherAssert; 38 39import java.lang.reflect.Field; 40import java.util.ArrayList; 41import java.util.LinkedHashMap; 42import java.util.List; 43import java.util.Map; 44import java.util.concurrent.CountDownLatch; 45import java.util.concurrent.TimeUnit; 46 47public class BaseLinearLayoutManagerTest extends BaseRecyclerViewInstrumentationTest { 48 49 protected static final boolean DEBUG = false; 50 protected static final String TAG = "LinearLayoutManagerTest"; 51 52 protected static List<Config> createBaseVariations() { 53 List<Config> variations = new ArrayList<>(); 54 for (int orientation : new int[]{VERTICAL, HORIZONTAL}) { 55 for (boolean reverseLayout : new boolean[]{false, true}) { 56 for (boolean stackFromBottom : new boolean[]{false, true}) { 57 for (boolean wrap : new boolean[]{false, true}) { 58 variations.add( 59 new Config(orientation, reverseLayout, stackFromBottom).wrap(wrap)); 60 } 61 62 } 63 } 64 } 65 return variations; 66 } 67 68 WrappedLinearLayoutManager mLayoutManager; 69 TestAdapter mTestAdapter; 70 71 protected static List<Config> addConfigVariation(List<Config> base, String fieldName, 72 Object... variations) 73 throws CloneNotSupportedException, NoSuchFieldException, IllegalAccessException { 74 List<Config> newConfigs = new ArrayList<Config>(); 75 Field field = Config.class.getDeclaredField(fieldName); 76 for (Config config : base) { 77 for (Object variation : variations) { 78 Config newConfig = (Config) config.clone(); 79 field.set(newConfig, variation); 80 newConfigs.add(newConfig); 81 } 82 } 83 return newConfigs; 84 } 85 86 void setupByConfig(Config config, boolean waitForFirstLayout) throws Throwable { 87 setupByConfig(config, waitForFirstLayout, null, null); 88 } 89 90 void setupByConfig(Config config, boolean waitForFirstLayout, 91 @Nullable RecyclerView.LayoutParams childLayoutParams, 92 @Nullable RecyclerView.LayoutParams parentLayoutParams) throws Throwable { 93 mRecyclerView = inflateWrappedRV(); 94 95 mRecyclerView.setHasFixedSize(true); 96 mTestAdapter = config.mTestAdapter == null 97 ? new TestAdapter(config.mItemCount, childLayoutParams) 98 : config.mTestAdapter; 99 mRecyclerView.setAdapter(mTestAdapter); 100 mLayoutManager = new WrappedLinearLayoutManager(getActivity(), config.mOrientation, 101 config.mReverseLayout); 102 mLayoutManager.setStackFromEnd(config.mStackFromEnd); 103 mLayoutManager.setRecycleChildrenOnDetach(config.mRecycleChildrenOnDetach); 104 mRecyclerView.setLayoutManager(mLayoutManager); 105 if (config.mWrap) { 106 mRecyclerView.setLayoutParams( 107 new ViewGroup.LayoutParams( 108 config.mOrientation == HORIZONTAL ? WRAP_CONTENT : MATCH_PARENT, 109 config.mOrientation == VERTICAL ? WRAP_CONTENT : MATCH_PARENT 110 ) 111 ); 112 } 113 if (parentLayoutParams != null) { 114 mRecyclerView.setLayoutParams(parentLayoutParams); 115 } 116 117 if (waitForFirstLayout) { 118 waitForFirstLayout(); 119 } 120 } 121 122 public void scrollToPositionWithPredictive(final int scrollPosition, final int scrollOffset) 123 throws Throwable { 124 setupByConfig(new Config(VERTICAL, false, false), true); 125 126 mLayoutManager.mOnLayoutListener = new OnLayoutListener() { 127 @Override 128 void after(RecyclerView.Recycler recycler, RecyclerView.State state) { 129 if (state.isPreLayout()) { 130 assertEquals("pending scroll position should still be pending", 131 scrollPosition, mLayoutManager.mPendingScrollPosition); 132 if (scrollOffset != LinearLayoutManager.INVALID_OFFSET) { 133 assertEquals("pending scroll position offset should still be pending", 134 scrollOffset, mLayoutManager.mPendingScrollPositionOffset); 135 } 136 } else { 137 RecyclerView.ViewHolder vh = 138 mRecyclerView.findViewHolderForLayoutPosition(scrollPosition); 139 assertNotNull("scroll to position should work", vh); 140 if (scrollOffset != LinearLayoutManager.INVALID_OFFSET) { 141 assertEquals("scroll offset should be applied properly", 142 mLayoutManager.getPaddingTop() + scrollOffset + 143 ((RecyclerView.LayoutParams) vh.itemView 144 .getLayoutParams()).topMargin, 145 mLayoutManager.getDecoratedTop(vh.itemView)); 146 } 147 } 148 } 149 }; 150 mLayoutManager.expectLayouts(2); 151 mActivityRule.runOnUiThread(new Runnable() { 152 @Override 153 public void run() { 154 try { 155 mTestAdapter.addAndNotify(0, 1); 156 if (scrollOffset == LinearLayoutManager.INVALID_OFFSET) { 157 mLayoutManager.scrollToPosition(scrollPosition); 158 } else { 159 mLayoutManager.scrollToPositionWithOffset(scrollPosition, 160 scrollOffset); 161 } 162 163 } catch (Throwable throwable) { 164 throwable.printStackTrace(); 165 } 166 167 } 168 }); 169 mLayoutManager.waitForLayout(2); 170 checkForMainThreadException(); 171 } 172 173 protected void waitForFirstLayout() throws Throwable { 174 mLayoutManager.expectLayouts(1); 175 setRecyclerView(mRecyclerView); 176 mLayoutManager.waitForLayout(2); 177 } 178 179 void scrollToPositionWithOffset(final int position, final int offset) throws Throwable { 180 mActivityRule.runOnUiThread(new Runnable() { 181 @Override 182 public void run() { 183 mLayoutManager.scrollToPositionWithOffset(position, offset); 184 } 185 }); 186 } 187 188 public void assertRectSetsNotEqual(String message, Map<Item, Rect> before, 189 Map<Item, Rect> after, boolean strictItemEquality) { 190 Throwable throwable = null; 191 try { 192 assertRectSetsEqual("NOT " + message, before, after, strictItemEquality); 193 } catch (Throwable t) { 194 throwable = t; 195 } 196 assertNotNull(message + "\ntwo layout should be different", throwable); 197 } 198 199 public void assertRectSetsEqual(String message, Map<Item, Rect> before, Map<Item, Rect> after) { 200 assertRectSetsEqual(message, before, after, true); 201 } 202 203 public void assertRectSetsEqual(String message, Map<Item, Rect> before, Map<Item, Rect> after, 204 boolean strictItemEquality) { 205 StringBuilder sb = new StringBuilder(); 206 sb.append("checking rectangle equality.\n"); 207 sb.append("before:\n"); 208 for (Map.Entry<Item, Rect> entry : before.entrySet()) { 209 sb.append(entry.getKey().mAdapterIndex + ":" + entry.getValue()).append("\n"); 210 } 211 sb.append("after:\n"); 212 for (Map.Entry<Item, Rect> entry : after.entrySet()) { 213 sb.append(entry.getKey().mAdapterIndex + ":" + entry.getValue()).append("\n"); 214 } 215 message = message + "\n" + sb.toString(); 216 assertEquals(message + ":\nitem counts should be equal", before.size() 217 , after.size()); 218 for (Map.Entry<Item, Rect> entry : before.entrySet()) { 219 final Item beforeItem = entry.getKey(); 220 Rect afterRect = null; 221 if (strictItemEquality) { 222 afterRect = after.get(beforeItem); 223 assertNotNull(message + ":\nSame item should be visible after simple re-layout", 224 afterRect); 225 } else { 226 for (Map.Entry<Item, Rect> afterEntry : after.entrySet()) { 227 final Item afterItem = afterEntry.getKey(); 228 if (afterItem.mAdapterIndex == beforeItem.mAdapterIndex) { 229 afterRect = afterEntry.getValue(); 230 break; 231 } 232 } 233 assertNotNull(message + ":\nItem with same adapter index should be visible " + 234 "after simple re-layout", 235 afterRect); 236 } 237 assertEquals(message + ":\nItem should be laid out at the same coordinates", 238 entry.getValue(), afterRect); 239 } 240 } 241 242 static class VisibleChildren { 243 244 int firstVisiblePosition = RecyclerView.NO_POSITION; 245 246 int firstFullyVisiblePosition = RecyclerView.NO_POSITION; 247 248 int lastVisiblePosition = RecyclerView.NO_POSITION; 249 250 int lastFullyVisiblePosition = RecyclerView.NO_POSITION; 251 252 @Override 253 public String toString() { 254 return "VisibleChildren{" + 255 "firstVisiblePosition=" + firstVisiblePosition + 256 ", firstFullyVisiblePosition=" + firstFullyVisiblePosition + 257 ", lastVisiblePosition=" + lastVisiblePosition + 258 ", lastFullyVisiblePosition=" + lastFullyVisiblePosition + 259 '}'; 260 } 261 } 262 263 static class OnLayoutListener { 264 265 void before(RecyclerView.Recycler recycler, RecyclerView.State state) { 266 } 267 268 void after(RecyclerView.Recycler recycler, RecyclerView.State state) { 269 } 270 } 271 272 static class Config implements Cloneable { 273 274 static final int DEFAULT_ITEM_COUNT = 250; 275 276 boolean mStackFromEnd; 277 278 int mOrientation = VERTICAL; 279 280 boolean mReverseLayout = false; 281 282 boolean mRecycleChildrenOnDetach = false; 283 284 int mItemCount = DEFAULT_ITEM_COUNT; 285 286 boolean mWrap = false; 287 288 TestAdapter mTestAdapter; 289 290 Config(int orientation, boolean reverseLayout, boolean stackFromEnd) { 291 mOrientation = orientation; 292 mReverseLayout = reverseLayout; 293 mStackFromEnd = stackFromEnd; 294 } 295 296 public Config() { 297 298 } 299 300 Config adapter(TestAdapter adapter) { 301 mTestAdapter = adapter; 302 return this; 303 } 304 305 Config recycleChildrenOnDetach(boolean recycleChildrenOnDetach) { 306 mRecycleChildrenOnDetach = recycleChildrenOnDetach; 307 return this; 308 } 309 310 Config orientation(int orientation) { 311 mOrientation = orientation; 312 return this; 313 } 314 315 Config stackFromBottom(boolean stackFromBottom) { 316 mStackFromEnd = stackFromBottom; 317 return this; 318 } 319 320 Config reverseLayout(boolean reverseLayout) { 321 mReverseLayout = reverseLayout; 322 return this; 323 } 324 325 public Config itemCount(int itemCount) { 326 mItemCount = itemCount; 327 return this; 328 } 329 330 // required by convention 331 @Override 332 public Object clone() throws CloneNotSupportedException { 333 return super.clone(); 334 } 335 336 @Override 337 public String toString() { 338 return "Config{" 339 + "mStackFromEnd=" + mStackFromEnd 340 + ",mOrientation=" + mOrientation 341 + ",mReverseLayout=" + mReverseLayout 342 + ",mRecycleChildrenOnDetach=" + mRecycleChildrenOnDetach 343 + ",mItemCount=" + mItemCount 344 + ",wrap=" + mWrap 345 + '}'; 346 } 347 348 public Config wrap(boolean wrap) { 349 mWrap = wrap; 350 return this; 351 } 352 } 353 354 class WrappedLinearLayoutManager extends LinearLayoutManager { 355 356 CountDownLatch layoutLatch; 357 CountDownLatch snapLatch; 358 CountDownLatch prefetchLatch; 359 CountDownLatch callbackLatch; 360 361 OrientationHelper mSecondaryOrientation; 362 363 OnLayoutListener mOnLayoutListener; 364 365 RecyclerView.OnScrollListener mCallbackListener = new RecyclerView.OnScrollListener() { 366 367 @Override 368 public void onScrollStateChanged(RecyclerView recyclerView, int newState) { 369 super.onScrollStateChanged(recyclerView, newState); 370 callbackLatch.countDown(); 371 if (callbackLatch.getCount() == 0L) { 372 removeOnScrollListener(this); 373 } 374 } 375 }; 376 377 public WrappedLinearLayoutManager(Context context, int orientation, boolean reverseLayout) { 378 super(context, orientation, reverseLayout); 379 } 380 381 public void expectLayouts(int count) { 382 layoutLatch = new CountDownLatch(count); 383 } 384 385 public void expectCallbacks(int count) throws Throwable { 386 callbackLatch = new CountDownLatch(count); 387 mRecyclerView.addOnScrollListener(mCallbackListener); 388 } 389 390 private void removeOnScrollListener(RecyclerView.OnScrollListener listener) { 391 mRecyclerView.removeOnScrollListener(listener); 392 } 393 394 public void waitForLayout(int seconds) throws Throwable { 395 layoutLatch.await(seconds * (DEBUG ? 100 : 1), SECONDS); 396 checkForMainThreadException(); 397 MatcherAssert.assertThat("all layouts should complete on time", 398 layoutLatch.getCount(), CoreMatchers.is(0L)); 399 // use a runnable to ensure RV layout is finished 400 getInstrumentation().runOnMainSync(new Runnable() { 401 @Override 402 public void run() { 403 } 404 }); 405 } 406 407 public void assertNoCallbacks(String msg, long timeout) throws Throwable { 408 callbackLatch.await(timeout, TimeUnit.SECONDS); 409 long latchCount = callbackLatch.getCount(); 410 assertFalse(msg + " :" + latchCount, latchCount == 0); 411 removeOnScrollListener(mCallbackListener); 412 } 413 414 public void expectPrefetch(int count) { 415 prefetchLatch = new CountDownLatch(count); 416 } 417 418 public void waitForPrefetch(int seconds) throws Throwable { 419 prefetchLatch.await(seconds * (DEBUG ? 100 : 1), SECONDS); 420 checkForMainThreadException(); 421 MatcherAssert.assertThat("all prefetches should complete on time", 422 prefetchLatch.getCount(), CoreMatchers.is(0L)); 423 // use a runnable to ensure RV layout is finished 424 getInstrumentation().runOnMainSync(new Runnable() { 425 @Override 426 public void run() { 427 } 428 }); 429 } 430 431 public void expectIdleState(int count) { 432 snapLatch = new CountDownLatch(count); 433 mRecyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() { 434 @Override 435 public void onScrollStateChanged(RecyclerView recyclerView, int newState) { 436 super.onScrollStateChanged(recyclerView, newState); 437 if (newState == RecyclerView.SCROLL_STATE_IDLE) { 438 snapLatch.countDown(); 439 if (snapLatch.getCount() == 0L) { 440 mRecyclerView.removeOnScrollListener(this); 441 } 442 } 443 } 444 }); 445 } 446 447 public void waitForSnap(int seconds) throws Throwable { 448 snapLatch.await(seconds * (DEBUG ? 100 : 1), SECONDS); 449 checkForMainThreadException(); 450 MatcherAssert.assertThat("all scrolling should complete on time", 451 snapLatch.getCount(), CoreMatchers.is(0L)); 452 // use a runnable to ensure RV layout is finished 453 getInstrumentation().runOnMainSync(new Runnable() { 454 @Override 455 public void run() {} 456 }); 457 } 458 459 @Override 460 public void setOrientation(int orientation) { 461 super.setOrientation(orientation); 462 mSecondaryOrientation = null; 463 } 464 465 @Override 466 public void removeAndRecycleView(View child, RecyclerView.Recycler recycler) { 467 if (DEBUG) { 468 Log.d(TAG, "recycling view " + mRecyclerView.getChildViewHolder(child)); 469 } 470 super.removeAndRecycleView(child, recycler); 471 } 472 473 @Override 474 public void removeAndRecycleViewAt(int index, RecyclerView.Recycler recycler) { 475 if (DEBUG) { 476 Log.d(TAG, 477 "recycling view at" + mRecyclerView.getChildViewHolder(getChildAt(index))); 478 } 479 super.removeAndRecycleViewAt(index, recycler); 480 } 481 482 @Override 483 void ensureLayoutState() { 484 super.ensureLayoutState(); 485 if (mSecondaryOrientation == null) { 486 mSecondaryOrientation = OrientationHelper.createOrientationHelper(this, 487 1 - getOrientation()); 488 } 489 } 490 491 @Override 492 LayoutState createLayoutState() { 493 return new LayoutState() { 494 @Override 495 View next(RecyclerView.Recycler recycler) { 496 final boolean hadMore = hasMore(mRecyclerView.mState); 497 final int position = mCurrentPosition; 498 View next = super.next(recycler); 499 assertEquals("if has more, should return a view", hadMore, next != null); 500 assertEquals("position of the returned view must match current position", 501 position, RecyclerView.getChildViewHolderInt(next).getLayoutPosition()); 502 return next; 503 } 504 }; 505 } 506 507 public String getBoundsLog() { 508 StringBuilder sb = new StringBuilder(); 509 sb.append("view bounds:[start:").append(mOrientationHelper.getStartAfterPadding()) 510 .append(",").append(" end").append(mOrientationHelper.getEndAfterPadding()); 511 sb.append("\nchildren bounds\n"); 512 final int childCount = getChildCount(); 513 for (int i = 0; i < childCount; i++) { 514 View child = getChildAt(i); 515 sb.append("child (ind:").append(i).append(", pos:").append(getPosition(child)) 516 .append("[").append("start:").append( 517 mOrientationHelper.getDecoratedStart(child)).append(", end:") 518 .append(mOrientationHelper.getDecoratedEnd(child)).append("]\n"); 519 } 520 return sb.toString(); 521 } 522 523 public void waitForAnimationsToEnd(int timeoutInSeconds) throws InterruptedException { 524 RecyclerView.ItemAnimator itemAnimator = mRecyclerView.getItemAnimator(); 525 if (itemAnimator == null) { 526 return; 527 } 528 final CountDownLatch latch = new CountDownLatch(1); 529 final boolean running = itemAnimator.isRunning( 530 new RecyclerView.ItemAnimator.ItemAnimatorFinishedListener() { 531 @Override 532 public void onAnimationsFinished() { 533 latch.countDown(); 534 } 535 } 536 ); 537 if (running) { 538 latch.await(timeoutInSeconds, TimeUnit.SECONDS); 539 } 540 } 541 542 public VisibleChildren traverseAndFindVisibleChildren() { 543 int childCount = getChildCount(); 544 final VisibleChildren visibleChildren = new VisibleChildren(); 545 final int start = mOrientationHelper.getStartAfterPadding(); 546 final int end = mOrientationHelper.getEndAfterPadding(); 547 for (int i = 0; i < childCount; i++) { 548 View child = getChildAt(i); 549 final int childStart = mOrientationHelper.getDecoratedStart(child); 550 final int childEnd = mOrientationHelper.getDecoratedEnd(child); 551 final boolean fullyVisible = childStart >= start && childEnd <= end; 552 final boolean hidden = childEnd <= start || childStart >= end; 553 if (hidden) { 554 continue; 555 } 556 final int position = getPosition(child); 557 if (fullyVisible) { 558 if (position < visibleChildren.firstFullyVisiblePosition || 559 visibleChildren.firstFullyVisiblePosition == RecyclerView.NO_POSITION) { 560 visibleChildren.firstFullyVisiblePosition = position; 561 } 562 563 if (position > visibleChildren.lastFullyVisiblePosition) { 564 visibleChildren.lastFullyVisiblePosition = position; 565 } 566 } 567 568 if (position < visibleChildren.firstVisiblePosition || 569 visibleChildren.firstVisiblePosition == RecyclerView.NO_POSITION) { 570 visibleChildren.firstVisiblePosition = position; 571 } 572 573 if (position > visibleChildren.lastVisiblePosition) { 574 visibleChildren.lastVisiblePosition = position; 575 } 576 577 } 578 return visibleChildren; 579 } 580 581 Rect getViewBounds(View view) { 582 if (getOrientation() == HORIZONTAL) { 583 return new Rect( 584 mOrientationHelper.getDecoratedStart(view), 585 mSecondaryOrientation.getDecoratedStart(view), 586 mOrientationHelper.getDecoratedEnd(view), 587 mSecondaryOrientation.getDecoratedEnd(view)); 588 } else { 589 return new Rect( 590 mSecondaryOrientation.getDecoratedStart(view), 591 mOrientationHelper.getDecoratedStart(view), 592 mSecondaryOrientation.getDecoratedEnd(view), 593 mOrientationHelper.getDecoratedEnd(view)); 594 } 595 596 } 597 598 Map<Item, Rect> collectChildCoordinates() throws Throwable { 599 final Map<Item, Rect> items = new LinkedHashMap<Item, Rect>(); 600 mActivityRule.runOnUiThread(new Runnable() { 601 @Override 602 public void run() { 603 final int childCount = getChildCount(); 604 Rect layoutBounds = new Rect(0, 0, 605 mLayoutManager.getWidth(), mLayoutManager.getHeight()); 606 for (int i = 0; i < childCount; i++) { 607 View child = getChildAt(i); 608 RecyclerView.LayoutParams lp = (RecyclerView.LayoutParams) child 609 .getLayoutParams(); 610 TestViewHolder vh = (TestViewHolder) lp.mViewHolder; 611 Rect childBounds = getViewBounds(child); 612 if (new Rect(childBounds).intersect(layoutBounds)) { 613 items.put(vh.mBoundItem, childBounds); 614 } 615 } 616 } 617 }); 618 return items; 619 } 620 621 @Override 622 public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) { 623 try { 624 if (mOnLayoutListener != null) { 625 mOnLayoutListener.before(recycler, state); 626 } 627 super.onLayoutChildren(recycler, state); 628 if (mOnLayoutListener != null) { 629 mOnLayoutListener.after(recycler, state); 630 } 631 } catch (Throwable t) { 632 postExceptionToInstrumentation(t); 633 } 634 layoutLatch.countDown(); 635 } 636 637 @Override 638 public void collectAdjacentPrefetchPositions(int dx, int dy, RecyclerView.State state, 639 LayoutPrefetchRegistry layoutPrefetchRegistry) { 640 if (prefetchLatch != null) prefetchLatch.countDown(); 641 super.collectAdjacentPrefetchPositions(dx, dy, state, layoutPrefetchRegistry); 642 } 643 } 644} 645