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