BaseRecyclerViewInstrumentationTest.java revision 9f5e74d12c89334c2bd439b1bf19fdc5ebfbc137
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 static android.support.v7.widget.RecyclerView.SCROLL_STATE_IDLE; 20 21import org.hamcrest.CoreMatchers; 22import org.hamcrest.MatcherAssert; 23import org.junit.After; 24import org.junit.Before; 25import org.junit.Rule; 26 27import android.app.Instrumentation; 28import android.graphics.Rect; 29import android.os.Looper; 30import android.support.test.InstrumentationRegistry; 31import android.support.test.rule.ActivityTestRule; 32import android.support.v4.view.ViewCompat; 33import android.support.v7.recyclerview.test.SameActivityTestRule; 34import android.util.Log; 35import android.view.LayoutInflater; 36import android.view.View; 37import android.view.ViewGroup; 38import android.widget.FrameLayout; 39import android.widget.TextView; 40 41import java.lang.reflect.InvocationTargetException; 42import java.lang.reflect.Method; 43import java.util.ArrayList; 44import java.util.HashSet; 45import java.util.List; 46import java.util.Set; 47import java.util.concurrent.CountDownLatch; 48import java.util.concurrent.TimeUnit; 49import java.util.concurrent.atomic.AtomicBoolean; 50import java.util.concurrent.atomic.AtomicInteger; 51import java.util.concurrent.locks.ReentrantLock; 52import android.support.v7.recyclerview.test.R; 53 54import static org.junit.Assert.*; 55 56import static java.util.concurrent.TimeUnit.SECONDS; 57 58abstract public class BaseRecyclerViewInstrumentationTest { 59 60 private static final String TAG = "RecyclerViewTest"; 61 62 private boolean mDebug; 63 64 protected RecyclerView mRecyclerView; 65 66 protected AdapterHelper mAdapterHelper; 67 68 private Throwable mMainThreadException; 69 70 private boolean mIgnoreMainThreadException = false; 71 72 Thread mInstrumentationThread; 73 74 @Rule 75 public ActivityTestRule<TestActivity> mActivityRule = new SameActivityTestRule() { 76 @Override 77 public boolean canReUseActivity() { 78 return BaseRecyclerViewInstrumentationTest.this.canReUseActivity(); 79 } 80 }; 81 82 public BaseRecyclerViewInstrumentationTest() { 83 this(false); 84 } 85 86 public BaseRecyclerViewInstrumentationTest(boolean debug) { 87 mDebug = debug; 88 } 89 90 void checkForMainThreadException() throws Throwable { 91 if (!mIgnoreMainThreadException && mMainThreadException != null) { 92 throw mMainThreadException; 93 } 94 } 95 96 public void setIgnoreMainThreadException(boolean ignoreMainThreadException) { 97 mIgnoreMainThreadException = ignoreMainThreadException; 98 } 99 100 public Throwable getMainThreadException() { 101 return mMainThreadException; 102 } 103 104 protected TestActivity getActivity() { 105 return mActivityRule.getActivity(); 106 } 107 108 @Before 109 public final void setUpInsThread() throws Exception { 110 mInstrumentationThread = Thread.currentThread(); 111 Item.idCounter.set(0); 112 } 113 114 void setHasTransientState(final View view, final boolean value) { 115 try { 116 runTestOnUiThread(new Runnable() { 117 @Override 118 public void run() { 119 ViewCompat.setHasTransientState(view, value); 120 } 121 }); 122 } catch (Throwable throwable) { 123 Log.e(TAG, "", throwable); 124 } 125 } 126 127 public boolean canReUseActivity() { 128 return true; 129 } 130 131 protected void enableAccessibility() 132 throws NoSuchMethodException, InvocationTargetException, IllegalAccessException { 133 Method getUIAutomation = Instrumentation.class.getMethod("getUiAutomation"); 134 getUIAutomation.invoke(InstrumentationRegistry.getInstrumentation()); 135 } 136 137 void setAdapter(final RecyclerView.Adapter adapter) throws Throwable { 138 runTestOnUiThread(new Runnable() { 139 @Override 140 public void run() { 141 mRecyclerView.setAdapter(adapter); 142 } 143 }); 144 } 145 146 public View focusSearch(final View focused, final int direction) 147 throws Throwable { 148 final View[] result = new View[1]; 149 runTestOnUiThread(new Runnable() { 150 @Override 151 public void run() { 152 View view = focused.focusSearch(direction); 153 if (view != null && view != focused) { 154 view.requestFocus(); 155 } 156 result[0] = view; 157 } 158 }); 159 return result[0]; 160 } 161 162 protected WrappedRecyclerView inflateWrappedRV() { 163 return (WrappedRecyclerView) 164 LayoutInflater.from(getActivity()).inflate(R.layout.wrapped_test_rv, 165 getRecyclerViewContainer(), false); 166 } 167 168 void swapAdapter(final RecyclerView.Adapter adapter, 169 final boolean removeAndRecycleExistingViews) throws Throwable { 170 runTestOnUiThread(new Runnable() { 171 @Override 172 public void run() { 173 try { 174 mRecyclerView.swapAdapter(adapter, removeAndRecycleExistingViews); 175 } catch (Throwable t) { 176 postExceptionToInstrumentation(t); 177 } 178 } 179 }); 180 checkForMainThreadException(); 181 } 182 183 void postExceptionToInstrumentation(Throwable t) { 184 if (mInstrumentationThread == Thread.currentThread()) { 185 throw new RuntimeException(t); 186 } 187 if (mMainThreadException != null) { 188 Log.e(TAG, "receiving another main thread exception. dropping.", t); 189 } else { 190 Log.e(TAG, "captured exception on main thread", t); 191 mMainThreadException = t; 192 } 193 194 if (mRecyclerView != null && mRecyclerView 195 .getLayoutManager() instanceof TestLayoutManager) { 196 TestLayoutManager lm = (TestLayoutManager) mRecyclerView.getLayoutManager(); 197 // finish all layouts so that we get the correct exception 198 if (lm.layoutLatch != null) { 199 while (lm.layoutLatch.getCount() > 0) { 200 lm.layoutLatch.countDown(); 201 } 202 } 203 } 204 } 205 206 public Instrumentation getInstrumentation() { 207 return InstrumentationRegistry.getInstrumentation(); 208 } 209 210 @After 211 public final void tearDown() throws Exception { 212 if (mRecyclerView != null) { 213 try { 214 removeRecyclerView(); 215 } catch (Throwable throwable) { 216 throwable.printStackTrace(); 217 } 218 } 219 getInstrumentation().waitForIdleSync(); 220 221 try { 222 checkForMainThreadException(); 223 } catch (Exception e) { 224 throw e; 225 } catch (Throwable throwable) { 226 throw new Exception(Log.getStackTraceString(throwable)); 227 } 228 } 229 230 public Rect getDecoratedRecyclerViewBounds() { 231 return new Rect( 232 mRecyclerView.getPaddingLeft(), 233 mRecyclerView.getPaddingTop(), 234 mRecyclerView.getWidth() - mRecyclerView.getPaddingRight(), 235 mRecyclerView.getHeight() - mRecyclerView.getPaddingBottom() 236 ); 237 } 238 239 public void removeRecyclerView() throws Throwable { 240 if (mRecyclerView == null) { 241 return; 242 } 243 if (!isMainThread()) { 244 getInstrumentation().waitForIdleSync(); 245 } 246 runTestOnUiThread(new Runnable() { 247 @Override 248 public void run() { 249 try { 250 // do not run validation if we already have an error 251 if (mMainThreadException == null) { 252 final RecyclerView.Adapter adapter = mRecyclerView.getAdapter(); 253 if (adapter instanceof AttachDetachCountingAdapter) { 254 ((AttachDetachCountingAdapter) adapter).getCounter() 255 .validateRemaining(mRecyclerView); 256 } 257 } 258 getActivity().getContainer().removeAllViews(); 259 } catch (Throwable t) { 260 postExceptionToInstrumentation(t); 261 } 262 } 263 }); 264 mRecyclerView = null; 265 } 266 267 void waitForAnimations(int seconds) throws Throwable { 268 final CountDownLatch latch = new CountDownLatch(1); 269 runTestOnUiThread(new Runnable() { 270 @Override 271 public void run() { 272 mRecyclerView.mItemAnimator 273 .isRunning(new RecyclerView.ItemAnimator.ItemAnimatorFinishedListener() { 274 @Override 275 public void onAnimationsFinished() { 276 latch.countDown(); 277 } 278 }); 279 } 280 }); 281 282 assertTrue("animations didn't finish on expected time of " + seconds + " seconds", 283 latch.await(seconds, TimeUnit.SECONDS)); 284 } 285 286 public void waitForIdleScroll(final RecyclerView recyclerView) throws Throwable { 287 final CountDownLatch latch = new CountDownLatch(1); 288 runTestOnUiThread(new Runnable() { 289 @Override 290 public void run() { 291 RecyclerView.OnScrollListener listener = new RecyclerView.OnScrollListener() { 292 @Override 293 public void onScrollStateChanged(RecyclerView recyclerView, int newState) { 294 if (newState == SCROLL_STATE_IDLE) { 295 latch.countDown(); 296 recyclerView.removeOnScrollListener(this); 297 } 298 } 299 }; 300 if (recyclerView.getScrollState() == SCROLL_STATE_IDLE) { 301 latch.countDown(); 302 } else { 303 recyclerView.addOnScrollListener(listener); 304 } 305 } 306 }); 307 assertTrue("should go idle in 10 seconds", latch.await(10, TimeUnit.SECONDS)); 308 } 309 310 public boolean requestFocus(final View view, boolean waitForScroll) throws Throwable { 311 final boolean[] result = new boolean[1]; 312 try { 313 runTestOnUiThread(new Runnable() { 314 @Override 315 public void run() { 316 result[0] = view.requestFocus(); 317 } 318 }); 319 } catch (Throwable throwable) { 320 fail(throwable.getMessage()); 321 } 322 if (waitForScroll && result[0]) { 323 waitForIdleScroll(mRecyclerView); 324 } 325 return result[0]; 326 } 327 328 public void setRecyclerView(final RecyclerView recyclerView) throws Throwable { 329 setRecyclerView(recyclerView, true); 330 } 331 public void setRecyclerView(final RecyclerView recyclerView, boolean assignDummyPool) 332 throws Throwable { 333 setRecyclerView(recyclerView, assignDummyPool, true); 334 } 335 public void setRecyclerView(final RecyclerView recyclerView, boolean assignDummyPool, 336 boolean addPositionCheckItemAnimator) 337 throws Throwable { 338 mRecyclerView = recyclerView; 339 if (assignDummyPool) { 340 RecyclerView.RecycledViewPool pool = new RecyclerView.RecycledViewPool() { 341 @Override 342 public RecyclerView.ViewHolder getRecycledView(int viewType) { 343 RecyclerView.ViewHolder viewHolder = super.getRecycledView(viewType); 344 if (viewHolder == null) { 345 return null; 346 } 347 viewHolder.addFlags(RecyclerView.ViewHolder.FLAG_BOUND); 348 viewHolder.mPosition = 200; 349 viewHolder.mOldPosition = 300; 350 viewHolder.mPreLayoutPosition = 500; 351 return viewHolder; 352 } 353 354 @Override 355 public void putRecycledView(RecyclerView.ViewHolder scrap) { 356 assertNull(scrap.mOwnerRecyclerView); 357 super.putRecycledView(scrap); 358 } 359 }; 360 mRecyclerView.setRecycledViewPool(pool); 361 } 362 if (addPositionCheckItemAnimator) { 363 mRecyclerView.addItemDecoration(new RecyclerView.ItemDecoration() { 364 @Override 365 public void getItemOffsets(Rect outRect, View view, RecyclerView parent, 366 RecyclerView.State state) { 367 RecyclerView.ViewHolder vh = parent.getChildViewHolder(view); 368 if (!vh.isRemoved()) { 369 assertNotSame("If getItemOffsets is called, child should have a valid" 370 + " adapter position unless it is removed : " + vh, 371 vh.getAdapterPosition(), RecyclerView.NO_POSITION); 372 } 373 } 374 }); 375 } 376 mAdapterHelper = recyclerView.mAdapterHelper; 377 runTestOnUiThread(new Runnable() { 378 @Override 379 public void run() { 380 getActivity().getContainer().addView(recyclerView); 381 } 382 }); 383 } 384 385 protected FrameLayout getRecyclerViewContainer() { 386 return getActivity().getContainer(); 387 } 388 389 public void requestLayoutOnUIThread(final View view) { 390 try { 391 runTestOnUiThread(new Runnable() { 392 @Override 393 public void run() { 394 view.requestLayout(); 395 } 396 }); 397 } catch (Throwable throwable) { 398 Log.e(TAG, "", throwable); 399 } 400 } 401 402 public void scrollBy(final int dt) { 403 try { 404 runTestOnUiThread(new Runnable() { 405 @Override 406 public void run() { 407 if (mRecyclerView.getLayoutManager().canScrollHorizontally()) { 408 mRecyclerView.scrollBy(dt, 0); 409 } else { 410 mRecyclerView.scrollBy(0, dt); 411 } 412 413 } 414 }); 415 } catch (Throwable throwable) { 416 Log.e(TAG, "", throwable); 417 } 418 } 419 420 public void smoothScrollBy(final int dt) { 421 try { 422 runTestOnUiThread(new Runnable() { 423 @Override 424 public void run() { 425 if (mRecyclerView.getLayoutManager().canScrollHorizontally()) { 426 mRecyclerView.smoothScrollBy(dt, 0); 427 } else { 428 mRecyclerView.smoothScrollBy(0, dt); 429 } 430 431 } 432 }); 433 } catch (Throwable throwable) { 434 Log.e(TAG, "", throwable); 435 } 436 getInstrumentation().waitForIdleSync(); 437 } 438 439 void scrollToPosition(final int position) throws Throwable { 440 runTestOnUiThread(new Runnable() { 441 @Override 442 public void run() { 443 mRecyclerView.getLayoutManager().scrollToPosition(position); 444 } 445 }); 446 } 447 448 void smoothScrollToPosition(final int position) throws Throwable { 449 smoothScrollToPosition(position, true); 450 } 451 452 void smoothScrollToPosition(final int position, boolean assertArrival) throws Throwable { 453 if (mDebug) { 454 Log.d(TAG, "SMOOTH scrolling to " + position); 455 } 456 final CountDownLatch viewAdded = new CountDownLatch(1); 457 final RecyclerView.OnChildAttachStateChangeListener listener = 458 new RecyclerView.OnChildAttachStateChangeListener() { 459 @Override 460 public void onChildViewAttachedToWindow(View view) { 461 if (position == mRecyclerView.getChildAdapterPosition(view)) { 462 viewAdded.countDown(); 463 } 464 } 465 @Override 466 public void onChildViewDetachedFromWindow(View view) { 467 } 468 }; 469 final AtomicBoolean addedListener = new AtomicBoolean(false); 470 runTestOnUiThread(new Runnable() { 471 @Override 472 public void run() { 473 RecyclerView.ViewHolder viewHolderForAdapterPosition = 474 mRecyclerView.findViewHolderForAdapterPosition(position); 475 if (viewHolderForAdapterPosition != null) { 476 viewAdded.countDown(); 477 } else { 478 mRecyclerView.addOnChildAttachStateChangeListener(listener); 479 addedListener.set(true); 480 } 481 482 } 483 }); 484 runTestOnUiThread(new Runnable() { 485 @Override 486 public void run() { 487 mRecyclerView.smoothScrollToPosition(position); 488 } 489 }); 490 getInstrumentation().waitForIdleSync(); 491 assertThat("should be able to scroll in 10 seconds", !assertArrival || 492 viewAdded.await(10, TimeUnit.SECONDS), 493 CoreMatchers.is(true)); 494 waitForIdleScroll(mRecyclerView); 495 if (mDebug) { 496 Log.d(TAG, "SMOOTH scrolling done"); 497 } 498 if (addedListener.get()) { 499 runTestOnUiThread(new Runnable() { 500 @Override 501 public void run() { 502 mRecyclerView.removeOnChildAttachStateChangeListener(listener); 503 } 504 }); 505 } 506 getInstrumentation().waitForIdleSync(); 507 } 508 509 void freezeLayout(final boolean freeze) throws Throwable { 510 runTestOnUiThread(new Runnable() { 511 @Override 512 public void run() { 513 mRecyclerView.setLayoutFrozen(freeze); 514 } 515 }); 516 } 517 518 public void setVisibility(final View view, final int visibility) throws Throwable { 519 runTestOnUiThread(new Runnable() { 520 @Override 521 public void run() { 522 view.setVisibility(visibility); 523 } 524 }); 525 } 526 527 public class TestViewHolder extends RecyclerView.ViewHolder { 528 529 Item mBoundItem; 530 531 public TestViewHolder(View itemView) { 532 super(itemView); 533 itemView.setFocusable(true); 534 } 535 536 @Override 537 public String toString() { 538 return super.toString() + " item:" + mBoundItem; 539 } 540 } 541 class DumbLayoutManager extends TestLayoutManager { 542 @Override 543 public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) { 544 detachAndScrapAttachedViews(recycler); 545 layoutRange(recycler, 0, state.getItemCount()); 546 if (layoutLatch != null) { 547 layoutLatch.countDown(); 548 } 549 } 550 } 551 public class TestLayoutManager extends RecyclerView.LayoutManager { 552 int mScrollVerticallyAmount; 553 int mScrollHorizontallyAmount; 554 protected CountDownLatch layoutLatch; 555 private boolean mSupportsPredictive = false; 556 557 public void expectLayouts(int count) { 558 layoutLatch = new CountDownLatch(count); 559 } 560 561 public void waitForLayout(int seconds) throws Throwable { 562 layoutLatch.await(seconds * (mDebug ? 1000 : 1), SECONDS); 563 checkForMainThreadException(); 564 MatcherAssert.assertThat("all layouts should complete on time", 565 layoutLatch.getCount(), CoreMatchers.is(0L)); 566 // use a runnable to ensure RV layout is finished 567 getInstrumentation().runOnMainSync(new Runnable() { 568 @Override 569 public void run() { 570 } 571 }); 572 } 573 574 public boolean isSupportsPredictive() { 575 return mSupportsPredictive; 576 } 577 578 public void setSupportsPredictive(boolean supportsPredictive) { 579 mSupportsPredictive = supportsPredictive; 580 } 581 582 @Override 583 public boolean supportsPredictiveItemAnimations() { 584 return mSupportsPredictive; 585 } 586 587 public void assertLayoutCount(int count, String msg, long timeout) throws Throwable { 588 layoutLatch.await(timeout, TimeUnit.SECONDS); 589 assertEquals(msg, count, layoutLatch.getCount()); 590 } 591 592 public void assertNoLayout(String msg, long timeout) throws Throwable { 593 layoutLatch.await(timeout, TimeUnit.SECONDS); 594 assertFalse(msg, layoutLatch.getCount() == 0); 595 } 596 597 @Override 598 public RecyclerView.LayoutParams generateDefaultLayoutParams() { 599 return new RecyclerView.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, 600 ViewGroup.LayoutParams.WRAP_CONTENT); 601 } 602 603 void assertVisibleItemPositions() { 604 int i = getChildCount(); 605 TestAdapter testAdapter = (TestAdapter) mRecyclerView.getAdapter(); 606 while (i-- > 0) { 607 View view = getChildAt(i); 608 RecyclerView.LayoutParams lp = (RecyclerView.LayoutParams) view.getLayoutParams(); 609 Item item = ((TestViewHolder) lp.mViewHolder).mBoundItem; 610 if (mDebug) { 611 Log.d(TAG, "testing item " + i); 612 } 613 if (!lp.isItemRemoved()) { 614 RecyclerView.ViewHolder vh = mRecyclerView.getChildViewHolder(view); 615 assertSame("item position in LP should match adapter value :" + vh, 616 testAdapter.mItems.get(vh.mPosition), item); 617 } 618 } 619 } 620 621 RecyclerView.LayoutParams getLp(View v) { 622 return (RecyclerView.LayoutParams) v.getLayoutParams(); 623 } 624 625 protected void layoutRange(RecyclerView.Recycler recycler, int start, int end) { 626 assertScrap(recycler); 627 if (mDebug) { 628 Log.d(TAG, "will layout items from " + start + " to " + end); 629 } 630 int diff = end > start ? 1 : -1; 631 int top = 0; 632 for (int i = start; i != end; i+=diff) { 633 if (mDebug) { 634 Log.d(TAG, "laying out item " + i); 635 } 636 View view = recycler.getViewForPosition(i); 637 assertNotNull("view should not be null for valid position. " 638 + "got null view at position " + i, view); 639 if (!mRecyclerView.mState.isPreLayout()) { 640 RecyclerView.LayoutParams layoutParams = (RecyclerView.LayoutParams) view 641 .getLayoutParams(); 642 assertFalse("In post layout, getViewForPosition should never return a view " 643 + "that is removed", layoutParams != null 644 && layoutParams.isItemRemoved()); 645 646 } 647 assertEquals("getViewForPosition should return correct position", 648 i, getPosition(view)); 649 addView(view); 650 measureChildWithMargins(view, 0, 0); 651 if (getLayoutDirection() == ViewCompat.LAYOUT_DIRECTION_RTL) { 652 layoutDecorated(view, getWidth() - getDecoratedMeasuredWidth(view), top, 653 getWidth(), top + getDecoratedMeasuredHeight(view)); 654 } else { 655 layoutDecorated(view, 0, top, getDecoratedMeasuredWidth(view) 656 , top + getDecoratedMeasuredHeight(view)); 657 } 658 659 top += view.getMeasuredHeight(); 660 } 661 } 662 663 private void assertScrap(RecyclerView.Recycler recycler) { 664 if (mRecyclerView.getAdapter() != null && 665 !mRecyclerView.getAdapter().hasStableIds()) { 666 for (RecyclerView.ViewHolder viewHolder : recycler.getScrapList()) { 667 assertFalse("Invalid scrap should be no kept", viewHolder.isInvalid()); 668 } 669 } 670 } 671 672 @Override 673 public boolean canScrollHorizontally() { 674 return true; 675 } 676 677 @Override 678 public boolean canScrollVertically() { 679 return true; 680 } 681 682 @Override 683 public int scrollHorizontallyBy(int dx, RecyclerView.Recycler recycler, 684 RecyclerView.State state) { 685 mScrollHorizontallyAmount += dx; 686 return dx; 687 } 688 689 @Override 690 public int scrollVerticallyBy(int dy, RecyclerView.Recycler recycler, 691 RecyclerView.State state) { 692 mScrollVerticallyAmount += dy; 693 return dy; 694 } 695 } 696 697 static class Item { 698 final static AtomicInteger idCounter = new AtomicInteger(0); 699 final public int mId = idCounter.incrementAndGet(); 700 701 int mAdapterIndex; 702 703 final String mText; 704 int mType = 0; 705 706 Item(int adapterIndex, String text) { 707 mAdapterIndex = adapterIndex; 708 mText = text; 709 } 710 711 @Override 712 public String toString() { 713 return "Item{" + 714 "mId=" + mId + 715 ", originalIndex=" + mAdapterIndex + 716 ", text='" + mText + '\'' + 717 '}'; 718 } 719 } 720 721 public class TestAdapter extends RecyclerView.Adapter<TestViewHolder> 722 implements AttachDetachCountingAdapter { 723 724 public static final String DEFAULT_ITEM_PREFIX = "Item "; 725 726 ViewAttachDetachCounter mAttachmentCounter = new ViewAttachDetachCounter(); 727 List<Item> mItems; 728 729 public TestAdapter(int count) { 730 mItems = new ArrayList<Item>(count); 731 addItems(0, count, DEFAULT_ITEM_PREFIX); 732 } 733 734 private void addItems(int pos, int count, String prefix) { 735 for (int i = 0; i < count; i++, pos++) { 736 mItems.add(pos, new Item(pos, prefix)); 737 } 738 } 739 740 @Override 741 public int getItemViewType(int position) { 742 return getItemAt(position).mType; 743 } 744 745 @Override 746 public void onViewAttachedToWindow(TestViewHolder holder) { 747 super.onViewAttachedToWindow(holder); 748 mAttachmentCounter.onViewAttached(holder); 749 } 750 751 @Override 752 public void onViewDetachedFromWindow(TestViewHolder holder) { 753 super.onViewDetachedFromWindow(holder); 754 mAttachmentCounter.onViewDetached(holder); 755 } 756 757 @Override 758 public void onAttachedToRecyclerView(RecyclerView recyclerView) { 759 super.onAttachedToRecyclerView(recyclerView); 760 mAttachmentCounter.onAttached(recyclerView); 761 } 762 763 @Override 764 public void onDetachedFromRecyclerView(RecyclerView recyclerView) { 765 super.onDetachedFromRecyclerView(recyclerView); 766 mAttachmentCounter.onDetached(recyclerView); 767 } 768 769 @Override 770 public TestViewHolder onCreateViewHolder(ViewGroup parent, 771 int viewType) { 772 TextView itemView = new TextView(parent.getContext()); 773 itemView.setFocusableInTouchMode(true); 774 itemView.setFocusable(true); 775 return new TestViewHolder(itemView); 776 } 777 778 @Override 779 public void onBindViewHolder(TestViewHolder holder, int position) { 780 assertNotNull(holder.mOwnerRecyclerView); 781 assertEquals(position, holder.getAdapterPosition()); 782 final Item item = mItems.get(position); 783 ((TextView) (holder.itemView)).setText(item.mText + "(" + item.mId + ")"); 784 holder.mBoundItem = item; 785 } 786 787 public Item getItemAt(int position) { 788 return mItems.get(position); 789 } 790 791 @Override 792 public void onViewRecycled(TestViewHolder holder) { 793 super.onViewRecycled(holder); 794 final int adapterPosition = holder.getAdapterPosition(); 795 final boolean shouldHavePosition = !holder.isRemoved() && holder.isBound() && 796 !holder.isAdapterPositionUnknown() && !holder.isInvalid(); 797 String log = "Position check for " + holder.toString(); 798 assertEquals(log, shouldHavePosition, adapterPosition != RecyclerView.NO_POSITION); 799 if (shouldHavePosition) { 800 assertTrue(log, mItems.size() > adapterPosition); 801 assertSame(log, holder.mBoundItem, mItems.get(adapterPosition)); 802 } 803 } 804 805 public void deleteAndNotify(final int start, final int count) throws Throwable { 806 deleteAndNotify(new int[]{start, count}); 807 } 808 809 /** 810 * Deletes items in the given ranges. 811 * <p> 812 * Note that each operation affects the one after so you should offset them properly. 813 * <p> 814 * For example, if adapter has 5 items (A,B,C,D,E), and then you call this method with 815 * <code>[1, 2],[2, 1]</code>, it will first delete items B,C and the new adapter will be 816 * A D E. Then it will delete 2,1 which means it will delete E. 817 */ 818 public void deleteAndNotify(final int[]... startCountTuples) throws Throwable { 819 for (int[] tuple : startCountTuples) { 820 tuple[1] = -tuple[1]; 821 } 822 new AddRemoveRunnable(startCountTuples).runOnMainThread(); 823 } 824 825 @Override 826 public long getItemId(int position) { 827 return hasStableIds() ? mItems.get(position).mId : super.getItemId(position); 828 } 829 830 public void offsetOriginalIndices(int start, int offset) { 831 for (int i = start; i < mItems.size(); i++) { 832 mItems.get(i).mAdapterIndex += offset; 833 } 834 } 835 836 /** 837 * @param start inclusive 838 * @param end exclusive 839 * @param offset 840 */ 841 public void offsetOriginalIndicesBetween(int start, int end, int offset) { 842 for (int i = start; i < end && i < mItems.size(); i++) { 843 mItems.get(i).mAdapterIndex += offset; 844 } 845 } 846 847 public void addAndNotify(final int count) throws Throwable { 848 assertEquals(0, mItems.size()); 849 new AddRemoveRunnable(DEFAULT_ITEM_PREFIX, new int[]{0, count}).runOnMainThread(); 850 } 851 852 public void resetItemsTo(final List<Item> testItems) throws Throwable { 853 if (!mItems.isEmpty()) { 854 deleteAndNotify(0, mItems.size()); 855 } 856 mItems = testItems; 857 runTestOnUiThread(new Runnable() { 858 @Override 859 public void run() { 860 notifyItemRangeInserted(0, testItems.size()); 861 } 862 }); 863 } 864 865 public void addAndNotify(final int start, final int count) throws Throwable { 866 addAndNotify(new int[]{start, count}); 867 } 868 869 public void addAndNotify(final int[]... startCountTuples) throws Throwable { 870 new AddRemoveRunnable(startCountTuples).runOnMainThread(); 871 } 872 873 public void dispatchDataSetChanged() throws Throwable { 874 runTestOnUiThread(new Runnable() { 875 @Override 876 public void run() { 877 notifyDataSetChanged(); 878 } 879 }); 880 } 881 882 public void changeAndNotify(final int start, final int count) throws Throwable { 883 runTestOnUiThread(new Runnable() { 884 @Override 885 public void run() { 886 notifyItemRangeChanged(start, count); 887 } 888 }); 889 } 890 891 public void changeAndNotifyWithPayload(final int start, final int count, 892 final Object payload) throws Throwable { 893 runTestOnUiThread(new Runnable() { 894 @Override 895 public void run() { 896 notifyItemRangeChanged(start, count, payload); 897 } 898 }); 899 } 900 901 public void changePositionsAndNotify(final int... positions) throws Throwable { 902 runTestOnUiThread(new Runnable() { 903 @Override 904 public void run() { 905 for (int i = 0; i < positions.length; i += 1) { 906 TestAdapter.super.notifyItemRangeChanged(positions[i], 1); 907 } 908 } 909 }); 910 } 911 912 /** 913 * Similar to other methods but negative count means delete and position count means add. 914 * <p> 915 * For instance, calling this method with <code>[1,1], [2,-1]</code> it will first add an 916 * item to index 1, then remove an item from index 2 (updated index 2) 917 */ 918 public void addDeleteAndNotify(final int[]... startCountTuples) throws Throwable { 919 new AddRemoveRunnable(startCountTuples).runOnMainThread(); 920 } 921 922 @Override 923 public int getItemCount() { 924 return mItems.size(); 925 } 926 927 /** 928 * Uses notifyDataSetChanged 929 */ 930 public void moveItems(boolean notifyChange, int[]... fromToTuples) throws Throwable { 931 for (int i = 0; i < fromToTuples.length; i += 1) { 932 int[] tuple = fromToTuples[i]; 933 moveItem(tuple[0], tuple[1], false); 934 } 935 if (notifyChange) { 936 dispatchDataSetChanged(); 937 } 938 } 939 940 /** 941 * Uses notifyDataSetChanged 942 */ 943 public void moveItem(final int from, final int to, final boolean notifyChange) 944 throws Throwable { 945 runTestOnUiThread(new Runnable() { 946 @Override 947 public void run() { 948 moveInUIThread(from, to); 949 if (notifyChange) { 950 notifyDataSetChanged(); 951 } 952 } 953 }); 954 } 955 956 /** 957 * Uses notifyItemMoved 958 */ 959 public void moveAndNotify(final int from, final int to) throws Throwable { 960 runTestOnUiThread(new Runnable() { 961 @Override 962 public void run() { 963 moveInUIThread(from, to); 964 notifyItemMoved(from, to); 965 } 966 }); 967 } 968 969 public void clearOnUIThread() { 970 assertEquals("clearOnUIThread called from a wrong thread", 971 Looper.getMainLooper(), Looper.myLooper()); 972 mItems = new ArrayList<Item>(); 973 notifyDataSetChanged(); 974 } 975 976 protected void moveInUIThread(int from, int to) { 977 Item item = mItems.remove(from); 978 offsetOriginalIndices(from, -1); 979 mItems.add(to, item); 980 offsetOriginalIndices(to + 1, 1); 981 item.mAdapterIndex = to; 982 } 983 984 985 @Override 986 public ViewAttachDetachCounter getCounter() { 987 return mAttachmentCounter; 988 } 989 990 private class AddRemoveRunnable implements Runnable { 991 final String mNewItemPrefix; 992 final int[][] mStartCountTuples; 993 994 public AddRemoveRunnable(String newItemPrefix, int[]... startCountTuples) { 995 mNewItemPrefix = newItemPrefix; 996 mStartCountTuples = startCountTuples; 997 } 998 999 public AddRemoveRunnable(int[][] startCountTuples) { 1000 this("new item ", startCountTuples); 1001 } 1002 1003 public void runOnMainThread() throws Throwable { 1004 if (Looper.myLooper() == Looper.getMainLooper()) { 1005 run(); 1006 } else { 1007 runTestOnUiThread(this); 1008 } 1009 } 1010 1011 @Override 1012 public void run() { 1013 for (int[] tuple : mStartCountTuples) { 1014 if (tuple[1] < 0) { 1015 delete(tuple); 1016 } else { 1017 add(tuple); 1018 } 1019 } 1020 } 1021 1022 private void add(int[] tuple) { 1023 // offset others 1024 offsetOriginalIndices(tuple[0], tuple[1]); 1025 addItems(tuple[0], tuple[1], mNewItemPrefix); 1026 notifyItemRangeInserted(tuple[0], tuple[1]); 1027 } 1028 1029 private void delete(int[] tuple) { 1030 final int count = -tuple[1]; 1031 offsetOriginalIndices(tuple[0] + count, tuple[1]); 1032 for (int i = 0; i < count; i++) { 1033 mItems.remove(tuple[0]); 1034 } 1035 notifyItemRangeRemoved(tuple[0], count); 1036 } 1037 } 1038 } 1039 1040 public boolean isMainThread() { 1041 return Looper.myLooper() == Looper.getMainLooper(); 1042 } 1043 1044 public void runTestOnUiThread(Runnable r) throws Throwable { 1045 if (Looper.myLooper() == Looper.getMainLooper()) { 1046 r.run(); 1047 } else { 1048 InstrumentationRegistry.getInstrumentation().runOnMainSync(r); 1049 } 1050 } 1051 1052 static class TargetTuple { 1053 1054 final int mPosition; 1055 1056 final int mLayoutDirection; 1057 1058 TargetTuple(int position, int layoutDirection) { 1059 this.mPosition = position; 1060 this.mLayoutDirection = layoutDirection; 1061 } 1062 1063 @Override 1064 public String toString() { 1065 return "TargetTuple{" + 1066 "mPosition=" + mPosition + 1067 ", mLayoutDirection=" + mLayoutDirection + 1068 '}'; 1069 } 1070 } 1071 1072 public interface AttachDetachCountingAdapter { 1073 1074 ViewAttachDetachCounter getCounter(); 1075 } 1076 1077 public class ViewAttachDetachCounter { 1078 1079 Set<RecyclerView.ViewHolder> mAttachedSet = new HashSet<RecyclerView.ViewHolder>(); 1080 1081 public void validateRemaining(RecyclerView recyclerView) { 1082 final int childCount = recyclerView.getChildCount(); 1083 for (int i = 0; i < childCount; i++) { 1084 View view = recyclerView.getChildAt(i); 1085 RecyclerView.ViewHolder vh = recyclerView.getChildViewHolder(view); 1086 assertTrue("remaining view should be in attached set " + vh, 1087 mAttachedSet.contains(vh)); 1088 } 1089 assertEquals("there should not be any views left in attached set", 1090 childCount, mAttachedSet.size()); 1091 } 1092 1093 public void onViewDetached(RecyclerView.ViewHolder viewHolder) { 1094 try { 1095 assertTrue("view holder should be in attached set", 1096 mAttachedSet.remove(viewHolder)); 1097 } catch (Throwable t) { 1098 postExceptionToInstrumentation(t); 1099 } 1100 } 1101 1102 public void onViewAttached(RecyclerView.ViewHolder viewHolder) { 1103 try { 1104 assertTrue("view holder should not be in attached set", 1105 mAttachedSet.add(viewHolder)); 1106 } catch (Throwable t) { 1107 postExceptionToInstrumentation(t); 1108 } 1109 } 1110 1111 public void onAttached(RecyclerView recyclerView) { 1112 // when a new RV is attached, clear the set and add all view holders 1113 mAttachedSet.clear(); 1114 final int childCount = recyclerView.getChildCount(); 1115 for (int i = 0; i < childCount; i ++) { 1116 View view = recyclerView.getChildAt(i); 1117 mAttachedSet.add(recyclerView.getChildViewHolder(view)); 1118 } 1119 } 1120 1121 public void onDetached(RecyclerView recyclerView) { 1122 validateRemaining(recyclerView); 1123 } 1124 } 1125} 1126