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 */
16package android.support.v7.widget;
17
18import static org.hamcrest.CoreMatchers.instanceOf;
19import static org.hamcrest.CoreMatchers.is;
20import static org.hamcrest.CoreMatchers.not;
21import static org.hamcrest.CoreMatchers.notNullValue;
22import static org.hamcrest.CoreMatchers.sameInstance;
23import static org.hamcrest.MatcherAssert.assertThat;
24
25import android.support.annotation.NonNull;
26import android.support.annotation.Nullable;
27import android.support.v7.recyclerview.test.R;
28import android.view.LayoutInflater;
29import android.view.View;
30import android.view.ViewGroup;
31import android.widget.TextView;
32
33import org.junit.Test;
34import org.junit.runner.RunWith;
35import org.junit.runners.Parameterized;
36
37import java.util.Arrays;
38import java.util.List;
39import java.util.concurrent.atomic.AtomicLong;
40
41/**
42 * This class only tests the RV's focus recovery logic as focus moves between two views that
43 * represent the same item in the adapter. Keeping a focused view visible is up-to-the
44 * LayoutManager and all FW LayoutManagers already have tests for it.
45 */
46@RunWith(Parameterized.class)
47public class RecyclerViewFocusRecoveryTest extends BaseRecyclerViewInstrumentationTest {
48    TestLayoutManager mLayoutManager;
49    TestAdapter mAdapter;
50
51    private final boolean mFocusOnChild;
52    private final boolean mDisableRecovery;
53
54    @Parameterized.Parameters(name = "focusSubChild:{0}, disable:{1}")
55    public static List<Object[]> getParams() {
56        return Arrays.asList(
57                new Object[]{false, false},
58                new Object[]{true, false},
59                new Object[]{false, true},
60                new Object[]{true, true}
61        );
62    }
63
64    public RecyclerViewFocusRecoveryTest(boolean focusOnChild, boolean disableRecovery) {
65        super(false);
66        mFocusOnChild = focusOnChild;
67        mDisableRecovery = disableRecovery;
68    }
69
70    void setupBasic() throws Throwable {
71        setupBasic(false);
72    }
73
74    void setupBasic(boolean hasStableIds) throws Throwable {
75        TestAdapter adapter = new FocusTestAdapter(10);
76        adapter.setHasStableIds(hasStableIds);
77        setupBasic(adapter, null);
78    }
79
80    void setupBasic(TestLayoutManager layoutManager) throws Throwable {
81        setupBasic(null, layoutManager);
82    }
83
84    void setupBasic(TestAdapter adapter) throws Throwable {
85        setupBasic(adapter, null);
86    }
87
88    void setupBasic(@Nullable TestAdapter adapter, @Nullable TestLayoutManager layoutManager)
89            throws Throwable {
90        RecyclerView recyclerView = new RecyclerView(getActivity());
91        if (layoutManager == null) {
92            layoutManager = new FocusLayoutManager();
93        }
94
95        if (adapter == null) {
96            adapter = new FocusTestAdapter(10);
97        }
98        mLayoutManager = layoutManager;
99        mAdapter = adapter;
100        recyclerView.setAdapter(adapter);
101        recyclerView.setLayoutManager(mLayoutManager);
102        recyclerView.setPreserveFocusAfterLayout(!mDisableRecovery);
103        mLayoutManager.expectLayouts(1);
104        setRecyclerView(recyclerView);
105        mLayoutManager.waitForLayout(1);
106    }
107
108    @Test
109    public void testFocusRecoveryInChange() throws Throwable {
110        setupBasic();
111        ((SimpleItemAnimator) (mRecyclerView.getItemAnimator())).setSupportsChangeAnimations(true);
112        mLayoutManager.setSupportsPredictive(true);
113        final RecyclerView.ViewHolder oldVh = focusVh(3);
114
115        mLayoutManager.expectLayouts(2);
116        mAdapter.changeAndNotify(3, 1);
117        mLayoutManager.waitForLayout(2);
118
119        runTestOnUiThread(new Runnable() {
120            @Override
121            public void run() {
122                RecyclerView.ViewHolder newVh = mRecyclerView.findViewHolderForAdapterPosition(3);
123                assertFocusTransition(oldVh, newVh);
124
125            }
126        });
127        mLayoutManager.expectLayouts(1);
128    }
129
130    private void assertFocusTransition(RecyclerView.ViewHolder oldVh,
131            RecyclerView.ViewHolder newVh) {
132        if (mDisableRecovery) {
133            assertFocus(newVh, false);
134            return;
135        }
136        assertThat("test sanity", newVh, notNullValue());
137        assertThat(oldVh, not(sameInstance(newVh)));
138        assertFocus(oldVh, false);
139        assertFocus(newVh, true);
140    }
141
142    @Test
143    public void testFocusRecoveryInTypeChangeWithPredictive() throws Throwable {
144        testFocusRecoveryInTypeChange(true);
145    }
146
147    @Test
148    public void testFocusRecoveryInTypeChangeWithoutPredictive() throws Throwable {
149        testFocusRecoveryInTypeChange(false);
150    }
151
152    private void testFocusRecoveryInTypeChange(boolean withAnimation) throws Throwable {
153        setupBasic();
154        ((SimpleItemAnimator) (mRecyclerView.getItemAnimator())).setSupportsChangeAnimations(true);
155        mLayoutManager.setSupportsPredictive(withAnimation);
156        final RecyclerView.ViewHolder oldVh = focusVh(3);
157        mLayoutManager.expectLayouts(withAnimation ? 2 : 1);
158        runTestOnUiThread(new Runnable() {
159            @Override
160            public void run() {
161                Item item = mAdapter.mItems.get(3);
162                item.mType += 2;
163                mAdapter.notifyItemChanged(3);
164            }
165        });
166        mLayoutManager.waitForLayout(2);
167
168        RecyclerView.ViewHolder newVh = mRecyclerView.findViewHolderForAdapterPosition(3);
169        assertFocusTransition(oldVh, newVh);
170        assertThat("test sanity", oldVh.getItemViewType(), not(newVh.getItemViewType()));
171    }
172
173    @Test
174    public void testRecoverAdapterChangeViaStableIdOnDataSetChanged() throws Throwable {
175        recoverAdapterChangeViaStableId(false, false);
176    }
177
178    @Test
179    public void testRecoverAdapterChangeViaStableIdOnSwap() throws Throwable {
180        recoverAdapterChangeViaStableId(true, false);
181    }
182
183    @Test
184    public void testRecoverAdapterChangeViaStableIdOnDataSetChangedWithTypeChange()
185            throws Throwable {
186        recoverAdapterChangeViaStableId(false, true);
187    }
188
189    @Test
190    public void testRecoverAdapterChangeViaStableIdOnSwapWithTypeChange() throws Throwable {
191        recoverAdapterChangeViaStableId(true, true);
192    }
193
194    private void recoverAdapterChangeViaStableId(final boolean swap, final boolean changeType)
195            throws Throwable {
196        setupBasic(true);
197        RecyclerView.ViewHolder oldVh = focusVh(4);
198        long itemId = oldVh.getItemId();
199
200        mLayoutManager.expectLayouts(1);
201        runTestOnUiThread(new Runnable() {
202            @Override
203            public void run() {
204                Item item = mAdapter.mItems.get(4);
205                if (changeType) {
206                    item.mType += 2;
207                }
208                if (swap) {
209                    mAdapter = new FocusTestAdapter(8);
210                    mAdapter.setHasStableIds(true);
211                    mAdapter.mItems.add(2, item);
212                    mRecyclerView.swapAdapter(mAdapter, false);
213                } else {
214                    mAdapter.mItems.remove(0);
215                    mAdapter.mItems.remove(0);
216                    mAdapter.notifyDataSetChanged();
217                }
218            }
219        });
220        mLayoutManager.waitForLayout(1);
221
222        RecyclerView.ViewHolder newVh = mRecyclerView.findViewHolderForItemId(itemId);
223        if (changeType) {
224            assertFocusTransition(oldVh, newVh);
225        } else {
226            // in this case we should use the same VH because we have stable ids
227            assertThat(oldVh, sameInstance(newVh));
228            assertFocus(newVh, true);
229        }
230    }
231
232    @Test
233    public void testDoNotRecoverViaPositionOnSetAdapter() throws Throwable {
234        testDoNotRecoverViaPositionOnNewDataSet(new RecyclerViewLayoutTest.AdapterRunnable() {
235            @Override
236            public void run(TestAdapter adapter) throws Throwable {
237                mRecyclerView.setAdapter(new FocusTestAdapter(10));
238            }
239        });
240    }
241
242    @Test
243    public void testDoNotRecoverViaPositionOnSwapAdapterWithRecycle() throws Throwable {
244        testDoNotRecoverViaPositionOnNewDataSet(new RecyclerViewLayoutTest.AdapterRunnable() {
245            @Override
246            public void run(TestAdapter adapter) throws Throwable {
247                mRecyclerView.swapAdapter(new FocusTestAdapter(10), true);
248            }
249        });
250    }
251
252    @Test
253    public void testDoNotRecoverViaPositionOnSwapAdapterWithoutRecycle() throws Throwable {
254        testDoNotRecoverViaPositionOnNewDataSet(new RecyclerViewLayoutTest.AdapterRunnable() {
255            @Override
256            public void run(TestAdapter adapter) throws Throwable {
257                mRecyclerView.swapAdapter(new FocusTestAdapter(10), false);
258            }
259        });
260    }
261
262    public void testDoNotRecoverViaPositionOnNewDataSet(
263            final RecyclerViewLayoutTest.AdapterRunnable runnable) throws Throwable {
264        setupBasic(false);
265        assertThat("test sanity", mAdapter.hasStableIds(), is(false));
266        focusVh(4);
267        mLayoutManager.expectLayouts(1);
268        runTestOnUiThread(new Runnable() {
269            @Override
270            public void run() {
271                try {
272                    runnable.run(mAdapter);
273                } catch (Throwable throwable) {
274                    postExceptionToInstrumentation(throwable);
275                }
276            }
277        });
278
279        mLayoutManager.waitForLayout(1);
280        RecyclerView.ViewHolder otherVh = mRecyclerView.findViewHolderForAdapterPosition(4);
281        checkForMainThreadException();
282        // even if the VH is re-used, it will be removed-reAdded so focus will go away from it.
283        assertFocus("should not recover focus if data set is badly invalid", otherVh, false);
284
285    }
286
287    @Test
288    public void testDoNotRecoverIfReplacementIsNotFocusable() throws Throwable {
289        final int TYPE_NO_FOCUS = 1001;
290        TestAdapter adapter = new FocusTestAdapter(10) {
291            @Override
292            public void onBindViewHolder(TestViewHolder holder,
293                    int position) {
294                super.onBindViewHolder(holder, position);
295                if (holder.getItemViewType() == TYPE_NO_FOCUS) {
296                    cast(holder).setFocusable(false);
297                }
298            }
299        };
300        adapter.setHasStableIds(true);
301        setupBasic(adapter);
302        RecyclerView.ViewHolder oldVh = focusVh(3);
303        final long itemId = oldVh.getItemId();
304        mLayoutManager.expectLayouts(1);
305        runTestOnUiThread(new Runnable() {
306            @Override
307            public void run() {
308                mAdapter.mItems.get(3).mType = TYPE_NO_FOCUS;
309                mAdapter.notifyDataSetChanged();
310            }
311        });
312        mLayoutManager.waitForLayout(2);
313        RecyclerView.ViewHolder newVh = mRecyclerView.findViewHolderForItemId(itemId);
314        assertFocus(newVh, false);
315    }
316
317    @NonNull
318    private RecyclerView.ViewHolder focusVh(int pos) throws Throwable {
319        final RecyclerView.ViewHolder oldVh = mRecyclerView.findViewHolderForAdapterPosition(pos);
320        assertThat("test sanity", oldVh, notNullValue());
321        requestFocus(oldVh);
322        assertFocus("test sanity", oldVh, true);
323        getInstrumentation().waitForIdleSync();
324        return oldVh;
325    }
326
327    @Test
328    public void testDoNotOverrideAdapterRequestedFocus() throws Throwable {
329        final AtomicLong toFocusId = new AtomicLong(-1);
330
331        FocusTestAdapter adapter = new FocusTestAdapter(10) {
332            @Override
333            public void onBindViewHolder(TestViewHolder holder,
334                    int position) {
335                super.onBindViewHolder(holder, position);
336                if (holder.getItemId() == toFocusId.get()) {
337                    try {
338                        requestFocus(holder);
339                    } catch (Throwable throwable) {
340                        postExceptionToInstrumentation(throwable);
341                    }
342                }
343            }
344        };
345        adapter.setHasStableIds(true);
346        toFocusId.set(adapter.mItems.get(3).mId);
347        long firstFocusId = toFocusId.get();
348        setupBasic(adapter);
349        RecyclerView.ViewHolder oldVh = mRecyclerView.findViewHolderForItemId(toFocusId.get());
350        assertFocus(oldVh, true);
351        toFocusId.set(mAdapter.mItems.get(5).mId);
352        mLayoutManager.expectLayouts(1);
353        runTestOnUiThread(new Runnable() {
354            @Override
355            public void run() {
356                mAdapter.mItems.get(3).mType += 2;
357                mAdapter.mItems.get(5).mType += 2;
358                mAdapter.notifyDataSetChanged();
359            }
360        });
361        mLayoutManager.waitForLayout(2);
362        RecyclerView.ViewHolder requested = mRecyclerView.findViewHolderForItemId(toFocusId.get());
363        assertFocus(oldVh, false);
364        assertFocus(requested, true);
365        RecyclerView.ViewHolder oldReplacement = mRecyclerView
366                .findViewHolderForItemId(firstFocusId);
367        assertFocus(oldReplacement, false);
368        checkForMainThreadException();
369    }
370
371    @Test
372    public void testDoNotOverrideLayoutManagerRequestedFocus() throws Throwable {
373        final AtomicLong toFocusId = new AtomicLong(-1);
374        FocusTestAdapter adapter = new FocusTestAdapter(10);
375        adapter.setHasStableIds(true);
376
377        FocusLayoutManager lm = new FocusLayoutManager() {
378            @Override
379            public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
380                detachAndScrapAttachedViews(recycler);
381                layoutRange(recycler, 0, state.getItemCount());
382                RecyclerView.ViewHolder toFocus = mRecyclerView
383                        .findViewHolderForItemId(toFocusId.get());
384                if (toFocus != null) {
385                    try {
386                        requestFocus(toFocus);
387                    } catch (Throwable throwable) {
388                        postExceptionToInstrumentation(throwable);
389                    }
390                }
391                layoutLatch.countDown();
392            }
393        };
394
395        toFocusId.set(adapter.mItems.get(3).mId);
396        long firstFocusId = toFocusId.get();
397        setupBasic(adapter, lm);
398
399        RecyclerView.ViewHolder oldVh = mRecyclerView.findViewHolderForItemId(toFocusId.get());
400        assertFocus(oldVh, true);
401        toFocusId.set(mAdapter.mItems.get(5).mId);
402        mLayoutManager.expectLayouts(1);
403        requestLayoutOnUIThread(mRecyclerView);
404        mLayoutManager.waitForLayout(2);
405        RecyclerView.ViewHolder requested = mRecyclerView.findViewHolderForItemId(toFocusId.get());
406        assertFocus(oldVh, false);
407        assertFocus(requested, true);
408        RecyclerView.ViewHolder oldReplacement = mRecyclerView
409                .findViewHolderForItemId(firstFocusId);
410        assertFocus(oldReplacement, false);
411        checkForMainThreadException();
412    }
413
414    private void requestFocus(RecyclerView.ViewHolder viewHolder) throws Throwable {
415        FocusViewHolder fvh = cast(viewHolder);
416        requestFocus(fvh.getViewToFocus(), false);
417    }
418
419    private void assertFocus(RecyclerView.ViewHolder viewHolder, boolean hasFocus) {
420        assertFocus("", viewHolder, hasFocus);
421    }
422
423    private void assertFocus(String msg, RecyclerView.ViewHolder vh, boolean hasFocus) {
424        FocusViewHolder fvh = cast(vh);
425        assertThat(msg, fvh.getViewToFocus().hasFocus(), is(hasFocus));
426    }
427
428    private <T extends FocusViewHolder> T cast(RecyclerView.ViewHolder vh) {
429        assertThat(vh, instanceOf(FocusViewHolder.class));
430        //noinspection unchecked
431        return (T) vh;
432    }
433
434    private class FocusTestAdapter extends TestAdapter {
435
436        public FocusTestAdapter(int count) {
437            super(count);
438        }
439
440        @Override
441        public FocusViewHolder onCreateViewHolder(ViewGroup parent,
442                int viewType) {
443            final FocusViewHolder fvh;
444            if (mFocusOnChild) {
445                fvh = new FocusViewHolderWithChildren(
446                        LayoutInflater.from(parent.getContext())
447                                .inflate(R.layout.focus_test_item_view, parent, false));
448            } else {
449                fvh = new SimpleFocusViewHolder(new TextView(parent.getContext()));
450            }
451            fvh.setFocusable(true);
452            return fvh;
453        }
454
455        @Override
456        public void onBindViewHolder(TestViewHolder holder, int position) {
457            cast(holder).bindTo(mItems.get(position));
458        }
459    }
460
461    private class FocusLayoutManager extends TestLayoutManager {
462        @Override
463        public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
464            detachAndScrapAttachedViews(recycler);
465            layoutRange(recycler, 0, state.getItemCount());
466            layoutLatch.countDown();
467        }
468    }
469
470    private class FocusViewHolderWithChildren extends FocusViewHolder {
471        public final ViewGroup root;
472        public final ViewGroup parent1;
473        public final ViewGroup parent2;
474        public final TextView textView;
475
476        public FocusViewHolderWithChildren(View view) {
477            super(view);
478            root = (ViewGroup) view;
479            parent1 = (ViewGroup) root.findViewById(R.id.parent1);
480            parent2 = (ViewGroup) root.findViewById(R.id.parent2);
481            textView = (TextView) root.findViewById(R.id.text_view);
482
483        }
484
485        @Override
486        void setFocusable(boolean focusable) {
487            parent1.setFocusableInTouchMode(focusable);
488            parent2.setFocusableInTouchMode(focusable);
489            textView.setFocusableInTouchMode(focusable);
490            root.setFocusableInTouchMode(focusable);
491
492            parent1.setFocusable(focusable);
493            parent2.setFocusable(focusable);
494            textView.setFocusable(focusable);
495            root.setFocusable(focusable);
496        }
497
498        @Override
499        void onBind(Item item) {
500            textView.setText(getText(item));
501        }
502
503        @Override
504        View getViewToFocus() {
505            return textView;
506        }
507    }
508
509    private class SimpleFocusViewHolder extends FocusViewHolder {
510
511        public SimpleFocusViewHolder(View itemView) {
512            super(itemView);
513        }
514
515        @Override
516        void setFocusable(boolean focusable) {
517            itemView.setFocusableInTouchMode(focusable);
518            itemView.setFocusable(focusable);
519        }
520
521        @Override
522        View getViewToFocus() {
523            return itemView;
524        }
525
526        @Override
527        void onBind(Item item) {
528            ((TextView) (itemView)).setText(getText(item));
529        }
530    }
531
532    private abstract class FocusViewHolder extends TestViewHolder {
533
534        public FocusViewHolder(View itemView) {
535            super(itemView);
536        }
537
538        protected String getText(Item item) {
539            return item.mText + "(" + item.mId + ")";
540        }
541
542        abstract void setFocusable(boolean focusable);
543
544        abstract View getViewToFocus();
545
546        abstract void onBind(Item item);
547
548        final void bindTo(Item item) {
549            mBoundItem = item;
550            onBind(item);
551        }
552    }
553}
554