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