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