1 /* 2 * Copyright (C) 2014 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 17 package android.support.v7.widget; 18 19 import static android.support.v7.widget.LinearLayoutManager.HORIZONTAL; 20 import static android.support.v7.widget.LinearLayoutManager.VERTICAL; 21 22 import static org.junit.Assert.assertEquals; 23 import static org.junit.Assert.assertNotNull; 24 import static org.junit.Assert.assertSame; 25 import static org.junit.Assert.assertTrue; 26 27 import android.support.v4.view.AccessibilityDelegateCompat; 28 import android.support.v4.view.accessibility.AccessibilityEventCompat; 29 import android.support.v4.view.accessibility.AccessibilityRecordCompat; 30 import android.test.suitebuilder.annotation.MediumTest; 31 import android.util.Log; 32 import android.view.View; 33 import android.view.ViewGroup; 34 import android.view.accessibility.AccessibilityEvent; 35 36 import org.junit.Test; 37 38 import java.util.ArrayList; 39 import java.util.List; 40 import java.util.concurrent.atomic.AtomicInteger; 41 42 /** 43 * Includes tests for {@link LinearLayoutManager}. 44 * <p> 45 * Since most UI tests are not practical, these tests are focused on internal data representation 46 * and stability of LinearLayoutManager in response to different events (state change, scrolling 47 * etc) where it is very hard to do manual testing. 48 */ 49 @MediumTest 50 public class LinearLayoutManagerTest extends BaseLinearLayoutManagerTest { 51 52 @Test removeAnchorItem()53 public void removeAnchorItem() throws Throwable { 54 removeAnchorItemTest( 55 new Config().orientation(VERTICAL).stackFromBottom(false).reverseLayout( 56 false), 100, 0); 57 } 58 59 @Test removeAnchorItemReverse()60 public void removeAnchorItemReverse() throws Throwable { 61 removeAnchorItemTest( 62 new Config().orientation(VERTICAL).stackFromBottom(false).reverseLayout(true), 100, 63 0); 64 } 65 66 @Test removeAnchorItemStackFromEnd()67 public void removeAnchorItemStackFromEnd() throws Throwable { 68 removeAnchorItemTest( 69 new Config().orientation(VERTICAL).stackFromBottom(true).reverseLayout(false), 100, 70 99); 71 } 72 73 @Test removeAnchorItemStackFromEndAndReverse()74 public void removeAnchorItemStackFromEndAndReverse() throws Throwable { 75 removeAnchorItemTest( 76 new Config().orientation(VERTICAL).stackFromBottom(true).reverseLayout(true), 100, 77 99); 78 } 79 80 @Test removeAnchorItemHorizontal()81 public void removeAnchorItemHorizontal() throws Throwable { 82 removeAnchorItemTest( 83 new Config().orientation(HORIZONTAL).stackFromBottom(false).reverseLayout( 84 false), 100, 0); 85 } 86 87 @Test removeAnchorItemReverseHorizontal()88 public void removeAnchorItemReverseHorizontal() throws Throwable { 89 removeAnchorItemTest( 90 new Config().orientation(HORIZONTAL).stackFromBottom(false).reverseLayout(true), 91 100, 0); 92 } 93 94 @Test removeAnchorItemStackFromEndHorizontal()95 public void removeAnchorItemStackFromEndHorizontal() throws Throwable { 96 removeAnchorItemTest( 97 new Config().orientation(HORIZONTAL).stackFromBottom(true).reverseLayout(false), 98 100, 99); 99 } 100 101 @Test removeAnchorItemStackFromEndAndReverseHorizontal()102 public void removeAnchorItemStackFromEndAndReverseHorizontal() throws Throwable { 103 removeAnchorItemTest( 104 new Config().orientation(HORIZONTAL).stackFromBottom(true).reverseLayout(true), 100, 105 99); 106 } 107 108 /** 109 * This tests a regression where predictive animations were not working as expected when the 110 * first item is removed and there aren't any more items to add from that direction. 111 * First item refers to the default anchor item. 112 */ removeAnchorItemTest(final Config config, int adapterSize, final int removePos)113 public void removeAnchorItemTest(final Config config, int adapterSize, 114 final int removePos) throws Throwable { 115 config.adapter(new TestAdapter(adapterSize) { 116 @Override 117 public void onBindViewHolder(TestViewHolder holder, 118 int position) { 119 super.onBindViewHolder(holder, position); 120 ViewGroup.LayoutParams lp = holder.itemView.getLayoutParams(); 121 if (!(lp instanceof ViewGroup.MarginLayoutParams)) { 122 lp = new ViewGroup.MarginLayoutParams(0, 0); 123 holder.itemView.setLayoutParams(lp); 124 } 125 ViewGroup.MarginLayoutParams mlp = (ViewGroup.MarginLayoutParams) lp; 126 final int maxSize; 127 if (config.mOrientation == HORIZONTAL) { 128 maxSize = mRecyclerView.getWidth(); 129 mlp.height = ViewGroup.MarginLayoutParams.MATCH_PARENT; 130 } else { 131 maxSize = mRecyclerView.getHeight(); 132 mlp.width = ViewGroup.MarginLayoutParams.MATCH_PARENT; 133 } 134 135 final int desiredSize; 136 if (position == removePos) { 137 // make it large 138 desiredSize = maxSize / 4; 139 } else { 140 // make it small 141 desiredSize = maxSize / 8; 142 } 143 if (config.mOrientation == HORIZONTAL) { 144 mlp.width = desiredSize; 145 } else { 146 mlp.height = desiredSize; 147 } 148 } 149 }); 150 setupByConfig(config, true); 151 final int childCount = mLayoutManager.getChildCount(); 152 RecyclerView.ViewHolder toBeRemoved = null; 153 List<RecyclerView.ViewHolder> toBeMoved = new ArrayList<RecyclerView.ViewHolder>(); 154 for (int i = 0; i < childCount; i++) { 155 View child = mLayoutManager.getChildAt(i); 156 RecyclerView.ViewHolder holder = mRecyclerView.getChildViewHolder(child); 157 if (holder.getAdapterPosition() == removePos) { 158 toBeRemoved = holder; 159 } else { 160 toBeMoved.add(holder); 161 } 162 } 163 assertNotNull("test sanity", toBeRemoved); 164 assertEquals("test sanity", childCount - 1, toBeMoved.size()); 165 LoggingItemAnimator loggingItemAnimator = new LoggingItemAnimator(); 166 mRecyclerView.setItemAnimator(loggingItemAnimator); 167 loggingItemAnimator.reset(); 168 loggingItemAnimator.expectRunPendingAnimationsCall(1); 169 mLayoutManager.expectLayouts(2); 170 mTestAdapter.deleteAndNotify(removePos, 1); 171 mLayoutManager.waitForLayout(1); 172 loggingItemAnimator.waitForPendingAnimationsCall(2); 173 assertTrue("removed child should receive remove animation", 174 loggingItemAnimator.mRemoveVHs.contains(toBeRemoved)); 175 for (RecyclerView.ViewHolder vh : toBeMoved) { 176 assertTrue("view holder should be in moved list", 177 loggingItemAnimator.mMoveVHs.contains(vh)); 178 } 179 List<RecyclerView.ViewHolder> newHolders = new ArrayList<RecyclerView.ViewHolder>(); 180 for (int i = 0; i < mLayoutManager.getChildCount(); i++) { 181 View child = mLayoutManager.getChildAt(i); 182 RecyclerView.ViewHolder holder = mRecyclerView.getChildViewHolder(child); 183 if (toBeRemoved != holder && !toBeMoved.contains(holder)) { 184 newHolders.add(holder); 185 } 186 } 187 assertTrue("some new children should show up for the new space", newHolders.size() > 0); 188 assertEquals("no items should receive animate add since they are not new", 0, 189 loggingItemAnimator.mAddVHs.size()); 190 for (RecyclerView.ViewHolder holder : newHolders) { 191 assertTrue("new holder should receive a move animation", 192 loggingItemAnimator.mMoveVHs.contains(holder)); 193 } 194 assertTrue("control against adding too many children due to bad layout state preparation." 195 + " initial:" + childCount + ", current:" + mRecyclerView.getChildCount(), 196 mRecyclerView.getChildCount() <= childCount + 3 /*1 for removed view, 2 for its size*/); 197 } 198 199 @Test keepFocusOnRelayout()200 public void keepFocusOnRelayout() throws Throwable { 201 setupByConfig(new Config(VERTICAL, false, false).itemCount(500), true); 202 int center = (mLayoutManager.findLastVisibleItemPosition() 203 - mLayoutManager.findFirstVisibleItemPosition()) / 2; 204 final RecyclerView.ViewHolder vh = mRecyclerView.findViewHolderForLayoutPosition(center); 205 final int top = mLayoutManager.mOrientationHelper.getDecoratedStart(vh.itemView); 206 requestFocus(vh.itemView, true); 207 assertTrue("view should have the focus", vh.itemView.hasFocus()); 208 // add a bunch of items right before that view, make sure it keeps its position 209 mLayoutManager.expectLayouts(2); 210 final int childCountToAdd = mRecyclerView.getChildCount() * 2; 211 mTestAdapter.addAndNotify(center, childCountToAdd); 212 center += childCountToAdd; // offset item 213 mLayoutManager.waitForLayout(2); 214 mLayoutManager.waitForAnimationsToEnd(20); 215 final RecyclerView.ViewHolder postVH = mRecyclerView.findViewHolderForLayoutPosition(center); 216 assertNotNull("focused child should stay in layout", postVH); 217 assertSame("same view holder should be kept for unchanged child", vh, postVH); 218 assertEquals("focused child's screen position should stay unchanged", top, 219 mLayoutManager.mOrientationHelper.getDecoratedStart(postVH.itemView)); 220 } 221 222 @Test keepFullFocusOnResize()223 public void keepFullFocusOnResize() throws Throwable { 224 keepFocusOnResizeTest(new Config(VERTICAL, false, false).itemCount(500), true); 225 } 226 227 @Test keepPartialFocusOnResize()228 public void keepPartialFocusOnResize() throws Throwable { 229 keepFocusOnResizeTest(new Config(VERTICAL, false, false).itemCount(500), false); 230 } 231 232 @Test keepReverseFullFocusOnResize()233 public void keepReverseFullFocusOnResize() throws Throwable { 234 keepFocusOnResizeTest(new Config(VERTICAL, true, false).itemCount(500), true); 235 } 236 237 @Test keepReversePartialFocusOnResize()238 public void keepReversePartialFocusOnResize() throws Throwable { 239 keepFocusOnResizeTest(new Config(VERTICAL, true, false).itemCount(500), false); 240 } 241 242 @Test keepStackFromEndFullFocusOnResize()243 public void keepStackFromEndFullFocusOnResize() throws Throwable { 244 keepFocusOnResizeTest(new Config(VERTICAL, false, true).itemCount(500), true); 245 } 246 247 @Test keepStackFromEndPartialFocusOnResize()248 public void keepStackFromEndPartialFocusOnResize() throws Throwable { 249 keepFocusOnResizeTest(new Config(VERTICAL, false, true).itemCount(500), false); 250 } 251 keepFocusOnResizeTest(final Config config, boolean fullyVisible)252 public void keepFocusOnResizeTest(final Config config, boolean fullyVisible) throws Throwable { 253 setupByConfig(config, true); 254 final int targetPosition; 255 if (config.mStackFromEnd) { 256 targetPosition = mLayoutManager.findFirstVisibleItemPosition(); 257 } else { 258 targetPosition = mLayoutManager.findLastVisibleItemPosition(); 259 } 260 final OrientationHelper helper = mLayoutManager.mOrientationHelper; 261 final RecyclerView.ViewHolder vh = mRecyclerView 262 .findViewHolderForLayoutPosition(targetPosition); 263 264 // scroll enough to offset the child 265 int startMargin = helper.getDecoratedStart(vh.itemView) - 266 helper.getStartAfterPadding(); 267 int endMargin = helper.getEndAfterPadding() - 268 helper.getDecoratedEnd(vh.itemView); 269 Log.d(TAG, "initial start margin " + startMargin + " , end margin:" + endMargin); 270 requestFocus(vh.itemView, true); 271 assertTrue("view should gain the focus", vh.itemView.hasFocus()); 272 // scroll enough to offset the child 273 startMargin = helper.getDecoratedStart(vh.itemView) - 274 helper.getStartAfterPadding(); 275 endMargin = helper.getEndAfterPadding() - 276 helper.getDecoratedEnd(vh.itemView); 277 278 Log.d(TAG, "start margin " + startMargin + " , end margin:" + endMargin); 279 assertTrue("View should become fully visible", startMargin >= 0 && endMargin >= 0); 280 281 int expectedOffset = 0; 282 boolean offsetAtStart = false; 283 if (!fullyVisible) { 284 // move it a bit such that it is no more fully visible 285 final int childSize = helper 286 .getDecoratedMeasurement(vh.itemView); 287 expectedOffset = childSize / 3; 288 if (startMargin < endMargin) { 289 scrollBy(expectedOffset); 290 offsetAtStart = true; 291 } else { 292 scrollBy(-expectedOffset); 293 offsetAtStart = false; 294 } 295 startMargin = helper.getDecoratedStart(vh.itemView) - 296 helper.getStartAfterPadding(); 297 endMargin = helper.getEndAfterPadding() - 298 helper.getDecoratedEnd(vh.itemView); 299 assertTrue("test sanity, view should not be fully visible", startMargin < 0 300 || endMargin < 0); 301 } 302 303 mLayoutManager.expectLayouts(1); 304 runTestOnUiThread(new Runnable() { 305 @Override 306 public void run() { 307 final ViewGroup.LayoutParams layoutParams = mRecyclerView.getLayoutParams(); 308 if (config.mOrientation == HORIZONTAL) { 309 layoutParams.width = mRecyclerView.getWidth() / 2; 310 } else { 311 layoutParams.height = mRecyclerView.getHeight() / 2; 312 } 313 mRecyclerView.setLayoutParams(layoutParams); 314 } 315 }); 316 Thread.sleep(100); 317 // add a bunch of items right before that view, make sure it keeps its position 318 mLayoutManager.waitForLayout(2); 319 mLayoutManager.waitForAnimationsToEnd(20); 320 assertTrue("view should preserve the focus", vh.itemView.hasFocus()); 321 final RecyclerView.ViewHolder postVH = mRecyclerView 322 .findViewHolderForLayoutPosition(targetPosition); 323 assertNotNull("focused child should stay in layout", postVH); 324 assertSame("same view holder should be kept for unchanged child", vh, postVH); 325 View focused = postVH.itemView; 326 327 startMargin = helper.getDecoratedStart(focused) - helper.getStartAfterPadding(); 328 endMargin = helper.getEndAfterPadding() - helper.getDecoratedEnd(focused); 329 330 assertTrue("focused child should be somewhat visible", 331 helper.getDecoratedStart(focused) < helper.getEndAfterPadding() 332 && helper.getDecoratedEnd(focused) > helper.getStartAfterPadding()); 333 if (fullyVisible) { 334 assertTrue("focused child end should stay fully visible", 335 endMargin >= 0); 336 assertTrue("focused child start should stay fully visible", 337 startMargin >= 0); 338 } else { 339 if (offsetAtStart) { 340 assertTrue("start should preserve its offset", startMargin < 0); 341 assertTrue("end should be visible", endMargin >= 0); 342 } else { 343 assertTrue("end should preserve its offset", endMargin < 0); 344 assertTrue("start should be visible", startMargin >= 0); 345 } 346 } 347 } 348 349 @Test scrollToPositionWithPredictive()350 public void scrollToPositionWithPredictive() throws Throwable { 351 scrollToPositionWithPredictive(0, LinearLayoutManager.INVALID_OFFSET); 352 removeRecyclerView(); 353 scrollToPositionWithPredictive(3, 20); 354 removeRecyclerView(); 355 scrollToPositionWithPredictive(Config.DEFAULT_ITEM_COUNT / 2, 356 LinearLayoutManager.INVALID_OFFSET); 357 removeRecyclerView(); 358 scrollToPositionWithPredictive(Config.DEFAULT_ITEM_COUNT / 2, 10); 359 } 360 361 @Test recycleDuringAnimations()362 public void recycleDuringAnimations() throws Throwable { 363 final AtomicInteger childCount = new AtomicInteger(0); 364 final TestAdapter adapter = new TestAdapter(300) { 365 @Override 366 public TestViewHolder onCreateViewHolder(ViewGroup parent, 367 int viewType) { 368 final int cnt = childCount.incrementAndGet(); 369 final TestViewHolder testViewHolder = super.onCreateViewHolder(parent, viewType); 370 if (DEBUG) { 371 Log.d(TAG, "CHILD_CNT(create):" + cnt + ", " + testViewHolder); 372 } 373 return testViewHolder; 374 } 375 }; 376 setupByConfig(new Config(VERTICAL, false, false).itemCount(300) 377 .adapter(adapter), true); 378 379 final RecyclerView.RecycledViewPool pool = new RecyclerView.RecycledViewPool() { 380 @Override 381 public void putRecycledView(RecyclerView.ViewHolder scrap) { 382 super.putRecycledView(scrap); 383 int cnt = childCount.decrementAndGet(); 384 if (DEBUG) { 385 Log.d(TAG, "CHILD_CNT(put):" + cnt + ", " + scrap); 386 } 387 } 388 389 @Override 390 public RecyclerView.ViewHolder getRecycledView(int viewType) { 391 final RecyclerView.ViewHolder recycledView = super.getRecycledView(viewType); 392 if (recycledView != null) { 393 final int cnt = childCount.incrementAndGet(); 394 if (DEBUG) { 395 Log.d(TAG, "CHILD_CNT(get):" + cnt + ", " + recycledView); 396 } 397 } 398 return recycledView; 399 } 400 }; 401 pool.setMaxRecycledViews(mTestAdapter.getItemViewType(0), 500); 402 mRecyclerView.setRecycledViewPool(pool); 403 404 405 // now keep adding children to trigger more children being created etc. 406 for (int i = 0; i < 100; i ++) { 407 adapter.addAndNotify(15, 1); 408 Thread.sleep(15); 409 } 410 getInstrumentation().waitForIdleSync(); 411 waitForAnimations(2); 412 assertEquals("Children count should add up", childCount.get(), 413 mRecyclerView.getChildCount() + mRecyclerView.mRecycler.mCachedViews.size()); 414 415 // now trigger lots of add again, followed by a scroll to position 416 for (int i = 0; i < 100; i ++) { 417 adapter.addAndNotify(5 + (i % 3) * 3, 1); 418 Thread.sleep(25); 419 } 420 smoothScrollToPosition(mLayoutManager.findLastVisibleItemPosition() + 20); 421 waitForAnimations(2); 422 getInstrumentation().waitForIdleSync(); 423 assertEquals("Children count should add up", childCount.get(), 424 mRecyclerView.getChildCount() + mRecyclerView.mRecycler.mCachedViews.size()); 425 } 426 427 428 @Test dontRecycleChildrenOnDetach()429 public void dontRecycleChildrenOnDetach() throws Throwable { 430 setupByConfig(new Config().recycleChildrenOnDetach(false), true); 431 runTestOnUiThread(new Runnable() { 432 @Override 433 public void run() { 434 int recyclerSize = mRecyclerView.mRecycler.getRecycledViewPool().size(); 435 ((ViewGroup)mRecyclerView.getParent()).removeView(mRecyclerView); 436 assertEquals("No views are recycled", recyclerSize, 437 mRecyclerView.mRecycler.getRecycledViewPool().size()); 438 } 439 }); 440 } 441 442 @Test recycleChildrenOnDetach()443 public void recycleChildrenOnDetach() throws Throwable { 444 setupByConfig(new Config().recycleChildrenOnDetach(true), true); 445 final int childCount = mLayoutManager.getChildCount(); 446 runTestOnUiThread(new Runnable() { 447 @Override 448 public void run() { 449 int recyclerSize = mRecyclerView.mRecycler.getRecycledViewPool().size(); 450 mRecyclerView.mRecycler.getRecycledViewPool().setMaxRecycledViews( 451 mTestAdapter.getItemViewType(0), recyclerSize + childCount); 452 ((ViewGroup)mRecyclerView.getParent()).removeView(mRecyclerView); 453 assertEquals("All children should be recycled", childCount + recyclerSize, 454 mRecyclerView.mRecycler.getRecycledViewPool().size()); 455 } 456 }); 457 } 458 459 @Test scrollAndClear()460 public void scrollAndClear() throws Throwable { 461 setupByConfig(new Config(), true); 462 463 assertTrue("Children not laid out", mLayoutManager.collectChildCoordinates().size() > 0); 464 465 mLayoutManager.expectLayouts(1); 466 runTestOnUiThread(new Runnable() { 467 @Override 468 public void run() { 469 mLayoutManager.scrollToPositionWithOffset(1, 0); 470 mTestAdapter.clearOnUIThread(); 471 } 472 }); 473 mLayoutManager.waitForLayout(2); 474 475 assertEquals("Remaining children", 0, mLayoutManager.collectChildCoordinates().size()); 476 } 477 478 479 @Test accessibilityPositions()480 public void accessibilityPositions() throws Throwable { 481 setupByConfig(new Config(VERTICAL, false, false), true); 482 final AccessibilityDelegateCompat delegateCompat = mRecyclerView 483 .getCompatAccessibilityDelegate(); 484 final AccessibilityEvent event = AccessibilityEvent.obtain(); 485 runTestOnUiThread(new Runnable() { 486 @Override 487 public void run() { 488 delegateCompat.onInitializeAccessibilityEvent(mRecyclerView, event); 489 } 490 }); 491 final AccessibilityRecordCompat record = AccessibilityEventCompat 492 .asRecord(event); 493 assertEquals("result should have first position", 494 record.getFromIndex(), 495 mLayoutManager.findFirstVisibleItemPosition()); 496 assertEquals("result should have last position", 497 record.getToIndex(), 498 mLayoutManager.findLastVisibleItemPosition()); 499 } 500 } 501