| /* |
| * Copyright 2018 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 androidx.recyclerview.widget; |
| |
| |
| import static androidx.recyclerview.widget.RecyclerView.HORIZONTAL; |
| import static androidx.recyclerview.widget.RecyclerView.SCROLL_STATE_IDLE; |
| import static androidx.recyclerview.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.view.LayoutInflater; |
| import android.view.View; |
| import android.view.ViewGroup; |
| import android.view.ViewParent; |
| import android.widget.LinearLayout; |
| |
| import androidx.annotation.NonNull; |
| import androidx.core.view.ViewCompat; |
| import androidx.recyclerview.test.R; |
| import androidx.recyclerview.test.RecyclerViewTestActivity; |
| |
| 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<RecyclerViewTestActivity> 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<Object[]> 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.<ViewParent>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(@NonNull 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(@NonNull 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<Object> { |
| @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)); |
| } |
| } |
| } |
| } |