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 17 package androidx.recyclerview.widget; 18 19 import static androidx.recyclerview.widget.LayoutState.LAYOUT_END; 20 import static androidx.recyclerview.widget.LayoutState.LAYOUT_START; 21 import static androidx.recyclerview.widget.LinearLayoutManager.HORIZONTAL; 22 23 import static org.hamcrest.CoreMatchers.hasItem; 24 import static org.hamcrest.CoreMatchers.is; 25 import static org.hamcrest.CoreMatchers.not; 26 import static org.hamcrest.CoreMatchers.sameInstance; 27 import static org.junit.Assert.assertEquals; 28 import static org.junit.Assert.assertNotNull; 29 import static org.junit.Assert.assertThat; 30 31 import android.graphics.Rect; 32 import android.support.test.filters.LargeTest; 33 import android.util.Log; 34 import android.view.View; 35 import android.view.ViewParent; 36 37 import androidx.annotation.NonNull; 38 39 import org.junit.Test; 40 import org.junit.runner.RunWith; 41 import org.junit.runners.Parameterized; 42 43 import java.util.ArrayList; 44 import java.util.List; 45 import java.util.Map; 46 47 /** 48 * Tests that rely on the basic configuration and does not do any additions / removals 49 */ 50 @RunWith(Parameterized.class) 51 @LargeTest 52 public class LinearLayoutManagerBaseConfigSetTest extends BaseLinearLayoutManagerTest { 53 54 private final Config mConfig; 55 56 public LinearLayoutManagerBaseConfigSetTest(Config config) { 57 mConfig = config; 58 } 59 60 61 @Parameterized.Parameters(name = "{0}") 62 public static List<Config> configs() throws CloneNotSupportedException { 63 List<Config> result = new ArrayList<>(); 64 for (Config config : createBaseVariations()) { 65 result.add(config); 66 } 67 return result; 68 } 69 70 @Test 71 public void scrollToPositionWithOffsetTest() throws Throwable { 72 Config config = ((Config) mConfig.clone()).itemCount(300); 73 setupByConfig(config, true); 74 OrientationHelper orientationHelper = OrientationHelper 75 .createOrientationHelper(mLayoutManager, config.mOrientation); 76 Rect layoutBounds = getDecoratedRecyclerViewBounds(); 77 // try scrolling towards head, should not affect anything 78 Map<Item, Rect> before = mLayoutManager.collectChildCoordinates(); 79 if (config.mStackFromEnd) { 80 scrollToPositionWithOffset(mTestAdapter.getItemCount() - 1, 81 mLayoutManager.mOrientationHelper.getEnd() - 500); 82 } else { 83 scrollToPositionWithOffset(0, 20); 84 } 85 assertRectSetsEqual(config + " trying to over scroll with offset should be no-op", 86 before, mLayoutManager.collectChildCoordinates()); 87 // try offsetting some visible children 88 int testCount = 10; 89 while (testCount-- > 0) { 90 // get middle child 91 final View child = mLayoutManager.getChildAt(mLayoutManager.getChildCount() / 2); 92 final int position = mRecyclerView.getChildLayoutPosition(child); 93 final int startOffset = config.mReverseLayout ? 94 orientationHelper.getEndAfterPadding() - orientationHelper 95 .getDecoratedEnd(child) 96 : orientationHelper.getDecoratedStart(child) - orientationHelper 97 .getStartAfterPadding(); 98 final int scrollOffset = config.mStackFromEnd ? startOffset + startOffset / 2 99 : startOffset / 2; 100 mLayoutManager.expectLayouts(1); 101 scrollToPositionWithOffset(position, scrollOffset); 102 mLayoutManager.waitForLayout(2); 103 final int finalOffset = config.mReverseLayout ? 104 orientationHelper.getEndAfterPadding() - orientationHelper 105 .getDecoratedEnd(child) 106 : orientationHelper.getDecoratedStart(child) - orientationHelper 107 .getStartAfterPadding(); 108 assertEquals(config + " scroll with offset on a visible child should work fine " + 109 " offset:" + finalOffset + " , existing offset:" + startOffset + ", " 110 + "child " + position, 111 scrollOffset, finalOffset); 112 } 113 114 // try scrolling to invisible children 115 testCount = 10; 116 // we test above and below, one by one 117 int offsetMultiplier = -1; 118 while (testCount-- > 0) { 119 final TargetTuple target = findInvisibleTarget(config); 120 final String logPrefix = config + " " + target; 121 mLayoutManager.expectLayouts(1); 122 final int offset = offsetMultiplier 123 * orientationHelper.getDecoratedMeasurement(mLayoutManager.getChildAt(0)) / 3; 124 scrollToPositionWithOffset(target.mPosition, offset); 125 mLayoutManager.waitForLayout(2); 126 final View child = mLayoutManager.findViewByPosition(target.mPosition); 127 assertNotNull(logPrefix + " scrolling to a mPosition with offset " + offset 128 + " should layout it", child); 129 final Rect bounds = mLayoutManager.getViewBounds(child); 130 if (DEBUG) { 131 Log.d(TAG, logPrefix + " post scroll to invisible mPosition " + bounds + " in " 132 + layoutBounds + " with offset " + offset); 133 } 134 135 if (config.mReverseLayout) { 136 assertEquals(logPrefix + " when scrolling with offset to an invisible in reverse " 137 + "layout, its end should align with recycler view's end - offset", 138 orientationHelper.getEndAfterPadding() - offset, 139 orientationHelper.getDecoratedEnd(child) 140 ); 141 } else { 142 assertEquals( 143 logPrefix + " when scrolling with offset to an invisible child in normal" 144 + " layout its start should align with recycler view's start + " 145 + "offset", 146 orientationHelper.getStartAfterPadding() + offset, 147 orientationHelper.getDecoratedStart(child) 148 ); 149 } 150 offsetMultiplier *= -1; 151 } 152 } 153 154 @Test 155 public void getFirstLastChildrenTest() throws Throwable { 156 final Config config = ((Config) mConfig.clone()).itemCount(300); 157 setupByConfig(config, true); 158 Runnable viewInBoundsTest = new Runnable() { 159 @Override 160 public void run() { 161 VisibleChildren visibleChildren = mLayoutManager.traverseAndFindVisibleChildren(); 162 final String boundsLog = mLayoutManager.getBoundsLog(); 163 assertEquals(config + ":\nfirst visible child should match traversal result\n" 164 + boundsLog, visibleChildren.firstVisiblePosition, 165 mLayoutManager.findFirstVisibleItemPosition() 166 ); 167 assertEquals( 168 config + ":\nfirst fully visible child should match traversal result\n" 169 + boundsLog, visibleChildren.firstFullyVisiblePosition, 170 mLayoutManager.findFirstCompletelyVisibleItemPosition() 171 ); 172 173 assertEquals(config + ":\nlast visible child should match traversal result\n" 174 + boundsLog, visibleChildren.lastVisiblePosition, 175 mLayoutManager.findLastVisibleItemPosition() 176 ); 177 assertEquals( 178 config + ":\nlast fully visible child should match traversal result\n" 179 + boundsLog, visibleChildren.lastFullyVisiblePosition, 180 mLayoutManager.findLastCompletelyVisibleItemPosition() 181 ); 182 } 183 }; 184 mActivityRule.runOnUiThread(viewInBoundsTest); 185 // smooth scroll to end of the list and keep testing meanwhile. This will test pre-caching 186 // case 187 final int scrollPosition = config.mStackFromEnd ? 0 : mTestAdapter.getItemCount(); 188 mActivityRule.runOnUiThread(new Runnable() { 189 @Override 190 public void run() { 191 mRecyclerView.smoothScrollToPosition(scrollPosition); 192 } 193 }); 194 while (mLayoutManager.isSmoothScrolling() || 195 mRecyclerView.getScrollState() != RecyclerView.SCROLL_STATE_IDLE) { 196 mActivityRule.runOnUiThread(viewInBoundsTest); 197 Thread.sleep(400); 198 } 199 // delete all items 200 mLayoutManager.expectLayouts(2); 201 mTestAdapter.deleteAndNotify(0, mTestAdapter.getItemCount()); 202 mLayoutManager.waitForLayout(2); 203 // test empty case 204 mActivityRule.runOnUiThread(viewInBoundsTest); 205 // set a new adapter with huge items to test full bounds check 206 mLayoutManager.expectLayouts(1); 207 final int totalSpace = mLayoutManager.mOrientationHelper.getTotalSpace(); 208 final TestAdapter newAdapter = new TestAdapter(100) { 209 @Override 210 public void onBindViewHolder(@NonNull TestViewHolder holder, 211 int position) { 212 super.onBindViewHolder(holder, position); 213 if (config.mOrientation == HORIZONTAL) { 214 holder.itemView.setMinimumWidth(totalSpace + 5); 215 } else { 216 holder.itemView.setMinimumHeight(totalSpace + 5); 217 } 218 } 219 }; 220 mActivityRule.runOnUiThread(new Runnable() { 221 @Override 222 public void run() { 223 mRecyclerView.setAdapter(newAdapter); 224 } 225 }); 226 mLayoutManager.waitForLayout(2); 227 mActivityRule.runOnUiThread(viewInBoundsTest); 228 } 229 230 @Test 231 public void dontRecycleViewsTranslatedOutOfBoundsFromStart() throws Throwable { 232 final Config config = ((Config) mConfig.clone()).itemCount(1000); 233 setupByConfig(config, true); 234 mLayoutManager.expectLayouts(1); 235 scrollToPosition(500); 236 mLayoutManager.waitForLayout(2); 237 final RecyclerView.ViewHolder vh = mRecyclerView.findViewHolderForAdapterPosition(500); 238 OrientationHelper helper = mLayoutManager.mOrientationHelper; 239 int gap = helper.getDecoratedStart(vh.itemView); 240 scrollBy(gap); 241 gap = helper.getDecoratedStart(vh.itemView); 242 assertThat("test sanity", gap, is(0)); 243 244 final int size = helper.getDecoratedMeasurement(vh.itemView); 245 AttachDetachCollector collector = new AttachDetachCollector(mRecyclerView); 246 mActivityRule.runOnUiThread(new Runnable() { 247 @Override 248 public void run() { 249 if (mConfig.mOrientation == HORIZONTAL) { 250 vh.itemView.setTranslationX(size * 2); 251 } else { 252 vh.itemView.setTranslationY(size * 2); 253 } 254 } 255 }); 256 scrollBy(size * 2); 257 assertThat(collector.getDetached(), not(hasItem(sameInstance(vh.itemView)))); 258 assertThat(vh.itemView.getParent(), is((ViewParent) mRecyclerView)); 259 assertThat(vh.getAdapterPosition(), is(500)); 260 scrollBy(size * 2); 261 assertThat(collector.getDetached(), hasItem(sameInstance(vh.itemView))); 262 } 263 264 @Test 265 public void dontRecycleViewsTranslatedOutOfBoundsFromEnd() throws Throwable { 266 final Config config = ((Config) mConfig.clone()).itemCount(1000); 267 setupByConfig(config, true); 268 mLayoutManager.expectLayouts(1); 269 scrollToPosition(500); 270 mLayoutManager.waitForLayout(2); 271 final RecyclerView.ViewHolder vh = mRecyclerView.findViewHolderForAdapterPosition(500); 272 OrientationHelper helper = mLayoutManager.mOrientationHelper; 273 int gap = helper.getEnd() - helper.getDecoratedEnd(vh.itemView); 274 scrollBy(-gap); 275 gap = helper.getEnd() - helper.getDecoratedEnd(vh.itemView); 276 assertThat("test sanity", gap, is(0)); 277 278 final int size = helper.getDecoratedMeasurement(vh.itemView); 279 AttachDetachCollector collector = new AttachDetachCollector(mRecyclerView); 280 mActivityRule.runOnUiThread(new Runnable() { 281 @Override 282 public void run() { 283 if (mConfig.mOrientation == HORIZONTAL) { 284 vh.itemView.setTranslationX(-size * 2); 285 } else { 286 vh.itemView.setTranslationY(-size * 2); 287 } 288 } 289 }); 290 scrollBy(-size * 2); 291 assertThat(collector.getDetached(), not(hasItem(sameInstance(vh.itemView)))); 292 assertThat(vh.itemView.getParent(), is((ViewParent) mRecyclerView)); 293 assertThat(vh.getAdapterPosition(), is(500)); 294 scrollBy(-size * 2); 295 assertThat(collector.getDetached(), hasItem(sameInstance(vh.itemView))); 296 } 297 298 private TargetTuple findInvisibleTarget(Config config) { 299 int minPosition = Integer.MAX_VALUE, maxPosition = Integer.MIN_VALUE; 300 for (int i = 0; i < mLayoutManager.getChildCount(); i++) { 301 View child = mLayoutManager.getChildAt(i); 302 int position = mRecyclerView.getChildLayoutPosition(child); 303 if (position < minPosition) { 304 minPosition = position; 305 } 306 if (position > maxPosition) { 307 maxPosition = position; 308 } 309 } 310 final int tailTarget = maxPosition + 311 (mRecyclerView.getAdapter().getItemCount() - maxPosition) / 2; 312 final int headTarget = minPosition / 2; 313 final int target; 314 // where will the child come from ? 315 final int itemLayoutDirection; 316 if (Math.abs(tailTarget - maxPosition) > Math.abs(headTarget - minPosition)) { 317 target = tailTarget; 318 itemLayoutDirection = config.mReverseLayout ? LAYOUT_START : LAYOUT_END; 319 } else { 320 target = headTarget; 321 itemLayoutDirection = config.mReverseLayout ? LAYOUT_END : LAYOUT_START; 322 } 323 if (DEBUG) { 324 Log.d(TAG, 325 config + " target:" + target + " min:" + minPosition + ", max:" + maxPosition); 326 } 327 return new TargetTuple(target, itemLayoutDirection); 328 } 329 } 330