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