1/*
2 * Copyright 2018 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *      http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17package androidx.recyclerview.widget;
18
19
20import static androidx.recyclerview.widget.RecyclerView.HORIZONTAL;
21import static androidx.recyclerview.widget.RecyclerView.SCROLL_STATE_IDLE;
22import static androidx.recyclerview.widget.RecyclerView.VERTICAL;
23
24import static org.hamcrest.CoreMatchers.is;
25import static org.hamcrest.CoreMatchers.notNullValue;
26import static org.hamcrest.MatcherAssert.assertThat;
27import static org.junit.Assert.assertTrue;
28
29import android.app.Activity;
30import android.content.Context;
31import android.os.Build;
32import android.support.test.InstrumentationRegistry;
33import android.support.test.filters.LargeTest;
34import android.support.test.rule.ActivityTestRule;
35import android.view.LayoutInflater;
36import android.view.View;
37import android.view.ViewGroup;
38import android.view.ViewParent;
39import android.widget.LinearLayout;
40
41import androidx.annotation.NonNull;
42import androidx.core.view.ViewCompat;
43import androidx.recyclerview.test.R;
44import androidx.recyclerview.test.RecyclerViewTestActivity;
45
46import org.hamcrest.BaseMatcher;
47import org.hamcrest.CoreMatchers;
48import org.hamcrest.Description;
49import org.junit.Rule;
50import org.junit.Test;
51import org.junit.runner.RunWith;
52import org.junit.runners.Parameterized;
53
54import java.util.Arrays;
55import java.util.List;
56import java.util.concurrent.CountDownLatch;
57import java.util.concurrent.TimeUnit;
58
59/**
60 * This class tests RecyclerView focus search failure handling by using a real LayoutManager.
61 */
62@LargeTest
63@RunWith(Parameterized.class)
64public class FocusSearchNavigationTest {
65    @Rule
66    public ActivityTestRule<RecyclerViewTestActivity> mActivityRule =
67            new ActivityTestRule<>(RecyclerViewTestActivity.class);
68
69    private final int mOrientation;
70    private final int mLayoutDir;
71
72    public FocusSearchNavigationTest(int orientation, int layoutDir) {
73        mOrientation = orientation;
74        mLayoutDir = layoutDir;
75    }
76
77    @Parameterized.Parameters(name = "orientation:{0},layoutDir:{1}")
78    public static List<Object[]> params() {
79        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
80            return Arrays.asList(
81                    new Object[]{VERTICAL, ViewCompat.LAYOUT_DIRECTION_LTR},
82                    new Object[]{HORIZONTAL, ViewCompat.LAYOUT_DIRECTION_LTR},
83                    new Object[]{HORIZONTAL, ViewCompat.LAYOUT_DIRECTION_RTL}
84            );
85        } else {
86            // Do not test RTL before API 17
87            return Arrays.asList(
88                    new Object[]{VERTICAL, ViewCompat.LAYOUT_DIRECTION_LTR},
89                    new Object[]{HORIZONTAL, ViewCompat.LAYOUT_DIRECTION_LTR}
90            );
91        }
92    }
93
94    private Activity mActivity;
95    private RecyclerView mRecyclerView;
96    private View mBefore;
97    private View mAfter;
98
99    private void setup(final int itemCount) throws Throwable {
100        mActivity = mActivityRule.getActivity();
101        mActivityRule.runOnUiThread(new Runnable() {
102            @Override
103            public void run() {
104                mActivity.setContentView(R.layout.focus_search_activity);
105                ViewCompat.setLayoutDirection(mActivity.getWindow().getDecorView(), mLayoutDir);
106                LinearLayout linearLayout = (LinearLayout) mActivity.findViewById(R.id.root);
107                linearLayout.setOrientation(mOrientation);
108                mRecyclerView = (RecyclerView) mActivity.findViewById(R.id.recycler_view);
109                ViewCompat.setLayoutDirection(mRecyclerView, mLayoutDir);
110                LinearLayoutManager layout = new LinearLayoutManager(mActivity.getBaseContext());
111                layout.setOrientation(mOrientation);
112                mRecyclerView.setLayoutManager(layout);
113                mRecyclerView.setAdapter(new FocusSearchAdapter(itemCount, mOrientation));
114                if (mOrientation == VERTICAL) {
115                    mRecyclerView.setLayoutParams(new LinearLayout.LayoutParams(
116                            ViewGroup.LayoutParams.MATCH_PARENT, 250));
117                } else {
118                    mRecyclerView.setLayoutParams(new LinearLayout.LayoutParams(
119                            250, ViewGroup.LayoutParams.MATCH_PARENT));
120                }
121
122                mBefore = mActivity.findViewById(R.id.before);
123                mAfter = mActivity.findViewById(R.id.after);
124            }
125        });
126        waitForIdleSync();
127        assertThat("test sanity", mRecyclerView.getLayoutManager().getLayoutDirection(),
128                is(mLayoutDir));
129        assertThat("test sanity", ViewCompat.getLayoutDirection(mRecyclerView), is(mLayoutDir));
130    }
131
132    @Test
133    public void focusSearchForward() throws Throwable {
134        setup(20);
135        requestFocus(mBefore);
136        assertThat(mBefore, hasFocus());
137        View focused = mBefore;
138        for (int i = 0; i < 20; i++) {
139            focusSearchAndGive(focused, View.FOCUS_FORWARD);
140            RecyclerView.ViewHolder viewHolder = mRecyclerView.findViewHolderForAdapterPosition(i);
141            assertThat("vh at " + i, viewHolder, hasFocus());
142            focused = viewHolder.itemView;
143        }
144        focusSearchAndGive(focused, View.FOCUS_FORWARD);
145        assertThat(mAfter, hasFocus());
146        focusSearchAndGive(mAfter, View.FOCUS_FORWARD);
147        assertThat(mBefore, hasFocus());
148        focusSearchAndGive(mBefore, View.FOCUS_FORWARD);
149        focused = mActivity.getCurrentFocus();
150        //noinspection ConstantConditions
151        assertThat(focused.getParent(), CoreMatchers.<ViewParent>sameInstance(mRecyclerView));
152    }
153
154    @Test
155    public void focusSearchBackwards() throws Throwable {
156        setup(20);
157        requestFocus(mAfter);
158        assertThat(mAfter, hasFocus());
159        View focused = mAfter;
160        RecyclerView.ViewHolder lastViewHolder = null;
161        int i = 20;
162        while(lastViewHolder == null) {
163            lastViewHolder = mRecyclerView.findViewHolderForAdapterPosition(--i);
164        }
165        assertThat(lastViewHolder, notNullValue());
166
167        while(i >= 0) {
168            focusSearchAndGive(focused, View.FOCUS_BACKWARD);
169            RecyclerView.ViewHolder viewHolder = mRecyclerView.findViewHolderForAdapterPosition(i);
170            assertThat("vh at " + i, viewHolder, hasFocus());
171            focused = viewHolder.itemView;
172            i--;
173        }
174        focusSearchAndGive(focused, View.FOCUS_BACKWARD);
175        assertThat(mBefore, hasFocus());
176        focusSearchAndGive(mBefore, View.FOCUS_BACKWARD);
177        assertThat(mAfter, hasFocus());
178    }
179
180    private View focusSearchAndGive(final View view, final int focusDir) throws Throwable {
181        View next = focusSearch(view, focusDir);
182        if (next != null && next != view) {
183            requestFocus(next);
184            return next;
185        }
186        return null;
187    }
188
189    private View focusSearch(final View view, final int focusDir) throws Throwable {
190        final View[] result = new View[1];
191        mActivityRule.runOnUiThread(new Runnable() {
192            @Override
193            public void run() {
194                result[0] = view.focusSearch(focusDir);
195            }
196        });
197        waitForIdleSync();
198        return result[0];
199    }
200
201    private void waitForIdleSync() throws Throwable {
202        waitForIdleScroll(mRecyclerView);
203        InstrumentationRegistry.getInstrumentation().waitForIdleSync();
204    }
205
206    private void requestFocus(final View view) throws Throwable {
207        mActivityRule.runOnUiThread(new Runnable() {
208            @Override
209            public void run() {
210                view.requestFocus();
211            }
212        });
213        waitForIdleSync();
214    }
215
216    public void waitForIdleScroll(final RecyclerView recyclerView) throws Throwable {
217        final CountDownLatch latch = new CountDownLatch(1);
218        mActivityRule.runOnUiThread(new Runnable() {
219            @Override
220            public void run() {
221                RecyclerView.OnScrollListener listener = new RecyclerView.OnScrollListener() {
222                    @Override
223                    public void onScrollStateChanged(RecyclerView recyclerView, int newState) {
224                        if (newState == SCROLL_STATE_IDLE) {
225                            latch.countDown();
226                            recyclerView.removeOnScrollListener(this);
227                        }
228                    }
229                };
230                if (recyclerView.getScrollState() == SCROLL_STATE_IDLE) {
231                    latch.countDown();
232                } else {
233                    recyclerView.addOnScrollListener(listener);
234                }
235            }
236        });
237        assertTrue("should go idle in 10 seconds", latch.await(10, TimeUnit.SECONDS));
238    }
239
240    static class FocusSearchAdapter extends RecyclerView.Adapter {
241        private int mItemCount;
242        private int mOrientation;
243        public FocusSearchAdapter(int itemCount, int orientation) {
244            mItemCount = itemCount;
245            mOrientation = orientation;
246        }
247
248        @Override
249        public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent,
250        int viewType) {
251            View view = LayoutInflater.from(parent.getContext()).inflate(R.layout.item_view,
252                    parent, false);
253            if (mOrientation == VERTICAL) {
254                view.setLayoutParams(new ViewGroup.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT,
255                        50));
256            } else {
257                view.setLayoutParams(new ViewGroup.LayoutParams(50,
258                        ViewGroup.LayoutParams.MATCH_PARENT));
259            }
260            return new RecyclerView.ViewHolder(view) {};
261        }
262
263        @Override
264        public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) {
265            holder.itemView.setTag("pos " + position);
266        }
267
268        @Override
269        public int getItemCount() {
270            return mItemCount;
271        }
272    }
273
274    static HasFocusMatcher hasFocus() {
275        return new HasFocusMatcher();
276    }
277
278    static class HasFocusMatcher extends BaseMatcher<Object> {
279        @Override
280        public boolean matches(Object item) {
281            if (item instanceof RecyclerView.ViewHolder) {
282                item = ((RecyclerView.ViewHolder) item).itemView;
283            }
284            return item instanceof View && ((View) item).hasFocus();
285        }
286
287        @Override
288        public void describeTo(Description description) {
289            description.appendText("view has focus");
290        }
291
292        private String objectToLog(Object item) {
293            if (item instanceof RecyclerView.ViewHolder) {
294                RecyclerView.ViewHolder vh = (RecyclerView.ViewHolder) item;
295                return vh.toString();
296            }
297            if (item instanceof View) {
298                final Object tag = ((View) item).getTag();
299                return tag == null ? item.toString() : tag.toString();
300            }
301            final String classLog = item == null ? "null" : item.getClass().getSimpleName();
302            return classLog;
303        }
304
305        @Override
306        public void describeMismatch(Object item, Description description) {
307            String noun = objectToLog(item);
308            description.appendText(noun + " does not have focus");
309            Context context = null;
310            if (item instanceof RecyclerView.ViewHolder) {
311                context = ((RecyclerView.ViewHolder)item).itemView.getContext();
312            } else  if (item instanceof View) {
313                context = ((View) item).getContext();
314            }
315            if (context instanceof Activity) {
316                View currentFocus = ((Activity) context).getWindow().getCurrentFocus();
317                description.appendText(". Current focus is in " + objectToLog(currentFocus));
318            }
319        }
320    }
321}
322