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