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