/* * 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 static android.support.v7.widget.LinearLayoutManager.HORIZONTAL; import static android.support.v7.widget.LinearLayoutManager.VERTICAL; import static org.hamcrest.CoreMatchers.is; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertNotNull; import static org.junit.Assert.assertNull; import static org.junit.Assert.assertSame; import static org.junit.Assert.assertThat; import static org.junit.Assert.assertTrue; import android.graphics.Color; import android.graphics.drawable.ColorDrawable; import android.graphics.drawable.StateListDrawable; import android.support.test.filters.LargeTest; import android.support.v4.view.AccessibilityDelegateCompat; import android.support.v4.view.accessibility.AccessibilityEventCompat; import android.support.v4.view.accessibility.AccessibilityRecordCompat; import android.util.Log; import android.util.StateSet; import android.view.View; import android.view.ViewGroup; import android.view.accessibility.AccessibilityEvent; import org.junit.Test; import java.util.ArrayList; import java.util.List; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicInteger; /** * 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. */ @LargeTest public class LinearLayoutManagerTest extends BaseLinearLayoutManagerTest { @Test public void topUnfocusableViewsVisibility() throws Throwable { // The maximum number of child views that can be visible at any time. final int visibleChildCount = 5; final int consecutiveFocusablesCount = 2; final int consecutiveUnFocusablesCount = 18; final TestAdapter adapter = new TestAdapter( consecutiveFocusablesCount + consecutiveUnFocusablesCount) { RecyclerView mAttachedRv; @Override public TestViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { TestViewHolder testViewHolder = super.onCreateViewHolder(parent, viewType); // Good to have colors for debugging StateListDrawable stl = new StateListDrawable(); stl.addState(new int[]{android.R.attr.state_focused}, new ColorDrawable(Color.RED)); stl.addState(StateSet.WILD_CARD, new ColorDrawable(Color.BLUE)); //noinspection deprecation used to support kitkat tests testViewHolder.itemView.setBackgroundDrawable(stl); return testViewHolder; } @Override public void onAttachedToRecyclerView(RecyclerView recyclerView) { mAttachedRv = recyclerView; } @Override public void onBindViewHolder(TestViewHolder holder, int position) { super.onBindViewHolder(holder, position); if (position < consecutiveFocusablesCount) { holder.itemView.setFocusable(true); holder.itemView.setFocusableInTouchMode(true); } else { holder.itemView.setFocusable(false); holder.itemView.setFocusableInTouchMode(false); } // This height ensures that some portion of #visibleChildCount'th child is // off-bounds, creating more interesting test scenario. holder.itemView.setMinimumHeight((mAttachedRv.getHeight() + mAttachedRv.getHeight() / (2 * visibleChildCount)) / visibleChildCount); } }; setupByConfig(new Config(VERTICAL, false, false).adapter(adapter).reverseLayout(true), false); waitForFirstLayout(); // adapter position of the currently focused item. int focusIndex = 0; View newFocused = mRecyclerView.getChildAt(focusIndex); requestFocus(newFocused, true); RecyclerView.ViewHolder toFocus = mRecyclerView.findViewHolderForAdapterPosition( focusIndex); assertThat("Child at position " + focusIndex + " should be focused", toFocus.itemView.hasFocus(), is(true)); // adapter position of the item (whether focusable or not) that just becomes fully // visible after focusSearch. int visibleIndex = 0; // The VH of the above adapter position RecyclerView.ViewHolder toVisible = null; // Navigate up through the focusable and unfocusable chunks. The focusable items should // become focused one by one until hitting the last focusable item, at which point, // unfocusable items should become visible on the screen until the currently focused item // stays on the screen. for (int i = 0; i < adapter.getItemCount(); i++) { focusSearch(mRecyclerView.getFocusedChild(), View.FOCUS_UP, true); // adapter position of the currently focused item. focusIndex = Math.min(consecutiveFocusablesCount - 1, (focusIndex + 1)); toFocus = mRecyclerView.findViewHolderForAdapterPosition(focusIndex); visibleIndex = Math.min(consecutiveFocusablesCount + visibleChildCount - 2, (visibleIndex + 1)); toVisible = mRecyclerView.findViewHolderForAdapterPosition(visibleIndex); assertThat("Child at position " + focusIndex + " should be focused", toFocus.itemView.hasFocus(), is(true)); assertTrue("Focused child should be at least partially visible.", isViewPartiallyInBound(mRecyclerView, toFocus.itemView)); assertTrue("Child view at adapter pos " + visibleIndex + " should be fully visible.", isViewFullyInBound(mRecyclerView, toVisible.itemView)); } } @Test public void bottomUnfocusableViewsVisibility() throws Throwable { // The maximum number of child views that can be visible at any time. final int visibleChildCount = 5; final int consecutiveFocusablesCount = 2; final int consecutiveUnFocusablesCount = 18; final TestAdapter adapter = new TestAdapter( consecutiveFocusablesCount + consecutiveUnFocusablesCount) { RecyclerView mAttachedRv; @Override public TestViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { TestViewHolder testViewHolder = super.onCreateViewHolder(parent, viewType); // Good to have colors for debugging StateListDrawable stl = new StateListDrawable(); stl.addState(new int[]{android.R.attr.state_focused}, new ColorDrawable(Color.RED)); stl.addState(StateSet.WILD_CARD, new ColorDrawable(Color.BLUE)); //noinspection deprecation used to support kitkat tests testViewHolder.itemView.setBackgroundDrawable(stl); return testViewHolder; } @Override public void onAttachedToRecyclerView(RecyclerView recyclerView) { mAttachedRv = recyclerView; } @Override public void onBindViewHolder(TestViewHolder holder, int position) { super.onBindViewHolder(holder, position); if (position < consecutiveFocusablesCount) { holder.itemView.setFocusable(true); holder.itemView.setFocusableInTouchMode(true); } else { holder.itemView.setFocusable(false); holder.itemView.setFocusableInTouchMode(false); } // This height ensures that some portion of #visibleChildCount'th child is // off-bounds, creating more interesting test scenario. holder.itemView.setMinimumHeight((mAttachedRv.getHeight() + mAttachedRv.getHeight() / (2 * visibleChildCount)) / visibleChildCount); } }; setupByConfig(new Config(VERTICAL, false, false).adapter(adapter), false); waitForFirstLayout(); // adapter position of the currently focused item. int focusIndex = 0; View newFocused = mRecyclerView.getChildAt(focusIndex); requestFocus(newFocused, true); RecyclerView.ViewHolder toFocus = mRecyclerView.findViewHolderForAdapterPosition( focusIndex); assertThat("Child at position " + focusIndex + " should be focused", toFocus.itemView.hasFocus(), is(true)); // adapter position of the item (whether focusable or not) that just becomes fully // visible after focusSearch. int visibleIndex = 0; // The VH of the above adapter position RecyclerView.ViewHolder toVisible = null; // Navigate down through the focusable and unfocusable chunks. The focusable items should // become focused one by one until hitting the last focusable item, at which point, // unfocusable items should become visible on the screen until the currently focused item // stays on the screen. for (int i = 0; i < adapter.getItemCount(); i++) { focusSearch(mRecyclerView.getFocusedChild(), View.FOCUS_DOWN, true); // adapter position of the currently focused item. focusIndex = Math.min(consecutiveFocusablesCount - 1, (focusIndex + 1)); toFocus = mRecyclerView.findViewHolderForAdapterPosition(focusIndex); visibleIndex = Math.min(consecutiveFocusablesCount + visibleChildCount - 2, (visibleIndex + 1)); toVisible = mRecyclerView.findViewHolderForAdapterPosition(visibleIndex); assertThat("Child at position " + focusIndex + " should be focused", toFocus.itemView.hasFocus(), is(true)); assertTrue("Focused child should be at least partially visible.", isViewPartiallyInBound(mRecyclerView, toFocus.itemView)); assertTrue("Child view at adapter pos " + visibleIndex + " should be fully visible.", isViewFullyInBound(mRecyclerView, toVisible.itemView)); } } @Test public void leftUnfocusableViewsVisibility() throws Throwable { // The maximum number of child views that can be visible at any time. final int visibleChildCount = 5; final int consecutiveFocusablesCount = 2; final int consecutiveUnFocusablesCount = 18; final TestAdapter adapter = new TestAdapter( consecutiveFocusablesCount + consecutiveUnFocusablesCount) { RecyclerView mAttachedRv; @Override public TestViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { TestViewHolder testViewHolder = super.onCreateViewHolder(parent, viewType); // Good to have colors for debugging StateListDrawable stl = new StateListDrawable(); stl.addState(new int[]{android.R.attr.state_focused}, new ColorDrawable(Color.RED)); stl.addState(StateSet.WILD_CARD, new ColorDrawable(Color.BLUE)); //noinspection deprecation used to support kitkat tests testViewHolder.itemView.setBackgroundDrawable(stl); return testViewHolder; } @Override public void onAttachedToRecyclerView(RecyclerView recyclerView) { mAttachedRv = recyclerView; } @Override public void onBindViewHolder(TestViewHolder holder, int position) { super.onBindViewHolder(holder, position); if (position < consecutiveFocusablesCount) { holder.itemView.setFocusable(true); holder.itemView.setFocusableInTouchMode(true); } else { holder.itemView.setFocusable(false); holder.itemView.setFocusableInTouchMode(false); } // This width ensures that some portion of #visibleChildCount'th child is // off-bounds, creating more interesting test scenario. holder.itemView.setMinimumWidth((mAttachedRv.getWidth() + mAttachedRv.getWidth() / (2 * visibleChildCount)) / visibleChildCount); } }; setupByConfig(new Config(HORIZONTAL, false, false).adapter(adapter).reverseLayout(true), false); waitForFirstLayout(); // adapter position of the currently focused item. int focusIndex = 0; View newFocused = mRecyclerView.getChildAt(focusIndex); requestFocus(newFocused, true); RecyclerView.ViewHolder toFocus = mRecyclerView.findViewHolderForAdapterPosition( focusIndex); assertThat("Child at position " + focusIndex + " should be focused", toFocus.itemView.hasFocus(), is(true)); // adapter position of the item (whether focusable or not) that just becomes fully // visible after focusSearch. int visibleIndex = 0; // The VH of the above adapter position RecyclerView.ViewHolder toVisible = null; // Navigate left through the focusable and unfocusable chunks. The focusable items should // become focused one by one until hitting the last focusable item, at which point, // unfocusable items should become visible on the screen until the currently focused item // stays on the screen. for (int i = 0; i < adapter.getItemCount(); i++) { focusSearch(mRecyclerView.getFocusedChild(), View.FOCUS_LEFT, true); // adapter position of the currently focused item. focusIndex = Math.min(consecutiveFocusablesCount - 1, (focusIndex + 1)); toFocus = mRecyclerView.findViewHolderForAdapterPosition(focusIndex); visibleIndex = Math.min(consecutiveFocusablesCount + visibleChildCount - 2, (visibleIndex + 1)); toVisible = mRecyclerView.findViewHolderForAdapterPosition(visibleIndex); assertThat("Child at position " + focusIndex + " should be focused", toFocus.itemView.hasFocus(), is(true)); assertTrue("Focused child should be at least partially visible.", isViewPartiallyInBound(mRecyclerView, toFocus.itemView)); assertTrue("Child view at adapter pos " + visibleIndex + " should be fully visible.", isViewFullyInBound(mRecyclerView, toVisible.itemView)); } } @Test public void rightUnfocusableViewsVisibility() throws Throwable { // The maximum number of child views that can be visible at any time. final int visibleChildCount = 5; final int consecutiveFocusablesCount = 2; final int consecutiveUnFocusablesCount = 18; final TestAdapter adapter = new TestAdapter( consecutiveFocusablesCount + consecutiveUnFocusablesCount) { RecyclerView mAttachedRv; @Override public TestViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { TestViewHolder testViewHolder = super.onCreateViewHolder(parent, viewType); // Good to have colors for debugging StateListDrawable stl = new StateListDrawable(); stl.addState(new int[]{android.R.attr.state_focused}, new ColorDrawable(Color.RED)); stl.addState(StateSet.WILD_CARD, new ColorDrawable(Color.BLUE)); //noinspection deprecation used to support kitkat tests testViewHolder.itemView.setBackgroundDrawable(stl); return testViewHolder; } @Override public void onAttachedToRecyclerView(RecyclerView recyclerView) { mAttachedRv = recyclerView; } @Override public void onBindViewHolder(TestViewHolder holder, int position) { super.onBindViewHolder(holder, position); if (position < consecutiveFocusablesCount) { holder.itemView.setFocusable(true); holder.itemView.setFocusableInTouchMode(true); } else { holder.itemView.setFocusable(false); holder.itemView.setFocusableInTouchMode(false); } // This width ensures that some portion of #visibleChildCount'th child is // off-bounds, creating more interesting test scenario. holder.itemView.setMinimumWidth((mAttachedRv.getWidth() + mAttachedRv.getWidth() / (2 * visibleChildCount)) / visibleChildCount); } }; setupByConfig(new Config(HORIZONTAL, false, false).adapter(adapter), false); waitForFirstLayout(); // adapter position of the currently focused item. int focusIndex = 0; View newFocused = mRecyclerView.getChildAt(focusIndex); requestFocus(newFocused, true); RecyclerView.ViewHolder toFocus = mRecyclerView.findViewHolderForAdapterPosition( focusIndex); assertThat("Child at position " + focusIndex + " should be focused", toFocus.itemView.hasFocus(), is(true)); // adapter position of the item (whether focusable or not) that just becomes fully // visible after focusSearch. int visibleIndex = 0; // The VH of the above adapter position RecyclerView.ViewHolder toVisible = null; // Navigate right through the focusable and unfocusable chunks. The focusable items should // become focused one by one until hitting the last focusable item, at which point, // unfocusable items should become visible on the screen until the currently focused item // stays on the screen. for (int i = 0; i < adapter.getItemCount(); i++) { focusSearch(mRecyclerView.getFocusedChild(), View.FOCUS_RIGHT, true); // adapter position of the currently focused item. focusIndex = Math.min(consecutiveFocusablesCount - 1, (focusIndex + 1)); toFocus = mRecyclerView.findViewHolderForAdapterPosition(focusIndex); visibleIndex = Math.min(consecutiveFocusablesCount + visibleChildCount - 2, (visibleIndex + 1)); toVisible = mRecyclerView.findViewHolderForAdapterPosition(visibleIndex); assertThat("Child at position " + focusIndex + " should be focused", toFocus.itemView.hasFocus(), is(true)); assertTrue("Focused child should be at least partially visible.", isViewPartiallyInBound(mRecyclerView, toFocus.itemView)); assertTrue("Child view at adapter pos " + visibleIndex + " should be fully visible.", isViewFullyInBound(mRecyclerView, toVisible.itemView)); } } @Test public void unfocusableScrollingWhenFocusCleared() throws Throwable { // The maximum number of child views that can be visible at any time. final int visibleChildCount = 5; final int consecutiveFocusablesCount = 2; final int consecutiveUnFocusablesCount = 18; final TestAdapter adapter = new TestAdapter( consecutiveFocusablesCount + consecutiveUnFocusablesCount) { RecyclerView mAttachedRv; @Override public TestViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { TestViewHolder testViewHolder = super.onCreateViewHolder(parent, viewType); // Good to have colors for debugging StateListDrawable stl = new StateListDrawable(); stl.addState(new int[]{android.R.attr.state_focused}, new ColorDrawable(Color.RED)); stl.addState(StateSet.WILD_CARD, new ColorDrawable(Color.BLUE)); //noinspection deprecation used to support kitkat tests testViewHolder.itemView.setBackgroundDrawable(stl); return testViewHolder; } @Override public void onAttachedToRecyclerView(RecyclerView recyclerView) { mAttachedRv = recyclerView; } @Override public void onBindViewHolder(TestViewHolder holder, int position) { super.onBindViewHolder(holder, position); if (position < consecutiveFocusablesCount) { holder.itemView.setFocusable(true); holder.itemView.setFocusableInTouchMode(true); } else { holder.itemView.setFocusable(false); holder.itemView.setFocusableInTouchMode(false); } // This height ensures that some portion of #visibleChildCount'th child is // off-bounds, creating more interesting test scenario. holder.itemView.setMinimumHeight((mAttachedRv.getHeight() + mAttachedRv.getHeight() / (2 * visibleChildCount)) / visibleChildCount); } }; setupByConfig(new Config(VERTICAL, false, false).adapter(adapter), false); waitForFirstLayout(); // adapter position of the currently focused item. int focusIndex = 0; View newFocused = mRecyclerView.getChildAt(focusIndex); requestFocus(newFocused, true); RecyclerView.ViewHolder toFocus = mRecyclerView.findViewHolderForAdapterPosition( focusIndex); assertThat("Child at position " + focusIndex + " should be focused", toFocus.itemView.hasFocus(), is(true)); final View nextView = focusSearch(mRecyclerView.getFocusedChild(), View.FOCUS_DOWN, true); focusIndex++; assertThat("Child at position " + focusIndex + " should be focused", mRecyclerView.findViewHolderForAdapterPosition(focusIndex).itemView.hasFocus(), is(true)); final CountDownLatch focusLatch = new CountDownLatch(1); mActivityRule.runOnUiThread(new Runnable() { @Override public void run() { nextView.setOnFocusChangeListener(new View.OnFocusChangeListener(){ @Override public void onFocusChange(View v, boolean hasFocus) { assertNull("Focus just got cleared and no children should be holding" + " focus now.", mRecyclerView.getFocusedChild()); try { // Calling focusSearch should be a no-op here since even though there // are unfocusable views down to scroll to, none of RV's children hold // focus at this stage. View focusedChild = focusSearch(v, View.FOCUS_DOWN, true); assertNull("Calling focusSearch should be no-op when no children hold" + "focus", focusedChild); // No scrolling should have happened, so any unfocusables that were // invisible should still be invisible. RecyclerView.ViewHolder unforcusablePartiallyVisibleChild = mRecyclerView.findViewHolderForAdapterPosition( visibleChildCount - 1); assertFalse("Child view at adapter pos " + (visibleChildCount - 1) + " should not be fully visible.", isViewFullyInBound(mRecyclerView, unforcusablePartiallyVisibleChild.itemView)); } catch (Throwable t) { postExceptionToInstrumentation(t); } } }); nextView.clearFocus(); focusLatch.countDown(); } }); assertTrue(focusLatch.await(2, TimeUnit.SECONDS)); assertThat("Child at position " + focusIndex + " should no longer be focused", mRecyclerView.findViewHolderForAdapterPosition(focusIndex).itemView.hasFocus(), is(false)); } @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.MATCH_PARENT; } else { maxSize = mRecyclerView.getHeight(); mlp.width = ViewGroup.MarginLayoutParams.MATCH_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); mActivityRule.runOnUiThread(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); mActivityRule.runOnUiThread(new Runnable() { @Override public void run() { int recyclerSize = mRecyclerView.mRecycler.getRecycledViewPool().size(); ((ViewGroup)mRecyclerView.getParent()).removeView(mRecyclerView); 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(); mActivityRule.runOnUiThread(new Runnable() { @Override public void run() { int recyclerSize = mRecyclerView.mRecycler.getRecycledViewPool().size(); mRecyclerView.mRecycler.getRecycledViewPool().setMaxRecycledViews( mTestAdapter.getItemViewType(0), recyclerSize + childCount); ((ViewGroup)mRecyclerView.getParent()).removeView(mRecyclerView); 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); mActivityRule.runOnUiThread(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(); mActivityRule.runOnUiThread(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()); } }