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