/* * Copyright (C) 2016 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 org.hamcrest.CoreMatchers.instanceOf; import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.CoreMatchers.not; import static org.hamcrest.CoreMatchers.notNullValue; import static org.hamcrest.CoreMatchers.sameInstance; import static org.hamcrest.MatcherAssert.assertThat; import android.support.annotation.NonNull; import android.support.annotation.Nullable; import android.support.test.filters.MediumTest; import android.support.v7.recyclerview.test.R; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.widget.TextView; import org.junit.Test; import org.junit.runner.RunWith; import org.junit.runners.Parameterized; import java.util.Arrays; import java.util.List; import java.util.concurrent.atomic.AtomicLong; /** * This class only tests the RV's focus recovery logic as focus moves between two views that * represent the same item in the adapter. Keeping a focused view visible is up-to-the * LayoutManager and all FW LayoutManagers already have tests for it. */ @MediumTest @RunWith(Parameterized.class) public class RecyclerViewFocusRecoveryTest extends BaseRecyclerViewInstrumentationTest { TestLayoutManager mLayoutManager; TestAdapter mAdapter; int mChildCount = 10; // Parameter indicating whether RV's children are simple views (false) or ViewGroups (true). private final boolean mFocusOnChild; // Parameter indicating whether RV recovers focus after layout is finished. private final boolean mDisableRecovery; // Parameter indicating whether animation is enabled for the ViewHolder items. private final boolean mDisableAnimation; @Parameterized.Parameters(name = "focusSubChild:{0},disableRecovery:{1}," + "disableAnimation:{2}") public static List<Object[]> getParams() { return Arrays.asList( new Object[]{false, false, true}, new Object[]{true, false, true}, new Object[]{false, true, true}, new Object[]{true, true, true}, new Object[]{false, false, false}, new Object[]{true, false, false}, new Object[]{false, true, false}, new Object[]{true, true, false} ); } public RecyclerViewFocusRecoveryTest(boolean focusOnChild, boolean disableRecovery, boolean disableAnimation) { super(false); mFocusOnChild = focusOnChild; mDisableRecovery = disableRecovery; mDisableAnimation = disableAnimation; } void setupBasic() throws Throwable { setupBasic(false); } void setupBasic(boolean hasStableIds) throws Throwable { TestAdapter adapter = new FocusTestAdapter(mChildCount); adapter.setHasStableIds(hasStableIds); setupBasic(adapter, null); } void setupBasic(TestLayoutManager layoutManager) throws Throwable { setupBasic(null, layoutManager); } void setupBasic(TestAdapter adapter) throws Throwable { setupBasic(adapter, null); } void setupBasic(@Nullable TestAdapter adapter, @Nullable TestLayoutManager layoutManager) throws Throwable { RecyclerView recyclerView = new RecyclerView(getActivity()); if (layoutManager == null) { layoutManager = new FocusLayoutManager(); } if (adapter == null) { adapter = new FocusTestAdapter(mChildCount); } mLayoutManager = layoutManager; mAdapter = adapter; recyclerView.setAdapter(adapter); recyclerView.setLayoutManager(mLayoutManager); recyclerView.setPreserveFocusAfterLayout(!mDisableRecovery); if (mDisableAnimation) { recyclerView.setItemAnimator(null); } mLayoutManager.expectLayouts(1); setRecyclerView(recyclerView); mLayoutManager.waitForLayout(1); } @Test public void testFocusRecoveryInChange() throws Throwable { setupBasic(); mLayoutManager.setSupportsPredictive(true); final RecyclerView.ViewHolder oldVh = focusVh(3); mLayoutManager.expectLayouts(mDisableAnimation ? 1 : 2); mAdapter.changeAndNotify(3, 1); mLayoutManager.waitForLayout(2); if (!mDisableAnimation) { // waiting for RV's ItemAnimator to finish the animation of the removed item waitForAnimations(2); } mActivityRule.runOnUiThread(new Runnable() { @Override public void run() { RecyclerView.ViewHolder newVh = mRecyclerView.findViewHolderForAdapterPosition(3); assertFocusTransition(oldVh, newVh, false); } }); } @Test public void testFocusRecoveryAfterRemovingFocusedChild() throws Throwable { setupBasic(true); FocusViewHolder fvh = cast(focusVh(4)); assertThat("test sanity", fvh, notNullValue()); assertThat("RV should have focus", mRecyclerView.hasFocus(), is(true)); assertThat("RV should pass the focus down to its children", mRecyclerView.isFocused(), is(false)); assertThat("Viewholder did not receive focus", fvh.itemView.hasFocus(), is(true)); assertThat("Viewholder is not focused", fvh.getViewToFocus().isFocused(), is(true)); mLayoutManager.expectLayouts(1); mActivityRule.runOnUiThread(new Runnable() { @Override public void run() { // removing focused child mAdapter.mItems.remove(4); mAdapter.notifyItemRemoved(4); } }); mLayoutManager.waitForLayout(1); if (!mDisableAnimation) { // waiting for RV's ItemAnimator to finish the animation of the removed item waitForAnimations(2); } assertThat("RV should have " + (mChildCount - 1) + " instead of " + mRecyclerView.getChildCount() + " children", mChildCount - 1, is(mRecyclerView.getChildCount())); assertFocusAfterLayout(4, 0); } @Test public void testFocusRecoveryAfterMovingFocusedChild() throws Throwable { setupBasic(true); FocusViewHolder fvh = cast(focusVh(3)); assertThat("test sanity", fvh, notNullValue()); assertThat("RV should have focus", mRecyclerView.hasFocus(), is(true)); assertThat("RV should pass the focus down to its children", mRecyclerView.isFocused(), is(false)); assertThat("Viewholder did not receive focus", fvh.itemView.hasFocus(), is(true)); assertThat("Viewholder is not focused", fvh.getViewToFocus().isFocused(), is(true)); mLayoutManager.expectLayouts(1); mAdapter.moveAndNotify(3, 1); mLayoutManager.waitForLayout(1); if (!mDisableAnimation) { // waiting for RV's ItemAnimator to finish the animation of the removed item waitForAnimations(2); } assertFocusAfterLayout(1, 1); } @Test public void testFocusRecoveryAfterRemovingLastChild() throws Throwable { mChildCount = 1; setupBasic(true); FocusViewHolder fvh = cast(focusVh(0)); assertThat("test sanity", fvh, notNullValue()); assertThat("RV should have focus", mRecyclerView.hasFocus(), is(true)); assertThat("RV should pass the focus down to its children", mRecyclerView.isFocused(), is(false)); assertThat("Viewholder did not receive focus", fvh.itemView.hasFocus(), is(true)); assertThat("Viewholder is not focused", fvh.getViewToFocus().isFocused(), is(true)); mLayoutManager.expectLayouts(1); mActivityRule.runOnUiThread(new Runnable() { @Override public void run() { // removing focused child mAdapter.mItems.remove(0); mAdapter.notifyDataSetChanged(); } }); mLayoutManager.waitForLayout(1); if (!mDisableAnimation) { // waiting for RV's ItemAnimator to finish the animation of the removed item waitForAnimations(2); } assertThat("RV should have " + (mChildCount - 1) + " instead of " + mRecyclerView.getChildCount() + " children", mChildCount - 1, is(mRecyclerView.getChildCount())); assertFocusAfterLayout(-1, -1); } @Test public void testFocusRecoveryAfterAddingFirstChild() throws Throwable { mChildCount = 0; setupBasic(true); mActivityRule.runOnUiThread(new Runnable() { @Override public void run() { requestFocusOnRV(); } }); mLayoutManager.expectLayouts(1); mActivityRule.runOnUiThread(new Runnable() { @Override public void run() { // adding first child mAdapter.mItems.add(0, new Item(0, TestAdapter.DEFAULT_ITEM_PREFIX)); mAdapter.notifyDataSetChanged(); } }); mLayoutManager.waitForLayout(1); if (!mDisableAnimation) { // waiting for RV's ItemAnimator to finish the animation of the removed item waitForAnimations(2); } assertFocusAfterLayout(0, -1); } @Test public void testFocusRecoveryAfterChangingFocusableFlag() throws Throwable { setupBasic(true); FocusViewHolder fvh = cast(focusVh(6)); assertThat("test sanity", fvh, notNullValue()); assertThat("RV should have focus", mRecyclerView.hasFocus(), is(true)); assertThat("RV should pass the focus down to its children", mRecyclerView.isFocused(), is(false)); assertThat("Viewholder did not receive focus", fvh.itemView.hasFocus(), is(true)); assertThat("Viewholder is not focused", fvh.getViewToFocus().isFocused(), is(true)); mLayoutManager.expectLayouts(1); mActivityRule.runOnUiThread(new Runnable() { @Override public void run() { Item item = mAdapter.mItems.get(6); item.setFocusable(false); mAdapter.notifyItemChanged(6); } }); mLayoutManager.waitForLayout(1); if (!mDisableAnimation) { waitForAnimations(2); } FocusViewHolder newVh = cast(mRecyclerView.findViewHolderForAdapterPosition(6)); assertThat("VH should no longer be focusable", newVh.getViewToFocus().isFocusable(), is(false)); assertFocusAfterLayout(7, 0); } @Test public void testFocusRecoveryBeforeLayoutWithFocusBefore() throws Throwable { testFocusRecoveryBeforeLayout(ViewGroup.FOCUS_BEFORE_DESCENDANTS); } @Test public void testFocusRecoveryBeforeLayoutWithFocusAfter() throws Throwable { testFocusRecoveryBeforeLayout(ViewGroup.FOCUS_AFTER_DESCENDANTS); } @Test public void testFocusRecoveryBeforeLayoutWithFocusBlocked() throws Throwable { testFocusRecoveryBeforeLayout(ViewGroup.FOCUS_BLOCK_DESCENDANTS); } @Test public void testFocusRecoveryDuringLayoutWithFocusBefore() throws Throwable { testFocusRecoveryDuringLayout(ViewGroup.FOCUS_BEFORE_DESCENDANTS); } @Test public void testFocusRecoveryDuringLayoutWithFocusAfter() throws Throwable { testFocusRecoveryDuringLayout(ViewGroup.FOCUS_AFTER_DESCENDANTS); } @Test public void testFocusRecoveryDuringLayoutWithFocusBlocked() throws Throwable { testFocusRecoveryDuringLayout(ViewGroup.FOCUS_BLOCK_DESCENDANTS); } /** * Tests whether the focus is correctly recovered when requestFocus on RV is called before * laying out the children. * @throws Throwable */ private void testFocusRecoveryBeforeLayout(int descendantFocusability) throws Throwable { RecyclerView recyclerView = new RecyclerView(getActivity()); recyclerView.setDescendantFocusability(descendantFocusability); mLayoutManager = new FocusLayoutManager(); mAdapter = new FocusTestAdapter(10); recyclerView.setLayoutManager(mLayoutManager); recyclerView.setPreserveFocusAfterLayout(!mDisableRecovery); if (mDisableAnimation) { recyclerView.setItemAnimator(null); } setRecyclerView(recyclerView); assertThat("RV should always be focusable", mRecyclerView.isFocusable(), is(true)); mLayoutManager.expectLayouts(1); mActivityRule.runOnUiThread(new Runnable() { @Override public void run() { requestFocusOnRV(); mRecyclerView.setAdapter(mAdapter); } }); mLayoutManager.waitForLayout(1); assertFocusAfterLayout(0, -1); } /** * Tests whether the focus is correctly recovered when requestFocus on RV is called during * laying out the children. * @throws Throwable */ private void testFocusRecoveryDuringLayout(int descendantFocusability) throws Throwable { RecyclerView recyclerView = new RecyclerView(getActivity()); recyclerView.setDescendantFocusability(descendantFocusability); mLayoutManager = new FocusLayoutManager() { @Override public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) { super.onLayoutChildren(recycler, state); requestFocusOnRV(); } }; mAdapter = new FocusTestAdapter(10); recyclerView.setAdapter(mAdapter); recyclerView.setLayoutManager(mLayoutManager); if (mDisableAnimation) { recyclerView.setItemAnimator(null); } recyclerView.setPreserveFocusAfterLayout(!mDisableRecovery); mLayoutManager.expectLayouts(1); setRecyclerView(recyclerView); mLayoutManager.waitForLayout(1); assertFocusAfterLayout(0, -1); } private void requestFocusOnRV() { assertThat("RV initially has no focus", mRecyclerView.hasFocus(), is(false)); assertThat("RV initially is not focused", mRecyclerView.isFocused(), is(false)); mRecyclerView.requestFocus(); String msg = !mRecyclerView.isComputingLayout() ? " before laying out the children" : " during laying out the children"; assertThat("RV should have focus after calling requestFocus()" + msg, mRecyclerView.hasFocus(), is(true)); assertThat("RV after calling requestFocus() should become focused" + msg, mRecyclerView.isFocused(), is(true)); } /** * Asserts whether RV and one of its children have the correct focus flags after the layout is * complete. This is normally called once the RV layout is complete after initiating * notifyItemChanged. * @param focusedChildIndexWhenRecoveryEnabled * This index is relevant when mDisableRecovery is false. In that case, it refers to the index * of the child that should have focus if the ancestors allow passing down the focus. -1 * indicates none of the children can receive focus even if the ancestors don't block focus, in * which case RV holds and becomes focused. * @param focusedChildIndexWhenRecoveryDisabled * This index is relevant when mDisableRecovery is true. In that case, it refers to the index * of the child that should have focus if the ancestors allow passing down the focus. -1 * indicates none of the children can receive focus even if the ancestors don't block focus, in * which case RV holds and becomes focused. */ private void assertFocusAfterLayout(int focusedChildIndexWhenRecoveryEnabled, int focusedChildIndexWhenRecoveryDisabled) { if (mDisableAnimation && mDisableRecovery) { // This case is not quite handled properly at the moment. For now, RV may become focused // without re-delivering the focus down to the children. Skip the checks for now. return; } if (mRecyclerView.getChildCount() == 0) { assertThat("RV should have focus when it has no children", mRecyclerView.hasFocus(), is(true)); assertThat("RV should be focused when it has no children", mRecyclerView.isFocused(), is(true)); return; } assertThat("RV should still have focus after layout", mRecyclerView.hasFocus(), is(true)); if ((mDisableRecovery && focusedChildIndexWhenRecoveryDisabled == -1) || (!mDisableRecovery && focusedChildIndexWhenRecoveryEnabled == -1) || mRecyclerView.getDescendantFocusability() == ViewGroup.FOCUS_BLOCK_DESCENDANTS || mRecyclerView.getDescendantFocusability() == ViewGroup.FOCUS_BEFORE_DESCENDANTS) { FocusViewHolder fvh = cast(mRecyclerView.findViewHolderForAdapterPosition(0)); String msg1 = " when focus recovery is disabled"; String msg2 = " when descendant focusability is FOCUS_BLOCK_DESCENDANTS"; String msg3 = " when descendant focusability is FOCUS_BEFORE_DESCENDANTS"; assertThat("RV should not pass the focus down to its children" + (mDisableRecovery ? msg1 : (mRecyclerView.getDescendantFocusability() == ViewGroup.FOCUS_BLOCK_DESCENDANTS ? msg2 : msg3)), mRecyclerView.isFocused(), is(true)); assertThat("RV's first child should not have focus" + (mDisableRecovery ? msg1 : (mRecyclerView.getDescendantFocusability() == ViewGroup.FOCUS_BLOCK_DESCENDANTS ? msg2 : msg3)), fvh.itemView.hasFocus(), is(false)); assertThat("RV's first child should not be focused" + (mDisableRecovery ? msg1 : (mRecyclerView.getDescendantFocusability() == ViewGroup.FOCUS_BLOCK_DESCENDANTS ? msg2 : msg3)), fvh.getViewToFocus().isFocused(), is(false)); } else { FocusViewHolder fvh = mDisableRecovery ? cast(mRecyclerView.findViewHolderForAdapterPosition( focusedChildIndexWhenRecoveryDisabled)) : (focusedChildIndexWhenRecoveryEnabled != -1 ? cast(mRecyclerView.findViewHolderForAdapterPosition( focusedChildIndexWhenRecoveryEnabled)) : cast(mRecyclerView.findViewHolderForAdapterPosition(0))); assertThat("test sanity", fvh, notNullValue()); assertThat("RV's first child should be focusable", fvh.getViewToFocus().isFocusable(), is(true)); String msg = " when descendant focusability is FOCUS_AFTER_DESCENDANTS"; assertThat("RV should pass the focus down to its children after layout" + msg, mRecyclerView.isFocused(), is(false)); assertThat("RV's child #" + focusedChildIndexWhenRecoveryEnabled + " should have focus" + " after layout" + msg, fvh.itemView.hasFocus(), is(true)); if (mFocusOnChild) { assertThat("Either the ViewGroup or the TextView within the first child of RV" + "should be focused after layout" + msg, fvh.itemView.isFocused() || fvh.getViewToFocus().isFocused(), is(true)); } else { assertThat("RV's first child should be focused after layout" + msg, fvh.getViewToFocus().isFocused(), is(true)); } } } private void assertFocusTransition(RecyclerView.ViewHolder oldVh, RecyclerView.ViewHolder newVh, boolean typeChanged) { if (mDisableRecovery) { if (mDisableAnimation) { return; } assertFocus(newVh, false); return; } assertThat("test sanity", newVh, notNullValue()); if (!typeChanged && mDisableAnimation) { assertThat(oldVh, sameInstance(newVh)); } else { assertThat(oldVh, not(sameInstance(newVh))); assertFocus(oldVh, false); } assertFocus(newVh, true); } @Test public void testFocusRecoveryInTypeChangeWithPredictive() throws Throwable { testFocusRecoveryInTypeChange(true); } @Test public void testFocusRecoveryInTypeChangeWithoutPredictive() throws Throwable { testFocusRecoveryInTypeChange(false); } private void testFocusRecoveryInTypeChange(boolean withAnimation) throws Throwable { setupBasic(); if (!mDisableAnimation) { ((SimpleItemAnimator) (mRecyclerView.getItemAnimator())) .setSupportsChangeAnimations(true); } mLayoutManager.setSupportsPredictive(withAnimation); final RecyclerView.ViewHolder oldVh = focusVh(3); mLayoutManager.expectLayouts(!mDisableAnimation && withAnimation ? 2 : 1); mActivityRule.runOnUiThread(new Runnable() { @Override public void run() { Item item = mAdapter.mItems.get(3); item.mType += 2; mAdapter.notifyItemChanged(3); } }); mLayoutManager.waitForLayout(2); RecyclerView.ViewHolder newVh = mRecyclerView.findViewHolderForAdapterPosition(3); assertFocusTransition(oldVh, newVh, true); assertThat("test sanity", oldVh.getItemViewType(), not(newVh.getItemViewType())); } @Test public void testRecoverAdapterChangeViaStableIdOnDataSetChanged() throws Throwable { recoverAdapterChangeViaStableId(false, false); } @Test public void testRecoverAdapterChangeViaStableIdOnSwap() throws Throwable { recoverAdapterChangeViaStableId(true, false); } @Test public void testRecoverAdapterChangeViaStableIdOnDataSetChangedWithTypeChange() throws Throwable { recoverAdapterChangeViaStableId(false, true); } @Test public void testRecoverAdapterChangeViaStableIdOnSwapWithTypeChange() throws Throwable { recoverAdapterChangeViaStableId(true, true); } private void recoverAdapterChangeViaStableId(final boolean swap, final boolean changeType) throws Throwable { setupBasic(true); RecyclerView.ViewHolder oldVh = focusVh(4); long itemId = oldVh.getItemId(); mLayoutManager.expectLayouts(1); mActivityRule.runOnUiThread(new Runnable() { @Override public void run() { Item item = mAdapter.mItems.get(4); if (changeType) { item.mType += 2; } if (swap) { mAdapter = new FocusTestAdapter(8); mAdapter.setHasStableIds(true); mAdapter.mItems.add(2, item); mRecyclerView.swapAdapter(mAdapter, false); } else { mAdapter.mItems.remove(0); mAdapter.mItems.remove(0); mAdapter.notifyDataSetChanged(); } } }); mLayoutManager.waitForLayout(1); if (!mDisableAnimation) { waitForAnimations(2); } RecyclerView.ViewHolder newVh = mRecyclerView.findViewHolderForItemId(itemId); if (changeType) { assertFocusTransition(oldVh, newVh, true); } else { // in this case we should use the same VH because we have stable ids assertThat(oldVh, sameInstance(newVh)); assertFocus(newVh, true); } } @Test public void testDoNotRecoverViaPositionOnSetAdapter() throws Throwable { testDoNotRecoverViaPositionOnNewDataSet(new RecyclerViewLayoutTest.AdapterRunnable() { @Override public void run(TestAdapter adapter) throws Throwable { mRecyclerView.setAdapter(new FocusTestAdapter(10)); } }); } @Test public void testDoNotRecoverViaPositionOnSwapAdapterWithRecycle() throws Throwable { testDoNotRecoverViaPositionOnNewDataSet(new RecyclerViewLayoutTest.AdapterRunnable() { @Override public void run(TestAdapter adapter) throws Throwable { mRecyclerView.swapAdapter(new FocusTestAdapter(10), true); } }); } @Test public void testDoNotRecoverViaPositionOnSwapAdapterWithoutRecycle() throws Throwable { testDoNotRecoverViaPositionOnNewDataSet(new RecyclerViewLayoutTest.AdapterRunnable() { @Override public void run(TestAdapter adapter) throws Throwable { mRecyclerView.swapAdapter(new FocusTestAdapter(10), false); } }); } public void testDoNotRecoverViaPositionOnNewDataSet( final RecyclerViewLayoutTest.AdapterRunnable runnable) throws Throwable { setupBasic(false); assertThat("test sanity", mAdapter.hasStableIds(), is(false)); focusVh(4); mLayoutManager.expectLayouts(1); mActivityRule.runOnUiThread(new Runnable() { @Override public void run() { try { runnable.run(mAdapter); } catch (Throwable throwable) { postExceptionToInstrumentation(throwable); } } }); mLayoutManager.waitForLayout(1); RecyclerView.ViewHolder otherVh = mRecyclerView.findViewHolderForAdapterPosition(4); checkForMainThreadException(); // even if the VH is re-used, it will be removed-reAdded so focus will go away from it. assertFocus("should not recover focus if data set is badly invalid", otherVh, false); } @Test public void testDoNotRecoverIfReplacementIsNotFocusable() throws Throwable { final int TYPE_NO_FOCUS = 1001; TestAdapter adapter = new FocusTestAdapter(10) { @Override public void onBindViewHolder(TestViewHolder holder, int position) { super.onBindViewHolder(holder, position); if (holder.getItemViewType() == TYPE_NO_FOCUS) { cast(holder).setFocusable(false); } } }; adapter.setHasStableIds(true); setupBasic(adapter); RecyclerView.ViewHolder oldVh = focusVh(3); final long itemId = oldVh.getItemId(); mLayoutManager.expectLayouts(1); mActivityRule.runOnUiThread(new Runnable() { @Override public void run() { mAdapter.mItems.get(3).mType = TYPE_NO_FOCUS; mAdapter.notifyDataSetChanged(); } }); mLayoutManager.waitForLayout(2); if (!mDisableAnimation) { waitForAnimations(2); } RecyclerView.ViewHolder newVh = mRecyclerView.findViewHolderForItemId(itemId); assertFocus(newVh, false); } @NonNull private RecyclerView.ViewHolder focusVh(int pos) throws Throwable { final RecyclerView.ViewHolder oldVh = mRecyclerView.findViewHolderForAdapterPosition(pos); assertThat("test sanity", oldVh, notNullValue()); requestFocus(oldVh); assertFocus("test sanity", oldVh, true); getInstrumentation().waitForIdleSync(); return oldVh; } @Test public void testDoNotOverrideAdapterRequestedFocus() throws Throwable { final AtomicLong toFocusId = new AtomicLong(-1); FocusTestAdapter adapter = new FocusTestAdapter(10) { @Override public void onBindViewHolder(TestViewHolder holder, int position) { super.onBindViewHolder(holder, position); if (holder.getItemId() == toFocusId.get()) { try { requestFocus(holder); } catch (Throwable throwable) { postExceptionToInstrumentation(throwable); } } } }; adapter.setHasStableIds(true); toFocusId.set(adapter.mItems.get(3).mId); long firstFocusId = toFocusId.get(); setupBasic(adapter); RecyclerView.ViewHolder oldVh = mRecyclerView.findViewHolderForItemId(toFocusId.get()); assertFocus(oldVh, true); toFocusId.set(mAdapter.mItems.get(5).mId); mLayoutManager.expectLayouts(1); mActivityRule.runOnUiThread(new Runnable() { @Override public void run() { mAdapter.mItems.get(3).mType += 2; mAdapter.mItems.get(5).mType += 2; mAdapter.notifyDataSetChanged(); } }); mLayoutManager.waitForLayout(2); if (!mDisableAnimation) { waitForAnimations(2); } RecyclerView.ViewHolder requested = mRecyclerView.findViewHolderForItemId(toFocusId.get()); assertFocus(oldVh, false); assertFocus(requested, true); RecyclerView.ViewHolder oldReplacement = mRecyclerView .findViewHolderForItemId(firstFocusId); assertFocus(oldReplacement, false); checkForMainThreadException(); } @Test public void testDoNotOverrideLayoutManagerRequestedFocus() throws Throwable { final AtomicLong toFocusId = new AtomicLong(-1); FocusTestAdapter adapter = new FocusTestAdapter(10); adapter.setHasStableIds(true); FocusLayoutManager lm = new FocusLayoutManager() { @Override public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) { detachAndScrapAttachedViews(recycler); layoutRange(recycler, 0, state.getItemCount()); RecyclerView.ViewHolder toFocus = mRecyclerView .findViewHolderForItemId(toFocusId.get()); if (toFocus != null) { try { requestFocus(toFocus); } catch (Throwable throwable) { postExceptionToInstrumentation(throwable); } } layoutLatch.countDown(); } }; toFocusId.set(adapter.mItems.get(3).mId); long firstFocusId = toFocusId.get(); setupBasic(adapter, lm); RecyclerView.ViewHolder oldVh = mRecyclerView.findViewHolderForItemId(toFocusId.get()); assertFocus(oldVh, true); toFocusId.set(mAdapter.mItems.get(5).mId); mLayoutManager.expectLayouts(1); requestLayoutOnUIThread(mRecyclerView); mLayoutManager.waitForLayout(2); RecyclerView.ViewHolder requested = mRecyclerView.findViewHolderForItemId(toFocusId.get()); assertFocus(oldVh, false); assertFocus(requested, true); RecyclerView.ViewHolder oldReplacement = mRecyclerView .findViewHolderForItemId(firstFocusId); assertFocus(oldReplacement, false); checkForMainThreadException(); } private void requestFocus(RecyclerView.ViewHolder viewHolder) throws Throwable { FocusViewHolder fvh = cast(viewHolder); requestFocus(fvh.getViewToFocus(), false); } private void assertFocus(RecyclerView.ViewHolder viewHolder, boolean hasFocus) { assertFocus("", viewHolder, hasFocus); } private void assertFocus(String msg, RecyclerView.ViewHolder vh, boolean hasFocus) { FocusViewHolder fvh = cast(vh); assertThat(msg, fvh.getViewToFocus().hasFocus(), is(hasFocus)); } private <T extends FocusViewHolder> T cast(RecyclerView.ViewHolder vh) { assertThat(vh, instanceOf(FocusViewHolder.class)); //noinspection unchecked return (T) vh; } private class FocusTestAdapter extends TestAdapter { public FocusTestAdapter(int count) { super(count); } @Override public FocusViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { final FocusViewHolder fvh; if (mFocusOnChild) { fvh = new FocusViewHolderWithChildren( LayoutInflater.from(parent.getContext()) .inflate(R.layout.focus_test_item_view, parent, false)); } else { fvh = new SimpleFocusViewHolder(new TextView(parent.getContext())); } fvh.setFocusable(true); return fvh; } @Override public void onBindViewHolder(TestViewHolder holder, int position) { cast(holder).bindTo(mItems.get(position)); } } private class FocusLayoutManager extends TestLayoutManager { @Override public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) { detachAndScrapAttachedViews(recycler); layoutRange(recycler, 0, state.getItemCount()); layoutLatch.countDown(); } } private class FocusViewHolderWithChildren extends FocusViewHolder { public final ViewGroup root; public final ViewGroup parent1; public final ViewGroup parent2; public final TextView textView; public FocusViewHolderWithChildren(View view) { super(view); root = (ViewGroup) view; parent1 = (ViewGroup) root.findViewById(R.id.parent1); parent2 = (ViewGroup) root.findViewById(R.id.parent2); textView = (TextView) root.findViewById(R.id.text_view); } @Override void setFocusable(boolean focusable) { parent1.setFocusableInTouchMode(focusable); parent2.setFocusableInTouchMode(focusable); textView.setFocusableInTouchMode(focusable); root.setFocusableInTouchMode(focusable); parent1.setFocusable(focusable); parent2.setFocusable(focusable); textView.setFocusable(focusable); root.setFocusable(focusable); } @Override void onBind(Item item) { textView.setText(getText(item)); } @Override View getViewToFocus() { return textView; } } private class SimpleFocusViewHolder extends FocusViewHolder { public SimpleFocusViewHolder(View itemView) { super(itemView); } @Override void setFocusable(boolean focusable) { itemView.setFocusableInTouchMode(focusable); itemView.setFocusable(focusable); } @Override View getViewToFocus() { return itemView; } @Override void onBind(Item item) { ((TextView) (itemView)).setText(getText(item)); } } private abstract class FocusViewHolder extends TestViewHolder { public FocusViewHolder(View itemView) { super(itemView); } protected String getText(Item item) { return item.mText + "(" + item.mId + ")"; } abstract void setFocusable(boolean focusable); abstract View getViewToFocus(); abstract void onBind(Item item); final void bindTo(Item item) { mBoundItem = item; setFocusable(item.isFocusable()); onBind(item); } } }