/* * 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.v7.recyclerview.test.R; import android.test.suitebuilder.annotation.MediumTest; 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; private final boolean mFocusOnChild; private final boolean mDisableRecovery; @Parameterized.Parameters(name = "focusSubChild:{0}, disable:{1}") public static List getParams() { return Arrays.asList( new Object[]{false, false}, new Object[]{true, false}, new Object[]{false, true}, new Object[]{true, true} ); } public RecyclerViewFocusRecoveryTest(boolean focusOnChild, boolean disableRecovery) { super(false); mFocusOnChild = focusOnChild; mDisableRecovery = disableRecovery; } void setupBasic() throws Throwable { setupBasic(false); } void setupBasic(boolean hasStableIds) throws Throwable { TestAdapter adapter = new FocusTestAdapter(10); 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(10); } mLayoutManager = layoutManager; mAdapter = adapter; recyclerView.setAdapter(adapter); recyclerView.setLayoutManager(mLayoutManager); recyclerView.setPreserveFocusAfterLayout(!mDisableRecovery); mLayoutManager.expectLayouts(1); setRecyclerView(recyclerView); mLayoutManager.waitForLayout(1); } @Test public void testFocusRecoveryInChange() throws Throwable { setupBasic(); ((SimpleItemAnimator) (mRecyclerView.getItemAnimator())).setSupportsChangeAnimations(true); mLayoutManager.setSupportsPredictive(true); final RecyclerView.ViewHolder oldVh = focusVh(3); mLayoutManager.expectLayouts(2); mAdapter.changeAndNotify(3, 1); mLayoutManager.waitForLayout(2); runTestOnUiThread(new Runnable() { @Override public void run() { RecyclerView.ViewHolder newVh = mRecyclerView.findViewHolderForAdapterPosition(3); assertFocusTransition(oldVh, newVh); } }); mLayoutManager.expectLayouts(1); } private void assertFocusTransition(RecyclerView.ViewHolder oldVh, RecyclerView.ViewHolder newVh) { if (mDisableRecovery) { assertFocus(newVh, false); return; } assertThat("test sanity", newVh, notNullValue()); 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(); ((SimpleItemAnimator) (mRecyclerView.getItemAnimator())).setSupportsChangeAnimations(true); mLayoutManager.setSupportsPredictive(withAnimation); final RecyclerView.ViewHolder oldVh = focusVh(3); mLayoutManager.expectLayouts(withAnimation ? 2 : 1); runTestOnUiThread(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); 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); runTestOnUiThread(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); RecyclerView.ViewHolder newVh = mRecyclerView.findViewHolderForItemId(itemId); if (changeType) { assertFocusTransition(oldVh, newVh); } 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); runTestOnUiThread(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); runTestOnUiThread(new Runnable() { @Override public void run() { mAdapter.mItems.get(3).mType = TYPE_NO_FOCUS; mAdapter.notifyDataSetChanged(); } }); mLayoutManager.waitForLayout(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); runTestOnUiThread(new Runnable() { @Override public void run() { mAdapter.mItems.get(3).mType += 2; mAdapter.mItems.get(5).mType += 2; mAdapter.notifyDataSetChanged(); } }); 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(); } @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 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; onBind(item); } } }