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