blob: 768f2f18ff0117b53f8d3c3fc326bcbbb9845c4a [file] [log] [blame]
/*
* 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<Object[]> 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 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;
onBind(item);
}
}
}