/* * Copyright (C) 2014 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package android.support.v7.widget; import android.content.Context; import android.graphics.Canvas; import android.util.AttributeSet; import android.util.Log; import android.view.View; import android.view.ViewGroup; import java.util.ArrayList; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; public class RecyclerViewAnimationsTest extends BaseRecyclerViewInstrumentationTest { private static final boolean DEBUG = false; private static final String TAG = "RecyclerViewAnimationsTest"; AnimationLayoutManager mLayoutManager; TestAdapter mTestAdapter; public RecyclerViewAnimationsTest() { super(DEBUG); } @Override protected void setUp() throws Exception { super.setUp(); } RecyclerView setupBasic(int itemCount) throws Throwable { return setupBasic(itemCount, 0, itemCount); } RecyclerView setupBasic(int itemCount, int firstLayoutStartIndex, int firstLayoutItemCount) throws Throwable { return setupBasic(itemCount, firstLayoutStartIndex, firstLayoutItemCount, null); } RecyclerView setupBasic(int itemCount, int firstLayoutStartIndex, int firstLayoutItemCount, TestAdapter testAdapter) throws Throwable { final TestRecyclerView recyclerView = new TestRecyclerView(getActivity()); recyclerView.setHasFixedSize(true); if (testAdapter == null) { mTestAdapter = new TestAdapter(itemCount); } else { mTestAdapter = testAdapter; } recyclerView.setAdapter(mTestAdapter); mLayoutManager = new AnimationLayoutManager(); recyclerView.setLayoutManager(mLayoutManager); mLayoutManager.mOnLayoutCallbacks.mLayoutMin = firstLayoutStartIndex; mLayoutManager.mOnLayoutCallbacks.mLayoutItemCount = firstLayoutItemCount; mLayoutManager.expectLayouts(1); recyclerView.expectDraw(1); setRecyclerView(recyclerView); mLayoutManager.waitForLayout(2); recyclerView.waitForDraw(1); mLayoutManager.mOnLayoutCallbacks.reset(); getInstrumentation().waitForIdleSync(); assertEquals("extra layouts should not happen", 1, mLayoutManager.getTotalLayoutCount()); assertEquals("all expected children should be laid out", firstLayoutItemCount, mLayoutManager.getChildCount()); return recyclerView; } public void testDetachBeforeAnimations() throws Throwable { setupBasic(10, 0, 5); final RecyclerView rv = mRecyclerView; waitForAnimations(2); final DefaultItemAnimator animator = new DefaultItemAnimator() { @Override public void runPendingAnimations() { super.runPendingAnimations(); } }; rv.setItemAnimator(animator); mLayoutManager.expectLayouts(2); mTestAdapter.deleteAndNotify(3, 4); mLayoutManager.waitForLayout(2); removeRecyclerView(); assertNull("test sanity check RV should be removed", rv.getParent()); assertEquals("no views should be hidden", 0, rv.mChildHelper.mHiddenViews.size()); assertFalse("there should not be any animations running", animator.isRunning()); } public void testPreLayoutPositionCleanup() throws Throwable { setupBasic(4, 0, 4); mLayoutManager.expectLayouts(2); mLayoutManager.mOnLayoutCallbacks = new OnLayoutCallbacks() { @Override void beforePreLayout(RecyclerView.Recycler recycler, AnimationLayoutManager lm, RecyclerView.State state) { mLayoutMin = 0; mLayoutItemCount = 3; } @Override void beforePostLayout(RecyclerView.Recycler recycler, AnimationLayoutManager layoutManager, RecyclerView.State state) { mLayoutMin = 0; mLayoutItemCount = 4; } }; mTestAdapter.addAndNotify(0, 1); mLayoutManager.waitForLayout(2); } public void testAddRemoveSamePass() throws Throwable { final List mRecycledViews = new ArrayList(); TestAdapter adapter = new TestAdapter(50) { @Override public void onViewRecycled(TestViewHolder holder) { super.onViewRecycled(holder); mRecycledViews.add(holder); } }; adapter.setHasStableIds(true); setupBasic(50, 3, 5, adapter); mRecyclerView.setItemViewCacheSize(0); final ArrayList addVH = new ArrayList(); final ArrayList removeVH = new ArrayList(); final ArrayList moveVH = new ArrayList(); final View[] testView = new View[1]; mRecyclerView.setItemAnimator(new DefaultItemAnimator() { @Override public boolean animateAdd(RecyclerView.ViewHolder holder) { addVH.add(holder); return true; } @Override public boolean animateRemove(RecyclerView.ViewHolder holder) { removeVH.add(holder); return true; } @Override public boolean animateMove(RecyclerView.ViewHolder holder, int fromX, int fromY, int toX, int toY) { moveVH.add(holder); return true; } }); mLayoutManager.mOnLayoutCallbacks = new OnLayoutCallbacks() { @Override void afterPreLayout(RecyclerView.Recycler recycler, AnimationLayoutManager layoutManager, RecyclerView.State state) { super.afterPreLayout(recycler, layoutManager, state); testView[0] = recycler.getViewForPosition(45); testView[0].measure(View.MeasureSpec.makeMeasureSpec(100, View.MeasureSpec.AT_MOST), View.MeasureSpec.makeMeasureSpec(100, View.MeasureSpec.AT_MOST)); testView[0].layout(10, 10, 10 + testView[0].getMeasuredWidth(), 10 + testView[0].getMeasuredHeight()); layoutManager.addView(testView[0], 4); } @Override void afterPostLayout(RecyclerView.Recycler recycler, AnimationLayoutManager layoutManager, RecyclerView.State state) { super.afterPostLayout(recycler, layoutManager, state); testView[0].layout(50, 50, 50 + testView[0].getMeasuredWidth(), 50 + testView[0].getMeasuredHeight()); layoutManager.addDisappearingView(testView[0], 4); } }; mLayoutManager.mOnLayoutCallbacks.mLayoutMin = 3; mLayoutManager.mOnLayoutCallbacks.mLayoutItemCount = 5; mRecycledViews.clear(); mLayoutManager.expectLayouts(2); mTestAdapter.deleteAndNotify(3, 1); mLayoutManager.waitForLayout(2); for (RecyclerView.ViewHolder vh : addVH) { assertNotSame("add-remove item should not animate add", testView[0], vh.itemView); } for (RecyclerView.ViewHolder vh : moveVH) { assertNotSame("add-remove item should not animate move", testView[0], vh.itemView); } for (RecyclerView.ViewHolder vh : removeVH) { assertNotSame("add-remove item should not animate remove", testView[0], vh.itemView); } boolean found = false; for (RecyclerView.ViewHolder vh : mRecycledViews) { found |= vh.itemView == testView[0]; } assertTrue("added-removed view should be recycled", found); } public void testChangeAnimations() throws Throwable { final boolean[] booleans = {true, false}; for (boolean supportsChange : booleans) { for (boolean changeType : booleans) { for (boolean hasStableIds : booleans) { for (boolean deleteSomeItems : booleans) { changeAnimTest(supportsChange, changeType, hasStableIds, deleteSomeItems); } removeRecyclerView(); } } } } public void changeAnimTest(final boolean supportsChangeAnim, final boolean changeType, final boolean hasStableIds, final boolean deleteSomeItems) throws Throwable { final int changedIndex = 3; final int defaultType = 1; final AtomicInteger changedIndexNewType = new AtomicInteger(defaultType); final String logPrefix = "supportsChangeAnim:" + supportsChangeAnim + ", change view type:" + changeType + ", has stable ids:" + hasStableIds + ", force predictive:" + deleteSomeItems; TestAdapter testAdapter = new TestAdapter(10) { @Override public int getItemViewType(int position) { return position == changedIndex ? changedIndexNewType.get() : defaultType; } @Override public TestViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { TestViewHolder vh = super.onCreateViewHolder(parent, viewType); if (DEBUG) { Log.d(TAG, logPrefix + " onCreateVH" + vh.toString()); } return vh; } @Override public void onBindViewHolder(TestViewHolder holder, int position) { super.onBindViewHolder(holder, position); if (DEBUG) { Log.d(TAG, logPrefix + " onBind to " + position + "" + holder.toString()); } } }; testAdapter.setHasStableIds(hasStableIds); setupBasic(testAdapter.getItemCount(), 0, 10, testAdapter); mRecyclerView.getItemAnimator().setSupportsChangeAnimations(supportsChangeAnim); final RecyclerView.ViewHolder toBeChangedVH = mRecyclerView.findViewHolderForPosition(changedIndex); mLayoutManager.mOnLayoutCallbacks = new OnLayoutCallbacks() { @Override void afterPreLayout(RecyclerView.Recycler recycler, AnimationLayoutManager layoutManager, RecyclerView.State state) { RecyclerView.ViewHolder vh = mRecyclerView.findViewHolderForPosition(changedIndex); if (supportsChangeAnim) { assertTrue(logPrefix + " changed view holder should have correct flag" , vh.isChanged()); } else { assertFalse(logPrefix + " changed view holder should have correct flag" , vh.isChanged()); } } @Override void afterPostLayout(RecyclerView.Recycler recycler, AnimationLayoutManager layoutManager, RecyclerView.State state) { RecyclerView.ViewHolder vh = mRecyclerView.findViewHolderForPosition(changedIndex); assertFalse(logPrefix + "VH should not be marked as changed", vh.isChanged()); if (supportsChangeAnim) { assertNotSame(logPrefix + "a new VH should be given if change is supported", toBeChangedVH, vh); } else if (!changeType && hasStableIds) { assertSame(logPrefix + "if change animations are not supported but we have " + "stable ids, same view holder should be returned", toBeChangedVH, vh); } super.beforePostLayout(recycler, layoutManager, state); } }; mLayoutManager.expectLayouts(1); if (changeType) { changedIndexNewType.set(defaultType + 1); } if (deleteSomeItems) { runTestOnUiThread(new Runnable() { @Override public void run() { try { mTestAdapter.deleteAndNotify(changedIndex + 2, 1); mTestAdapter.notifyItemChanged(3); } catch (Throwable throwable) { throwable.printStackTrace(); } } }); } else { mTestAdapter.notifyItemChanged(3); } mLayoutManager.waitForLayout(2); } public void testRecycleDuringAnimations() throws Throwable { final AtomicInteger childCount = new AtomicInteger(0); final TestAdapter adapter = new TestAdapter(1000) { @Override public TestViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { childCount.incrementAndGet(); return super.onCreateViewHolder(parent, viewType); } }; setupBasic(1000, 10, 20, adapter); mLayoutManager.mOnLayoutCallbacks.mLayoutMin = 10; mLayoutManager.mOnLayoutCallbacks.mLayoutItemCount = 20; mRecyclerView.setRecycledViewPool(new RecyclerView.RecycledViewPool() { @Override public void putRecycledView(RecyclerView.ViewHolder scrap) { super.putRecycledView(scrap); childCount.decrementAndGet(); } @Override public RecyclerView.ViewHolder getRecycledView(int viewType) { final RecyclerView.ViewHolder recycledView = super.getRecycledView(viewType); if (recycledView != null) { childCount.incrementAndGet(); } return recycledView; } }); // now keep adding children to trigger more children being created etc. for (int i = 0; i < 100; i ++) { adapter.addAndNotify(15, 1); Thread.sleep(50); } getInstrumentation().waitForIdleSync(); waitForAnimations(2); assertEquals("Children count should add up", childCount.get(), mRecyclerView.getChildCount() + mRecyclerView.mRecycler.mCachedViews.size()); } public void testNotifyDataSetChanged() throws Throwable { setupBasic(10, 3, 4); int layoutCount = mLayoutManager.mTotalLayoutCount; mLayoutManager.expectLayouts(1); runTestOnUiThread(new Runnable() { @Override public void run() { try { mTestAdapter.deleteAndNotify(4, 1); mTestAdapter.dispatchDataSetChanged(); } catch (Throwable throwable) { throwable.printStackTrace(); } } }); mLayoutManager.waitForLayout(2); getInstrumentation().waitForIdleSync(); assertEquals("on notify data set changed, predictive animations should not run", layoutCount + 1, mLayoutManager.mTotalLayoutCount); mLayoutManager.expectLayouts(2); mTestAdapter.addAndNotify(4, 2); // make sure animations recover mLayoutManager.waitForLayout(2); } public void testStableIdNotifyDataSetChanged() throws Throwable { final int itemCount = 20; List initialSet = new ArrayList(); final TestAdapter adapter = new TestAdapter(itemCount) { @Override public long getItemId(int position) { return mItems.get(position).mId; } }; adapter.setHasStableIds(true); initialSet.addAll(adapter.mItems); positionStatesTest(itemCount, 5, 5, adapter, new AdapterOps() { @Override void onRun(TestAdapter testAdapter) throws Throwable { Item item5 = adapter.mItems.get(5); Item item6 = adapter.mItems.get(6); item5.mAdapterIndex = 6; item6.mAdapterIndex = 5; adapter.mItems.remove(5); adapter.mItems.add(6, item5); adapter.dispatchDataSetChanged(); //hacky, we support only 1 layout pass mLayoutManager.layoutLatch.countDown(); } }, PositionConstraint.scrap(6, -1, 5), PositionConstraint.scrap(5, -1, 6), PositionConstraint.scrap(7, -1, 7), PositionConstraint.scrap(8, -1, 8), PositionConstraint.scrap(9, -1, 9)); // now mix items. } public void testGetItemForDeletedView() throws Throwable { getItemForDeletedViewTest(false); getItemForDeletedViewTest(true); } public void getItemForDeletedViewTest(boolean stableIds) throws Throwable { final Set itemViewTypeQueries = new HashSet(); final Set itemIdQueries = new HashSet(); TestAdapter adapter = new TestAdapter(10) { @Override public int getItemViewType(int position) { itemViewTypeQueries.add(position); return super.getItemViewType(position); } @Override public long getItemId(int position) { itemIdQueries.add(position); return mItems.get(position).mId; } }; adapter.setHasStableIds(stableIds); setupBasic(10, 0, 10, adapter); assertEquals("getItemViewType for all items should be called", 10, itemViewTypeQueries.size()); if (adapter.hasStableIds()) { assertEquals("getItemId should be called when adapter has stable ids", 10, itemIdQueries.size()); } else { assertEquals("getItemId should not be called when adapter does not have stable ids", 0, itemIdQueries.size()); } itemViewTypeQueries.clear(); itemIdQueries.clear(); mLayoutManager.expectLayouts(2); // delete last two final int deleteStart = 8; final int deleteCount = adapter.getItemCount() - deleteStart; adapter.deleteAndNotify(deleteStart, deleteCount); mLayoutManager.waitForLayout(2); for (int i = 0; i < deleteStart; i++) { assertTrue("getItemViewType for existing item " + i + " should be called", itemViewTypeQueries.contains(i)); if (adapter.hasStableIds()) { assertTrue("getItemId for existing item " + i + " should be called when adapter has stable ids", itemIdQueries.contains(i)); } } for (int i = deleteStart; i < deleteStart + deleteCount; i++) { assertFalse("getItemViewType for deleted item " + i + " SHOULD NOT be called", itemViewTypeQueries.contains(i)); if (adapter.hasStableIds()) { assertFalse("getItemId for deleted item " + i + " SHOULD NOT be called", itemIdQueries.contains(i)); } } } public void testDeleteInvisibleMultiStep() throws Throwable { setupBasic(1000, 1, 7); mLayoutManager.mOnLayoutCallbacks.mLayoutMin = 1; mLayoutManager.mOnLayoutCallbacks.mLayoutItemCount = 7; mLayoutManager.expectLayouts(1); // try to trigger race conditions int targetItemCount = mTestAdapter.getItemCount(); for (int i = 0; i < 100; i++) { mTestAdapter.deleteAndNotify(new int[]{0, 1}, new int[]{7, 1}); targetItemCount -= 2; } // wait until main thread runnables are consumed while (targetItemCount != mTestAdapter.getItemCount()) { Thread.sleep(100); } mLayoutManager.waitForLayout(2); } public void testAddManyMultiStep() throws Throwable { setupBasic(10, 1, 7); mLayoutManager.mOnLayoutCallbacks.mLayoutMin = 1; mLayoutManager.mOnLayoutCallbacks.mLayoutItemCount = 7; mLayoutManager.expectLayouts(1); // try to trigger race conditions int targetItemCount = mTestAdapter.getItemCount(); for (int i = 0; i < 100; i++) { mTestAdapter.addAndNotify(0, 1); mTestAdapter.addAndNotify(7, 1); targetItemCount += 2; } // wait until main thread runnables are consumed while (targetItemCount != mTestAdapter.getItemCount()) { Thread.sleep(100); } mLayoutManager.waitForLayout(2); } public void testBasicDelete() throws Throwable { setupBasic(10); final OnLayoutCallbacks callbacks = new OnLayoutCallbacks() { @Override public void postDispatchLayout() { // verify this only in first layout assertEquals("deleted views should still be children of RV", mLayoutManager.getChildCount() + mDeletedViewCount , mRecyclerView.getChildCount()); } @Override void afterPreLayout(RecyclerView.Recycler recycler, AnimationLayoutManager layoutManager, RecyclerView.State state) { super.afterPreLayout(recycler, layoutManager, state); mLayoutItemCount = 3; mLayoutMin = 0; } }; callbacks.mLayoutItemCount = 10; callbacks.setExpectedItemCounts(10, 3); mLayoutManager.setOnLayoutCallbacks(callbacks); mLayoutManager.expectLayouts(2); mTestAdapter.deleteAndNotify(0, 7); mLayoutManager.waitForLayout(2); callbacks.reset();// when animations end another layout will happen } public void testAdapterChangeDuringScrolling() throws Throwable { setupBasic(10); final AtomicInteger onLayoutItemCount = new AtomicInteger(0); final AtomicInteger onScrollItemCount = new AtomicInteger(0); mLayoutManager.setOnLayoutCallbacks(new OnLayoutCallbacks() { @Override void onLayoutChildren(RecyclerView.Recycler recycler, AnimationLayoutManager lm, RecyclerView.State state) { onLayoutItemCount.set(state.getItemCount()); super.onLayoutChildren(recycler, lm, state); } @Override public void onScroll(int dx, RecyclerView.Recycler recycler, RecyclerView.State state) { onScrollItemCount.set(state.getItemCount()); super.onScroll(dx, recycler, state); } }); runTestOnUiThread(new Runnable() { @Override public void run() { mTestAdapter.mItems.remove(5); mTestAdapter.notifyItemRangeRemoved(5, 1); mRecyclerView.scrollBy(0, 100); assertTrue("scrolling while there are pending adapter updates should " + "trigger a layout", mLayoutManager.mOnLayoutCallbacks.mLayoutCount > 0); assertEquals("scroll by should be called w/ updated adapter count", mTestAdapter.mItems.size(), onScrollItemCount.get()); } }); } public void testAddInvisibleAndVisible() throws Throwable { setupBasic(10, 1, 7); mLayoutManager.expectLayouts(2); mLayoutManager.mOnLayoutCallbacks.setExpectedItemCounts(10, 12); mTestAdapter.addAndNotify(new int[]{0, 1}, new int[]{7, 1});// add a new item 0 // invisible mLayoutManager.waitForLayout(2); } public void testAddInvisible() throws Throwable { setupBasic(10, 1, 7); mLayoutManager.expectLayouts(1); mLayoutManager.mOnLayoutCallbacks.setExpectedItemCounts(10, 12); mTestAdapter.addAndNotify(new int[]{0, 1}, new int[]{8, 1});// add a new item 0 mLayoutManager.waitForLayout(2); } public void testBasicAdd() throws Throwable { setupBasic(10); mLayoutManager.expectLayouts(2); setExpectedItemCounts(10, 13); mTestAdapter.addAndNotify(2, 3); mLayoutManager.waitForLayout(2); } public TestRecyclerView getTestRecyclerView() { return (TestRecyclerView) mRecyclerView; } public void testRemoveScrapInvalidate() throws Throwable { setupBasic(10); TestRecyclerView testRecyclerView = getTestRecyclerView(); mLayoutManager.expectLayouts(1); testRecyclerView.expectDraw(1); runTestOnUiThread(new Runnable() { @Override public void run() { mTestAdapter.mItems.clear(); mTestAdapter.notifyDataSetChanged(); } }); mLayoutManager.waitForLayout(2); testRecyclerView.waitForDraw(2); } public void testDeleteVisibleAndInvisible() throws Throwable { setupBasic(11, 3, 5); //layout items 3 4 5 6 7 mLayoutManager.expectLayouts(2); setLayoutRange(3, 5); //layout previously invisible child 10 from end of the list setExpectedItemCounts(9, 8); mTestAdapter.deleteAndNotify(new int[]{4, 1}, new int[]{7, 2});// delete items 4, 8, 9 mLayoutManager.waitForLayout(2); } public void testFindPositionOffset() throws Throwable { setupBasic(10); runTestOnUiThread(new Runnable() { @Override public void run() { // [0,1,2,3,4] // delete 1 mTestAdapter.notifyItemRangeRemoved(1, 1); // delete 3 mTestAdapter.notifyItemRangeRemoved(2, 1); mAdapterHelper.preProcess(); // [0,2,4] assertEquals("offset check", 0, mAdapterHelper.findPositionOffset(0)); assertEquals("offset check", 1, mAdapterHelper.findPositionOffset(2)); assertEquals("offset check", 2, mAdapterHelper.findPositionOffset(4)); } }); } private void setLayoutRange(int start, int count) { mLayoutManager.mOnLayoutCallbacks.mLayoutMin = start; mLayoutManager.mOnLayoutCallbacks.mLayoutItemCount = count; } private void setExpectedItemCounts(int preLayout, int postLayout) { mLayoutManager.mOnLayoutCallbacks.setExpectedItemCounts(preLayout, postLayout); } public void testDeleteInvisible() throws Throwable { setupBasic(10, 1, 7); mLayoutManager.mOnLayoutCallbacks.mLayoutMin = 1; mLayoutManager.mOnLayoutCallbacks.mLayoutItemCount = 7; mLayoutManager.expectLayouts(1); mLayoutManager.mOnLayoutCallbacks.setExpectedItemCounts(8, 8); mTestAdapter.deleteAndNotify(new int[]{0, 1}, new int[]{7, 1});// delete item id 0,8 mLayoutManager.waitForLayout(2); } private CollectPositionResult findByPos(RecyclerView recyclerView, RecyclerView.Recycler recycler, RecyclerView.State state, int position) { View view = recycler.getViewForPosition(position, true); RecyclerView.ViewHolder vh = recyclerView.getChildViewHolder(view); if (vh.wasReturnedFromScrap()) { vh.clearReturnedFromScrapFlag(); //keep data consistent. return CollectPositionResult.fromScrap(vh); } else { return CollectPositionResult.fromAdapter(vh); } } public Map collectPositions(RecyclerView recyclerView, RecyclerView.Recycler recycler, RecyclerView.State state, int... positions) { Map positionToAdapterMapping = new HashMap(); for (int position : positions) { if (position < 0) { continue; } positionToAdapterMapping.put(position, findByPos(recyclerView, recycler, state, position)); } return positionToAdapterMapping; } public void testAddDelete2() throws Throwable { positionStatesTest(5, 0, 5, new AdapterOps() { // 0 1 2 3 4 // 0 1 2 a b 3 4 // 0 1 b 3 4 // pre: 0 1 2 3 4 // pre w/ adap: 0 1 2 b 3 4 @Override void onRun(TestAdapter adapter) throws Throwable { adapter.addDeleteAndNotify(new int[]{3, 2}, new int[]{2, -2}); } }, PositionConstraint.scrap(2, 2, -1), PositionConstraint.scrap(1, 1, 1), PositionConstraint.scrap(3, 3, 3) ); } public void testAddDelete1() throws Throwable { positionStatesTest(5, 0, 5, new AdapterOps() { // 0 1 2 3 4 // 0 1 2 a b 3 4 // 0 2 a b 3 4 // 0 c d 2 a b 3 4 // 0 c d 2 a 4 // c d 2 a 4 // pre: 0 1 2 3 4 @Override void onRun(TestAdapter adapter) throws Throwable { adapter.addDeleteAndNotify(new int[]{3, 2}, new int[]{1, -1}, new int[]{1, 2}, new int[]{5, -2}, new int[]{0, -1}); } }, PositionConstraint.scrap(0, 0, -1), PositionConstraint.scrap(1, 1, -1), PositionConstraint.scrap(2, 2, 2), PositionConstraint.scrap(3, 3, -1), PositionConstraint.scrap(4, 4, 4), PositionConstraint.adapter(0), PositionConstraint.adapter(1), PositionConstraint.adapter(3) ); } public void testAddSameIndexTwice() throws Throwable { positionStatesTest(12, 2, 7, new AdapterOps() { @Override void onRun(TestAdapter adapter) throws Throwable { adapter.addAndNotify(new int[]{1, 2}, new int[]{5, 1}, new int[]{5, 1}, new int[]{11, 1}); } }, PositionConstraint.adapterScrap(0, 0), PositionConstraint.adapterScrap(1, 3), PositionConstraint.scrap(2, 2, 4), PositionConstraint.scrap(3, 3, 7), PositionConstraint.scrap(4, 4, 8), PositionConstraint.scrap(7, 7, 12), PositionConstraint.scrap(8, 8, 13) ); } public void testDeleteTwice() throws Throwable { positionStatesTest(12, 2, 7, new AdapterOps() { @Override void onRun(TestAdapter adapter) throws Throwable { adapter.deleteAndNotify(new int[]{0, 1}, new int[]{1, 1}, new int[]{7, 1}, new int[]{0, 1});// delete item ids 0,2,9,1 } }, PositionConstraint.scrap(2, 0, -1), PositionConstraint.scrap(3, 1, 0), PositionConstraint.scrap(4, 2, 1), PositionConstraint.scrap(5, 3, 2), PositionConstraint.scrap(6, 4, 3), PositionConstraint.scrap(8, 6, 5), PositionConstraint.adapterScrap(7, 6), PositionConstraint.adapterScrap(8, 7) ); } public void positionStatesTest(int itemCount, int firstLayoutStartIndex, int firstLayoutItemCount, AdapterOps adapterChanges, final PositionConstraint... constraints) throws Throwable { positionStatesTest(itemCount, firstLayoutStartIndex, firstLayoutItemCount, null, adapterChanges, constraints); } public void positionStatesTest(int itemCount, int firstLayoutStartIndex, int firstLayoutItemCount,TestAdapter adapter, AdapterOps adapterChanges, final PositionConstraint... constraints) throws Throwable { setupBasic(itemCount, firstLayoutStartIndex, firstLayoutItemCount, adapter); mLayoutManager.expectLayouts(2); mLayoutManager.mOnLayoutCallbacks = new OnLayoutCallbacks() { @Override void beforePreLayout(RecyclerView.Recycler recycler, AnimationLayoutManager lm, RecyclerView.State state) { super.beforePreLayout(recycler, lm, state); //harmless lm.detachAndScrapAttachedViews(recycler); final int[] ids = new int[constraints.length]; for (int i = 0; i < constraints.length; i++) { ids[i] = constraints[i].mPreLayoutPos; } Map positions = collectPositions(lm.mRecyclerView, recycler, state, ids); for (PositionConstraint constraint : constraints) { if (constraint.mPreLayoutPos != -1) { constraint.validate(state, positions.get(constraint.mPreLayoutPos), lm.getLog()); } } } @Override void beforePostLayout(RecyclerView.Recycler recycler, AnimationLayoutManager lm, RecyclerView.State state) { super.beforePostLayout(recycler, lm, state); lm.detachAndScrapAttachedViews(recycler); final int[] ids = new int[constraints.length]; for (int i = 0; i < constraints.length; i++) { ids[i] = constraints[i].mPostLayoutPos; } Map positions = collectPositions(lm.mRecyclerView, recycler, state, ids); for (PositionConstraint constraint : constraints) { if (constraint.mPostLayoutPos >= 0) { constraint.validate(state, positions.get(constraint.mPostLayoutPos), lm.getLog()); } } } }; adapterChanges.run(mTestAdapter); mLayoutManager.waitForLayout(2); checkForMainThreadException(); for (PositionConstraint constraint : constraints) { constraint.assertValidate(); } } class AnimationLayoutManager extends TestLayoutManager { private int mTotalLayoutCount = 0; private String log; OnLayoutCallbacks mOnLayoutCallbacks = new OnLayoutCallbacks() { }; @Override public boolean supportsPredictiveItemAnimations() { return true; } public String getLog() { return log; } private String prepareLog(RecyclerView.Recycler recycler, RecyclerView.State state, boolean done) { StringBuilder builder = new StringBuilder(); builder.append("is pre layout:").append(state.isPreLayout()).append(", done:").append(done); builder.append("\nViewHolders:\n"); for (RecyclerView.ViewHolder vh : ((TestRecyclerView)mRecyclerView).collectViewHolders()) { builder.append(vh).append("\n"); } builder.append("scrap:\n"); for (RecyclerView.ViewHolder vh : recycler.getScrapList()) { builder.append(vh).append("\n"); } if (state.isPreLayout() && !done) { log = "\n" + builder.toString(); } else { log += "\n" + builder.toString(); } return log; } @Override public void expectLayouts(int count) { super.expectLayouts(count); mOnLayoutCallbacks.mLayoutCount = 0; } public void setOnLayoutCallbacks(OnLayoutCallbacks onLayoutCallbacks) { mOnLayoutCallbacks = onLayoutCallbacks; } @Override public final void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) { try { mTotalLayoutCount++; prepareLog(recycler, state, false); if (state.isPreLayout()) { validateOldPositions(recycler, state); } else { validateClearedOldPositions(recycler, state); } mOnLayoutCallbacks.onLayoutChildren(recycler, this, state); prepareLog(recycler, state, true); } finally { layoutLatch.countDown(); } } private void validateClearedOldPositions(RecyclerView.Recycler recycler, RecyclerView.State state) { if (getTestRecyclerView() == null) { return; } for (RecyclerView.ViewHolder viewHolder : getTestRecyclerView().collectViewHolders()) { assertEquals("there should NOT be an old position in post layout", RecyclerView.NO_POSITION, viewHolder.mOldPosition); assertEquals("there should NOT be a pre layout position in post layout", RecyclerView.NO_POSITION, viewHolder.mPreLayoutPosition); } } private void validateOldPositions(RecyclerView.Recycler recycler, RecyclerView.State state) { if (getTestRecyclerView() == null) { return; } for (RecyclerView.ViewHolder viewHolder : getTestRecyclerView().collectViewHolders()) { if (!viewHolder.isRemoved() && !viewHolder.isInvalid()) { assertTrue("there should be an old position in pre-layout", viewHolder.mOldPosition != RecyclerView.NO_POSITION); } } } public int getTotalLayoutCount() { return mTotalLayoutCount; } @Override public boolean canScrollVertically() { return true; } @Override public int scrollVerticallyBy(int dy, RecyclerView.Recycler recycler, RecyclerView.State state) { mOnLayoutCallbacks.onScroll(dy, recycler, state); return super.scrollVerticallyBy(dy, recycler, state); } public void onPostDispatchLayout() { mOnLayoutCallbacks.postDispatchLayout(); } @Override public void waitForLayout(long timeout, TimeUnit timeUnit) throws Throwable { super.waitForLayout(timeout, timeUnit); checkForMainThreadException(); } } abstract class OnLayoutCallbacks { int mLayoutMin = Integer.MIN_VALUE; int mLayoutItemCount = Integer.MAX_VALUE; int expectedPreLayoutItemCount = -1; int expectedPostLayoutItemCount = -1; int mDeletedViewCount; int mLayoutCount = 0; void setExpectedItemCounts(int preLayout, int postLayout) { expectedPreLayoutItemCount = preLayout; expectedPostLayoutItemCount = postLayout; } void reset() { mLayoutMin = Integer.MIN_VALUE; mLayoutItemCount = Integer.MAX_VALUE; expectedPreLayoutItemCount = -1; expectedPostLayoutItemCount = -1; mLayoutCount = 0; } void beforePreLayout(RecyclerView.Recycler recycler, AnimationLayoutManager lm, RecyclerView.State state) { mDeletedViewCount = 0; for (int i = 0; i < lm.getChildCount(); i++) { View v = lm.getChildAt(i); if (lm.getLp(v).isItemRemoved()) { mDeletedViewCount++; } } } void doLayout(RecyclerView.Recycler recycler, AnimationLayoutManager lm, RecyclerView.State state) { if (DEBUG) { Log.d(TAG, "item count " + state.getItemCount()); } lm.detachAndScrapAttachedViews(recycler); final int start = mLayoutMin == Integer.MIN_VALUE ? 0 : mLayoutMin; final int count = mLayoutItemCount == Integer.MAX_VALUE ? state.getItemCount() : mLayoutItemCount; lm.layoutRange(recycler, start, start + count); assertEquals("correct # of children should be laid out", count, lm.getChildCount()); lm.assertVisibleItemPositions(); } private void assertNoPreLayoutPosition(RecyclerView.Recycler recycler) { for (RecyclerView.ViewHolder vh : recycler.mAttachedScrap) { assertPreLayoutPosition(vh); } } private void assertNoPreLayoutPosition(RecyclerView.LayoutManager lm) { for (int i = 0; i < lm.getChildCount(); i ++) { final RecyclerView.ViewHolder vh = mRecyclerView .getChildViewHolder(lm.getChildAt(i)); assertPreLayoutPosition(vh); } } private void assertPreLayoutPosition(RecyclerView.ViewHolder vh) { assertEquals("in post layout, there should not be a view holder w/ a pre " + "layout position", RecyclerView.NO_POSITION, vh.mPreLayoutPosition); assertEquals("in post layout, there should not be a view holder w/ an old " + "layout position", RecyclerView.NO_POSITION, vh.mOldPosition); } void onLayoutChildren(RecyclerView.Recycler recycler, AnimationLayoutManager lm, RecyclerView.State state) { if (state.isPreLayout()) { if (expectedPreLayoutItemCount != -1) { assertEquals("on pre layout, state should return abstracted adapter size", expectedPreLayoutItemCount, state.getItemCount()); } beforePreLayout(recycler, lm, state); } else { if (expectedPostLayoutItemCount != -1) { assertEquals("on post layout, state should return real adapter size", expectedPostLayoutItemCount, state.getItemCount()); } beforePostLayout(recycler, lm, state); } if (!state.isPreLayout()) { assertNoPreLayoutPosition(recycler); } doLayout(recycler, lm, state); if (state.isPreLayout()) { afterPreLayout(recycler, lm, state); } else { afterPostLayout(recycler, lm, state); assertNoPreLayoutPosition(lm); } mLayoutCount++; } void afterPreLayout(RecyclerView.Recycler recycler, AnimationLayoutManager layoutManager, RecyclerView.State state) { } void beforePostLayout(RecyclerView.Recycler recycler, AnimationLayoutManager layoutManager, RecyclerView.State state) { } void afterPostLayout(RecyclerView.Recycler recycler, AnimationLayoutManager layoutManager, RecyclerView.State state) { } void postDispatchLayout() { } public void onScroll(int dx, RecyclerView.Recycler recycler, RecyclerView.State state) { } } class TestRecyclerView extends RecyclerView { CountDownLatch drawLatch; public TestRecyclerView(Context context) { super(context); } public TestRecyclerView(Context context, AttributeSet attrs) { super(context, attrs); } public TestRecyclerView(Context context, AttributeSet attrs, int defStyle) { super(context, attrs, defStyle); } @Override void initAdapterManager() { super.initAdapterManager(); mAdapterHelper.mOnItemProcessedCallback = new Runnable() { @Override public void run() { validatePostUpdateOp(); } }; } public void expectDraw(int count) { drawLatch = new CountDownLatch(count); } public void waitForDraw(long timeout) throws Throwable { drawLatch.await(timeout * (DEBUG ? 100 : 1), TimeUnit.SECONDS); assertEquals("all expected draws should happen at the expected time frame", 0, drawLatch.getCount()); } List collectViewHolders() { List holders = new ArrayList(); final int childCount = getChildCount(); for (int i = 0; i < childCount; i++) { ViewHolder holder = getChildViewHolderInt(getChildAt(i)); if (holder != null) { holders.add(holder); } } return holders; } private void validateViewHolderPositions() { final Set existingOffsets = new HashSet(); int childCount = getChildCount(); StringBuilder log = new StringBuilder(); for (int i = 0; i < childCount; i++) { ViewHolder vh = getChildViewHolderInt(getChildAt(i)); TestViewHolder tvh = (TestViewHolder) vh; log.append(tvh.mBindedItem).append(vh) .append(" hidden:") .append(mChildHelper.mHiddenViews.contains(vh.itemView)) .append("\n"); } for (int i = 0; i < childCount; i++) { ViewHolder vh = getChildViewHolderInt(getChildAt(i)); if (vh.isInvalid()) { continue; } if (vh.getPosition() < 0) { LayoutManager lm = getLayoutManager(); for (int j = 0; j < lm.getChildCount(); j ++) { assertNotSame("removed view holder should not be in LM's child list", vh.itemView, lm.getChildAt(j)); } } else if (!mChildHelper.mHiddenViews.contains(vh.itemView)) { if (!existingOffsets.add(vh.getPosition())) { throw new IllegalStateException("view holder position conflict for " + "existing views " + vh + "\n" + log); } } } } void validatePostUpdateOp() { try { validateViewHolderPositions(); if (super.mState.isPreLayout()) { validatePreLayoutSequence((AnimationLayoutManager) getLayoutManager()); } validateAdapterPosition((AnimationLayoutManager) getLayoutManager()); } catch (Throwable t) { postExceptionToInstrumentation(t); } } private void validateAdapterPosition(AnimationLayoutManager lm) { for (ViewHolder vh : collectViewHolders()) { if (!vh.isRemoved() && vh.mPreLayoutPosition >= 0) { assertEquals("adapter position calculations should match view holder " + "pre layout:" + mState.isPreLayout() + " positions\n" + vh + "\n" + lm.getLog(), mAdapterHelper.findPositionOffset(vh.mPreLayoutPosition), vh.mPosition); } } } // ensures pre layout positions are continuous block. This is not necessarily a case // but valid in test RV private void validatePreLayoutSequence(AnimationLayoutManager lm) { Set preLayoutPositions = new HashSet(); for (ViewHolder vh : collectViewHolders()) { assertTrue("pre layout positions should be distinct " + lm.getLog(), preLayoutPositions.add(vh.mPreLayoutPosition)); } int minPos = Integer.MAX_VALUE; for (Integer pos : preLayoutPositions) { if (pos < minPos) { minPos = pos; } } for (int i = 1; i < preLayoutPositions.size(); i++) { assertNotNull("next position should exist " + lm.getLog(), preLayoutPositions.contains(minPos + i)); } } @Override protected void dispatchDraw(Canvas canvas) { super.dispatchDraw(canvas); if (drawLatch != null) { drawLatch.countDown(); } } @Override void dispatchLayout() { try { super.dispatchLayout(); if (getLayoutManager() instanceof AnimationLayoutManager) { ((AnimationLayoutManager) getLayoutManager()).onPostDispatchLayout(); } } catch (Throwable t) { postExceptionToInstrumentation(t); } } } abstract class AdapterOps { final public void run(TestAdapter adapter) throws Throwable { onRun(adapter); } abstract void onRun(TestAdapter testAdapter) throws Throwable; } static class CollectPositionResult { // true if found in scrap public RecyclerView.ViewHolder scrapResult; public RecyclerView.ViewHolder adapterResult; static CollectPositionResult fromScrap(RecyclerView.ViewHolder viewHolder) { CollectPositionResult cpr = new CollectPositionResult(); cpr.scrapResult = viewHolder; return cpr; } static CollectPositionResult fromAdapter(RecyclerView.ViewHolder viewHolder) { CollectPositionResult cpr = new CollectPositionResult(); cpr.adapterResult = viewHolder; return cpr; } } static class PositionConstraint { public static enum Type { scrap, adapter, adapterScrap /*first pass adapter, second pass scrap*/ } Type mType; int mOldPos; // if VH int mPreLayoutPos; int mPostLayoutPos; int mValidateCount = 0; public static PositionConstraint scrap(int oldPos, int preLayoutPos, int postLayoutPos) { PositionConstraint constraint = new PositionConstraint(); constraint.mType = Type.scrap; constraint.mOldPos = oldPos; constraint.mPreLayoutPos = preLayoutPos; constraint.mPostLayoutPos = postLayoutPos; return constraint; } public static PositionConstraint adapterScrap(int preLayoutPos, int position) { PositionConstraint constraint = new PositionConstraint(); constraint.mType = Type.adapterScrap; constraint.mOldPos = RecyclerView.NO_POSITION; constraint.mPreLayoutPos = preLayoutPos; constraint.mPostLayoutPos = position;// adapter pos does not change return constraint; } public static PositionConstraint adapter(int position) { PositionConstraint constraint = new PositionConstraint(); constraint.mType = Type.adapter; constraint.mPreLayoutPos = RecyclerView.NO_POSITION; constraint.mOldPos = RecyclerView.NO_POSITION; constraint.mPostLayoutPos = position;// adapter pos does not change return constraint; } public void assertValidate() { int expectedValidate = 0; if (mPreLayoutPos >= 0) { expectedValidate ++; } if (mPostLayoutPos >= 0) { expectedValidate ++; } assertEquals("should run all validates", expectedValidate, mValidateCount); } @Override public String toString() { return "Cons{" + "t=" + mType.name() + ", old=" + mOldPos + ", pre=" + mPreLayoutPos + ", post=" + mPostLayoutPos + '}'; } public void validate(RecyclerView.State state, CollectPositionResult result, String log) { mValidateCount ++; assertNotNull(this + ": result should not be null\n" + log, result); RecyclerView.ViewHolder viewHolder; if (mType == Type.scrap || (mType == Type.adapterScrap && !state.isPreLayout())) { assertNotNull(this + ": result should come from scrap\n" + log, result.scrapResult); viewHolder = result.scrapResult; } else { assertNotNull(this + ": result should come from adapter\n" + log, result.adapterResult); assertEquals(this + ": old position should be none when it came from adapter\n" + log, RecyclerView.NO_POSITION, result.adapterResult.getOldPosition()); viewHolder = result.adapterResult; } if (state.isPreLayout()) { assertEquals(this + ": pre-layout position should match\n" + log, mPreLayoutPos, viewHolder.mPreLayoutPosition == -1 ? viewHolder.mPosition : viewHolder.mPreLayoutPosition); assertEquals(this + ": pre-layout getPosition should match\n" + log, mPreLayoutPos, viewHolder.getPosition()); if (mType == Type.scrap) { assertEquals(this + ": old position should match\n" + log, mOldPos, result.scrapResult.getOldPosition()); } } else if (mType == Type.adapter || mType == Type.adapterScrap || !result.scrapResult .isRemoved()) { assertEquals(this + ": post-layout position should match\n" + log + "\n\n" + viewHolder, mPostLayoutPos, viewHolder.getPosition()); } } } }