/* * 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 android.support.v7.widget.RecyclerView.HORIZONTAL; import static android.support.v7.widget.RecyclerView.SCROLL_STATE_IDLE; import static android.support.v7.widget.RecyclerView.VERTICAL; import static org.hamcrest.CoreMatchers.is; import static org.hamcrest.CoreMatchers.notNullValue; import static org.hamcrest.MatcherAssert.assertThat; import static org.junit.Assert.assertTrue; import android.app.Activity; import android.content.Context; import android.os.Build; import android.support.test.InstrumentationRegistry; import android.support.test.filters.LargeTest; import android.support.test.rule.ActivityTestRule; import android.support.v4.view.ViewCompat; import android.support.v7.recyclerview.test.R; import android.support.v7.widget.test.RecyclerViewTestActivity; import android.view.LayoutInflater; import android.view.View; import android.view.ViewGroup; import android.view.ViewParent; import android.widget.LinearLayout; import org.hamcrest.BaseMatcher; import org.hamcrest.CoreMatchers; import org.hamcrest.Description; import org.junit.Rule; 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.CountDownLatch; import java.util.concurrent.TimeUnit; /** * This class tests RecyclerView focus search failure handling by using a real LayoutManager. */ @LargeTest @RunWith(Parameterized.class) public class FocusSearchNavigationTest { @Rule public ActivityTestRule mActivityRule = new ActivityTestRule<>(RecyclerViewTestActivity.class); private final int mOrientation; private final int mLayoutDir; public FocusSearchNavigationTest(int orientation, int layoutDir) { mOrientation = orientation; mLayoutDir = layoutDir; } @Parameterized.Parameters(name = "orientation:{0},layoutDir:{1}") public static List params() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) { return Arrays.asList( new Object[]{VERTICAL, ViewCompat.LAYOUT_DIRECTION_LTR}, new Object[]{HORIZONTAL, ViewCompat.LAYOUT_DIRECTION_LTR}, new Object[]{HORIZONTAL, ViewCompat.LAYOUT_DIRECTION_RTL} ); } else { // Do not test RTL before API 17 return Arrays.asList( new Object[]{VERTICAL, ViewCompat.LAYOUT_DIRECTION_LTR}, new Object[]{HORIZONTAL, ViewCompat.LAYOUT_DIRECTION_LTR} ); } } private Activity mActivity; private RecyclerView mRecyclerView; private View mBefore; private View mAfter; private void setup(final int itemCount) throws Throwable { mActivity = mActivityRule.getActivity(); mActivityRule.runOnUiThread(new Runnable() { @Override public void run() { mActivity.setContentView(R.layout.focus_search_activity); ViewCompat.setLayoutDirection(mActivity.getWindow().getDecorView(), mLayoutDir); LinearLayout linearLayout = (LinearLayout) mActivity.findViewById(R.id.root); linearLayout.setOrientation(mOrientation); mRecyclerView = (RecyclerView) mActivity.findViewById(R.id.recycler_view); ViewCompat.setLayoutDirection(mRecyclerView, mLayoutDir); LinearLayoutManager layout = new LinearLayoutManager(mActivity.getBaseContext()); layout.setOrientation(mOrientation); mRecyclerView.setLayoutManager(layout); mRecyclerView.setAdapter(new FocusSearchAdapter(itemCount, mOrientation)); if (mOrientation == VERTICAL) { mRecyclerView.setLayoutParams(new LinearLayout.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, 250)); } else { mRecyclerView.setLayoutParams(new LinearLayout.LayoutParams( 250, ViewGroup.LayoutParams.MATCH_PARENT)); } mBefore = mActivity.findViewById(R.id.before); mAfter = mActivity.findViewById(R.id.after); } }); waitForIdleSync(); assertThat("test sanity", mRecyclerView.getLayoutManager().getLayoutDirection(), is(mLayoutDir)); assertThat("test sanity", ViewCompat.getLayoutDirection(mRecyclerView), is(mLayoutDir)); } @Test public void focusSearchForward() throws Throwable { setup(20); requestFocus(mBefore); assertThat(mBefore, hasFocus()); View focused = mBefore; for (int i = 0; i < 20; i++) { focusSearchAndGive(focused, View.FOCUS_FORWARD); RecyclerView.ViewHolder viewHolder = mRecyclerView.findViewHolderForAdapterPosition(i); assertThat("vh at " + i, viewHolder, hasFocus()); focused = viewHolder.itemView; } focusSearchAndGive(focused, View.FOCUS_FORWARD); assertThat(mAfter, hasFocus()); focusSearchAndGive(mAfter, View.FOCUS_FORWARD); assertThat(mBefore, hasFocus()); focusSearchAndGive(mBefore, View.FOCUS_FORWARD); focused = mActivity.getCurrentFocus(); //noinspection ConstantConditions assertThat(focused.getParent(), CoreMatchers.sameInstance(mRecyclerView)); } @Test public void focusSearchBackwards() throws Throwable { setup(20); requestFocus(mAfter); assertThat(mAfter, hasFocus()); View focused = mAfter; RecyclerView.ViewHolder lastViewHolder = null; int i = 20; while(lastViewHolder == null) { lastViewHolder = mRecyclerView.findViewHolderForAdapterPosition(--i); } assertThat(lastViewHolder, notNullValue()); while(i >= 0) { focusSearchAndGive(focused, View.FOCUS_BACKWARD); RecyclerView.ViewHolder viewHolder = mRecyclerView.findViewHolderForAdapterPosition(i); assertThat("vh at " + i, viewHolder, hasFocus()); focused = viewHolder.itemView; i--; } focusSearchAndGive(focused, View.FOCUS_BACKWARD); assertThat(mBefore, hasFocus()); focusSearchAndGive(mBefore, View.FOCUS_BACKWARD); assertThat(mAfter, hasFocus()); } private View focusSearchAndGive(final View view, final int focusDir) throws Throwable { View next = focusSearch(view, focusDir); if (next != null && next != view) { requestFocus(next); return next; } return null; } private View focusSearch(final View view, final int focusDir) throws Throwable { final View[] result = new View[1]; mActivityRule.runOnUiThread(new Runnable() { @Override public void run() { result[0] = view.focusSearch(focusDir); } }); waitForIdleSync(); return result[0]; } private void waitForIdleSync() throws Throwable { waitForIdleScroll(mRecyclerView); InstrumentationRegistry.getInstrumentation().waitForIdleSync(); } private void requestFocus(final View view) throws Throwable { mActivityRule.runOnUiThread(new Runnable() { @Override public void run() { view.requestFocus(); } }); waitForIdleSync(); } public void waitForIdleScroll(final RecyclerView recyclerView) throws Throwable { final CountDownLatch latch = new CountDownLatch(1); mActivityRule.runOnUiThread(new Runnable() { @Override public void run() { RecyclerView.OnScrollListener listener = new RecyclerView.OnScrollListener() { @Override public void onScrollStateChanged(RecyclerView recyclerView, int newState) { if (newState == SCROLL_STATE_IDLE) { latch.countDown(); recyclerView.removeOnScrollListener(this); } } }; if (recyclerView.getScrollState() == SCROLL_STATE_IDLE) { latch.countDown(); } else { recyclerView.addOnScrollListener(listener); } } }); assertTrue("should go idle in 10 seconds", latch.await(10, TimeUnit.SECONDS)); } static class FocusSearchAdapter extends RecyclerView.Adapter { private int mItemCount; private int mOrientation; public FocusSearchAdapter(int itemCount, int orientation) { mItemCount = itemCount; mOrientation = orientation; } @Override public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { View view = LayoutInflater.from(parent.getContext()).inflate(R.layout.item_view, parent, false); if (mOrientation == VERTICAL) { view.setLayoutParams(new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, 50)); } else { view.setLayoutParams(new ViewGroup.LayoutParams(50, ViewGroup.LayoutParams.MATCH_PARENT)); } return new RecyclerView.ViewHolder(view) {}; } @Override public void onBindViewHolder(RecyclerView.ViewHolder holder, int position) { holder.itemView.setTag("pos " + position); } @Override public int getItemCount() { return mItemCount; } } static HasFocusMatcher hasFocus() { return new HasFocusMatcher(); } static class HasFocusMatcher extends BaseMatcher { @Override public boolean matches(Object item) { if (item instanceof RecyclerView.ViewHolder) { item = ((RecyclerView.ViewHolder) item).itemView; } return item instanceof View && ((View) item).hasFocus(); } @Override public void describeTo(Description description) { description.appendText("view has focus"); } private String objectToLog(Object item) { if (item instanceof RecyclerView.ViewHolder) { RecyclerView.ViewHolder vh = (RecyclerView.ViewHolder) item; return vh.toString(); } if (item instanceof View) { final Object tag = ((View) item).getTag(); return tag == null ? item.toString() : tag.toString(); } final String classLog = item == null ? "null" : item.getClass().getSimpleName(); return classLog; } @Override public void describeMismatch(Object item, Description description) { String noun = objectToLog(item); description.appendText(noun + " does not have focus"); Context context = null; if (item instanceof RecyclerView.ViewHolder) { context = ((RecyclerView.ViewHolder)item).itemView.getContext(); } else if (item instanceof View) { context = ((View) item).getContext(); } if (context instanceof Activity) { View currentFocus = ((Activity) context).getWindow().getCurrentFocus(); description.appendText(". Current focus is in " + objectToLog(currentFocus)); } } } }