| /* |
| * Copyright (C) 2014 The Android Open Source Project |
| * |
| * Licensed under the Apache License, Version 2.0 (the "License"); |
| * you may not use this file except in compliance with the License. |
| * You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License. |
| */ |
| |
| package android.support.v7.widget; |
| |
| import android.content.Context; |
| import android.graphics.Rect; |
| import android.os.Parcel; |
| import android.os.Parcelable; |
| import android.support.v4.view.AccessibilityDelegateCompat; |
| import android.support.v4.view.accessibility.AccessibilityEventCompat; |
| import android.support.v4.view.accessibility.AccessibilityRecordCompat; |
| import android.util.Log; |
| import android.view.View; |
| import android.view.ViewGroup; |
| import android.view.accessibility.AccessibilityEvent; |
| import android.widget.FrameLayout; |
| |
| import static android.support.v7.widget.LayoutState.LAYOUT_END; |
| import static android.support.v7.widget.LayoutState.LAYOUT_START; |
| import static android.support.v7.widget.LinearLayoutManager.HORIZONTAL; |
| import static android.support.v7.widget.LinearLayoutManager.VERTICAL; |
| import java.lang.reflect.Field; |
| import java.util.ArrayList; |
| import java.util.LinkedHashMap; |
| import java.util.List; |
| import java.util.Map; |
| import java.util.UUID; |
| import java.util.concurrent.CountDownLatch; |
| import java.util.concurrent.TimeUnit; |
| import java.util.concurrent.atomic.AtomicInteger; |
| |
| /** |
| * Includes tests for {@link LinearLayoutManager}. |
| * <p> |
| * Since most UI tests are not practical, these tests are focused on internal data representation |
| * and stability of LinearLayoutManager in response to different events (state change, scrolling |
| * etc) where it is very hard to do manual testing. |
| */ |
| public class LinearLayoutManagerTest extends BaseRecyclerViewInstrumentationTest { |
| |
| private static final boolean DEBUG = false; |
| |
| private static final String TAG = "LinearLayoutManagerTest"; |
| |
| WrappedLinearLayoutManager mLayoutManager; |
| |
| TestAdapter mTestAdapter; |
| |
| final List<Config> mBaseVariations = new ArrayList<Config>(); |
| |
| @Override |
| protected void setUp() throws Exception { |
| super.setUp(); |
| for (int orientation : new int[]{VERTICAL, HORIZONTAL}) { |
| for (boolean reverseLayout : new boolean[]{false, true}) { |
| for (boolean stackFromBottom : new boolean[]{false, true}) { |
| mBaseVariations.add(new Config(orientation, reverseLayout, stackFromBottom)); |
| } |
| } |
| } |
| } |
| |
| protected List<Config> addConfigVariation(List<Config> base, String fieldName, |
| Object... variations) |
| throws CloneNotSupportedException, NoSuchFieldException, IllegalAccessException { |
| List<Config> newConfigs = new ArrayList<Config>(); |
| Field field = Config.class.getDeclaredField(fieldName); |
| for (Config config : base) { |
| for (Object variation : variations) { |
| Config newConfig = (Config) config.clone(); |
| field.set(newConfig, variation); |
| newConfigs.add(newConfig); |
| } |
| } |
| return newConfigs; |
| } |
| |
| void setupByConfig(Config config, boolean waitForFirstLayout) throws Throwable { |
| mRecyclerView = inflateWrappedRV(); |
| |
| mRecyclerView.setHasFixedSize(true); |
| mTestAdapter = config.mTestAdapter == null ? new TestAdapter(config.mItemCount) |
| : config.mTestAdapter; |
| mRecyclerView.setAdapter(mTestAdapter); |
| mLayoutManager = new WrappedLinearLayoutManager(getActivity(), config.mOrientation, |
| config.mReverseLayout); |
| mLayoutManager.setStackFromEnd(config.mStackFromEnd); |
| mLayoutManager.setRecycleChildrenOnDetach(config.mRecycleChildrenOnDetach); |
| mRecyclerView.setLayoutManager(mLayoutManager); |
| if (waitForFirstLayout) { |
| waitForFirstLayout(); |
| } |
| } |
| |
| public void testRemoveAnchorItem() throws Throwable { |
| removeAnchorItemTest( |
| new Config().orientation(VERTICAL).stackFromBottom(false).reverseLayout( |
| false), 100, 0); |
| } |
| |
| public void testRemoveAnchorItemReverse() throws Throwable { |
| removeAnchorItemTest( |
| new Config().orientation(VERTICAL).stackFromBottom(false).reverseLayout(true), 100, |
| 0); |
| } |
| |
| public void testRemoveAnchorItemStackFromEnd() throws Throwable { |
| removeAnchorItemTest( |
| new Config().orientation(VERTICAL).stackFromBottom(true).reverseLayout(false), 100, |
| 99); |
| } |
| |
| public void testRemoveAnchorItemStackFromEndAndReverse() throws Throwable { |
| removeAnchorItemTest( |
| new Config().orientation(VERTICAL).stackFromBottom(true).reverseLayout(true), 100, |
| 99); |
| } |
| |
| public void testRemoveAnchorItemHorizontal() throws Throwable { |
| removeAnchorItemTest( |
| new Config().orientation(HORIZONTAL).stackFromBottom(false).reverseLayout( |
| false), 100, 0); |
| } |
| |
| public void testRemoveAnchorItemReverseHorizontal() throws Throwable { |
| removeAnchorItemTest( |
| new Config().orientation(HORIZONTAL).stackFromBottom(false).reverseLayout(true), |
| 100, 0); |
| } |
| |
| public void testRemoveAnchorItemStackFromEndHorizontal() throws Throwable { |
| removeAnchorItemTest( |
| new Config().orientation(HORIZONTAL).stackFromBottom(true).reverseLayout(false), |
| 100, 99); |
| } |
| |
| public void testRemoveAnchorItemStackFromEndAndReverseHorizontal() throws Throwable { |
| removeAnchorItemTest( |
| new Config().orientation(HORIZONTAL).stackFromBottom(true).reverseLayout(true), 100, |
| 99); |
| } |
| |
| /** |
| * This tests a regression where predictive animations were not working as expected when the |
| * first item is removed and there aren't any more items to add from that direction. |
| * First item refers to the default anchor item. |
| */ |
| public void removeAnchorItemTest(final Config config, int adapterSize, |
| final int removePos) throws Throwable { |
| config.adapter(new TestAdapter(adapterSize) { |
| @Override |
| public void onBindViewHolder(TestViewHolder holder, |
| int position) { |
| super.onBindViewHolder(holder, position); |
| ViewGroup.LayoutParams lp = holder.itemView.getLayoutParams(); |
| if (!(lp instanceof ViewGroup.MarginLayoutParams)) { |
| lp = new ViewGroup.MarginLayoutParams(0, 0); |
| holder.itemView.setLayoutParams(lp); |
| } |
| ViewGroup.MarginLayoutParams mlp = (ViewGroup.MarginLayoutParams) lp; |
| final int maxSize; |
| if (config.mOrientation == HORIZONTAL) { |
| maxSize = mRecyclerView.getWidth(); |
| mlp.height = ViewGroup.MarginLayoutParams.FILL_PARENT; |
| } else { |
| maxSize = mRecyclerView.getHeight(); |
| mlp.width = ViewGroup.MarginLayoutParams.FILL_PARENT; |
| } |
| |
| final int desiredSize; |
| if (position == removePos) { |
| // make it large |
| desiredSize = maxSize / 4; |
| } else { |
| // make it small |
| desiredSize = maxSize / 8; |
| } |
| if (config.mOrientation == HORIZONTAL) { |
| mlp.width = desiredSize; |
| } else { |
| mlp.height = desiredSize; |
| } |
| } |
| }); |
| setupByConfig(config, true); |
| final int childCount = mLayoutManager.getChildCount(); |
| RecyclerView.ViewHolder toBeRemoved = null; |
| List<RecyclerView.ViewHolder> toBeMoved = new ArrayList<RecyclerView.ViewHolder>(); |
| for (int i = 0; i < childCount; i++) { |
| View child = mLayoutManager.getChildAt(i); |
| RecyclerView.ViewHolder holder = mRecyclerView.getChildViewHolder(child); |
| if (holder.getAdapterPosition() == removePos) { |
| toBeRemoved = holder; |
| } else { |
| toBeMoved.add(holder); |
| } |
| } |
| assertNotNull("test sanity", toBeRemoved); |
| assertEquals("test sanity", childCount - 1, toBeMoved.size()); |
| LoggingItemAnimator loggingItemAnimator = new LoggingItemAnimator(); |
| mRecyclerView.setItemAnimator(loggingItemAnimator); |
| loggingItemAnimator.reset(); |
| loggingItemAnimator.expectRunPendingAnimationsCall(1); |
| mLayoutManager.expectLayouts(2); |
| mTestAdapter.deleteAndNotify(removePos, 1); |
| mLayoutManager.waitForLayout(1); |
| loggingItemAnimator.waitForPendingAnimationsCall(2); |
| assertTrue("removed child should receive remove animation", |
| loggingItemAnimator.mRemoveVHs.contains(toBeRemoved)); |
| for (RecyclerView.ViewHolder vh : toBeMoved) { |
| assertTrue("view holder should be in moved list", |
| loggingItemAnimator.mMoveVHs.contains(vh)); |
| } |
| List<RecyclerView.ViewHolder> newHolders = new ArrayList<RecyclerView.ViewHolder>(); |
| for (int i = 0; i < mLayoutManager.getChildCount(); i++) { |
| View child = mLayoutManager.getChildAt(i); |
| RecyclerView.ViewHolder holder = mRecyclerView.getChildViewHolder(child); |
| if (toBeRemoved != holder && !toBeMoved.contains(holder)) { |
| newHolders.add(holder); |
| } |
| } |
| assertTrue("some new children should show up for the new space", newHolders.size() > 0); |
| assertEquals("no items should receive animate add since they are not new", 0, |
| loggingItemAnimator.mAddVHs.size()); |
| for (RecyclerView.ViewHolder holder : newHolders) { |
| assertTrue("new holder should receive a move animation", |
| loggingItemAnimator.mMoveVHs.contains(holder)); |
| } |
| assertTrue("control against adding too many children due to bad layout state preparation." |
| + " initial:" + childCount + ", current:" + mRecyclerView.getChildCount(), |
| mRecyclerView.getChildCount() <= childCount + 3 /*1 for removed view, 2 for its size*/); |
| } |
| |
| public void testKeepFocusOnRelayout() throws Throwable { |
| setupByConfig(new Config(VERTICAL, false, false).itemCount(500), true); |
| int center = (mLayoutManager.findLastVisibleItemPosition() |
| - mLayoutManager.findFirstVisibleItemPosition()) / 2; |
| final RecyclerView.ViewHolder vh = mRecyclerView.findViewHolderForLayoutPosition(center); |
| final int top = mLayoutManager.mOrientationHelper.getDecoratedStart(vh.itemView); |
| runTestOnUiThread(new Runnable() { |
| @Override |
| public void run() { |
| vh.itemView.requestFocus(); |
| } |
| }); |
| assertTrue("view should have the focus", vh.itemView.hasFocus()); |
| // add a bunch of items right before that view, make sure it keeps its position |
| mLayoutManager.expectLayouts(2); |
| final int childCountToAdd = mRecyclerView.getChildCount() * 2; |
| mTestAdapter.addAndNotify(center, childCountToAdd); |
| center += childCountToAdd; // offset item |
| mLayoutManager.waitForLayout(2); |
| mLayoutManager.waitForAnimationsToEnd(20); |
| final RecyclerView.ViewHolder postVH = mRecyclerView.findViewHolderForLayoutPosition(center); |
| assertNotNull("focused child should stay in layout", postVH); |
| assertSame("same view holder should be kept for unchanged child", vh, postVH); |
| assertEquals("focused child's screen position should stay unchanged", top, |
| mLayoutManager.mOrientationHelper.getDecoratedStart(postVH.itemView)); |
| } |
| |
| public void testKeepFullFocusOnResize() throws Throwable { |
| keepFocusOnResizeTest(new Config(VERTICAL, false, false).itemCount(500), true); |
| } |
| |
| public void testKeepPartialFocusOnResize() throws Throwable { |
| keepFocusOnResizeTest(new Config(VERTICAL, false, false).itemCount(500), false); |
| } |
| |
| public void testKeepReverseFullFocusOnResize() throws Throwable { |
| keepFocusOnResizeTest(new Config(VERTICAL, true, false).itemCount(500), true); |
| } |
| |
| public void testKeepReversePartialFocusOnResize() throws Throwable { |
| keepFocusOnResizeTest(new Config(VERTICAL, true, false).itemCount(500), false); |
| } |
| |
| public void testKeepStackFromEndFullFocusOnResize() throws Throwable { |
| keepFocusOnResizeTest(new Config(VERTICAL, false, true).itemCount(500), true); |
| } |
| |
| public void testKeepStackFromEndPartialFocusOnResize() throws Throwable { |
| keepFocusOnResizeTest(new Config(VERTICAL, false, true).itemCount(500), false); |
| } |
| |
| public void keepFocusOnResizeTest(final Config config, boolean fullyVisible) throws Throwable { |
| setupByConfig(config, true); |
| final int targetPosition; |
| if (config.mStackFromEnd) { |
| targetPosition = mLayoutManager.findFirstVisibleItemPosition(); |
| } else { |
| targetPosition = mLayoutManager.findLastVisibleItemPosition(); |
| } |
| final OrientationHelper helper = mLayoutManager.mOrientationHelper; |
| final RecyclerView.ViewHolder vh = mRecyclerView |
| .findViewHolderForLayoutPosition(targetPosition); |
| |
| // scroll enough to offset the child |
| int startMargin = helper.getDecoratedStart(vh.itemView) - |
| helper.getStartAfterPadding(); |
| int endMargin = helper.getEndAfterPadding() - |
| helper.getDecoratedEnd(vh.itemView); |
| Log.d(TAG, "initial start margin " + startMargin + " , end margin:" + endMargin); |
| requestFocus(vh.itemView); |
| runTestOnUiThread(new Runnable() { |
| @Override |
| public void run() { |
| assertTrue("view should gain the focus", vh.itemView.hasFocus()); |
| } |
| }); |
| do { |
| Thread.sleep(100); |
| } while (mRecyclerView.getScrollState() != RecyclerView.SCROLL_STATE_IDLE); |
| // scroll enough to offset the child |
| startMargin = helper.getDecoratedStart(vh.itemView) - |
| helper.getStartAfterPadding(); |
| endMargin = helper.getEndAfterPadding() - |
| helper.getDecoratedEnd(vh.itemView); |
| |
| Log.d(TAG, "start margin " + startMargin + " , end margin:" + endMargin); |
| assertTrue("View should become fully visible", startMargin >= 0 && endMargin >= 0); |
| |
| int expectedOffset = 0; |
| boolean offsetAtStart = false; |
| if (!fullyVisible) { |
| // move it a bit such that it is no more fully visible |
| final int childSize = helper |
| .getDecoratedMeasurement(vh.itemView); |
| expectedOffset = childSize / 3; |
| if (startMargin < endMargin) { |
| scrollBy(expectedOffset); |
| offsetAtStart = true; |
| } else { |
| scrollBy(-expectedOffset); |
| offsetAtStart = false; |
| } |
| startMargin = helper.getDecoratedStart(vh.itemView) - |
| helper.getStartAfterPadding(); |
| endMargin = helper.getEndAfterPadding() - |
| helper.getDecoratedEnd(vh.itemView); |
| assertTrue("test sanity, view should not be fully visible", startMargin < 0 |
| || endMargin < 0); |
| } |
| |
| mLayoutManager.expectLayouts(1); |
| runTestOnUiThread(new Runnable() { |
| @Override |
| public void run() { |
| final ViewGroup.LayoutParams layoutParams = mRecyclerView.getLayoutParams(); |
| if (config.mOrientation == HORIZONTAL) { |
| layoutParams.width = mRecyclerView.getWidth() / 2; |
| } else { |
| layoutParams.height = mRecyclerView.getHeight() / 2; |
| } |
| mRecyclerView.setLayoutParams(layoutParams); |
| } |
| }); |
| Thread.sleep(100); |
| // add a bunch of items right before that view, make sure it keeps its position |
| mLayoutManager.waitForLayout(2); |
| mLayoutManager.waitForAnimationsToEnd(20); |
| assertTrue("view should preserve the focus", vh.itemView.hasFocus()); |
| final RecyclerView.ViewHolder postVH = mRecyclerView |
| .findViewHolderForLayoutPosition(targetPosition); |
| assertNotNull("focused child should stay in layout", postVH); |
| assertSame("same view holder should be kept for unchanged child", vh, postVH); |
| View focused = postVH.itemView; |
| |
| startMargin = helper.getDecoratedStart(focused) - helper.getStartAfterPadding(); |
| endMargin = helper.getEndAfterPadding() - helper.getDecoratedEnd(focused); |
| |
| assertTrue("focused child should be somewhat visible", |
| helper.getDecoratedStart(focused) < helper.getEndAfterPadding() |
| && helper.getDecoratedEnd(focused) > helper.getStartAfterPadding()); |
| if (fullyVisible) { |
| assertTrue("focused child end should stay fully visible", |
| endMargin >= 0); |
| assertTrue("focused child start should stay fully visible", |
| startMargin >= 0); |
| } else { |
| if (offsetAtStart) { |
| assertTrue("start should preserve its offset", startMargin < 0); |
| assertTrue("end should be visible", endMargin >= 0); |
| } else { |
| assertTrue("end should preserve its offset", endMargin < 0); |
| assertTrue("start should be visible", startMargin >= 0); |
| } |
| } |
| } |
| |
| public void testResize() throws Throwable { |
| for(Config config : addConfigVariation(mBaseVariations, "mItemCount", 5 |
| , Config.DEFAULT_ITEM_COUNT)) { |
| stackFromEndTest(config); |
| removeRecyclerView(); |
| } |
| } |
| |
| public void testScrollToPositionWithOffset() throws Throwable { |
| for (Config config : mBaseVariations) { |
| scrollToPositionWithOffsetTest(config.itemCount(300)); |
| removeRecyclerView(); |
| } |
| } |
| |
| public void scrollToPositionWithOffsetTest(Config config) throws Throwable { |
| setupByConfig(config, true); |
| OrientationHelper orientationHelper = OrientationHelper |
| .createOrientationHelper(mLayoutManager, config.mOrientation); |
| Rect layoutBounds = getDecoratedRecyclerViewBounds(); |
| // try scrolling towards head, should not affect anything |
| Map<Item, Rect> before = mLayoutManager.collectChildCoordinates(); |
| if (config.mStackFromEnd) { |
| scrollToPositionWithOffset(mTestAdapter.getItemCount() - 1, |
| mLayoutManager.mOrientationHelper.getEnd() - 500); |
| } else { |
| scrollToPositionWithOffset(0, 20); |
| } |
| assertRectSetsEqual(config + " trying to over scroll with offset should be no-op", |
| before, mLayoutManager.collectChildCoordinates()); |
| // try offsetting some visible children |
| int testCount = 10; |
| while (testCount-- > 0) { |
| // get middle child |
| final View child = mLayoutManager.getChildAt(mLayoutManager.getChildCount() / 2); |
| final int position = mRecyclerView.getChildLayoutPosition(child); |
| final int startOffset = config.mReverseLayout ? |
| orientationHelper.getEndAfterPadding() - orientationHelper |
| .getDecoratedEnd(child) |
| : orientationHelper.getDecoratedStart(child) - orientationHelper |
| .getStartAfterPadding(); |
| final int scrollOffset = config.mStackFromEnd ? startOffset + startOffset / 2 |
| : startOffset / 2; |
| mLayoutManager.expectLayouts(1); |
| scrollToPositionWithOffset(position, scrollOffset); |
| mLayoutManager.waitForLayout(2); |
| final int finalOffset = config.mReverseLayout ? |
| orientationHelper.getEndAfterPadding() - orientationHelper |
| .getDecoratedEnd(child) |
| : orientationHelper.getDecoratedStart(child) - orientationHelper |
| .getStartAfterPadding(); |
| assertEquals(config + " scroll with offset on a visible child should work fine " + |
| " offset:" + finalOffset + " , existing offset:" + startOffset + ", " |
| + "child " + position, |
| scrollOffset, finalOffset); |
| } |
| |
| // try scrolling to invisible children |
| testCount = 10; |
| // we test above and below, one by one |
| int offsetMultiplier = -1; |
| while (testCount-- > 0) { |
| final TargetTuple target = findInvisibleTarget(config); |
| final String logPrefix = config + " " + target; |
| mLayoutManager.expectLayouts(1); |
| final int offset = offsetMultiplier |
| * orientationHelper.getDecoratedMeasurement(mLayoutManager.getChildAt(0)) / 3; |
| scrollToPositionWithOffset(target.mPosition, offset); |
| mLayoutManager.waitForLayout(2); |
| final View child = mLayoutManager.findViewByPosition(target.mPosition); |
| assertNotNull(logPrefix + " scrolling to a mPosition with offset " + offset |
| + " should layout it", child); |
| final Rect bounds = mLayoutManager.getViewBounds(child); |
| if (DEBUG) { |
| Log.d(TAG, logPrefix + " post scroll to invisible mPosition " + bounds + " in " |
| + layoutBounds + " with offset " + offset); |
| } |
| |
| if (config.mReverseLayout) { |
| assertEquals(logPrefix + " when scrolling with offset to an invisible in reverse " |
| + "layout, its end should align with recycler view's end - offset", |
| orientationHelper.getEndAfterPadding() - offset, |
| orientationHelper.getDecoratedEnd(child) |
| ); |
| } else { |
| assertEquals(logPrefix + " when scrolling with offset to an invisible child in normal" |
| + " layout its start should align with recycler view's start + " |
| + "offset", |
| orientationHelper.getStartAfterPadding() + offset, |
| orientationHelper.getDecoratedStart(child) |
| ); |
| } |
| offsetMultiplier *= -1; |
| } |
| } |
| |
| private TargetTuple findInvisibleTarget(Config config) { |
| int minPosition = Integer.MAX_VALUE, maxPosition = Integer.MIN_VALUE; |
| for (int i = 0; i < mLayoutManager.getChildCount(); i++) { |
| View child = mLayoutManager.getChildAt(i); |
| int position = mRecyclerView.getChildLayoutPosition(child); |
| if (position < minPosition) { |
| minPosition = position; |
| } |
| if (position > maxPosition) { |
| maxPosition = position; |
| } |
| } |
| final int tailTarget = maxPosition + |
| (mRecyclerView.getAdapter().getItemCount() - maxPosition) / 2; |
| final int headTarget = minPosition / 2; |
| final int target; |
| // where will the child come from ? |
| final int itemLayoutDirection; |
| if (Math.abs(tailTarget - maxPosition) > Math.abs(headTarget - minPosition)) { |
| target = tailTarget; |
| itemLayoutDirection = config.mReverseLayout ? LAYOUT_START : LAYOUT_END; |
| } else { |
| target = headTarget; |
| itemLayoutDirection = config.mReverseLayout ? LAYOUT_END : LAYOUT_START; |
| } |
| if (DEBUG) { |
| Log.d(TAG, |
| config + " target:" + target + " min:" + minPosition + ", max:" + maxPosition); |
| } |
| return new TargetTuple(target, itemLayoutDirection); |
| } |
| |
| public void stackFromEndTest(final Config config) throws Throwable { |
| final FrameLayout container = getRecyclerViewContainer(); |
| runTestOnUiThread(new Runnable() { |
| @Override |
| public void run() { |
| container.setPadding(0, 0, 0, 0); |
| } |
| }); |
| |
| setupByConfig(config, true); |
| int lastVisibleItemPosition = mLayoutManager.findLastVisibleItemPosition(); |
| int firstVisibleItemPosition = mLayoutManager.findFirstVisibleItemPosition(); |
| int lastCompletelyVisibleItemPosition = mLayoutManager.findLastCompletelyVisibleItemPosition(); |
| int firstCompletelyVisibleItemPosition = mLayoutManager.findFirstCompletelyVisibleItemPosition(); |
| mLayoutManager.expectLayouts(1); |
| // resize the recycler view to half |
| runTestOnUiThread(new Runnable() { |
| @Override |
| public void run() { |
| if (config.mOrientation == HORIZONTAL) { |
| container.setPadding(0, 0, container.getWidth() / 2, 0); |
| } else { |
| container.setPadding(0, 0, 0, container.getWidth() / 2); |
| } |
| } |
| }); |
| mLayoutManager.waitForLayout(1); |
| if (config.mStackFromEnd) { |
| assertEquals("[" + config + "]: last visible position should not change.", |
| lastVisibleItemPosition, mLayoutManager.findLastVisibleItemPosition()); |
| assertEquals("[" + config + "]: last completely visible position should not change", |
| lastCompletelyVisibleItemPosition, |
| mLayoutManager.findLastCompletelyVisibleItemPosition()); |
| } else { |
| assertEquals("[" + config + "]: first visible position should not change.", |
| firstVisibleItemPosition, mLayoutManager.findFirstVisibleItemPosition()); |
| assertEquals("[" + config + "]: last completely visible position should not change", |
| firstCompletelyVisibleItemPosition, |
| mLayoutManager.findFirstCompletelyVisibleItemPosition()); |
| } |
| } |
| |
| public void testScrollToPositionWithPredictive() throws Throwable { |
| scrollToPositionWithPredictive(0, LinearLayoutManager.INVALID_OFFSET); |
| removeRecyclerView(); |
| scrollToPositionWithPredictive(3, 20); |
| removeRecyclerView(); |
| scrollToPositionWithPredictive(Config.DEFAULT_ITEM_COUNT / 2, |
| LinearLayoutManager.INVALID_OFFSET); |
| removeRecyclerView(); |
| scrollToPositionWithPredictive(Config.DEFAULT_ITEM_COUNT / 2, 10); |
| } |
| |
| public void scrollToPositionWithPredictive(final int scrollPosition, final int scrollOffset) |
| throws Throwable { |
| setupByConfig(new Config(VERTICAL, false, false), true); |
| |
| mLayoutManager.mOnLayoutListener = new OnLayoutListener() { |
| @Override |
| void after(RecyclerView.Recycler recycler, RecyclerView.State state) { |
| if (state.isPreLayout()) { |
| assertEquals("pending scroll position should still be pending", |
| scrollPosition, mLayoutManager.mPendingScrollPosition); |
| if (scrollOffset != LinearLayoutManager.INVALID_OFFSET) { |
| assertEquals("pending scroll position offset should still be pending", |
| scrollOffset, mLayoutManager.mPendingScrollPositionOffset); |
| } |
| } else { |
| RecyclerView.ViewHolder vh = |
| mRecyclerView.findViewHolderForLayoutPosition(scrollPosition); |
| assertNotNull("scroll to position should work", vh); |
| if (scrollOffset != LinearLayoutManager.INVALID_OFFSET) { |
| assertEquals("scroll offset should be applied properly", |
| mLayoutManager.getPaddingTop() + scrollOffset + |
| ((RecyclerView.LayoutParams) vh.itemView |
| .getLayoutParams()).topMargin, |
| mLayoutManager.getDecoratedTop(vh.itemView)); |
| } |
| } |
| } |
| }; |
| mLayoutManager.expectLayouts(2); |
| runTestOnUiThread(new Runnable() { |
| @Override |
| public void run() { |
| try { |
| mTestAdapter.addAndNotify(0, 1); |
| if (scrollOffset == LinearLayoutManager.INVALID_OFFSET) { |
| mLayoutManager.scrollToPosition(scrollPosition); |
| } else { |
| mLayoutManager.scrollToPositionWithOffset(scrollPosition, |
| scrollOffset); |
| } |
| |
| } catch (Throwable throwable) { |
| throwable.printStackTrace(); |
| } |
| |
| } |
| }); |
| mLayoutManager.waitForLayout(2); |
| checkForMainThreadException(); |
| } |
| |
| private void waitForFirstLayout() throws Throwable { |
| mLayoutManager.expectLayouts(1); |
| setRecyclerView(mRecyclerView); |
| mLayoutManager.waitForLayout(2); |
| } |
| |
| public void testRecycleDuringAnimations() throws Throwable { |
| final AtomicInteger childCount = new AtomicInteger(0); |
| final TestAdapter adapter = new TestAdapter(300) { |
| @Override |
| public TestViewHolder onCreateViewHolder(ViewGroup parent, |
| int viewType) { |
| final int cnt = childCount.incrementAndGet(); |
| final TestViewHolder testViewHolder = super.onCreateViewHolder(parent, viewType); |
| if (DEBUG) { |
| Log.d(TAG, "CHILD_CNT(create):" + cnt + ", " + testViewHolder); |
| } |
| return testViewHolder; |
| } |
| }; |
| setupByConfig(new Config(VERTICAL, false, false).itemCount(300) |
| .adapter(adapter), true); |
| |
| final RecyclerView.RecycledViewPool pool = new RecyclerView.RecycledViewPool() { |
| @Override |
| public void putRecycledView(RecyclerView.ViewHolder scrap) { |
| super.putRecycledView(scrap); |
| int cnt = childCount.decrementAndGet(); |
| if (DEBUG) { |
| Log.d(TAG, "CHILD_CNT(put):" + cnt + ", " + scrap); |
| } |
| } |
| |
| @Override |
| public RecyclerView.ViewHolder getRecycledView(int viewType) { |
| final RecyclerView.ViewHolder recycledView = super.getRecycledView(viewType); |
| if (recycledView != null) { |
| final int cnt = childCount.incrementAndGet(); |
| if (DEBUG) { |
| Log.d(TAG, "CHILD_CNT(get):" + cnt + ", " + recycledView); |
| } |
| } |
| return recycledView; |
| } |
| }; |
| pool.setMaxRecycledViews(mTestAdapter.getItemViewType(0), 500); |
| mRecyclerView.setRecycledViewPool(pool); |
| |
| |
| // now keep adding children to trigger more children being created etc. |
| for (int i = 0; i < 100; i ++) { |
| adapter.addAndNotify(15, 1); |
| Thread.sleep(15); |
| } |
| getInstrumentation().waitForIdleSync(); |
| waitForAnimations(2); |
| assertEquals("Children count should add up", childCount.get(), |
| mRecyclerView.getChildCount() + mRecyclerView.mRecycler.mCachedViews.size()); |
| |
| // now trigger lots of add again, followed by a scroll to position |
| for (int i = 0; i < 100; i ++) { |
| adapter.addAndNotify(5 + (i % 3) * 3, 1); |
| Thread.sleep(25); |
| } |
| smoothScrollToPosition(mLayoutManager.findLastVisibleItemPosition() + 20); |
| waitForAnimations(2); |
| getInstrumentation().waitForIdleSync(); |
| assertEquals("Children count should add up", childCount.get(), |
| mRecyclerView.getChildCount() + mRecyclerView.mRecycler.mCachedViews.size()); |
| } |
| |
| |
| public void testGetFirstLastChildrenTest() throws Throwable { |
| for (Config config : mBaseVariations) { |
| getFirstLastChildrenTest(config); |
| } |
| } |
| |
| public void testDontRecycleChildrenOnDetach() throws Throwable { |
| setupByConfig(new Config().recycleChildrenOnDetach(false), true); |
| runTestOnUiThread(new Runnable() { |
| @Override |
| public void run() { |
| int recyclerSize = mRecyclerView.mRecycler.getRecycledViewPool().size(); |
| mRecyclerView.setLayoutManager(new TestLayoutManager()); |
| assertEquals("No views are recycled", recyclerSize, |
| mRecyclerView.mRecycler.getRecycledViewPool().size()); |
| } |
| }); |
| } |
| |
| public void testRecycleChildrenOnDetach() throws Throwable { |
| setupByConfig(new Config().recycleChildrenOnDetach(true), true); |
| final int childCount = mLayoutManager.getChildCount(); |
| runTestOnUiThread(new Runnable() { |
| @Override |
| public void run() { |
| int recyclerSize = mRecyclerView.mRecycler.getRecycledViewPool().size(); |
| mRecyclerView.mRecycler.getRecycledViewPool().setMaxRecycledViews( |
| mTestAdapter.getItemViewType(0), recyclerSize + childCount); |
| mRecyclerView.setLayoutManager(new TestLayoutManager()); |
| assertEquals("All children should be recycled", childCount + recyclerSize, |
| mRecyclerView.mRecycler.getRecycledViewPool().size()); |
| } |
| }); |
| } |
| |
| public void getFirstLastChildrenTest(final Config config) throws Throwable { |
| setupByConfig(config, true); |
| Runnable viewInBoundsTest = new Runnable() { |
| @Override |
| public void run() { |
| VisibleChildren visibleChildren = mLayoutManager.traverseAndFindVisibleChildren(); |
| final String boundsLog = mLayoutManager.getBoundsLog(); |
| assertEquals(config + ":\nfirst visible child should match traversal result\n" |
| + boundsLog, visibleChildren.firstVisiblePosition, |
| mLayoutManager.findFirstVisibleItemPosition() |
| ); |
| assertEquals( |
| config + ":\nfirst fully visible child should match traversal result\n" |
| + boundsLog, visibleChildren.firstFullyVisiblePosition, |
| mLayoutManager.findFirstCompletelyVisibleItemPosition() |
| ); |
| |
| assertEquals(config + ":\nlast visible child should match traversal result\n" |
| + boundsLog, visibleChildren.lastVisiblePosition, |
| mLayoutManager.findLastVisibleItemPosition() |
| ); |
| assertEquals( |
| config + ":\nlast fully visible child should match traversal result\n" |
| + boundsLog, visibleChildren.lastFullyVisiblePosition, |
| mLayoutManager.findLastCompletelyVisibleItemPosition() |
| ); |
| } |
| }; |
| runTestOnUiThread(viewInBoundsTest); |
| // smooth scroll to end of the list and keep testing meanwhile. This will test pre-caching |
| // case |
| final int scrollPosition = config.mStackFromEnd ? 0 : mTestAdapter.getItemCount(); |
| runTestOnUiThread(new Runnable() { |
| @Override |
| public void run() { |
| mRecyclerView.smoothScrollToPosition(scrollPosition); |
| } |
| }); |
| while (mLayoutManager.isSmoothScrolling() || |
| mRecyclerView.getScrollState() != RecyclerView.SCROLL_STATE_IDLE) { |
| runTestOnUiThread(viewInBoundsTest); |
| Thread.sleep(400); |
| } |
| // delete all items |
| mLayoutManager.expectLayouts(2); |
| mTestAdapter.deleteAndNotify(0, mTestAdapter.getItemCount()); |
| mLayoutManager.waitForLayout(2); |
| // test empty case |
| runTestOnUiThread(viewInBoundsTest); |
| // set a new adapter with huge items to test full bounds check |
| mLayoutManager.expectLayouts(1); |
| final int totalSpace = mLayoutManager.mOrientationHelper.getTotalSpace(); |
| final TestAdapter newAdapter = new TestAdapter(100) { |
| @Override |
| public void onBindViewHolder(TestViewHolder holder, |
| int position) { |
| super.onBindViewHolder(holder, position); |
| if (config.mOrientation == HORIZONTAL) { |
| holder.itemView.setMinimumWidth(totalSpace + 5); |
| } else { |
| holder.itemView.setMinimumHeight(totalSpace + 5); |
| } |
| } |
| }; |
| runTestOnUiThread(new Runnable() { |
| @Override |
| public void run() { |
| mRecyclerView.setAdapter(newAdapter); |
| } |
| }); |
| mLayoutManager.waitForLayout(2); |
| runTestOnUiThread(viewInBoundsTest); |
| } |
| |
| public void testSavedState() throws Throwable { |
| PostLayoutRunnable[] postLayoutOptions = new PostLayoutRunnable[]{ |
| new PostLayoutRunnable() { |
| @Override |
| public void run() throws Throwable { |
| // do nothing |
| } |
| |
| @Override |
| public String describe() { |
| return "doing nothing"; |
| } |
| }, |
| new PostLayoutRunnable() { |
| @Override |
| public void run() throws Throwable { |
| mLayoutManager.expectLayouts(1); |
| scrollToPosition(mTestAdapter.getItemCount() * 3 / 4); |
| mLayoutManager.waitForLayout(2); |
| } |
| |
| @Override |
| public String describe() { |
| return "scroll to position"; |
| } |
| }, |
| new PostLayoutRunnable() { |
| @Override |
| public void run() throws Throwable { |
| mLayoutManager.expectLayouts(1); |
| scrollToPositionWithOffset(mTestAdapter.getItemCount() * 1 / 3, |
| 50); |
| mLayoutManager.waitForLayout(2); |
| } |
| |
| @Override |
| public String describe() { |
| return "scroll to position with positive offset"; |
| } |
| }, |
| new PostLayoutRunnable() { |
| @Override |
| public void run() throws Throwable { |
| mLayoutManager.expectLayouts(1); |
| scrollToPositionWithOffset(mTestAdapter.getItemCount() * 2 / 3, |
| -10); // Some tests break if this value is below the item height. |
| mLayoutManager.waitForLayout(2); |
| } |
| |
| @Override |
| public String describe() { |
| return "scroll to position with negative offset"; |
| } |
| } |
| }; |
| |
| PostRestoreRunnable[] postRestoreOptions = new PostRestoreRunnable[]{ |
| new PostRestoreRunnable() { |
| @Override |
| public String describe() { |
| return "Doing nothing"; |
| } |
| }, |
| new PostRestoreRunnable() { |
| @Override |
| void onAfterRestore(Config config) throws Throwable { |
| // update config as well so that restore assertions will work |
| config.mOrientation = 1 - config.mOrientation; |
| mLayoutManager.setOrientation(config.mOrientation); |
| } |
| |
| @Override |
| boolean shouldLayoutMatch(Config config) { |
| return config.mItemCount == 0; |
| } |
| |
| @Override |
| public String describe() { |
| return "Changing orientation"; |
| } |
| }, |
| new PostRestoreRunnable() { |
| @Override |
| void onAfterRestore(Config config) throws Throwable { |
| config.mStackFromEnd = !config.mStackFromEnd; |
| mLayoutManager.setStackFromEnd(config.mStackFromEnd); |
| } |
| |
| @Override |
| boolean shouldLayoutMatch(Config config) { |
| return true; //stack from end should not move items on change |
| } |
| |
| @Override |
| public String describe() { |
| return "Changing stack from end"; |
| } |
| }, |
| new PostRestoreRunnable() { |
| @Override |
| void onAfterRestore(Config config) throws Throwable { |
| config.mReverseLayout = !config.mReverseLayout; |
| mLayoutManager.setReverseLayout(config.mReverseLayout); |
| } |
| |
| @Override |
| boolean shouldLayoutMatch(Config config) { |
| return config.mItemCount == 0; |
| } |
| |
| @Override |
| public String describe() { |
| return "Changing reverse layout"; |
| } |
| }, |
| new PostRestoreRunnable() { |
| @Override |
| void onAfterRestore(Config config) throws Throwable { |
| config.mRecycleChildrenOnDetach = !config.mRecycleChildrenOnDetach; |
| mLayoutManager.setRecycleChildrenOnDetach(config.mRecycleChildrenOnDetach); |
| } |
| |
| @Override |
| boolean shouldLayoutMatch(Config config) { |
| return true; |
| } |
| |
| @Override |
| String describe() { |
| return "Change should recycle children"; |
| } |
| }, |
| new PostRestoreRunnable() { |
| int position; |
| @Override |
| void onAfterRestore(Config config) throws Throwable { |
| position = mTestAdapter.getItemCount() / 2; |
| mLayoutManager.scrollToPosition(position); |
| } |
| |
| @Override |
| boolean shouldLayoutMatch(Config config) { |
| return mTestAdapter.getItemCount() == 0; |
| } |
| |
| @Override |
| String describe() { |
| return "Scroll to position " + position ; |
| } |
| |
| @Override |
| void onAfterReLayout(Config config) { |
| if (mTestAdapter.getItemCount() > 0) { |
| assertEquals(config + ":scrolled view should be last completely visible", |
| position, |
| config.mStackFromEnd ? |
| mLayoutManager.findLastCompletelyVisibleItemPosition() |
| : mLayoutManager.findFirstCompletelyVisibleItemPosition()); |
| } |
| } |
| } |
| }; |
| boolean[] waitForLayoutOptions = new boolean[]{true, false}; |
| boolean[] loadDataAfterRestoreOptions = new boolean[]{true, false}; |
| List<Config> variations = addConfigVariation(mBaseVariations, "mItemCount", 0, 300); |
| variations = addConfigVariation(variations, "mRecycleChildrenOnDetach", true); |
| for (Config config : variations) { |
| for (PostLayoutRunnable postLayoutRunnable : postLayoutOptions) { |
| for (boolean waitForLayout : waitForLayoutOptions) { |
| for (PostRestoreRunnable postRestoreRunnable : postRestoreOptions) { |
| for (boolean loadDataAfterRestore : loadDataAfterRestoreOptions) { |
| savedStateTest((Config) config.clone(), waitForLayout, |
| loadDataAfterRestore, postLayoutRunnable, postRestoreRunnable); |
| removeRecyclerView(); |
| } |
| } |
| |
| } |
| } |
| } |
| } |
| |
| public void savedStateTest(Config config, boolean waitForLayout, boolean loadDataAfterRestore, |
| PostLayoutRunnable postLayoutOperation, PostRestoreRunnable postRestoreOperation) |
| throws Throwable { |
| if (DEBUG) { |
| Log.d(TAG, "testing saved state with wait for layout = " + waitForLayout + " config " + |
| config + " post layout action " + postLayoutOperation.describe() + |
| "post restore action " + postRestoreOperation.describe()); |
| } |
| setupByConfig(config, false); |
| if (waitForLayout) { |
| waitForFirstLayout(); |
| postLayoutOperation.run(); |
| } |
| Map<Item, Rect> before = mLayoutManager.collectChildCoordinates(); |
| Parcelable savedState = mRecyclerView.onSaveInstanceState(); |
| // we append a suffix to the parcelable to test out of bounds |
| String parcelSuffix = UUID.randomUUID().toString(); |
| Parcel parcel = Parcel.obtain(); |
| savedState.writeToParcel(parcel, 0); |
| parcel.writeString(parcelSuffix); |
| removeRecyclerView(); |
| // reset for reading |
| parcel.setDataPosition(0); |
| // re-create |
| savedState = RecyclerView.SavedState.CREATOR.createFromParcel(parcel); |
| removeRecyclerView(); |
| |
| final int itemCount = mTestAdapter.getItemCount(); |
| if (loadDataAfterRestore) { |
| mTestAdapter.deleteAndNotify(0, itemCount); |
| } |
| |
| RecyclerView restored = new RecyclerView(getActivity()); |
| // this config should be no op. |
| mLayoutManager = new WrappedLinearLayoutManager(getActivity(), |
| config.mOrientation, config.mReverseLayout); |
| mLayoutManager.setStackFromEnd(config.mStackFromEnd); |
| restored.setLayoutManager(mLayoutManager); |
| // use the same adapter for Rect matching |
| restored.setAdapter(mTestAdapter); |
| restored.onRestoreInstanceState(savedState); |
| |
| if (loadDataAfterRestore) { |
| mTestAdapter.addAndNotify(itemCount); |
| } |
| |
| postRestoreOperation.onAfterRestore(config); |
| assertEquals("Parcel reading should not go out of bounds", parcelSuffix, |
| parcel.readString()); |
| mLayoutManager.expectLayouts(1); |
| setRecyclerView(restored); |
| mLayoutManager.waitForLayout(2); |
| // calculate prefix here instead of above to include post restore changes |
| final String logPrefix = config + "\npostLayout:" + postLayoutOperation.describe() + |
| "\npostRestore:" + postRestoreOperation.describe() + "\n"; |
| assertEquals(logPrefix + " on saved state, reverse layout should be preserved", |
| config.mReverseLayout, mLayoutManager.getReverseLayout()); |
| assertEquals(logPrefix + " on saved state, orientation should be preserved", |
| config.mOrientation, mLayoutManager.getOrientation()); |
| assertEquals(logPrefix + " on saved state, stack from end should be preserved", |
| config.mStackFromEnd, mLayoutManager.getStackFromEnd()); |
| if (waitForLayout) { |
| final boolean strictItemEquality = !loadDataAfterRestore; |
| if (postRestoreOperation.shouldLayoutMatch(config)) { |
| assertRectSetsEqual( |
| logPrefix + ": on restore, previous view positions should be preserved", |
| before, mLayoutManager.collectChildCoordinates(), strictItemEquality); |
| } else { |
| assertRectSetsNotEqual( |
| logPrefix |
| + ": on restore with changes, previous view positions should NOT " |
| + "be preserved", |
| before, mLayoutManager.collectChildCoordinates(), strictItemEquality); |
| } |
| postRestoreOperation.onAfterReLayout(config); |
| } |
| } |
| |
| public void testScrollAndClear() throws Throwable { |
| setupByConfig(new Config(), true); |
| |
| assertTrue("Children not laid out", mLayoutManager.collectChildCoordinates().size() > 0); |
| |
| mLayoutManager.expectLayouts(1); |
| runTestOnUiThread(new Runnable() { |
| @Override |
| public void run() { |
| mLayoutManager.scrollToPositionWithOffset(1, 0); |
| mTestAdapter.clearOnUIThread(); |
| } |
| }); |
| mLayoutManager.waitForLayout(2); |
| |
| assertEquals("Remaining children", 0, mLayoutManager.collectChildCoordinates().size()); |
| } |
| |
| |
| void scrollToPositionWithOffset(final int position, final int offset) throws Throwable { |
| runTestOnUiThread(new Runnable() { |
| @Override |
| public void run() { |
| mLayoutManager.scrollToPositionWithOffset(position, offset); |
| } |
| }); |
| } |
| |
| public void assertRectSetsNotEqual(String message, Map<Item, Rect> before, |
| Map<Item, Rect> after, boolean strictItemEquality) { |
| Throwable throwable = null; |
| try { |
| assertRectSetsEqual("NOT " + message, before, after, strictItemEquality); |
| } catch (Throwable t) { |
| throwable = t; |
| } |
| assertNotNull(message + "\ntwo layout should be different", throwable); |
| } |
| |
| public void assertRectSetsEqual(String message, Map<Item, Rect> before, Map<Item, Rect> after) { |
| assertRectSetsEqual(message, before, after, true); |
| } |
| |
| public void assertRectSetsEqual(String message, Map<Item, Rect> before, Map<Item, Rect> after, |
| boolean strictItemEquality) { |
| StringBuilder sb = new StringBuilder(); |
| sb.append("checking rectangle equality.\n"); |
| sb.append("before:\n"); |
| for (Map.Entry<Item, Rect> entry : before.entrySet()) { |
| sb.append(entry.getKey().mAdapterIndex + ":" + entry.getValue()).append("\n"); |
| } |
| sb.append("after:\n"); |
| for (Map.Entry<Item, Rect> entry : after.entrySet()) { |
| sb.append(entry.getKey().mAdapterIndex + ":" + entry.getValue()).append("\n"); |
| } |
| message = message + "\n" + sb.toString(); |
| assertEquals(message + ":\nitem counts should be equal", before.size() |
| , after.size()); |
| for (Map.Entry<Item, Rect> entry : before.entrySet()) { |
| final Item beforeItem = entry.getKey(); |
| Rect afterRect = null; |
| if (strictItemEquality) { |
| afterRect = after.get(beforeItem); |
| assertNotNull(message + ":\nSame item should be visible after simple re-layout", |
| afterRect); |
| } else { |
| for (Map.Entry<Item, Rect> afterEntry : after.entrySet()) { |
| final Item afterItem = afterEntry.getKey(); |
| if (afterItem.mAdapterIndex == beforeItem.mAdapterIndex) { |
| afterRect = afterEntry.getValue(); |
| break; |
| } |
| } |
| assertNotNull(message + ":\nItem with same adapter index should be visible " + |
| "after simple re-layout", |
| afterRect); |
| } |
| assertEquals(message + ":\nItem should be laid out at the same coordinates", |
| entry.getValue(), afterRect); |
| } |
| } |
| |
| public void testAccessibilityPositions() throws Throwable { |
| setupByConfig(new Config(VERTICAL, false, false), true); |
| final AccessibilityDelegateCompat delegateCompat = mRecyclerView |
| .getCompatAccessibilityDelegate(); |
| final AccessibilityEvent event = AccessibilityEvent.obtain(); |
| runTestOnUiThread(new Runnable() { |
| @Override |
| public void run() { |
| delegateCompat.onInitializeAccessibilityEvent(mRecyclerView, event); |
| } |
| }); |
| final AccessibilityRecordCompat record = AccessibilityEventCompat |
| .asRecord(event); |
| assertEquals("result should have first position", |
| record.getFromIndex(), |
| mLayoutManager.findFirstVisibleItemPosition()); |
| assertEquals("result should have last position", |
| record.getToIndex(), |
| mLayoutManager.findLastVisibleItemPosition()); |
| } |
| |
| public void testPrepareForDrop() throws Throwable { |
| SelectTargetChildren[] selectors = new SelectTargetChildren[] { |
| new SelectTargetChildren() { |
| @Override |
| public int[] selectTargetChildren(int childCount) { |
| return new int[]{1, 0}; |
| } |
| }, |
| new SelectTargetChildren() { |
| @Override |
| public int[] selectTargetChildren(int childCount) { |
| return new int[]{0, 1}; |
| } |
| }, |
| new SelectTargetChildren() { |
| @Override |
| public int[] selectTargetChildren(int childCount) { |
| return new int[]{childCount - 1, childCount - 2}; |
| } |
| }, |
| new SelectTargetChildren() { |
| @Override |
| public int[] selectTargetChildren(int childCount) { |
| return new int[]{childCount - 2, childCount - 1}; |
| } |
| }, |
| new SelectTargetChildren() { |
| @Override |
| public int[] selectTargetChildren(int childCount) { |
| return new int[]{childCount / 2, childCount / 2 + 1}; |
| } |
| }, |
| new SelectTargetChildren() { |
| @Override |
| public int[] selectTargetChildren(int childCount) { |
| return new int[]{childCount / 2 + 1, childCount / 2}; |
| } |
| } |
| }; |
| for (SelectTargetChildren selector : selectors) { |
| for (Config config : mBaseVariations) { |
| prepareForDropTest(config, selector); |
| removeRecyclerView(); |
| } |
| } |
| } |
| |
| public void prepareForDropTest(final Config config, SelectTargetChildren selectTargetChildren) |
| throws Throwable { |
| config.mTestAdapter = new TestAdapter(100) { |
| @Override |
| public void onBindViewHolder(TestViewHolder holder, |
| int position) { |
| super.onBindViewHolder(holder, position); |
| if (config.mOrientation == HORIZONTAL) { |
| final int base = mRecyclerView.getWidth() / 5; |
| final int itemRand = holder.mBoundItem.mText.hashCode() % base; |
| holder.itemView.setMinimumWidth(base + itemRand); |
| } else { |
| final int base = mRecyclerView.getHeight() / 5; |
| final int itemRand = holder.mBoundItem.mText.hashCode() % base; |
| holder.itemView.setMinimumHeight(base + itemRand); |
| } |
| } |
| }; |
| setupByConfig(config, true); |
| mLayoutManager.expectLayouts(1); |
| scrollToPosition(mTestAdapter.getItemCount() / 2); |
| mLayoutManager.waitForLayout(1); |
| int[] positions = selectTargetChildren.selectTargetChildren(mRecyclerView.getChildCount()); |
| final View fromChild = mLayoutManager.getChildAt(positions[0]); |
| final int fromPos = mLayoutManager.getPosition(fromChild); |
| final View onChild = mLayoutManager.getChildAt(positions[1]); |
| final int toPos = mLayoutManager.getPosition(onChild); |
| final OrientationHelper helper = mLayoutManager.mOrientationHelper; |
| final int dragCoordinate; |
| final boolean towardsHead = toPos < fromPos; |
| final int referenceLine; |
| if (config.mReverseLayout == towardsHead) { |
| referenceLine = helper.getDecoratedEnd(onChild); |
| dragCoordinate = referenceLine + 3 - |
| helper.getDecoratedMeasurement(fromChild); |
| } else { |
| referenceLine = helper.getDecoratedStart(onChild); |
| dragCoordinate = referenceLine - 3; |
| } |
| mLayoutManager.expectLayouts(2); |
| |
| final int x,y; |
| if (config.mOrientation == HORIZONTAL) { |
| x = dragCoordinate; |
| y = fromChild.getTop(); |
| } else { |
| y = dragCoordinate; |
| x = fromChild.getLeft(); |
| } |
| runTestOnUiThread(new Runnable() { |
| @Override |
| public void run() { |
| mTestAdapter.moveInUIThread(fromPos, toPos); |
| mTestAdapter.notifyItemMoved(fromPos, toPos); |
| mLayoutManager.prepareForDrop(fromChild, onChild, x, y); |
| } |
| }); |
| mLayoutManager.waitForLayout(2); |
| |
| assertSame(fromChild, mRecyclerView.findViewHolderForAdapterPosition(toPos).itemView); |
| // make sure it has the position we wanted |
| if (config.mReverseLayout == towardsHead) { |
| assertEquals(referenceLine, helper.getDecoratedEnd(fromChild)); |
| } else { |
| assertEquals(referenceLine, helper.getDecoratedStart(fromChild)); |
| } |
| } |
| |
| static class VisibleChildren { |
| |
| int firstVisiblePosition = RecyclerView.NO_POSITION; |
| |
| int firstFullyVisiblePosition = RecyclerView.NO_POSITION; |
| |
| int lastVisiblePosition = RecyclerView.NO_POSITION; |
| |
| int lastFullyVisiblePosition = RecyclerView.NO_POSITION; |
| |
| @Override |
| public String toString() { |
| return "VisibleChildren{" + |
| "firstVisiblePosition=" + firstVisiblePosition + |
| ", firstFullyVisiblePosition=" + firstFullyVisiblePosition + |
| ", lastVisiblePosition=" + lastVisiblePosition + |
| ", lastFullyVisiblePosition=" + lastFullyVisiblePosition + |
| '}'; |
| } |
| } |
| |
| abstract private class PostLayoutRunnable { |
| |
| abstract void run() throws Throwable; |
| |
| abstract String describe(); |
| } |
| |
| abstract private class PostRestoreRunnable { |
| |
| void onAfterRestore(Config config) throws Throwable { |
| } |
| |
| abstract String describe(); |
| |
| boolean shouldLayoutMatch(Config config) { |
| return true; |
| } |
| |
| void onAfterReLayout(Config config) { |
| |
| }; |
| } |
| |
| class WrappedLinearLayoutManager extends LinearLayoutManager { |
| |
| CountDownLatch layoutLatch; |
| |
| OrientationHelper mSecondaryOrientation; |
| |
| OnLayoutListener mOnLayoutListener; |
| |
| public WrappedLinearLayoutManager(Context context, int orientation, boolean reverseLayout) { |
| super(context, orientation, reverseLayout); |
| } |
| |
| public void expectLayouts(int count) { |
| layoutLatch = new CountDownLatch(count); |
| } |
| |
| public void waitForLayout(long timeout) throws InterruptedException { |
| waitForLayout(timeout, TimeUnit.SECONDS); |
| } |
| |
| @Override |
| public void setOrientation(int orientation) { |
| super.setOrientation(orientation); |
| mSecondaryOrientation = null; |
| } |
| |
| @Override |
| public void removeAndRecycleView(View child, RecyclerView.Recycler recycler) { |
| if (DEBUG) { |
| Log.d(TAG, "recycling view " + mRecyclerView.getChildViewHolder(child)); |
| } |
| super.removeAndRecycleView(child, recycler); |
| } |
| |
| @Override |
| public void removeAndRecycleViewAt(int index, RecyclerView.Recycler recycler) { |
| if (DEBUG) { |
| Log.d(TAG, "recycling view at" + mRecyclerView.getChildViewHolder(getChildAt(index))); |
| } |
| super.removeAndRecycleViewAt(index, recycler); |
| } |
| |
| @Override |
| void ensureLayoutState() { |
| super.ensureLayoutState(); |
| if (mSecondaryOrientation == null) { |
| mSecondaryOrientation = OrientationHelper.createOrientationHelper(this, |
| 1 - getOrientation()); |
| } |
| } |
| |
| private void waitForLayout(long timeout, TimeUnit timeUnit) throws InterruptedException { |
| layoutLatch.await(timeout * (DEBUG ? 100 : 1), timeUnit); |
| assertEquals("all expected layouts should be executed at the expected time", |
| 0, layoutLatch.getCount()); |
| getInstrumentation().waitForIdleSync(); |
| } |
| |
| @Override |
| LayoutState createLayoutState() { |
| return new LayoutState() { |
| @Override |
| View next(RecyclerView.Recycler recycler) { |
| final boolean hadMore = hasMore(mRecyclerView.mState); |
| final int position = mCurrentPosition; |
| View next = super.next(recycler); |
| assertEquals("if has more, should return a view", hadMore, next != null); |
| assertEquals("position of the returned view must match current position", |
| position, RecyclerView.getChildViewHolderInt(next).getLayoutPosition()); |
| return next; |
| } |
| }; |
| } |
| |
| public String getBoundsLog() { |
| StringBuilder sb = new StringBuilder(); |
| sb.append("view bounds:[start:").append(mOrientationHelper.getStartAfterPadding()) |
| .append(",").append(" end").append(mOrientationHelper.getEndAfterPadding()); |
| sb.append("\nchildren bounds\n"); |
| final int childCount = getChildCount(); |
| for (int i = 0; i < childCount; i++) { |
| View child = getChildAt(i); |
| sb.append("child (ind:").append(i).append(", pos:").append(getPosition(child)) |
| .append("[").append("start:").append( |
| mOrientationHelper.getDecoratedStart(child)).append(", end:") |
| .append(mOrientationHelper.getDecoratedEnd(child)).append("]\n"); |
| } |
| return sb.toString(); |
| } |
| |
| public void waitForAnimationsToEnd(int timeoutInSeconds) throws InterruptedException { |
| RecyclerView.ItemAnimator itemAnimator = mRecyclerView.getItemAnimator(); |
| if (itemAnimator == null) { |
| return; |
| } |
| final CountDownLatch latch = new CountDownLatch(1); |
| final boolean running = itemAnimator.isRunning( |
| new RecyclerView.ItemAnimator.ItemAnimatorFinishedListener() { |
| @Override |
| public void onAnimationsFinished() { |
| latch.countDown(); |
| } |
| } |
| ); |
| if (running) { |
| latch.await(timeoutInSeconds, TimeUnit.SECONDS); |
| } |
| } |
| |
| public VisibleChildren traverseAndFindVisibleChildren() { |
| int childCount = getChildCount(); |
| final VisibleChildren visibleChildren = new VisibleChildren(); |
| final int start = mOrientationHelper.getStartAfterPadding(); |
| final int end = mOrientationHelper.getEndAfterPadding(); |
| for (int i = 0; i < childCount; i++) { |
| View child = getChildAt(i); |
| final int childStart = mOrientationHelper.getDecoratedStart(child); |
| final int childEnd = mOrientationHelper.getDecoratedEnd(child); |
| final boolean fullyVisible = childStart >= start && childEnd <= end; |
| final boolean hidden = childEnd <= start || childStart >= end; |
| if (hidden) { |
| continue; |
| } |
| final int position = getPosition(child); |
| if (fullyVisible) { |
| if (position < visibleChildren.firstFullyVisiblePosition || |
| visibleChildren.firstFullyVisiblePosition == RecyclerView.NO_POSITION) { |
| visibleChildren.firstFullyVisiblePosition = position; |
| } |
| |
| if (position > visibleChildren.lastFullyVisiblePosition) { |
| visibleChildren.lastFullyVisiblePosition = position; |
| } |
| } |
| |
| if (position < visibleChildren.firstVisiblePosition || |
| visibleChildren.firstVisiblePosition == RecyclerView.NO_POSITION) { |
| visibleChildren.firstVisiblePosition = position; |
| } |
| |
| if (position > visibleChildren.lastVisiblePosition) { |
| visibleChildren.lastVisiblePosition = position; |
| } |
| |
| } |
| return visibleChildren; |
| } |
| |
| Rect getViewBounds(View view) { |
| if (getOrientation() == HORIZONTAL) { |
| return new Rect( |
| mOrientationHelper.getDecoratedStart(view), |
| mSecondaryOrientation.getDecoratedStart(view), |
| mOrientationHelper.getDecoratedEnd(view), |
| mSecondaryOrientation.getDecoratedEnd(view)); |
| } else { |
| return new Rect( |
| mSecondaryOrientation.getDecoratedStart(view), |
| mOrientationHelper.getDecoratedStart(view), |
| mSecondaryOrientation.getDecoratedEnd(view), |
| mOrientationHelper.getDecoratedEnd(view)); |
| } |
| |
| } |
| |
| Map<Item, Rect> collectChildCoordinates() throws Throwable { |
| final Map<Item, Rect> items = new LinkedHashMap<Item, Rect>(); |
| runTestOnUiThread(new Runnable() { |
| @Override |
| public void run() { |
| final int childCount = getChildCount(); |
| Rect layoutBounds = new Rect(0, 0, |
| mLayoutManager.getWidth(), mLayoutManager.getHeight()); |
| for (int i = 0; i < childCount; i++) { |
| View child = getChildAt(i); |
| RecyclerView.LayoutParams lp = (RecyclerView.LayoutParams) child |
| .getLayoutParams(); |
| TestViewHolder vh = (TestViewHolder) lp.mViewHolder; |
| Rect childBounds = getViewBounds(child); |
| if (new Rect(childBounds).intersect(layoutBounds)) { |
| items.put(vh.mBoundItem, childBounds); |
| } |
| } |
| } |
| }); |
| return items; |
| } |
| |
| @Override |
| public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) { |
| try { |
| if (mOnLayoutListener != null) { |
| mOnLayoutListener.before(recycler, state); |
| } |
| super.onLayoutChildren(recycler, state); |
| if (mOnLayoutListener != null) { |
| mOnLayoutListener.after(recycler, state); |
| } |
| } catch (Throwable t) { |
| postExceptionToInstrumentation(t); |
| } |
| layoutLatch.countDown(); |
| } |
| |
| |
| } |
| |
| static class OnLayoutListener { |
| void before(RecyclerView.Recycler recycler, RecyclerView.State state){} |
| void after(RecyclerView.Recycler recycler, RecyclerView.State state){} |
| } |
| |
| static class Config implements Cloneable { |
| |
| private static final int DEFAULT_ITEM_COUNT = 100; |
| |
| private boolean mStackFromEnd; |
| |
| int mOrientation = VERTICAL; |
| |
| boolean mReverseLayout = false; |
| |
| boolean mRecycleChildrenOnDetach = false; |
| |
| int mItemCount = DEFAULT_ITEM_COUNT; |
| |
| TestAdapter mTestAdapter; |
| |
| Config(int orientation, boolean reverseLayout, boolean stackFromEnd) { |
| mOrientation = orientation; |
| mReverseLayout = reverseLayout; |
| mStackFromEnd = stackFromEnd; |
| } |
| |
| public Config() { |
| |
| } |
| |
| Config adapter(TestAdapter adapter) { |
| mTestAdapter = adapter; |
| return this; |
| } |
| |
| Config recycleChildrenOnDetach(boolean recycleChildrenOnDetach) { |
| mRecycleChildrenOnDetach = recycleChildrenOnDetach; |
| return this; |
| } |
| |
| Config orientation(int orientation) { |
| mOrientation = orientation; |
| return this; |
| } |
| |
| Config stackFromBottom(boolean stackFromBottom) { |
| mStackFromEnd = stackFromBottom; |
| return this; |
| } |
| |
| Config reverseLayout(boolean reverseLayout) { |
| mReverseLayout = reverseLayout; |
| return this; |
| } |
| |
| public Config itemCount(int itemCount) { |
| mItemCount = itemCount; |
| return this; |
| } |
| |
| // required by convention |
| @Override |
| public Object clone() throws CloneNotSupportedException { |
| return super.clone(); |
| } |
| |
| @Override |
| public String toString() { |
| return "Config{" + |
| "mStackFromEnd=" + mStackFromEnd + |
| ", mOrientation=" + mOrientation + |
| ", mReverseLayout=" + mReverseLayout + |
| ", mRecycleChildrenOnDetach=" + mRecycleChildrenOnDetach + |
| ", mItemCount=" + mItemCount + |
| '}'; |
| } |
| } |
| |
| private interface SelectTargetChildren { |
| int[] selectTargetChildren(int childCount); |
| } |
| } |