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