blob: 119ad800753ad123391800405a246e03eca8a06b [file] [log] [blame]
/*
* 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.graphics.Rect;
import android.support.v4.view.ViewCompat;
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.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 testMoveDeleted() throws Throwable {
setupBasic(4, 0, 3);
waitForAnimations(2);
final View[] targetChild = new View[1];
final LoggingItemAnimator animator = new LoggingItemAnimator();
runTestOnUiThread(new Runnable() {
@Override
public void run() {
mRecyclerView.setItemAnimator(animator);
targetChild[0] = mRecyclerView.getChildAt(1);
}
});
assertNotNull("test sanity", targetChild);
mLayoutManager.expectLayouts(1);
runTestOnUiThread(new Runnable() {
@Override
public void run() {
mRecyclerView.addItemDecoration(new RecyclerView.ItemDecoration() {
@Override
public void getItemOffsets(Rect outRect, View view, RecyclerView parent,
RecyclerView.State state) {
if (view == targetChild[0]) {
outRect.set(10, 20, 30, 40);
} else {
outRect.set(0, 0, 0, 0);
}
}
});
}
});
mLayoutManager.waitForLayout(1);
// now delete that item.
mLayoutManager.expectLayouts(2);
RecyclerView.ViewHolder targetVH = mRecyclerView.getChildViewHolder(targetChild[0]);
targetChild[0] = null;
mTestAdapter.deleteAndNotify(1, 1);
mLayoutManager.waitForLayout(2);
assertFalse("if deleted view moves, it should not be in move animations",
animator.mMoveVHs.contains(targetVH));
assertEquals("only 1 item is deleted", 1, animator.mRemoveVHs.size());
assertTrue("the target view is removed", animator.mRemoveVHs.contains(targetVH
));
}
private void runTestImportantForAccessibilityWhileDeteling(
final int boundImportantForAccessibility,
final int expectedImportantForAccessibility) throws Throwable {
// Adapter binding the item to the initial accessibility option.
// RecyclerView is expected to change it to 'expectedImportantForAccessibility'.
TestAdapter adapter = new TestAdapter(1) {
@Override
public void onBindViewHolder(TestViewHolder holder, int position) {
super.onBindViewHolder(holder, position);
ViewCompat.setImportantForAccessibility(
holder.itemView, boundImportantForAccessibility);
}
};
// Set up with 1 item.
setupBasic(1, 0, 1, adapter);
waitForAnimations(2);
final View[] targetChild = new View[1];
final LoggingItemAnimator animator = new LoggingItemAnimator();
animator.setRemoveDuration(500);
runTestOnUiThread(new Runnable() {
@Override
public void run() {
mRecyclerView.setItemAnimator(animator);
targetChild[0] = mRecyclerView.getChildAt(0);
assertEquals(
expectedImportantForAccessibility,
ViewCompat.getImportantForAccessibility(targetChild[0]));
}
});
assertNotNull("test sanity", targetChild[0]);
// now delete that item.
mLayoutManager.expectLayouts(2);
mTestAdapter.deleteAndNotify(0, 1);
mLayoutManager.waitForLayout(2);
runTestOnUiThread(new Runnable() {
@Override
public void run() {
// The view is still a child of mRecyclerView, and is invisible for accessibility.
assertTrue(targetChild[0].getParent() == mRecyclerView);
assertEquals(
ViewCompat.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS,
ViewCompat.getImportantForAccessibility(targetChild[0]));
}
});
waitForAnimations(2);
// Delete animation is now complete.
runTestOnUiThread(new Runnable() {
@Override
public void run() {
// The view is in recycled state, and back to the expected accessibility.
assertTrue(targetChild[0].getParent() == null);
assertEquals(
expectedImportantForAccessibility,
ViewCompat.getImportantForAccessibility(targetChild[0]));
}
});
// Add 1 element, which should use same view.
mLayoutManager.expectLayouts(2);
mTestAdapter.addAndNotify(1);
mLayoutManager.waitForLayout(2);
runTestOnUiThread(new Runnable() {
@Override
public void run() {
// The view should be reused, and have the expected accessibility.
assertTrue(
"the item must be reused", targetChild[0] == mRecyclerView.getChildAt(0));
assertEquals(
expectedImportantForAccessibility,
ViewCompat.getImportantForAccessibility(targetChild[0]));
}
});
}
public void testImportantForAccessibilityWhileDetelingAuto() throws Throwable {
runTestImportantForAccessibilityWhileDeteling(
ViewCompat.IMPORTANT_FOR_ACCESSIBILITY_AUTO,
ViewCompat.IMPORTANT_FOR_ACCESSIBILITY_YES);
}
public void testImportantForAccessibilityWhileDetelingNo() throws Throwable {
runTestImportantForAccessibilityWhileDeteling(
ViewCompat.IMPORTANT_FOR_ACCESSIBILITY_NO,
ViewCompat.IMPORTANT_FOR_ACCESSIBILITY_NO);
}
public void testImportantForAccessibilityWhileDetelingNoHideDescandants() throws Throwable {
runTestImportantForAccessibilityWhileDeteling(
ViewCompat.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS,
ViewCompat.IMPORTANT_FOR_ACCESSIBILITY_NO_HIDE_DESCENDANTS);
}
public void testImportantForAccessibilityWhileDetelingYes() throws Throwable {
runTestImportantForAccessibilityWhileDeteling(
ViewCompat.IMPORTANT_FOR_ACCESSIBILITY_YES,
ViewCompat.IMPORTANT_FOR_ACCESSIBILITY_YES);
}
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<RecyclerView.ViewHolder> mRecycledViews
= new ArrayList<RecyclerView.ViewHolder>();
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<RecyclerView.ViewHolder> addVH
= new ArrayList<RecyclerView.ViewHolder>();
final ArrayList<RecyclerView.ViewHolder> removeVH
= new ArrayList<RecyclerView.ViewHolder>();
final ArrayList<RecyclerView.ViewHolder> moveVH
= new ArrayList<RecyclerView.ViewHolder>();
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.findViewHolderForLayoutPosition(changedIndex);
mLayoutManager.mOnLayoutCallbacks = new OnLayoutCallbacks() {
@Override
void afterPreLayout(RecyclerView.Recycler recycler,
AnimationLayoutManager layoutManager,
RecyclerView.State state) {
RecyclerView.ViewHolder vh = mRecyclerView.findViewHolderForLayoutPosition(
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.findViewHolderForLayoutPosition(
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);
}
private static boolean listEquals(List list1, List list2) {
if (list1.size() != list2.size()) {
return false;
}
for (int i= 0; i < list1.size(); i++) {
if (!list1.get(i).equals(list2.get(i))) {
return false;
}
}
return true;
}
private void testChangeWithPayload(final boolean supportsChangeAnim,
Object[][] notifyPayloads, Object[][] expectedPayloadsInOnBind)
throws Throwable {
final List<Object> expectedPayloads = new ArrayList<Object>();
final int changedIndex = 3;
TestAdapter testAdapter = new TestAdapter(10) {
@Override
public int getItemViewType(int position) {
return 1;
}
@Override
public TestViewHolder onCreateViewHolder(ViewGroup parent,
int viewType) {
TestViewHolder vh = super.onCreateViewHolder(parent, viewType);
if (DEBUG) {
Log.d(TAG, " onCreateVH" + vh.toString());
}
return vh;
}
@Override
public void onBindViewHolder(TestViewHolder holder,
int position, List<Object> payloads) {
super.onBindViewHolder(holder, position);
if (DEBUG) {
Log.d(TAG, " onBind to " + position + "" + holder.toString());
}
assertTrue(listEquals(payloads, expectedPayloads));
}
};
testAdapter.setHasStableIds(false);
setupBasic(testAdapter.getItemCount(), 0, 10, testAdapter);
mRecyclerView.getItemAnimator().setSupportsChangeAnimations(supportsChangeAnim);
int numTests = notifyPayloads.length;
for (int i= 0; i < numTests; i++) {
mLayoutManager.expectLayouts(1);
expectedPayloads.clear();
for (int j = 0; j < expectedPayloadsInOnBind[i].length; j++) {
expectedPayloads.add(expectedPayloadsInOnBind[i][j]);
}
final Object[] payloadsToSend = notifyPayloads[i];
runTestOnUiThread(new Runnable() {
@Override
public void run() {
for (int j = 0; j < payloadsToSend.length; j++) {
mTestAdapter.notifyItemChanged(changedIndex, payloadsToSend[j]);
}
}
});
mLayoutManager.waitForLayout(2);
}
}
public void testCrossFadingChangeAnimationWithPayload() throws Throwable {
// for crossfading change animation, will receive EMPTY payload in onBindViewHolder
testChangeWithPayload(true,
new Object[][]{
new Object[]{"abc"},
new Object[]{"abc", null, "cdf"},
new Object[]{"abc", null},
new Object[]{null, "abc"},
new Object[]{"abc", "cdf"}
},
new Object[][]{
new Object[0],
new Object[0],
new Object[0],
new Object[0],
new Object[0]
});
}
public void testNoChangeAnimationWithPayload() throws Throwable {
// for Change Animation disabled, payload should match the payloads unless
// null payload is fired.
testChangeWithPayload(false,
new Object[][]{
new Object[]{"abc"},
new Object[]{"abc", null, "cdf"},
new Object[]{"abc", null},
new Object[]{null, "abc"},
new Object[]{"abc", "cdf"}
},
new Object[][]{
new Object[]{"abc"},
new Object[0],
new Object[0],
new Object[0],
new Object[]{"abc", "cdf"}
});
}
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<Item> initialSet = new ArrayList<Item>();
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<Integer> itemViewTypeQueries = new HashSet<Integer>();
final Set<Integer> itemIdQueries = new HashSet<Integer>();
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});
checkForMainThreadException();
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 testNotifyDataSetChangedDuringScroll() 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.notifyDataSetChanged();
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 void testAppCancelAnimationInDetach() throws Throwable {
final View[] addedView = new View[2];
TestAdapter adapter = new TestAdapter(1) {
@Override
public void onViewDetachedFromWindow(TestViewHolder holder) {
if ((addedView[0] == holder.itemView || addedView[1] == holder.itemView)
&& ViewCompat.hasTransientState(holder.itemView)) {
ViewCompat.animate(holder.itemView).cancel();
}
super.onViewDetachedFromWindow(holder);
}
};
// original 1 item
setupBasic(1, 0, 1, adapter);
mRecyclerView.getItemAnimator().setAddDuration(10000);
mLayoutManager.expectLayouts(2);
// add 2 items
setExpectedItemCounts(1, 3);
mTestAdapter.addAndNotify(0, 2);
mLayoutManager.waitForLayout(2, false);
checkForMainThreadException();
// wait till "add animation" starts
int limit = 200;
while (addedView[0] == null || addedView[1] == null) {
Thread.sleep(100);
runTestOnUiThread(new Runnable() {
@Override
public void run() {
if (mRecyclerView.getChildCount() == 3) {
View view = mRecyclerView.getChildAt(0);
if (ViewCompat.hasTransientState(view)) {
addedView[0] = view;
}
view = mRecyclerView.getChildAt(1);
if (ViewCompat.hasTransientState(view)) {
addedView[1] = view;
}
}
}
});
assertTrue("add should start on time", --limit > 0);
}
// Layout from item2, exclude the current adding items
mLayoutManager.expectLayouts(1);
mLayoutManager.mOnLayoutCallbacks = new OnLayoutCallbacks() {
@Override
void beforePostLayout(RecyclerView.Recycler recycler,
AnimationLayoutManager layoutManager,
RecyclerView.State state) {
mLayoutMin = 2;
mLayoutItemCount = 1;
}
};
requestLayoutOnUIThread(mRecyclerView);
mLayoutManager.waitForLayout(2);
}
public void testAdapterChangeFrozen() throws Throwable {
setupBasic(10, 1, 7);
assertTrue(mRecyclerView.getChildCount() == 7);
mLayoutManager.expectLayouts(2);
mLayoutManager.mOnLayoutCallbacks.mLayoutMin = 1;
mLayoutManager.mOnLayoutCallbacks.mLayoutItemCount = 8;
freezeLayout(true);
mTestAdapter.addAndNotify(0, 1);
mLayoutManager.assertNoLayout("RV should keep old child during frozen", 2);
assertEquals(7, mRecyclerView.getChildCount());
freezeLayout(false);
mLayoutManager.waitForLayout(2);
assertEquals("RV should get updated after waken from frozen",
8, mRecyclerView.getChildCount());
}
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);
mLayoutManager.mOnLayoutCallbacks = new OnLayoutCallbacks() {
@Override
void beforePreLayout(RecyclerView.Recycler recycler,
AnimationLayoutManager lm, RecyclerView.State state) {
super.beforePreLayout(recycler, lm, state);
// [0,2,4]
assertEquals("offset check", 0, mAdapterHelper.findPositionOffset(0));
assertEquals("offset check", 1, mAdapterHelper.findPositionOffset(2));
assertEquals("offset check", 2, mAdapterHelper.findPositionOffset(4));
}
};
runTestOnUiThread(new Runnable() {
@Override
public void run() {
// [0,1,2,3,4]
// delete 1
mTestAdapter.notifyItemRangeRemoved(1, 1);
// delete 3
mTestAdapter.notifyItemRangeRemoved(2, 1);
}
});
mLayoutManager.waitForLayout(2);
}
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<Integer, CollectPositionResult> collectPositions(RecyclerView recyclerView,
RecyclerView.Recycler recycler, RecyclerView.State state, int... positions) {
Map<Integer, CollectPositionResult> positionToAdapterMapping
= new HashMap<Integer, CollectPositionResult>();
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<Integer, CollectPositionResult> 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<Integer, CollectPositionResult> 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();
}
}
public void testAddThenRecycleRemovedView() throws Throwable {
setupBasic(10);
final AtomicInteger step = new AtomicInteger(0);
final List<RecyclerView.ViewHolder> animateRemoveList = new ArrayList<RecyclerView.ViewHolder>();
DefaultItemAnimator animator = new DefaultItemAnimator() {
@Override
public boolean animateRemove(RecyclerView.ViewHolder holder) {
animateRemoveList.add(holder);
return super.animateRemove(holder);
}
};
mRecyclerView.setItemAnimator(animator);
final List<RecyclerView.ViewHolder> pooledViews = new ArrayList<RecyclerView.ViewHolder>();
mRecyclerView.setRecycledViewPool(new RecyclerView.RecycledViewPool() {
@Override
public void putRecycledView(RecyclerView.ViewHolder scrap) {
pooledViews.add(scrap);
super.putRecycledView(scrap);
}
});
final RecyclerView.ViewHolder[] targetVh = new RecyclerView.ViewHolder[1];
mLayoutManager.mOnLayoutCallbacks = new OnLayoutCallbacks() {
@Override
void doLayout(RecyclerView.Recycler recycler,
AnimationLayoutManager lm, RecyclerView.State state) {
switch (step.get()) {
case 1:
super.doLayout(recycler, lm, state);
if (state.isPreLayout()) {
View view = mLayoutManager.getChildAt(1);
RecyclerView.ViewHolder holder =
mRecyclerView.getChildViewHolderInt(view);
targetVh[0] = holder;
assertTrue("test sanity", holder.isRemoved());
mLayoutManager.removeAndRecycleView(view, recycler);
}
break;
}
}
};
step.set(1);
animateRemoveList.clear();
mLayoutManager.expectLayouts(2);
mTestAdapter.deleteAndNotify(1, 1);
mLayoutManager.waitForLayout(2);
assertTrue("test sanity, view should be recycled", pooledViews.contains(targetVh[0]));
assertTrue("since LM force recycled a view, animate disappearance should not be called",
animateRemoveList.isEmpty());
}
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();
}
};
}
@Override
boolean isAccessibilityEnabled() {
return true;
}
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<ViewHolder> collectViewHolders() {
List<ViewHolder> holders = new ArrayList<ViewHolder>();
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<Integer> existingOffsets = new HashSet<Integer>();
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.mBoundItem).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.getLayoutPosition() < 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.getLayoutPosition())) {
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<Integer> preLayoutPositions = new HashSet<Integer>();
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.getLayoutPosition());
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.getLayoutPosition());
}
}
}
}