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 package androidx.recyclerview.widget;
17 
18 import static org.hamcrest.CoreMatchers.instanceOf;
19 import static org.hamcrest.CoreMatchers.is;
20 import static org.hamcrest.CoreMatchers.not;
21 import static org.hamcrest.CoreMatchers.notNullValue;
22 import static org.hamcrest.CoreMatchers.sameInstance;
23 import static org.hamcrest.MatcherAssert.assertThat;
24 
25 import android.support.test.filters.MediumTest;
26 import android.view.LayoutInflater;
27 import android.view.View;
28 import android.view.ViewGroup;
29 import android.widget.TextView;
30 
31 import androidx.annotation.NonNull;
32 import androidx.annotation.Nullable;
33 import androidx.recyclerview.test.R;
34 
35 import org.junit.Test;
36 import org.junit.runner.RunWith;
37 import org.junit.runners.Parameterized;
38 
39 import java.util.Arrays;
40 import java.util.List;
41 import java.util.concurrent.atomic.AtomicLong;
42 
43 /**
44  * This class only tests the RV's focus recovery logic as focus moves between two views that
45  * represent the same item in the adapter. Keeping a focused view visible is up-to-the
46  * LayoutManager and all FW LayoutManagers already have tests for it.
47  */
48 @MediumTest
49 @RunWith(Parameterized.class)
50 public class RecyclerViewFocusRecoveryTest extends BaseRecyclerViewInstrumentationTest {
51     TestLayoutManager mLayoutManager;
52     TestAdapter mAdapter;
53     int mChildCount = 10;
54 
55     // Parameter indicating whether RV's children are simple views (false) or ViewGroups (true).
56     private final boolean mFocusOnChild;
57     // Parameter indicating whether RV recovers focus after layout is finished.
58     private final boolean mDisableRecovery;
59     // Parameter indicating whether animation is enabled for the ViewHolder items.
60     private final boolean mDisableAnimation;
61 
62     @Parameterized.Parameters(name = "focusSubChild:{0},disableRecovery:{1},"
63             + "disableAnimation:{2}")
getParams()64     public static List<Object[]> getParams() {
65         return Arrays.asList(
66                 new Object[]{false, false, true},
67                 new Object[]{true, false, true},
68                 new Object[]{false, true, true},
69                 new Object[]{true, true, true},
70                 new Object[]{false, false, false},
71                 new Object[]{true, false, false},
72                 new Object[]{false, true, false},
73                 new Object[]{true, true, false}
74         );
75     }
76 
RecyclerViewFocusRecoveryTest(boolean focusOnChild, boolean disableRecovery, boolean disableAnimation)77     public RecyclerViewFocusRecoveryTest(boolean focusOnChild, boolean disableRecovery,
78                                          boolean disableAnimation) {
79         super(false);
80         mFocusOnChild = focusOnChild;
81         mDisableRecovery = disableRecovery;
82         mDisableAnimation = disableAnimation;
83     }
84 
setupBasic()85     void setupBasic() throws Throwable {
86         setupBasic(false);
87     }
88 
setupBasic(boolean hasStableIds)89     void setupBasic(boolean hasStableIds) throws Throwable {
90         TestAdapter adapter = new FocusTestAdapter(mChildCount);
91         adapter.setHasStableIds(hasStableIds);
92         setupBasic(adapter, null);
93     }
94 
setupBasic(TestLayoutManager layoutManager)95     void setupBasic(TestLayoutManager layoutManager) throws Throwable {
96         setupBasic(null, layoutManager);
97     }
98 
setupBasic(TestAdapter adapter)99     void setupBasic(TestAdapter adapter) throws Throwable {
100         setupBasic(adapter, null);
101     }
102 
setupBasic(@ullable TestAdapter adapter, @Nullable TestLayoutManager layoutManager)103     void setupBasic(@Nullable TestAdapter adapter, @Nullable TestLayoutManager layoutManager)
104             throws Throwable {
105         RecyclerView recyclerView = new RecyclerView(getActivity());
106         if (layoutManager == null) {
107             layoutManager = new FocusLayoutManager();
108         }
109 
110         if (adapter == null) {
111             adapter = new FocusTestAdapter(mChildCount);
112         }
113         mLayoutManager = layoutManager;
114         mAdapter = adapter;
115         recyclerView.setAdapter(adapter);
116         recyclerView.setLayoutManager(mLayoutManager);
117         recyclerView.setPreserveFocusAfterLayout(!mDisableRecovery);
118         if (mDisableAnimation) {
119             recyclerView.setItemAnimator(null);
120         }
121         mLayoutManager.expectLayouts(1);
122         setRecyclerView(recyclerView);
123         mLayoutManager.waitForLayout(1);
124     }
125 
126     @Test
testFocusRecoveryInChange()127     public void testFocusRecoveryInChange() throws Throwable {
128         setupBasic();
129         mLayoutManager.setSupportsPredictive(true);
130         final RecyclerView.ViewHolder oldVh = focusVh(3);
131 
132         mLayoutManager.expectLayouts(mDisableAnimation ? 1 : 2);
133         mAdapter.changeAndNotify(3, 1);
134         mLayoutManager.waitForLayout(2);
135         if (!mDisableAnimation) {
136             // waiting for RV's ItemAnimator to finish the animation of the removed item
137             waitForAnimations(2);
138         }
139 
140         mActivityRule.runOnUiThread(new Runnable() {
141             @Override
142             public void run() {
143                 RecyclerView.ViewHolder newVh = mRecyclerView.findViewHolderForAdapterPosition(3);
144                 assertFocusTransition(oldVh, newVh, false);
145 
146             }
147         });
148     }
149 
150     @Test
testFocusRecoveryAfterRemovingFocusedChild()151     public void testFocusRecoveryAfterRemovingFocusedChild() throws Throwable {
152         setupBasic(true);
153         FocusViewHolder fvh = cast(focusVh(4));
154 
155         assertThat("test sanity", fvh, notNullValue());
156         assertThat("RV should have focus", mRecyclerView.hasFocus(), is(true));
157 
158         assertThat("RV should pass the focus down to its children",
159                 mRecyclerView.isFocused(), is(false));
160         assertThat("Viewholder did not receive focus", fvh.itemView.hasFocus(),
161                 is(true));
162         assertThat("Viewholder is not focused", fvh.getViewToFocus().isFocused(),
163                 is(true));
164 
165         mLayoutManager.expectLayouts(1);
166         mActivityRule.runOnUiThread(new Runnable() {
167             @Override
168             public void run() {
169                 // removing focused child
170                 mAdapter.mItems.remove(4);
171                 mAdapter.notifyItemRemoved(4);
172             }
173         });
174         mLayoutManager.waitForLayout(1);
175         if (!mDisableAnimation) {
176             // waiting for RV's ItemAnimator to finish the animation of the removed item
177             waitForAnimations(2);
178         }
179         assertThat("RV should have " + (mChildCount - 1) + " instead of "
180                         + mRecyclerView.getChildCount() + " children",
181                 mChildCount - 1, is(mRecyclerView.getChildCount()));
182         assertFocusAfterLayout(4, 0);
183     }
184 
185     @Test
testFocusRecoveryAfterMovingFocusedChild()186     public void testFocusRecoveryAfterMovingFocusedChild() throws Throwable {
187         setupBasic(true);
188         FocusViewHolder fvh = cast(focusVh(3));
189 
190         assertThat("test sanity", fvh, notNullValue());
191         assertThat("RV should have focus", mRecyclerView.hasFocus(), is(true));
192 
193         assertThat("RV should pass the focus down to its children",
194                 mRecyclerView.isFocused(), is(false));
195         assertThat("Viewholder did not receive focus", fvh.itemView.hasFocus(),
196                 is(true));
197         assertThat("Viewholder is not focused", fvh.getViewToFocus().isFocused(),
198                 is(true));
199 
200         mLayoutManager.expectLayouts(1);
201         mAdapter.moveAndNotify(3, 1);
202         mLayoutManager.waitForLayout(1);
203         if (!mDisableAnimation) {
204             // waiting for RV's ItemAnimator to finish the animation of the removed item
205             waitForAnimations(2);
206         }
207         assertFocusAfterLayout(1, 1);
208     }
209 
210     @Test
testFocusRecoveryAfterRemovingLastChild()211     public void testFocusRecoveryAfterRemovingLastChild() throws Throwable {
212         mChildCount = 1;
213         setupBasic(true);
214         FocusViewHolder fvh = cast(focusVh(0));
215 
216         assertThat("test sanity", fvh, notNullValue());
217         assertThat("RV should have focus", mRecyclerView.hasFocus(), is(true));
218 
219         assertThat("RV should pass the focus down to its children",
220                 mRecyclerView.isFocused(), is(false));
221         assertThat("Viewholder did not receive focus", fvh.itemView.hasFocus(),
222                 is(true));
223         assertThat("Viewholder is not focused", fvh.getViewToFocus().isFocused(),
224                 is(true));
225 
226         mLayoutManager.expectLayouts(1);
227         mActivityRule.runOnUiThread(new Runnable() {
228             @Override
229             public void run() {
230                 // removing focused child
231                 mAdapter.mItems.remove(0);
232                 mAdapter.notifyDataSetChanged();
233             }
234         });
235         mLayoutManager.waitForLayout(1);
236         if (!mDisableAnimation) {
237             // waiting for RV's ItemAnimator to finish the animation of the removed item
238             waitForAnimations(2);
239         }
240         assertThat("RV should have " + (mChildCount - 1) + " instead of "
241                         + mRecyclerView.getChildCount() + " children",
242                 mChildCount - 1, is(mRecyclerView.getChildCount()));
243         assertFocusAfterLayout(-1, -1);
244     }
245 
246     @Test
testFocusRecoveryAfterAddingFirstChild()247     public void testFocusRecoveryAfterAddingFirstChild() throws Throwable {
248         mChildCount = 0;
249         setupBasic(true);
250         mActivityRule.runOnUiThread(new Runnable() {
251             @Override
252             public void run() {
253                 requestFocusOnRV();
254             }
255         });
256 
257         mLayoutManager.expectLayouts(1);
258         mActivityRule.runOnUiThread(new Runnable() {
259             @Override
260             public void run() {
261                 // adding first child
262                 mAdapter.mItems.add(0, new Item(0, TestAdapter.DEFAULT_ITEM_PREFIX));
263                 mAdapter.notifyDataSetChanged();
264             }
265         });
266         mLayoutManager.waitForLayout(1);
267         if (!mDisableAnimation) {
268             // waiting for RV's ItemAnimator to finish the animation of the removed item
269             waitForAnimations(2);
270         }
271         assertFocusAfterLayout(0, -1);
272     }
273 
274     @Test
testFocusRecoveryAfterChangingFocusableFlag()275     public void testFocusRecoveryAfterChangingFocusableFlag() throws Throwable {
276         setupBasic(true);
277         FocusViewHolder fvh = cast(focusVh(6));
278 
279         assertThat("test sanity", fvh, notNullValue());
280         assertThat("RV should have focus", mRecyclerView.hasFocus(), is(true));
281 
282         assertThat("RV should pass the focus down to its children",
283                 mRecyclerView.isFocused(), is(false));
284         assertThat("Viewholder did not receive focus", fvh.itemView.hasFocus(),
285                 is(true));
286         assertThat("Viewholder is not focused", fvh.getViewToFocus().isFocused(),
287                 is(true));
288 
289         mLayoutManager.expectLayouts(1);
290         mActivityRule.runOnUiThread(new Runnable() {
291             @Override
292             public void run() {
293                 Item item = mAdapter.mItems.get(6);
294                 item.setFocusable(false);
295                 mAdapter.notifyItemChanged(6);
296             }
297         });
298         mLayoutManager.waitForLayout(1);
299         if (!mDisableAnimation) {
300             waitForAnimations(2);
301         }
302         FocusViewHolder newVh = cast(mRecyclerView.findViewHolderForAdapterPosition(6));
303         assertThat("VH should no longer be focusable", newVh.getViewToFocus().isFocusable(),
304                 is(false));
305         assertFocusAfterLayout(7, 0);
306     }
307 
308     @Test
testFocusRecoveryBeforeLayoutWithFocusBefore()309     public void testFocusRecoveryBeforeLayoutWithFocusBefore() throws Throwable {
310         testFocusRecoveryBeforeLayout(ViewGroup.FOCUS_BEFORE_DESCENDANTS);
311     }
312 
313     @Test
testFocusRecoveryBeforeLayoutWithFocusAfter()314     public void testFocusRecoveryBeforeLayoutWithFocusAfter() throws Throwable {
315         testFocusRecoveryBeforeLayout(ViewGroup.FOCUS_AFTER_DESCENDANTS);
316     }
317 
318     @Test
testFocusRecoveryBeforeLayoutWithFocusBlocked()319     public void testFocusRecoveryBeforeLayoutWithFocusBlocked() throws Throwable {
320         testFocusRecoveryBeforeLayout(ViewGroup.FOCUS_BLOCK_DESCENDANTS);
321     }
322 
323     @Test
testFocusRecoveryDuringLayoutWithFocusBefore()324     public void testFocusRecoveryDuringLayoutWithFocusBefore() throws Throwable {
325         testFocusRecoveryDuringLayout(ViewGroup.FOCUS_BEFORE_DESCENDANTS);
326     }
327 
328     @Test
testFocusRecoveryDuringLayoutWithFocusAfter()329     public void testFocusRecoveryDuringLayoutWithFocusAfter() throws Throwable {
330         testFocusRecoveryDuringLayout(ViewGroup.FOCUS_AFTER_DESCENDANTS);
331     }
332 
333     @Test
testFocusRecoveryDuringLayoutWithFocusBlocked()334     public void testFocusRecoveryDuringLayoutWithFocusBlocked() throws Throwable {
335         testFocusRecoveryDuringLayout(ViewGroup.FOCUS_BLOCK_DESCENDANTS);
336     }
337 
338     /**
339      * Tests whether the focus is correctly recovered when requestFocus on RV is called before
340      * laying out the children.
341      * @throws Throwable
342      */
testFocusRecoveryBeforeLayout(int descendantFocusability)343     private void testFocusRecoveryBeforeLayout(int descendantFocusability) throws Throwable {
344         RecyclerView recyclerView = new RecyclerView(getActivity());
345         recyclerView.setDescendantFocusability(descendantFocusability);
346         mLayoutManager = new FocusLayoutManager();
347         mAdapter = new FocusTestAdapter(10);
348         recyclerView.setLayoutManager(mLayoutManager);
349         recyclerView.setPreserveFocusAfterLayout(!mDisableRecovery);
350         if (mDisableAnimation) {
351             recyclerView.setItemAnimator(null);
352         }
353         setRecyclerView(recyclerView);
354         assertThat("RV should always be focusable", mRecyclerView.isFocusable(), is(true));
355 
356         mLayoutManager.expectLayouts(1);
357         mActivityRule.runOnUiThread(new Runnable() {
358             @Override
359             public void run() {
360                 requestFocusOnRV();
361                 mRecyclerView.setAdapter(mAdapter);
362             }
363         });
364         mLayoutManager.waitForLayout(1);
365         assertFocusAfterLayout(0, -1);
366     }
367 
368     /**
369      * Tests whether the focus is correctly recovered when requestFocus on RV is called during
370      * laying out the children.
371      * @throws Throwable
372      */
testFocusRecoveryDuringLayout(int descendantFocusability)373     private void testFocusRecoveryDuringLayout(int descendantFocusability) throws Throwable {
374         RecyclerView recyclerView = new RecyclerView(getActivity());
375         recyclerView.setDescendantFocusability(descendantFocusability);
376         mLayoutManager = new FocusLayoutManager() {
377             @Override
378             public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
379                 super.onLayoutChildren(recycler, state);
380                 requestFocusOnRV();
381             }
382         };
383         mAdapter = new FocusTestAdapter(10);
384         recyclerView.setAdapter(mAdapter);
385         recyclerView.setLayoutManager(mLayoutManager);
386         if (mDisableAnimation) {
387             recyclerView.setItemAnimator(null);
388         }
389         recyclerView.setPreserveFocusAfterLayout(!mDisableRecovery);
390         mLayoutManager.expectLayouts(1);
391         setRecyclerView(recyclerView);
392         mLayoutManager.waitForLayout(1);
393         assertFocusAfterLayout(0, -1);
394     }
395 
requestFocusOnRV()396     private void requestFocusOnRV() {
397         assertThat("RV initially has no focus", mRecyclerView.hasFocus(), is(false));
398         assertThat("RV initially is not focused", mRecyclerView.isFocused(), is(false));
399         mRecyclerView.requestFocus();
400         String msg = !mRecyclerView.isComputingLayout() ? " before laying out the children"
401                 : " during laying out the children";
402         assertThat("RV should have focus after calling requestFocus()" + msg,
403                 mRecyclerView.hasFocus(), is(true));
404         assertThat("RV after calling requestFocus() should become focused" + msg,
405                 mRecyclerView.isFocused(), is(true));
406     }
407 
408     /**
409      * Asserts whether RV and one of its children have the correct focus flags after the layout is
410      * complete. This is normally called once the RV layout is complete after initiating
411      * notifyItemChanged.
412      * @param focusedChildIndexWhenRecoveryEnabled
413      * This index is relevant when mDisableRecovery is false. In that case, it refers to the index
414      * of the child that should have focus if the ancestors allow passing down the focus. -1
415      * indicates none of the children can receive focus even if the ancestors don't block focus, in
416      * which case RV holds and becomes focused.
417      * @param focusedChildIndexWhenRecoveryDisabled
418      * This index is relevant when mDisableRecovery is true. In that case, it refers to the index
419      * of the child that should have focus if the ancestors allow passing down the focus. -1
420      * indicates none of the children can receive focus even if the ancestors don't block focus, in
421      * which case RV holds and becomes focused.
422      */
assertFocusAfterLayout(int focusedChildIndexWhenRecoveryEnabled, int focusedChildIndexWhenRecoveryDisabled)423     private void assertFocusAfterLayout(int focusedChildIndexWhenRecoveryEnabled,
424                                         int focusedChildIndexWhenRecoveryDisabled) {
425         if (mDisableAnimation && mDisableRecovery) {
426             // This case is not quite handled properly at the moment. For now, RV may become focused
427             // without re-delivering the focus down to the children. Skip the checks for now.
428             return;
429         }
430         if (mRecyclerView.getChildCount() == 0) {
431             assertThat("RV should have focus when it has no children",
432                     mRecyclerView.hasFocus(), is(true));
433             assertThat("RV should be focused when it has no children",
434                     mRecyclerView.isFocused(), is(true));
435             return;
436         }
437 
438         assertThat("RV should still have focus after layout", mRecyclerView.hasFocus(), is(true));
439         if ((mDisableRecovery && focusedChildIndexWhenRecoveryDisabled == -1)
440                 || (!mDisableRecovery && focusedChildIndexWhenRecoveryEnabled == -1)
441                 || mRecyclerView.getDescendantFocusability() == ViewGroup.FOCUS_BLOCK_DESCENDANTS
442                 || mRecyclerView.getDescendantFocusability()
443                 == ViewGroup.FOCUS_BEFORE_DESCENDANTS) {
444             FocusViewHolder fvh = cast(mRecyclerView.findViewHolderForAdapterPosition(0));
445             String msg1 = " when focus recovery is disabled";
446             String msg2 = " when descendant focusability is FOCUS_BLOCK_DESCENDANTS";
447             String msg3 = " when descendant focusability is FOCUS_BEFORE_DESCENDANTS";
448 
449             assertThat("RV should not pass the focus down to its children"
450                             + (mDisableRecovery ? msg1 : (mRecyclerView.getDescendantFocusability()
451                                     == ViewGroup.FOCUS_BLOCK_DESCENDANTS ? msg2 : msg3)),
452                     mRecyclerView.isFocused(), is(true));
453             assertThat("RV's first child should not have focus"
454                             + (mDisableRecovery ? msg1 : (mRecyclerView.getDescendantFocusability()
455                                     == ViewGroup.FOCUS_BLOCK_DESCENDANTS ? msg2 : msg3)),
456                     fvh.itemView.hasFocus(), is(false));
457             assertThat("RV's first child should not be focused"
458                             + (mDisableRecovery ? msg1 : (mRecyclerView.getDescendantFocusability()
459                                     == ViewGroup.FOCUS_BLOCK_DESCENDANTS ? msg2 : msg3)),
460                     fvh.getViewToFocus().isFocused(), is(false));
461         } else {
462             FocusViewHolder fvh = mDisableRecovery
463                     ? cast(mRecyclerView.findViewHolderForAdapterPosition(
464                             focusedChildIndexWhenRecoveryDisabled)) :
465                     (focusedChildIndexWhenRecoveryEnabled != -1
466                             ? cast(mRecyclerView.findViewHolderForAdapterPosition(
467                                     focusedChildIndexWhenRecoveryEnabled)) :
468                     cast(mRecyclerView.findViewHolderForAdapterPosition(0)));
469 
470             assertThat("test sanity", fvh, notNullValue());
471             assertThat("RV's first child should be focusable", fvh.getViewToFocus().isFocusable(),
472                     is(true));
473             String msg = " when descendant focusability is FOCUS_AFTER_DESCENDANTS";
474             assertThat("RV should pass the focus down to its children after layout" + msg,
475                     mRecyclerView.isFocused(), is(false));
476             assertThat("RV's child #" + focusedChildIndexWhenRecoveryEnabled + " should have focus"
477                             + " after layout" + msg,
478                     fvh.itemView.hasFocus(), is(true));
479             if (mFocusOnChild) {
480                 assertThat("Either the ViewGroup or the TextView within the first child of RV"
481                                 + "should be focused after layout" + msg,
482                         fvh.itemView.isFocused() || fvh.getViewToFocus().isFocused(), is(true));
483             } else {
484                 assertThat("RV's first child should be focused after layout" + msg,
485                         fvh.getViewToFocus().isFocused(), is(true));
486             }
487 
488         }
489     }
490 
assertFocusTransition(RecyclerView.ViewHolder oldVh, RecyclerView.ViewHolder newVh, boolean typeChanged)491     private void assertFocusTransition(RecyclerView.ViewHolder oldVh,
492             RecyclerView.ViewHolder newVh, boolean typeChanged) {
493         if (mDisableRecovery) {
494             if (mDisableAnimation) {
495                 return;
496             }
497             assertFocus(newVh, false);
498             return;
499         }
500         assertThat("test sanity", newVh, notNullValue());
501         if (!typeChanged && mDisableAnimation) {
502             assertThat(oldVh, sameInstance(newVh));
503         } else {
504             assertThat(oldVh, not(sameInstance(newVh)));
505             assertFocus(oldVh, false);
506         }
507         assertFocus(newVh, true);
508     }
509 
510     @Test
testFocusRecoveryInTypeChangeWithPredictive()511     public void testFocusRecoveryInTypeChangeWithPredictive() throws Throwable {
512         testFocusRecoveryInTypeChange(true);
513     }
514 
515     @Test
testFocusRecoveryInTypeChangeWithoutPredictive()516     public void testFocusRecoveryInTypeChangeWithoutPredictive() throws Throwable {
517         testFocusRecoveryInTypeChange(false);
518     }
519 
testFocusRecoveryInTypeChange(boolean withAnimation)520     private void testFocusRecoveryInTypeChange(boolean withAnimation) throws Throwable {
521         setupBasic();
522         if (!mDisableAnimation) {
523             ((SimpleItemAnimator) (mRecyclerView.getItemAnimator()))
524                     .setSupportsChangeAnimations(true);
525         }
526         mLayoutManager.setSupportsPredictive(withAnimation);
527         final RecyclerView.ViewHolder oldVh = focusVh(3);
528         mLayoutManager.expectLayouts(!mDisableAnimation && withAnimation ? 2 : 1);
529         mActivityRule.runOnUiThread(new Runnable() {
530             @Override
531             public void run() {
532                 Item item = mAdapter.mItems.get(3);
533                 item.mType += 2;
534                 mAdapter.notifyItemChanged(3);
535             }
536         });
537         mLayoutManager.waitForLayout(2);
538 
539         RecyclerView.ViewHolder newVh = mRecyclerView.findViewHolderForAdapterPosition(3);
540         assertFocusTransition(oldVh, newVh, true);
541         assertThat("test sanity", oldVh.getItemViewType(), not(newVh.getItemViewType()));
542     }
543 
544     @Test
testRecoverAdapterChangeViaStableIdOnDataSetChanged()545     public void testRecoverAdapterChangeViaStableIdOnDataSetChanged() throws Throwable {
546         recoverAdapterChangeViaStableId(false, false);
547     }
548 
549     @Test
testRecoverAdapterChangeViaStableIdOnSwap()550     public void testRecoverAdapterChangeViaStableIdOnSwap() throws Throwable {
551         recoverAdapterChangeViaStableId(true, false);
552     }
553 
554     @Test
testRecoverAdapterChangeViaStableIdOnDataSetChangedWithTypeChange()555     public void testRecoverAdapterChangeViaStableIdOnDataSetChangedWithTypeChange()
556             throws Throwable {
557         recoverAdapterChangeViaStableId(false, true);
558     }
559 
560     @Test
testRecoverAdapterChangeViaStableIdOnSwapWithTypeChange()561     public void testRecoverAdapterChangeViaStableIdOnSwapWithTypeChange() throws Throwable {
562         recoverAdapterChangeViaStableId(true, true);
563     }
564 
recoverAdapterChangeViaStableId(final boolean swap, final boolean changeType)565     private void recoverAdapterChangeViaStableId(final boolean swap, final boolean changeType)
566             throws Throwable {
567         setupBasic(true);
568         RecyclerView.ViewHolder oldVh = focusVh(4);
569         long itemId = oldVh.getItemId();
570 
571         mLayoutManager.expectLayouts(1);
572         mActivityRule.runOnUiThread(new Runnable() {
573             @Override
574             public void run() {
575                 Item item = mAdapter.mItems.get(4);
576                 if (changeType) {
577                     item.mType += 2;
578                 }
579                 if (swap) {
580                     mAdapter = new FocusTestAdapter(8);
581                     mAdapter.setHasStableIds(true);
582                     mAdapter.mItems.add(2, item);
583                     mRecyclerView.swapAdapter(mAdapter, false);
584                 } else {
585                     mAdapter.mItems.remove(0);
586                     mAdapter.mItems.remove(0);
587                     mAdapter.notifyDataSetChanged();
588                 }
589             }
590         });
591         mLayoutManager.waitForLayout(1);
592         if (!mDisableAnimation) {
593             waitForAnimations(2);
594         }
595 
596         RecyclerView.ViewHolder newVh = mRecyclerView.findViewHolderForItemId(itemId);
597         if (changeType) {
598             assertFocusTransition(oldVh, newVh, true);
599         } else {
600             // in this case we should use the same VH because we have stable ids
601             assertThat(oldVh, sameInstance(newVh));
602             assertFocus(newVh, true);
603         }
604     }
605 
606     @Test
testDoNotRecoverViaPositionOnSetAdapter()607     public void testDoNotRecoverViaPositionOnSetAdapter() throws Throwable {
608         testDoNotRecoverViaPositionOnNewDataSet(new RecyclerViewLayoutTest.AdapterRunnable() {
609             @Override
610             public void run(TestAdapter adapter) throws Throwable {
611                 mRecyclerView.setAdapter(new FocusTestAdapter(10));
612             }
613         });
614     }
615 
616     @Test
testDoNotRecoverViaPositionOnSwapAdapterWithRecycle()617     public void testDoNotRecoverViaPositionOnSwapAdapterWithRecycle() throws Throwable {
618         testDoNotRecoverViaPositionOnNewDataSet(new RecyclerViewLayoutTest.AdapterRunnable() {
619             @Override
620             public void run(TestAdapter adapter) throws Throwable {
621                 mRecyclerView.swapAdapter(new FocusTestAdapter(10), true);
622             }
623         });
624     }
625 
626     @Test
testDoNotRecoverViaPositionOnSwapAdapterWithoutRecycle()627     public void testDoNotRecoverViaPositionOnSwapAdapterWithoutRecycle() throws Throwable {
628         testDoNotRecoverViaPositionOnNewDataSet(new RecyclerViewLayoutTest.AdapterRunnable() {
629             @Override
630             public void run(TestAdapter adapter) throws Throwable {
631                 mRecyclerView.swapAdapter(new FocusTestAdapter(10), false);
632             }
633         });
634     }
635 
testDoNotRecoverViaPositionOnNewDataSet( final RecyclerViewLayoutTest.AdapterRunnable runnable)636     public void testDoNotRecoverViaPositionOnNewDataSet(
637             final RecyclerViewLayoutTest.AdapterRunnable runnable) throws Throwable {
638         setupBasic(false);
639         assertThat("test sanity", mAdapter.hasStableIds(), is(false));
640         focusVh(4);
641         mLayoutManager.expectLayouts(1);
642         mActivityRule.runOnUiThread(new Runnable() {
643             @Override
644             public void run() {
645                 try {
646                     runnable.run(mAdapter);
647                 } catch (Throwable throwable) {
648                     postExceptionToInstrumentation(throwable);
649                 }
650             }
651         });
652 
653         mLayoutManager.waitForLayout(1);
654         RecyclerView.ViewHolder otherVh = mRecyclerView.findViewHolderForAdapterPosition(4);
655         checkForMainThreadException();
656         // even if the VH is re-used, it will be removed-reAdded so focus will go away from it.
657         assertFocus("should not recover focus if data set is badly invalid", otherVh, false);
658 
659     }
660 
661     @Test
testDoNotRecoverIfReplacementIsNotFocusable()662     public void testDoNotRecoverIfReplacementIsNotFocusable() throws Throwable {
663         final int TYPE_NO_FOCUS = 1001;
664         TestAdapter adapter = new FocusTestAdapter(10) {
665             @Override
666             public void onBindViewHolder(@NonNull TestViewHolder holder,
667                     int position) {
668                 super.onBindViewHolder(holder, position);
669                 if (holder.getItemViewType() == TYPE_NO_FOCUS) {
670                     cast(holder).setFocusable(false);
671                 }
672             }
673         };
674         adapter.setHasStableIds(true);
675         setupBasic(adapter);
676         RecyclerView.ViewHolder oldVh = focusVh(3);
677         final long itemId = oldVh.getItemId();
678         mLayoutManager.expectLayouts(1);
679         mActivityRule.runOnUiThread(new Runnable() {
680             @Override
681             public void run() {
682                 mAdapter.mItems.get(3).mType = TYPE_NO_FOCUS;
683                 mAdapter.notifyDataSetChanged();
684             }
685         });
686         mLayoutManager.waitForLayout(2);
687         if (!mDisableAnimation) {
688             waitForAnimations(2);
689         }
690         RecyclerView.ViewHolder newVh = mRecyclerView.findViewHolderForItemId(itemId);
691         assertFocus(newVh, false);
692     }
693 
694     @NonNull
focusVh(int pos)695     private RecyclerView.ViewHolder focusVh(int pos) throws Throwable {
696         final RecyclerView.ViewHolder oldVh = mRecyclerView.findViewHolderForAdapterPosition(pos);
697         assertThat("test sanity", oldVh, notNullValue());
698         requestFocus(oldVh);
699         assertFocus("test sanity", oldVh, true);
700         getInstrumentation().waitForIdleSync();
701         return oldVh;
702     }
703 
704     @Test
testDoNotOverrideAdapterRequestedFocus()705     public void testDoNotOverrideAdapterRequestedFocus() throws Throwable {
706         final AtomicLong toFocusId = new AtomicLong(-1);
707 
708         FocusTestAdapter adapter = new FocusTestAdapter(10) {
709             @Override
710             public void onBindViewHolder(@NonNull TestViewHolder holder,
711                     int position) {
712                 super.onBindViewHolder(holder, position);
713                 if (holder.getItemId() == toFocusId.get()) {
714                     try {
715                         requestFocus(holder);
716                     } catch (Throwable throwable) {
717                         postExceptionToInstrumentation(throwable);
718                     }
719                 }
720             }
721         };
722         adapter.setHasStableIds(true);
723         toFocusId.set(adapter.mItems.get(3).mId);
724         long firstFocusId = toFocusId.get();
725         setupBasic(adapter);
726         RecyclerView.ViewHolder oldVh = mRecyclerView.findViewHolderForItemId(toFocusId.get());
727         assertFocus(oldVh, true);
728         toFocusId.set(mAdapter.mItems.get(5).mId);
729         mLayoutManager.expectLayouts(1);
730         mActivityRule.runOnUiThread(new Runnable() {
731             @Override
732             public void run() {
733                 mAdapter.mItems.get(3).mType += 2;
734                 mAdapter.mItems.get(5).mType += 2;
735                 mAdapter.notifyDataSetChanged();
736             }
737         });
738         mLayoutManager.waitForLayout(2);
739         if (!mDisableAnimation) {
740             waitForAnimations(2);
741         }
742         RecyclerView.ViewHolder requested = mRecyclerView.findViewHolderForItemId(toFocusId.get());
743         assertFocus(oldVh, false);
744         assertFocus(requested, true);
745         RecyclerView.ViewHolder oldReplacement = mRecyclerView
746                 .findViewHolderForItemId(firstFocusId);
747         assertFocus(oldReplacement, false);
748         checkForMainThreadException();
749     }
750 
751     @Test
testDoNotOverrideLayoutManagerRequestedFocus()752     public void testDoNotOverrideLayoutManagerRequestedFocus() throws Throwable {
753         final AtomicLong toFocusId = new AtomicLong(-1);
754         FocusTestAdapter adapter = new FocusTestAdapter(10);
755         adapter.setHasStableIds(true);
756 
757         FocusLayoutManager lm = new FocusLayoutManager() {
758             @Override
759             public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
760                 detachAndScrapAttachedViews(recycler);
761                 layoutRange(recycler, 0, state.getItemCount());
762                 RecyclerView.ViewHolder toFocus = mRecyclerView
763                         .findViewHolderForItemId(toFocusId.get());
764                 if (toFocus != null) {
765                     try {
766                         requestFocus(toFocus);
767                     } catch (Throwable throwable) {
768                         postExceptionToInstrumentation(throwable);
769                     }
770                 }
771                 layoutLatch.countDown();
772             }
773         };
774 
775         toFocusId.set(adapter.mItems.get(3).mId);
776         long firstFocusId = toFocusId.get();
777         setupBasic(adapter, lm);
778 
779         RecyclerView.ViewHolder oldVh = mRecyclerView.findViewHolderForItemId(toFocusId.get());
780         assertFocus(oldVh, true);
781         toFocusId.set(mAdapter.mItems.get(5).mId);
782         mLayoutManager.expectLayouts(1);
783         requestLayoutOnUIThread(mRecyclerView);
784         mLayoutManager.waitForLayout(2);
785         RecyclerView.ViewHolder requested = mRecyclerView.findViewHolderForItemId(toFocusId.get());
786         assertFocus(oldVh, false);
787         assertFocus(requested, true);
788         RecyclerView.ViewHolder oldReplacement = mRecyclerView
789                 .findViewHolderForItemId(firstFocusId);
790         assertFocus(oldReplacement, false);
791         checkForMainThreadException();
792     }
793 
requestFocus(RecyclerView.ViewHolder viewHolder)794     private void requestFocus(RecyclerView.ViewHolder viewHolder) throws Throwable {
795         FocusViewHolder fvh = cast(viewHolder);
796         requestFocus(fvh.getViewToFocus(), false);
797     }
798 
assertFocus(RecyclerView.ViewHolder viewHolder, boolean hasFocus)799     private void assertFocus(RecyclerView.ViewHolder viewHolder, boolean hasFocus) {
800         assertFocus("", viewHolder, hasFocus);
801     }
802 
assertFocus(String msg, RecyclerView.ViewHolder vh, boolean hasFocus)803     private void assertFocus(String msg, RecyclerView.ViewHolder vh, boolean hasFocus) {
804         FocusViewHolder fvh = cast(vh);
805         assertThat(msg, fvh.getViewToFocus().hasFocus(), is(hasFocus));
806     }
807 
cast(RecyclerView.ViewHolder vh)808     private <T extends FocusViewHolder> T cast(RecyclerView.ViewHolder vh) {
809         assertThat(vh, instanceOf(FocusViewHolder.class));
810         //noinspection unchecked
811         return (T) vh;
812     }
813 
814     private class FocusTestAdapter extends TestAdapter {
815 
FocusTestAdapter(int count)816         public FocusTestAdapter(int count) {
817             super(count);
818         }
819 
820         @Override
onCreateViewHolder(@onNull ViewGroup parent, int viewType)821         public FocusViewHolder onCreateViewHolder(@NonNull ViewGroup parent,
822                 int viewType) {
823             final FocusViewHolder fvh;
824             if (mFocusOnChild) {
825                 fvh = new FocusViewHolderWithChildren(
826                         LayoutInflater.from(parent.getContext())
827                                 .inflate(R.layout.focus_test_item_view, parent, false));
828             } else {
829                 fvh = new SimpleFocusViewHolder(new TextView(parent.getContext()));
830             }
831             fvh.setFocusable(true);
832             return fvh;
833         }
834 
835         @Override
onBindViewHolder(@onNull TestViewHolder holder, int position)836         public void onBindViewHolder(@NonNull TestViewHolder holder, int position) {
837             cast(holder).bindTo(mItems.get(position));
838         }
839     }
840 
841     private class FocusLayoutManager extends TestLayoutManager {
842         @Override
onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state)843         public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) {
844             detachAndScrapAttachedViews(recycler);
845             layoutRange(recycler, 0, state.getItemCount());
846             layoutLatch.countDown();
847         }
848     }
849 
850     private class FocusViewHolderWithChildren extends FocusViewHolder {
851         public final ViewGroup root;
852         public final ViewGroup parent1;
853         public final ViewGroup parent2;
854         public final TextView textView;
855 
FocusViewHolderWithChildren(View view)856         public FocusViewHolderWithChildren(View view) {
857             super(view);
858             root = (ViewGroup) view;
859             parent1 = (ViewGroup) root.findViewById(R.id.parent1);
860             parent2 = (ViewGroup) root.findViewById(R.id.parent2);
861             textView = (TextView) root.findViewById(R.id.text_view);
862 
863         }
864 
865         @Override
setFocusable(boolean focusable)866         void setFocusable(boolean focusable) {
867             parent1.setFocusableInTouchMode(focusable);
868             parent2.setFocusableInTouchMode(focusable);
869             textView.setFocusableInTouchMode(focusable);
870             root.setFocusableInTouchMode(focusable);
871 
872             parent1.setFocusable(focusable);
873             parent2.setFocusable(focusable);
874             textView.setFocusable(focusable);
875             root.setFocusable(focusable);
876         }
877 
878         @Override
onBind(Item item)879         void onBind(Item item) {
880             textView.setText(getText(item));
881         }
882 
883         @Override
getViewToFocus()884         View getViewToFocus() {
885             return textView;
886         }
887     }
888 
889     private class SimpleFocusViewHolder extends FocusViewHolder {
890 
SimpleFocusViewHolder(View itemView)891         public SimpleFocusViewHolder(View itemView) {
892             super(itemView);
893         }
894 
895         @Override
setFocusable(boolean focusable)896         void setFocusable(boolean focusable) {
897             itemView.setFocusableInTouchMode(focusable);
898             itemView.setFocusable(focusable);
899         }
900 
901         @Override
getViewToFocus()902         View getViewToFocus() {
903             return itemView;
904         }
905 
906         @Override
onBind(Item item)907         void onBind(Item item) {
908             ((TextView) (itemView)).setText(getText(item));
909         }
910     }
911 
912     private abstract class FocusViewHolder extends TestViewHolder {
913 
FocusViewHolder(View itemView)914         public FocusViewHolder(View itemView) {
915             super(itemView);
916         }
917 
getText(Item item)918         protected String getText(Item item) {
919             return item.mText + "(" + item.mId + ")";
920         }
921 
setFocusable(boolean focusable)922         abstract void setFocusable(boolean focusable);
923 
getViewToFocus()924         abstract View getViewToFocus();
925 
onBind(Item item)926         abstract void onBind(Item item);
927 
bindTo(Item item)928         final void bindTo(Item item) {
929             mBoundItem = item;
930             setFocusable(item.isFocusable());
931             onBind(item);
932         }
933     }
934 }
935