blob: 2d6bee71aea984f62c295ce41cd10f8cd029c964 [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 org.junit.Test;
import android.content.Context;
import android.graphics.Rect;
import android.os.Parcel;
import android.os.Parcelable;
import android.support.v4.view.AccessibilityDelegateCompat;
import android.support.v4.view.accessibility.AccessibilityEventCompat;
import android.support.v4.view.accessibility.AccessibilityRecordCompat;
import android.test.suitebuilder.annotation.MediumTest;
import android.util.Log;
import android.view.View;
import android.view.ViewGroup;
import android.view.accessibility.AccessibilityEvent;
import android.widget.FrameLayout;
import static android.support.v7.widget.LayoutState.LAYOUT_END;
import static android.support.v7.widget.LayoutState.LAYOUT_START;
import static android.support.v7.widget.LinearLayoutManager.HORIZONTAL;
import static android.support.v7.widget.LinearLayoutManager.VERTICAL;
import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import static org.junit.Assert.*;
/**
* Includes tests for {@link LinearLayoutManager}.
* <p>
* Since most UI tests are not practical, these tests are focused on internal data representation
* and stability of LinearLayoutManager in response to different events (state change, scrolling
* etc) where it is very hard to do manual testing.
*/
@MediumTest
public class LinearLayoutManagerTest extends BaseLinearLayoutManagerTest {
@Test
public void removeAnchorItem() throws Throwable {
removeAnchorItemTest(
new Config().orientation(VERTICAL).stackFromBottom(false).reverseLayout(
false), 100, 0);
}
@Test
public void removeAnchorItemReverse() throws Throwable {
removeAnchorItemTest(
new Config().orientation(VERTICAL).stackFromBottom(false).reverseLayout(true), 100,
0);
}
@Test
public void removeAnchorItemStackFromEnd() throws Throwable {
removeAnchorItemTest(
new Config().orientation(VERTICAL).stackFromBottom(true).reverseLayout(false), 100,
99);
}
@Test
public void removeAnchorItemStackFromEndAndReverse() throws Throwable {
removeAnchorItemTest(
new Config().orientation(VERTICAL).stackFromBottom(true).reverseLayout(true), 100,
99);
}
@Test
public void removeAnchorItemHorizontal() throws Throwable {
removeAnchorItemTest(
new Config().orientation(HORIZONTAL).stackFromBottom(false).reverseLayout(
false), 100, 0);
}
@Test
public void removeAnchorItemReverseHorizontal() throws Throwable {
removeAnchorItemTest(
new Config().orientation(HORIZONTAL).stackFromBottom(false).reverseLayout(true),
100, 0);
}
@Test
public void removeAnchorItemStackFromEndHorizontal() throws Throwable {
removeAnchorItemTest(
new Config().orientation(HORIZONTAL).stackFromBottom(true).reverseLayout(false),
100, 99);
}
@Test
public void removeAnchorItemStackFromEndAndReverseHorizontal() throws Throwable {
removeAnchorItemTest(
new Config().orientation(HORIZONTAL).stackFromBottom(true).reverseLayout(true), 100,
99);
}
/**
* This tests a regression where predictive animations were not working as expected when the
* first item is removed and there aren't any more items to add from that direction.
* First item refers to the default anchor item.
*/
public void removeAnchorItemTest(final Config config, int adapterSize,
final int removePos) throws Throwable {
config.adapter(new TestAdapter(adapterSize) {
@Override
public void onBindViewHolder(TestViewHolder holder,
int position) {
super.onBindViewHolder(holder, position);
ViewGroup.LayoutParams lp = holder.itemView.getLayoutParams();
if (!(lp instanceof ViewGroup.MarginLayoutParams)) {
lp = new ViewGroup.MarginLayoutParams(0, 0);
holder.itemView.setLayoutParams(lp);
}
ViewGroup.MarginLayoutParams mlp = (ViewGroup.MarginLayoutParams) lp;
final int maxSize;
if (config.mOrientation == HORIZONTAL) {
maxSize = mRecyclerView.getWidth();
mlp.height = ViewGroup.MarginLayoutParams.FILL_PARENT;
} else {
maxSize = mRecyclerView.getHeight();
mlp.width = ViewGroup.MarginLayoutParams.FILL_PARENT;
}
final int desiredSize;
if (position == removePos) {
// make it large
desiredSize = maxSize / 4;
} else {
// make it small
desiredSize = maxSize / 8;
}
if (config.mOrientation == HORIZONTAL) {
mlp.width = desiredSize;
} else {
mlp.height = desiredSize;
}
}
});
setupByConfig(config, true);
final int childCount = mLayoutManager.getChildCount();
RecyclerView.ViewHolder toBeRemoved = null;
List<RecyclerView.ViewHolder> toBeMoved = new ArrayList<RecyclerView.ViewHolder>();
for (int i = 0; i < childCount; i++) {
View child = mLayoutManager.getChildAt(i);
RecyclerView.ViewHolder holder = mRecyclerView.getChildViewHolder(child);
if (holder.getAdapterPosition() == removePos) {
toBeRemoved = holder;
} else {
toBeMoved.add(holder);
}
}
assertNotNull("test sanity", toBeRemoved);
assertEquals("test sanity", childCount - 1, toBeMoved.size());
LoggingItemAnimator loggingItemAnimator = new LoggingItemAnimator();
mRecyclerView.setItemAnimator(loggingItemAnimator);
loggingItemAnimator.reset();
loggingItemAnimator.expectRunPendingAnimationsCall(1);
mLayoutManager.expectLayouts(2);
mTestAdapter.deleteAndNotify(removePos, 1);
mLayoutManager.waitForLayout(1);
loggingItemAnimator.waitForPendingAnimationsCall(2);
assertTrue("removed child should receive remove animation",
loggingItemAnimator.mRemoveVHs.contains(toBeRemoved));
for (RecyclerView.ViewHolder vh : toBeMoved) {
assertTrue("view holder should be in moved list",
loggingItemAnimator.mMoveVHs.contains(vh));
}
List<RecyclerView.ViewHolder> newHolders = new ArrayList<RecyclerView.ViewHolder>();
for (int i = 0; i < mLayoutManager.getChildCount(); i++) {
View child = mLayoutManager.getChildAt(i);
RecyclerView.ViewHolder holder = mRecyclerView.getChildViewHolder(child);
if (toBeRemoved != holder && !toBeMoved.contains(holder)) {
newHolders.add(holder);
}
}
assertTrue("some new children should show up for the new space", newHolders.size() > 0);
assertEquals("no items should receive animate add since they are not new", 0,
loggingItemAnimator.mAddVHs.size());
for (RecyclerView.ViewHolder holder : newHolders) {
assertTrue("new holder should receive a move animation",
loggingItemAnimator.mMoveVHs.contains(holder));
}
assertTrue("control against adding too many children due to bad layout state preparation."
+ " initial:" + childCount + ", current:" + mRecyclerView.getChildCount(),
mRecyclerView.getChildCount() <= childCount + 3 /*1 for removed view, 2 for its size*/);
}
@Test
public void keepFocusOnRelayout() throws Throwable {
setupByConfig(new Config(VERTICAL, false, false).itemCount(500), true);
int center = (mLayoutManager.findLastVisibleItemPosition()
- mLayoutManager.findFirstVisibleItemPosition()) / 2;
final RecyclerView.ViewHolder vh = mRecyclerView.findViewHolderForLayoutPosition(center);
final int top = mLayoutManager.mOrientationHelper.getDecoratedStart(vh.itemView);
requestFocus(vh.itemView, true);
assertTrue("view should have the focus", vh.itemView.hasFocus());
// add a bunch of items right before that view, make sure it keeps its position
mLayoutManager.expectLayouts(2);
final int childCountToAdd = mRecyclerView.getChildCount() * 2;
mTestAdapter.addAndNotify(center, childCountToAdd);
center += childCountToAdd; // offset item
mLayoutManager.waitForLayout(2);
mLayoutManager.waitForAnimationsToEnd(20);
final RecyclerView.ViewHolder postVH = mRecyclerView.findViewHolderForLayoutPosition(center);
assertNotNull("focused child should stay in layout", postVH);
assertSame("same view holder should be kept for unchanged child", vh, postVH);
assertEquals("focused child's screen position should stay unchanged", top,
mLayoutManager.mOrientationHelper.getDecoratedStart(postVH.itemView));
}
@Test
public void keepFullFocusOnResize() throws Throwable {
keepFocusOnResizeTest(new Config(VERTICAL, false, false).itemCount(500), true);
}
@Test
public void keepPartialFocusOnResize() throws Throwable {
keepFocusOnResizeTest(new Config(VERTICAL, false, false).itemCount(500), false);
}
@Test
public void keepReverseFullFocusOnResize() throws Throwable {
keepFocusOnResizeTest(new Config(VERTICAL, true, false).itemCount(500), true);
}
@Test
public void keepReversePartialFocusOnResize() throws Throwable {
keepFocusOnResizeTest(new Config(VERTICAL, true, false).itemCount(500), false);
}
@Test
public void keepStackFromEndFullFocusOnResize() throws Throwable {
keepFocusOnResizeTest(new Config(VERTICAL, false, true).itemCount(500), true);
}
@Test
public void keepStackFromEndPartialFocusOnResize() throws Throwable {
keepFocusOnResizeTest(new Config(VERTICAL, false, true).itemCount(500), false);
}
public void keepFocusOnResizeTest(final Config config, boolean fullyVisible) throws Throwable {
setupByConfig(config, true);
final int targetPosition;
if (config.mStackFromEnd) {
targetPosition = mLayoutManager.findFirstVisibleItemPosition();
} else {
targetPosition = mLayoutManager.findLastVisibleItemPosition();
}
final OrientationHelper helper = mLayoutManager.mOrientationHelper;
final RecyclerView.ViewHolder vh = mRecyclerView
.findViewHolderForLayoutPosition(targetPosition);
// scroll enough to offset the child
int startMargin = helper.getDecoratedStart(vh.itemView) -
helper.getStartAfterPadding();
int endMargin = helper.getEndAfterPadding() -
helper.getDecoratedEnd(vh.itemView);
Log.d(TAG, "initial start margin " + startMargin + " , end margin:" + endMargin);
requestFocus(vh.itemView, true);
assertTrue("view should gain the focus", vh.itemView.hasFocus());
// scroll enough to offset the child
startMargin = helper.getDecoratedStart(vh.itemView) -
helper.getStartAfterPadding();
endMargin = helper.getEndAfterPadding() -
helper.getDecoratedEnd(vh.itemView);
Log.d(TAG, "start margin " + startMargin + " , end margin:" + endMargin);
assertTrue("View should become fully visible", startMargin >= 0 && endMargin >= 0);
int expectedOffset = 0;
boolean offsetAtStart = false;
if (!fullyVisible) {
// move it a bit such that it is no more fully visible
final int childSize = helper
.getDecoratedMeasurement(vh.itemView);
expectedOffset = childSize / 3;
if (startMargin < endMargin) {
scrollBy(expectedOffset);
offsetAtStart = true;
} else {
scrollBy(-expectedOffset);
offsetAtStart = false;
}
startMargin = helper.getDecoratedStart(vh.itemView) -
helper.getStartAfterPadding();
endMargin = helper.getEndAfterPadding() -
helper.getDecoratedEnd(vh.itemView);
assertTrue("test sanity, view should not be fully visible", startMargin < 0
|| endMargin < 0);
}
mLayoutManager.expectLayouts(1);
runTestOnUiThread(new Runnable() {
@Override
public void run() {
final ViewGroup.LayoutParams layoutParams = mRecyclerView.getLayoutParams();
if (config.mOrientation == HORIZONTAL) {
layoutParams.width = mRecyclerView.getWidth() / 2;
} else {
layoutParams.height = mRecyclerView.getHeight() / 2;
}
mRecyclerView.setLayoutParams(layoutParams);
}
});
Thread.sleep(100);
// add a bunch of items right before that view, make sure it keeps its position
mLayoutManager.waitForLayout(2);
mLayoutManager.waitForAnimationsToEnd(20);
assertTrue("view should preserve the focus", vh.itemView.hasFocus());
final RecyclerView.ViewHolder postVH = mRecyclerView
.findViewHolderForLayoutPosition(targetPosition);
assertNotNull("focused child should stay in layout", postVH);
assertSame("same view holder should be kept for unchanged child", vh, postVH);
View focused = postVH.itemView;
startMargin = helper.getDecoratedStart(focused) - helper.getStartAfterPadding();
endMargin = helper.getEndAfterPadding() - helper.getDecoratedEnd(focused);
assertTrue("focused child should be somewhat visible",
helper.getDecoratedStart(focused) < helper.getEndAfterPadding()
&& helper.getDecoratedEnd(focused) > helper.getStartAfterPadding());
if (fullyVisible) {
assertTrue("focused child end should stay fully visible",
endMargin >= 0);
assertTrue("focused child start should stay fully visible",
startMargin >= 0);
} else {
if (offsetAtStart) {
assertTrue("start should preserve its offset", startMargin < 0);
assertTrue("end should be visible", endMargin >= 0);
} else {
assertTrue("end should preserve its offset", endMargin < 0);
assertTrue("start should be visible", startMargin >= 0);
}
}
}
@Test
public void scrollToPositionWithPredictive() throws Throwable {
scrollToPositionWithPredictive(0, LinearLayoutManager.INVALID_OFFSET);
removeRecyclerView();
scrollToPositionWithPredictive(3, 20);
removeRecyclerView();
scrollToPositionWithPredictive(Config.DEFAULT_ITEM_COUNT / 2,
LinearLayoutManager.INVALID_OFFSET);
removeRecyclerView();
scrollToPositionWithPredictive(Config.DEFAULT_ITEM_COUNT / 2, 10);
}
@Test
public void recycleDuringAnimations() throws Throwable {
final AtomicInteger childCount = new AtomicInteger(0);
final TestAdapter adapter = new TestAdapter(300) {
@Override
public TestViewHolder onCreateViewHolder(ViewGroup parent,
int viewType) {
final int cnt = childCount.incrementAndGet();
final TestViewHolder testViewHolder = super.onCreateViewHolder(parent, viewType);
if (DEBUG) {
Log.d(TAG, "CHILD_CNT(create):" + cnt + ", " + testViewHolder);
}
return testViewHolder;
}
};
setupByConfig(new Config(VERTICAL, false, false).itemCount(300)
.adapter(adapter), true);
final RecyclerView.RecycledViewPool pool = new RecyclerView.RecycledViewPool() {
@Override
public void putRecycledView(RecyclerView.ViewHolder scrap) {
super.putRecycledView(scrap);
int cnt = childCount.decrementAndGet();
if (DEBUG) {
Log.d(TAG, "CHILD_CNT(put):" + cnt + ", " + scrap);
}
}
@Override
public RecyclerView.ViewHolder getRecycledView(int viewType) {
final RecyclerView.ViewHolder recycledView = super.getRecycledView(viewType);
if (recycledView != null) {
final int cnt = childCount.incrementAndGet();
if (DEBUG) {
Log.d(TAG, "CHILD_CNT(get):" + cnt + ", " + recycledView);
}
}
return recycledView;
}
};
pool.setMaxRecycledViews(mTestAdapter.getItemViewType(0), 500);
mRecyclerView.setRecycledViewPool(pool);
// now keep adding children to trigger more children being created etc.
for (int i = 0; i < 100; i ++) {
adapter.addAndNotify(15, 1);
Thread.sleep(15);
}
getInstrumentation().waitForIdleSync();
waitForAnimations(2);
assertEquals("Children count should add up", childCount.get(),
mRecyclerView.getChildCount() + mRecyclerView.mRecycler.mCachedViews.size());
// now trigger lots of add again, followed by a scroll to position
for (int i = 0; i < 100; i ++) {
adapter.addAndNotify(5 + (i % 3) * 3, 1);
Thread.sleep(25);
}
smoothScrollToPosition(mLayoutManager.findLastVisibleItemPosition() + 20);
waitForAnimations(2);
getInstrumentation().waitForIdleSync();
assertEquals("Children count should add up", childCount.get(),
mRecyclerView.getChildCount() + mRecyclerView.mRecycler.mCachedViews.size());
}
@Test
public void dontRecycleChildrenOnDetach() throws Throwable {
setupByConfig(new Config().recycleChildrenOnDetach(false), true);
runTestOnUiThread(new Runnable() {
@Override
public void run() {
int recyclerSize = mRecyclerView.mRecycler.getRecycledViewPool().size();
mRecyclerView.setLayoutManager(new TestLayoutManager());
assertEquals("No views are recycled", recyclerSize,
mRecyclerView.mRecycler.getRecycledViewPool().size());
}
});
}
@Test
public void recycleChildrenOnDetach() throws Throwable {
setupByConfig(new Config().recycleChildrenOnDetach(true), true);
final int childCount = mLayoutManager.getChildCount();
runTestOnUiThread(new Runnable() {
@Override
public void run() {
int recyclerSize = mRecyclerView.mRecycler.getRecycledViewPool().size();
mRecyclerView.mRecycler.getRecycledViewPool().setMaxRecycledViews(
mTestAdapter.getItemViewType(0), recyclerSize + childCount);
mRecyclerView.setLayoutManager(new TestLayoutManager());
assertEquals("All children should be recycled", childCount + recyclerSize,
mRecyclerView.mRecycler.getRecycledViewPool().size());
}
});
}
@Test
public void scrollAndClear() throws Throwable {
setupByConfig(new Config(), true);
assertTrue("Children not laid out", mLayoutManager.collectChildCoordinates().size() > 0);
mLayoutManager.expectLayouts(1);
runTestOnUiThread(new Runnable() {
@Override
public void run() {
mLayoutManager.scrollToPositionWithOffset(1, 0);
mTestAdapter.clearOnUIThread();
}
});
mLayoutManager.waitForLayout(2);
assertEquals("Remaining children", 0, mLayoutManager.collectChildCoordinates().size());
}
@Test
public void accessibilityPositions() throws Throwable {
setupByConfig(new Config(VERTICAL, false, false), true);
final AccessibilityDelegateCompat delegateCompat = mRecyclerView
.getCompatAccessibilityDelegate();
final AccessibilityEvent event = AccessibilityEvent.obtain();
runTestOnUiThread(new Runnable() {
@Override
public void run() {
delegateCompat.onInitializeAccessibilityEvent(mRecyclerView, event);
}
});
final AccessibilityRecordCompat record = AccessibilityEventCompat
.asRecord(event);
assertEquals("result should have first position",
record.getFromIndex(),
mLayoutManager.findFirstVisibleItemPosition());
assertEquals("result should have last position",
record.getToIndex(),
mLayoutManager.findLastVisibleItemPosition());
}
}