| /* |
| * 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.Debug; |
| import android.support.v4.view.AccessibilityDelegateCompat; |
| import android.support.v4.view.accessibility.AccessibilityNodeInfoCompat; |
| import android.util.Log; |
| import android.util.SparseIntArray; |
| import android.view.View; |
| import android.view.ViewGroup; |
| |
| import java.util.ArrayList; |
| import java.util.Arrays; |
| import java.util.BitSet; |
| import java.util.HashMap; |
| import java.util.HashSet; |
| import java.util.List; |
| import java.util.Map; |
| import java.util.Set; |
| import java.util.concurrent.CountDownLatch; |
| import java.util.concurrent.atomic.AtomicBoolean; |
| |
| import static android.support.v7.widget.LinearLayoutManager.HORIZONTAL; |
| import static android.support.v7.widget.LinearLayoutManager.VERTICAL; |
| import static java.util.concurrent.TimeUnit.SECONDS; |
| |
| public class GridLayoutManagerTest extends BaseRecyclerViewInstrumentationTest { |
| |
| static final String TAG = "GridLayoutManagerTest"; |
| |
| static final boolean DEBUG = false; |
| |
| WrappedGridLayoutManager mGlm; |
| |
| GridTestAdapter mAdapter; |
| |
| 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 (int spanCount : new int[]{1, 3, 4}) { |
| mBaseVariations.add(new Config(spanCount, orientation, reverseLayout)); |
| } |
| } |
| } |
| } |
| |
| public RecyclerView setupBasic(Config config) throws Throwable { |
| return setupBasic(config, new GridTestAdapter(config.mItemCount)); |
| } |
| |
| public RecyclerView setupBasic(Config config, GridTestAdapter testAdapter) throws Throwable { |
| RecyclerView recyclerView = new RecyclerView(getActivity()); |
| mAdapter = testAdapter; |
| mGlm = new WrappedGridLayoutManager(getActivity(), config.mSpanCount, config.mOrientation, |
| config.mReverseLayout); |
| mAdapter.assignSpanSizeLookup(mGlm); |
| recyclerView.setAdapter(mAdapter); |
| recyclerView.setLayoutManager(mGlm); |
| return recyclerView; |
| } |
| |
| public void waitForFirstLayout(RecyclerView recyclerView) throws Throwable { |
| mGlm.expectLayout(1); |
| setRecyclerView(recyclerView); |
| mGlm.waitForLayout(2); |
| } |
| |
| public void testPredictiveSpanLookup1() throws Throwable { |
| predictiveSpanLookupTest(0, false); |
| } |
| |
| public void testPredictiveSpanLookup2() throws Throwable { |
| predictiveSpanLookupTest(0, true); |
| } |
| |
| public void testPredictiveSpanLookup3() throws Throwable { |
| predictiveSpanLookupTest(1, false); |
| } |
| |
| public void testPredictiveSpanLookup4() throws Throwable { |
| predictiveSpanLookupTest(1, true); |
| } |
| |
| public void predictiveSpanLookupTest(int remaining, boolean removeFromStart) throws Throwable { |
| RecyclerView recyclerView = setupBasic(new Config(3, 10)); |
| mGlm.setSpanSizeLookup(new GridLayoutManager.SpanSizeLookup() { |
| @Override |
| public int getSpanSize(int position) { |
| if (position < 0 || position >= mAdapter.getItemCount()) { |
| postExceptionToInstrumentation(new AssertionError("position is not within " + |
| "adapter range. pos:" + position + ", adapter size:" + |
| mAdapter.getItemCount())); |
| } |
| return 1; |
| } |
| |
| @Override |
| public int getSpanIndex(int position, int spanCount) { |
| if (position < 0 || position >= mAdapter.getItemCount()) { |
| postExceptionToInstrumentation(new AssertionError("position is not within " + |
| "adapter range. pos:" + position + ", adapter size:" + |
| mAdapter.getItemCount())); |
| } |
| return super.getSpanIndex(position, spanCount); |
| } |
| }); |
| waitForFirstLayout(recyclerView); |
| checkForMainThreadException(); |
| assertTrue("test sanity", mGlm.supportsPredictiveItemAnimations()); |
| mGlm.expectLayout(2); |
| int deleteCnt = 10 - remaining; |
| int deleteStart = removeFromStart ? 0 : remaining; |
| mAdapter.deleteAndNotify(deleteStart, deleteCnt); |
| mGlm.waitForLayout(2); |
| checkForMainThreadException(); |
| } |
| |
| public void testCustomWidthInHorizontal() throws Throwable { |
| customSizeInScrollDirectionTest(new Config(3, HORIZONTAL, false)); |
| } |
| |
| public void testCustomHeightInVertical() throws Throwable { |
| customSizeInScrollDirectionTest(new Config(3, VERTICAL, false)); |
| } |
| |
| public void customSizeInScrollDirectionTest(final Config config) throws Throwable { |
| Boolean[] options = new Boolean[]{true, false}; |
| for (boolean addMargins : options) { |
| for (boolean addDecorOffsets : options) { |
| customSizeInScrollDirectionTest(config, addDecorOffsets, addMargins); |
| } |
| } |
| } |
| |
| public void customSizeInScrollDirectionTest(final Config config, boolean addDecorOffsets, |
| boolean addMarigns) throws Throwable { |
| final int decorOffset = addDecorOffsets ? 7 : 0; |
| final int margin = addMarigns ? 11 : 0; |
| final int[] sizePerPosition = new int[]{3, 5, 9, 21, 3, 5, 9, 6, 9, 1}; |
| final int[] expectedSizePerPosition = new int[]{9, 9, 9, 21, 3, 5, 9, 9, 9, 1}; |
| |
| final GridTestAdapter testAdapter = new GridTestAdapter(10) { |
| @Override |
| public void onBindViewHolder(TestViewHolder holder, |
| int position) { |
| super.onBindViewHolder(holder, position); |
| ViewGroup.MarginLayoutParams layoutParams = (ViewGroup.MarginLayoutParams) |
| holder.itemView.getLayoutParams(); |
| if (layoutParams == null) { |
| layoutParams = new ViewGroup.MarginLayoutParams( |
| ViewGroup.LayoutParams.WRAP_CONTENT, |
| ViewGroup.LayoutParams.WRAP_CONTENT); |
| holder.itemView.setLayoutParams(layoutParams); |
| } |
| final int size = sizePerPosition[position]; |
| if (config.mOrientation == HORIZONTAL) { |
| layoutParams.width = size; |
| layoutParams.leftMargin = margin; |
| layoutParams.rightMargin = margin; |
| } else { |
| layoutParams.height = size; |
| layoutParams.topMargin = margin; |
| layoutParams.bottomMargin = margin; |
| } |
| } |
| }; |
| testAdapter.setFullSpan(3, 5); |
| final RecyclerView rv = setupBasic(config, testAdapter); |
| if (addDecorOffsets) { |
| rv.addItemDecoration(new RecyclerView.ItemDecoration() { |
| @Override |
| public void getItemOffsets(Rect outRect, View view, RecyclerView parent, |
| RecyclerView.State state) { |
| if (config.mOrientation == HORIZONTAL) { |
| outRect.set(decorOffset, 0, decorOffset, 0); |
| } else { |
| outRect.set(0, decorOffset, 0, decorOffset); |
| } |
| } |
| }); |
| } |
| waitForFirstLayout(rv); |
| |
| assertTrue("[test sanity] some views should be laid out", mRecyclerView.getChildCount() > 0); |
| for (int i = 0; i < mRecyclerView.getChildCount(); i++) { |
| View child = mRecyclerView.getChildAt(i); |
| final int size = config.mOrientation == HORIZONTAL ? child.getWidth() |
| : child.getHeight(); |
| assertEquals("child " + i + " should have the size specified in its layout params", |
| expectedSizePerPosition[i], size); |
| } |
| checkForMainThreadException(); |
| } |
| |
| public void testRTL() throws Throwable { |
| for (boolean changeRtlAfter : new boolean[]{false, true}) { |
| for (boolean oneLine : new boolean[]{false, true}) { |
| for (Config config : mBaseVariations) { |
| rtlTest(config, changeRtlAfter, oneLine); |
| removeRecyclerView(); |
| } |
| } |
| } |
| } |
| |
| void rtlTest(Config config, boolean changeRtlAfter, boolean oneLine) throws Throwable { |
| if (oneLine && config.mOrientation != VERTICAL) { |
| return;// nothing to test |
| } |
| if (config.mSpanCount == 1) { |
| config.mSpanCount = 2; |
| } |
| String logPrefix = config + ", changeRtlAfterLayout:" + changeRtlAfter + ", oneLine:" + oneLine; |
| config.mItemCount = 5; |
| if (oneLine) { |
| config.mSpanCount = config.mItemCount + 1; |
| } else { |
| config.mSpanCount = Math.min(config.mItemCount - 1, config.mSpanCount); |
| } |
| |
| RecyclerView rv = setupBasic(config); |
| if (changeRtlAfter) { |
| waitForFirstLayout(rv); |
| mGlm.expectLayout(1); |
| mGlm.setFakeRtl(true); |
| mGlm.waitForLayout(2); |
| } else { |
| mGlm.mFakeRTL = true; |
| waitForFirstLayout(rv); |
| } |
| |
| assertEquals("view should become rtl", true, mGlm.isLayoutRTL()); |
| OrientationHelper helper = OrientationHelper.createHorizontalHelper(mGlm); |
| View child0 = mGlm.findViewByPosition(0); |
| final int secondChildPos = config.mOrientation == VERTICAL ? 1 |
| : config.mSpanCount; |
| View child1 = mGlm.findViewByPosition(secondChildPos); |
| assertNotNull(logPrefix + " child position 0 should be laid out", child0); |
| assertNotNull( |
| logPrefix + " second child position " + (secondChildPos) + " should be laid out", |
| child1); |
| if (config.mOrientation == VERTICAL || !config.mReverseLayout) { |
| assertTrue(logPrefix + " second child should be to the left of first child", |
| helper.getDecoratedStart(child0) >= helper.getDecoratedEnd(child1)); |
| assertEquals(logPrefix + " first child should be right aligned", |
| helper.getDecoratedEnd(child0), helper.getEndAfterPadding()); |
| } else { |
| assertTrue(logPrefix + " first child should be to the left of second child", |
| helper.getDecoratedStart(child1) >= helper.getDecoratedEnd(child0)); |
| assertEquals(logPrefix + " first child should be left aligned", |
| helper.getDecoratedStart(child0), helper.getStartAfterPadding()); |
| } |
| checkForMainThreadException(); |
| } |
| |
| public void testMovingAGroupOffScreenForAddedItems() throws Throwable { |
| final RecyclerView rv = setupBasic(new Config(3, 100)); |
| final int[] maxId = new int[1]; |
| maxId[0] = -1; |
| final SparseIntArray spanLookups = new SparseIntArray(); |
| final AtomicBoolean enableSpanLookupLogging = new AtomicBoolean(false); |
| mGlm.setSpanSizeLookup(new GridLayoutManager.SpanSizeLookup() { |
| @Override |
| public int getSpanSize(int position) { |
| if (maxId[0] > 0 && mAdapter.getItemAt(position).mId > maxId[0]) { |
| return 1; |
| } else if (enableSpanLookupLogging.get() && !rv.mState.isPreLayout()) { |
| spanLookups.put(position, spanLookups.get(position, 0) + 1); |
| } |
| return 3; |
| } |
| }); |
| rv.getItemAnimator().setSupportsChangeAnimations(true); |
| waitForFirstLayout(rv); |
| View lastView = rv.getChildAt(rv.getChildCount() - 1); |
| final int lastPos = rv.getChildAdapterPosition(lastView); |
| maxId[0] = mAdapter.getItemAt(mAdapter.getItemCount() - 1).mId; |
| // now add a lot of items below this and those new views should have span size 3 |
| enableSpanLookupLogging.set(true); |
| mGlm.expectLayout(2); |
| mAdapter.addAndNotify(lastPos - 2, 30); |
| mGlm.waitForLayout(2); |
| checkForMainThreadException(); |
| |
| assertEquals("last items span count should be queried twice", 2, |
| spanLookups.get(lastPos + 30)); |
| |
| } |
| |
| public void testCachedBorders() throws Throwable { |
| List<Config> testConfigurations = new ArrayList<Config>(mBaseVariations); |
| testConfigurations.addAll(cachedBordersTestConfigs()); |
| for (Config config : testConfigurations) { |
| gridCachedBorderstTest(config); |
| } |
| } |
| |
| private void gridCachedBorderstTest(Config config) throws Throwable { |
| RecyclerView recyclerView = setupBasic(config); |
| waitForFirstLayout(recyclerView); |
| final boolean vertical = config.mOrientation == GridLayoutManager.VERTICAL; |
| final int expectedSizeSum = vertical ? recyclerView.getWidth() : recyclerView.getHeight(); |
| final int lastVisible = mGlm.findLastVisibleItemPosition(); |
| for (int i = 0; i < lastVisible; i += config.mSpanCount) { |
| if ((i+1)*config.mSpanCount - 1 < lastVisible) { |
| int childrenSizeSum = 0; |
| for (int j = 0; j < config.mSpanCount; j++) { |
| View child = recyclerView.getChildAt(i * config.mSpanCount + j); |
| childrenSizeSum += vertical ? child.getWidth() : child.getHeight(); |
| } |
| assertEquals(expectedSizeSum, childrenSizeSum); |
| } |
| } |
| removeRecyclerView(); |
| } |
| |
| private List<Config> cachedBordersTestConfigs() { |
| ArrayList<Config> configs = new ArrayList<Config>(); |
| final int [] spanCounts = new int[]{88, 279, 741}; |
| final int [] spanPerItem = new int[]{11, 9, 13}; |
| for (int orientation : new int[]{VERTICAL, HORIZONTAL}) { |
| for (boolean reverseLayout : new boolean[]{false, true}) { |
| for (int i = 0 ; i < spanCounts.length; i++) { |
| Config config = new Config(spanCounts[i], orientation, reverseLayout); |
| config.mSpanPerItem = spanPerItem[i]; |
| configs.add(config); |
| } |
| } |
| } |
| return configs; |
| } |
| |
| public void testLayoutParams() throws Throwable { |
| layoutParamsTest(GridLayoutManager.HORIZONTAL); |
| removeRecyclerView(); |
| layoutParamsTest(GridLayoutManager.VERTICAL); |
| } |
| |
| public void testHorizontalAccessibilitySpanIndices() throws Throwable { |
| accessibilitySpanIndicesTest(HORIZONTAL); |
| } |
| |
| public void testVerticalAccessibilitySpanIndices() throws Throwable { |
| accessibilitySpanIndicesTest(VERTICAL); |
| } |
| |
| public void accessibilitySpanIndicesTest(int orientation) throws Throwable { |
| final RecyclerView recyclerView = setupBasic(new Config(3, orientation, false)); |
| waitForFirstLayout(recyclerView); |
| final AccessibilityDelegateCompat delegateCompat = mRecyclerView |
| .getCompatAccessibilityDelegate().getItemDelegate(); |
| final AccessibilityNodeInfoCompat info = AccessibilityNodeInfoCompat.obtain(); |
| final View chosen = recyclerView.getChildAt(recyclerView.getChildCount() - 2); |
| final int position = recyclerView.getChildLayoutPosition(chosen); |
| runTestOnUiThread(new Runnable() { |
| @Override |
| public void run() { |
| delegateCompat.onInitializeAccessibilityNodeInfo(chosen, info); |
| } |
| }); |
| GridLayoutManager.SpanSizeLookup ssl = mGlm.mSpanSizeLookup; |
| AccessibilityNodeInfoCompat.CollectionItemInfoCompat itemInfo = info |
| .getCollectionItemInfo(); |
| assertNotNull(itemInfo); |
| assertEquals("result should have span group position", |
| ssl.getSpanGroupIndex(position, mGlm.getSpanCount()), |
| orientation == HORIZONTAL ? itemInfo.getColumnIndex() : itemInfo.getRowIndex()); |
| assertEquals("result should have span index", |
| ssl.getSpanIndex(position, mGlm.getSpanCount()), |
| orientation == HORIZONTAL ? itemInfo.getRowIndex() : itemInfo.getColumnIndex()); |
| assertEquals("result should have span size", |
| ssl.getSpanSize(position), |
| orientation == HORIZONTAL ? itemInfo.getRowSpan() : itemInfo.getColumnSpan()); |
| } |
| |
| public GridLayoutManager.LayoutParams ensureGridLp(View view) { |
| ViewGroup.LayoutParams lp = view.getLayoutParams(); |
| GridLayoutManager.LayoutParams glp; |
| if (lp instanceof GridLayoutManager.LayoutParams) { |
| glp = (GridLayoutManager.LayoutParams) lp; |
| } else if (lp == null) { |
| glp = (GridLayoutManager.LayoutParams) mGlm |
| .generateDefaultLayoutParams(); |
| view.setLayoutParams(glp); |
| } else { |
| glp = (GridLayoutManager.LayoutParams) mGlm.generateLayoutParams(lp); |
| view.setLayoutParams(glp); |
| } |
| return glp; |
| } |
| |
| public void layoutParamsTest(final int orientation) throws Throwable { |
| final RecyclerView rv = setupBasic(new Config(3, 100).orientation(orientation), |
| new GridTestAdapter(100) { |
| @Override |
| public void onBindViewHolder(TestViewHolder holder, |
| int position) { |
| super.onBindViewHolder(holder, position); |
| GridLayoutManager.LayoutParams glp = ensureGridLp(holder.itemView); |
| int val = 0; |
| switch (position % 5) { |
| case 0: |
| val = 10; |
| break; |
| case 1: |
| val = 30; |
| break; |
| case 2: |
| val = GridLayoutManager.LayoutParams.WRAP_CONTENT; |
| break; |
| case 3: |
| val = GridLayoutManager.LayoutParams.FILL_PARENT; |
| break; |
| case 4: |
| val = 200; |
| break; |
| } |
| if (orientation == GridLayoutManager.VERTICAL) { |
| glp.height = val; |
| } else { |
| glp.width = val; |
| } |
| holder.itemView.setLayoutParams(glp); |
| } |
| }); |
| waitForFirstLayout(rv); |
| final OrientationHelper helper = mGlm.mOrientationHelper; |
| final int firstRowSize = Math.max(30, getSize(mGlm.findViewByPosition(2))); |
| assertEquals(firstRowSize, |
| helper.getDecoratedMeasurement(mGlm.findViewByPosition(0))); |
| assertEquals(firstRowSize, |
| helper.getDecoratedMeasurement(mGlm.findViewByPosition(1))); |
| assertEquals(firstRowSize, |
| helper.getDecoratedMeasurement(mGlm.findViewByPosition(2))); |
| assertEquals(firstRowSize, getSize(mGlm.findViewByPosition(0))); |
| assertEquals(firstRowSize, getSize(mGlm.findViewByPosition(1))); |
| assertEquals(firstRowSize, getSize(mGlm.findViewByPosition(2))); |
| |
| final int secondRowSize = Math.max(200, getSize(mGlm.findViewByPosition(3))); |
| assertEquals(secondRowSize, |
| helper.getDecoratedMeasurement(mGlm.findViewByPosition(3))); |
| assertEquals(secondRowSize, |
| helper.getDecoratedMeasurement(mGlm.findViewByPosition(4))); |
| assertEquals(secondRowSize, |
| helper.getDecoratedMeasurement(mGlm.findViewByPosition(5))); |
| assertEquals(secondRowSize, getSize(mGlm.findViewByPosition(3))); |
| assertEquals(secondRowSize, getSize(mGlm.findViewByPosition(4))); |
| assertEquals(secondRowSize, getSize(mGlm.findViewByPosition(5))); |
| } |
| |
| private int getSize(View view) { |
| if (mGlm.getOrientation() == GridLayoutManager.HORIZONTAL) { |
| return view.getWidth(); |
| } |
| return view.getHeight(); |
| } |
| |
| public void testAnchorUpdate() throws InterruptedException { |
| GridLayoutManager glm = new GridLayoutManager(getActivity(), 11); |
| final GridLayoutManager.SpanSizeLookup spanSizeLookup |
| = new GridLayoutManager.SpanSizeLookup() { |
| @Override |
| public int getSpanSize(int position) { |
| if (position > 200) { |
| return 100; |
| } |
| if (position > 20) { |
| return 2; |
| } |
| return 1; |
| } |
| }; |
| glm.setSpanSizeLookup(spanSizeLookup); |
| glm.mAnchorInfo.mPosition = 11; |
| RecyclerView.State state = new RecyclerView.State(); |
| mRecyclerView = new RecyclerView(getActivity()); |
| state.mItemCount = 1000; |
| glm.onAnchorReady(mRecyclerView.mRecycler, state, glm.mAnchorInfo); |
| assertEquals("gm should keep anchor in first span", 11, glm.mAnchorInfo.mPosition); |
| |
| glm.mAnchorInfo.mPosition = 13; |
| glm.onAnchorReady(mRecyclerView.mRecycler, state, glm.mAnchorInfo); |
| assertEquals("gm should move anchor to first span", 11, glm.mAnchorInfo.mPosition); |
| |
| glm.mAnchorInfo.mPosition = 23; |
| glm.onAnchorReady(mRecyclerView.mRecycler, state, glm.mAnchorInfo); |
| assertEquals("gm should move anchor to first span", 21, glm.mAnchorInfo.mPosition); |
| |
| glm.mAnchorInfo.mPosition = 35; |
| glm.onAnchorReady(mRecyclerView.mRecycler, state, glm.mAnchorInfo); |
| assertEquals("gm should move anchor to first span", 31, glm.mAnchorInfo.mPosition); |
| } |
| |
| public void testSpanLookup() { |
| spanLookupTest(false); |
| } |
| |
| public void testSpanLookupWithCache() { |
| spanLookupTest(true); |
| } |
| |
| public void testSpanLookupCache() { |
| final GridLayoutManager.SpanSizeLookup ssl |
| = new GridLayoutManager.SpanSizeLookup() { |
| @Override |
| public int getSpanSize(int position) { |
| if (position > 6) { |
| return 2; |
| } |
| return 1; |
| } |
| }; |
| ssl.setSpanIndexCacheEnabled(true); |
| assertEquals("reference child non existent", -1, ssl.findReferenceIndexFromCache(2)); |
| ssl.getCachedSpanIndex(4, 5); |
| assertEquals("reference child non existent", -1, ssl.findReferenceIndexFromCache(3)); |
| // this should not happen and if happens, it is better to return -1 |
| assertEquals("reference child itself", -1, ssl.findReferenceIndexFromCache(4)); |
| assertEquals("reference child before", 4, ssl.findReferenceIndexFromCache(5)); |
| assertEquals("reference child before", 4, ssl.findReferenceIndexFromCache(100)); |
| ssl.getCachedSpanIndex(6, 5); |
| assertEquals("reference child before", 6, ssl.findReferenceIndexFromCache(7)); |
| assertEquals("reference child before", 4, ssl.findReferenceIndexFromCache(6)); |
| assertEquals("reference child itself", -1, ssl.findReferenceIndexFromCache(4)); |
| ssl.getCachedSpanIndex(12, 5); |
| assertEquals("reference child before", 12, ssl.findReferenceIndexFromCache(13)); |
| assertEquals("reference child before", 6, ssl.findReferenceIndexFromCache(12)); |
| assertEquals("reference child before", 6, ssl.findReferenceIndexFromCache(7)); |
| for (int i = 0; i < 6; i++) { |
| ssl.getCachedSpanIndex(i, 5); |
| } |
| |
| for (int i = 1; i < 7; i++) { |
| assertEquals("reference child right before " + i, i - 1, |
| ssl.findReferenceIndexFromCache(i)); |
| } |
| assertEquals("reference child before 0 ", -1, ssl.findReferenceIndexFromCache(0)); |
| } |
| |
| public void spanLookupTest(boolean enableCache) { |
| final GridLayoutManager.SpanSizeLookup ssl |
| = new GridLayoutManager.SpanSizeLookup() { |
| @Override |
| public int getSpanSize(int position) { |
| if (position > 200) { |
| return 100; |
| } |
| if (position > 6) { |
| return 2; |
| } |
| return 1; |
| } |
| }; |
| ssl.setSpanIndexCacheEnabled(enableCache); |
| assertEquals(0, ssl.getCachedSpanIndex(0, 5)); |
| assertEquals(4, ssl.getCachedSpanIndex(4, 5)); |
| assertEquals(0, ssl.getCachedSpanIndex(5, 5)); |
| assertEquals(1, ssl.getCachedSpanIndex(6, 5)); |
| assertEquals(2, ssl.getCachedSpanIndex(7, 5)); |
| assertEquals(2, ssl.getCachedSpanIndex(9, 5)); |
| assertEquals(0, ssl.getCachedSpanIndex(8, 5)); |
| } |
| |
| public void testRemoveAnchorItem() throws Throwable { |
| removeAnchorItemTest( |
| new Config(3, 0).orientation(VERTICAL).reverseLayout(false), 100, 0); |
| } |
| |
| public void testRemoveAnchorItemReverse() throws Throwable { |
| removeAnchorItemTest( |
| new Config(3, 0).orientation(VERTICAL).reverseLayout(true), 100, |
| 0); |
| } |
| |
| public void testRemoveAnchorItemHorizontal() throws Throwable { |
| removeAnchorItemTest( |
| new Config(3, 0).orientation(HORIZONTAL).reverseLayout( |
| false), 100, 0); |
| } |
| |
| public void testRemoveAnchorItemReverseHorizontal() throws Throwable { |
| removeAnchorItemTest( |
| new Config(3, 0).orientation(HORIZONTAL).reverseLayout(true), |
| 100, 0); |
| } |
| |
| /** |
| * 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 { |
| GridTestAdapter adapter = new GridTestAdapter(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; |
| } |
| } |
| }; |
| RecyclerView recyclerView = setupBasic(config, adapter); |
| waitForFirstLayout(recyclerView); |
| final int childCount = mGlm.getChildCount(); |
| RecyclerView.ViewHolder toBeRemoved = null; |
| List<RecyclerView.ViewHolder> toBeMoved = new ArrayList<RecyclerView.ViewHolder>(); |
| for (int i = 0; i < childCount; i++) { |
| View child = mGlm.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); |
| mGlm.expectLayout(2); |
| adapter.deleteAndNotify(removePos, 1); |
| mGlm.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 < mGlm.getChildCount(); i++) { |
| View child = mGlm.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)); |
| } |
| // for removed view, 3 for new row |
| assertTrue("control against adding too many children due to bad layout state preparation." |
| + " initial:" + childCount + ", current:" + mRecyclerView.getChildCount(), |
| mRecyclerView.getChildCount() <= childCount + 1 + 3); |
| } |
| |
| public void testSpanGroupIndex() { |
| final GridLayoutManager.SpanSizeLookup ssl |
| = new GridLayoutManager.SpanSizeLookup() { |
| @Override |
| public int getSpanSize(int position) { |
| if (position > 200) { |
| return 100; |
| } |
| if (position > 6) { |
| return 2; |
| } |
| return 1; |
| } |
| }; |
| assertEquals(0, ssl.getSpanGroupIndex(0, 5)); |
| assertEquals(0, ssl.getSpanGroupIndex(4, 5)); |
| assertEquals(1, ssl.getSpanGroupIndex(5, 5)); |
| assertEquals(1, ssl.getSpanGroupIndex(6, 5)); |
| assertEquals(1, ssl.getSpanGroupIndex(7, 5)); |
| assertEquals(2, ssl.getSpanGroupIndex(9, 5)); |
| assertEquals(2, ssl.getSpanGroupIndex(8, 5)); |
| } |
| |
| public void testNotifyDataSetChange() throws Throwable { |
| final RecyclerView recyclerView = setupBasic(new Config(3, 100)); |
| final GridLayoutManager.SpanSizeLookup ssl = mGlm.getSpanSizeLookup(); |
| ssl.setSpanIndexCacheEnabled(true); |
| waitForFirstLayout(recyclerView); |
| assertTrue("some positions should be cached", ssl.mSpanIndexCache.size() > 0); |
| final Callback callback = new Callback() { |
| @Override |
| public void onBeforeLayout(RecyclerView.Recycler recycler, RecyclerView.State state) { |
| if (!state.isPreLayout()) { |
| assertEquals("cache should be empty", 0, ssl.mSpanIndexCache.size()); |
| } |
| } |
| |
| @Override |
| public void onAfterLayout(RecyclerView.Recycler recycler, RecyclerView.State state) { |
| if (!state.isPreLayout()) { |
| assertTrue("some items should be cached", ssl.mSpanIndexCache.size() > 0); |
| } |
| } |
| }; |
| mGlm.mCallbacks.add(callback); |
| mGlm.expectLayout(2); |
| mAdapter.deleteAndNotify(2, 3); |
| mGlm.waitForLayout(2); |
| checkForMainThreadException(); |
| } |
| |
| public void testUnevenHeights() throws Throwable { |
| final Map<Integer, RecyclerView.ViewHolder> viewHolderMap = |
| new HashMap<Integer, RecyclerView.ViewHolder>(); |
| RecyclerView recyclerView = setupBasic(new Config(3, 3), new GridTestAdapter(3) { |
| @Override |
| public void onBindViewHolder(TestViewHolder holder, |
| int position) { |
| super.onBindViewHolder(holder, position); |
| final GridLayoutManager.LayoutParams glp = ensureGridLp(holder.itemView); |
| glp.height = 50 + position * 50; |
| viewHolderMap.put(position, holder); |
| } |
| }); |
| waitForFirstLayout(recyclerView); |
| for (RecyclerView.ViewHolder vh : viewHolderMap.values()) { |
| assertEquals("all items should get max height", 150, |
| vh.itemView.getHeight()); |
| } |
| |
| for (RecyclerView.ViewHolder vh : viewHolderMap.values()) { |
| assertEquals("all items should have measured the max height", 150, |
| vh.itemView.getMeasuredHeight()); |
| } |
| } |
| |
| public void testUnevenWidths() throws Throwable { |
| final Map<Integer, RecyclerView.ViewHolder> viewHolderMap = |
| new HashMap<Integer, RecyclerView.ViewHolder>(); |
| RecyclerView recyclerView = setupBasic(new Config(3, HORIZONTAL, false), |
| new GridTestAdapter(3) { |
| @Override |
| public void onBindViewHolder(TestViewHolder holder, |
| int position) { |
| super.onBindViewHolder(holder, position); |
| final GridLayoutManager.LayoutParams glp = ensureGridLp(holder.itemView); |
| glp.width = 50 + position * 50; |
| viewHolderMap.put(position, holder); |
| } |
| }); |
| waitForFirstLayout(recyclerView); |
| for (RecyclerView.ViewHolder vh : viewHolderMap.values()) { |
| assertEquals("all items should get max width", 150, |
| vh.itemView.getWidth()); |
| } |
| |
| for (RecyclerView.ViewHolder vh : viewHolderMap.values()) { |
| assertEquals("all items should have measured the max width", 150, |
| vh.itemView.getMeasuredWidth()); |
| } |
| } |
| |
| public void testScrollBackAndPreservePositions() throws Throwable { |
| for (Config config : mBaseVariations) { |
| config.mItemCount = 150; |
| scrollBackAndPreservePositionsTest(config); |
| removeRecyclerView(); |
| } |
| } |
| |
| public void testSpanSizeChange() throws Throwable { |
| final RecyclerView rv = setupBasic(new Config(3, 100)); |
| waitForFirstLayout(rv); |
| assertTrue(mGlm.supportsPredictiveItemAnimations()); |
| mGlm.expectLayout(1); |
| runTestOnUiThread(new Runnable() { |
| @Override |
| public void run() { |
| mGlm.setSpanCount(5); |
| assertFalse(mGlm.supportsPredictiveItemAnimations()); |
| } |
| }); |
| checkForMainThreadException(); |
| mGlm.waitForLayout(2); |
| mGlm.expectLayout(2); |
| mAdapter.deleteAndNotify(3, 2); |
| mGlm.waitForLayout(2); |
| assertTrue(mGlm.supportsPredictiveItemAnimations()); |
| } |
| |
| public void testCacheSpanIndices() throws Throwable { |
| final RecyclerView rv = setupBasic(new Config(3, 100)); |
| mGlm.mSpanSizeLookup.setSpanIndexCacheEnabled(true); |
| waitForFirstLayout(rv); |
| GridLayoutManager.SpanSizeLookup ssl = mGlm.mSpanSizeLookup; |
| assertTrue("cache should be filled", mGlm.mSpanSizeLookup.mSpanIndexCache.size() > 0); |
| assertEquals("item index 5 should be in span 2", 2, |
| getLp(mGlm.findViewByPosition(5)).getSpanIndex()); |
| mGlm.expectLayout(2); |
| mAdapter.mFullSpanItems.add(4); |
| mAdapter.changeAndNotify(4, 1); |
| mGlm.waitForLayout(2); |
| assertEquals("item index 5 should be in span 2", 0, |
| getLp(mGlm.findViewByPosition(5)).getSpanIndex()); |
| } |
| |
| GridLayoutManager.LayoutParams getLp(View view) { |
| return (GridLayoutManager.LayoutParams) view.getLayoutParams(); |
| } |
| |
| public void scrollBackAndPreservePositionsTest(final Config config) throws Throwable { |
| final RecyclerView rv = setupBasic(config); |
| for (int i = 1; i < mAdapter.getItemCount(); i += config.mSpanCount + 2) { |
| mAdapter.setFullSpan(i); |
| } |
| waitForFirstLayout(rv); |
| final int[] globalPositions = new int[mAdapter.getItemCount()]; |
| Arrays.fill(globalPositions, Integer.MIN_VALUE); |
| final int scrollStep = (mGlm.mOrientationHelper.getTotalSpace() / 20) |
| * (config.mReverseLayout ? -1 : 1); |
| final String logPrefix = config.toString(); |
| final int[] globalPos = new int[1]; |
| runTestOnUiThread(new Runnable() { |
| @Override |
| public void run() { |
| assertSame("test sanity", mRecyclerView, rv); |
| int globalScrollPosition = 0; |
| int visited = 0; |
| while (visited < mAdapter.getItemCount()) { |
| for (int i = 0; i < mRecyclerView.getChildCount(); i++) { |
| View child = mRecyclerView.getChildAt(i); |
| final int pos = mRecyclerView.getChildLayoutPosition(child); |
| if (globalPositions[pos] != Integer.MIN_VALUE) { |
| continue; |
| } |
| visited++; |
| GridLayoutManager.LayoutParams lp = (GridLayoutManager.LayoutParams) |
| child.getLayoutParams(); |
| if (config.mReverseLayout) { |
| globalPositions[pos] = globalScrollPosition + |
| mGlm.mOrientationHelper.getDecoratedEnd(child); |
| } else { |
| globalPositions[pos] = globalScrollPosition + |
| mGlm.mOrientationHelper.getDecoratedStart(child); |
| } |
| assertEquals(logPrefix + " span index should match", |
| mGlm.getSpanSizeLookup().getSpanIndex(pos, mGlm.getSpanCount()), |
| lp.getSpanIndex()); |
| } |
| int scrolled = mGlm.scrollBy(scrollStep, |
| mRecyclerView.mRecycler, mRecyclerView.mState); |
| globalScrollPosition += scrolled; |
| if (scrolled == 0) { |
| assertEquals( |
| logPrefix + " If scroll is complete, all views should be visited", |
| visited, mAdapter.getItemCount()); |
| } |
| } |
| if (DEBUG) { |
| Log.d(TAG, "done recording positions " + Arrays.toString(globalPositions)); |
| } |
| globalPos[0] = globalScrollPosition; |
| } |
| }); |
| checkForMainThreadException(); |
| // test sanity, ensure scroll happened |
| runTestOnUiThread(new Runnable() { |
| @Override |
| public void run() { |
| final int childCount = mGlm.getChildCount(); |
| final BitSet expectedPositions = new BitSet(); |
| for (int i = 0; i < childCount; i ++) { |
| expectedPositions.set(mAdapter.getItemCount() - i - 1); |
| } |
| for (int i = 0; i <childCount; i ++) { |
| final View view = mGlm.getChildAt(i); |
| int position = mGlm.getPosition(view); |
| assertTrue("child position should be in last page", expectedPositions.get(position)); |
| } |
| } |
| }); |
| getInstrumentation().waitForIdleSync(); |
| runTestOnUiThread(new Runnable() { |
| @Override |
| public void run() { |
| int globalScrollPosition = globalPos[0]; |
| // now scroll back and make sure global positions match |
| BitSet shouldTest = new BitSet(mAdapter.getItemCount()); |
| shouldTest.set(0, mAdapter.getItemCount() - 1, true); |
| String assertPrefix = config |
| + " global pos must match when scrolling in reverse for position "; |
| int scrollAmount = Integer.MAX_VALUE; |
| while (!shouldTest.isEmpty() && scrollAmount != 0) { |
| for (int i = 0; i < mRecyclerView.getChildCount(); i++) { |
| View child = mRecyclerView.getChildAt(i); |
| int pos = mRecyclerView.getChildLayoutPosition(child); |
| if (!shouldTest.get(pos)) { |
| continue; |
| } |
| GridLayoutManager.LayoutParams lp = (GridLayoutManager.LayoutParams) |
| child.getLayoutParams(); |
| shouldTest.clear(pos); |
| int globalPos; |
| if (config.mReverseLayout) { |
| globalPos = globalScrollPosition + |
| mGlm.mOrientationHelper.getDecoratedEnd(child); |
| } else { |
| globalPos = globalScrollPosition + |
| mGlm.mOrientationHelper.getDecoratedStart(child); |
| } |
| assertEquals(assertPrefix + pos, |
| globalPositions[pos], globalPos); |
| assertEquals("span index should match", |
| mGlm.getSpanSizeLookup().getSpanIndex(pos, mGlm.getSpanCount()), |
| lp.getSpanIndex()); |
| } |
| scrollAmount = mGlm.scrollBy(-scrollStep, |
| mRecyclerView.mRecycler, mRecyclerView.mState); |
| globalScrollPosition += scrollAmount; |
| } |
| assertTrue("all views should be seen", shouldTest.isEmpty()); |
| } |
| }); |
| checkForMainThreadException(); |
| } |
| |
| class WrappedGridLayoutManager extends GridLayoutManager { |
| |
| CountDownLatch mLayoutLatch; |
| |
| List<Callback> mCallbacks = new ArrayList<Callback>(); |
| |
| Boolean mFakeRTL; |
| |
| public WrappedGridLayoutManager(Context context, int spanCount) { |
| super(context, spanCount); |
| } |
| |
| public WrappedGridLayoutManager(Context context, int spanCount, int orientation, |
| boolean reverseLayout) { |
| super(context, spanCount, orientation, reverseLayout); |
| } |
| |
| @Override |
| protected boolean isLayoutRTL() { |
| return mFakeRTL == null ? super.isLayoutRTL() : mFakeRTL; |
| } |
| |
| public void setFakeRtl(Boolean fakeRtl) { |
| mFakeRTL = fakeRtl; |
| try { |
| requestLayoutOnUIThread(mRecyclerView); |
| } catch (Throwable throwable) { |
| postExceptionToInstrumentation(throwable); |
| } |
| } |
| |
| @Override |
| public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) { |
| try { |
| for (Callback callback : mCallbacks) { |
| callback.onBeforeLayout(recycler, state); |
| } |
| super.onLayoutChildren(recycler, state); |
| for (Callback callback : mCallbacks) { |
| callback.onAfterLayout(recycler, state); |
| } |
| } catch (Throwable t) { |
| postExceptionToInstrumentation(t); |
| } |
| mLayoutLatch.countDown(); |
| } |
| |
| @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 void expectLayout(int layoutCount) { |
| mLayoutLatch = new CountDownLatch(layoutCount); |
| } |
| |
| public void waitForLayout(int seconds) throws InterruptedException { |
| mLayoutLatch.await(seconds, SECONDS); |
| } |
| } |
| |
| class Config { |
| |
| int mSpanCount; |
| int mOrientation = GridLayoutManager.VERTICAL; |
| int mItemCount = 1000; |
| int mSpanPerItem = 1; |
| boolean mReverseLayout = false; |
| |
| Config(int spanCount, int itemCount) { |
| mSpanCount = spanCount; |
| mItemCount = itemCount; |
| } |
| |
| public Config(int spanCount, int orientation, boolean reverseLayout) { |
| mSpanCount = spanCount; |
| mOrientation = orientation; |
| mReverseLayout = reverseLayout; |
| } |
| |
| Config orientation(int orientation) { |
| mOrientation = orientation; |
| return this; |
| } |
| |
| @Override |
| public String toString() { |
| return "Config{" + |
| "mSpanCount=" + mSpanCount + |
| ", mOrientation=" + (mOrientation == GridLayoutManager.HORIZONTAL ? "h" : "v") + |
| ", mItemCount=" + mItemCount + |
| ", mReverseLayout=" + mReverseLayout + |
| '}'; |
| } |
| |
| public Config reverseLayout(boolean reverseLayout) { |
| mReverseLayout = reverseLayout; |
| return this; |
| } |
| |
| |
| } |
| |
| class GridTestAdapter extends TestAdapter { |
| |
| Set<Integer> mFullSpanItems = new HashSet<Integer>(); |
| int mSpanPerItem = 1; |
| |
| GridTestAdapter(int count) { |
| super(count); |
| } |
| |
| GridTestAdapter(int count, int spanPerItem) { |
| super(count); |
| mSpanPerItem = spanPerItem; |
| } |
| |
| void setFullSpan(int... items) { |
| for (int i : items) { |
| mFullSpanItems.add(i); |
| } |
| } |
| |
| void assignSpanSizeLookup(final GridLayoutManager glm) { |
| glm.setSpanSizeLookup(new GridLayoutManager.SpanSizeLookup() { |
| @Override |
| public int getSpanSize(int position) { |
| return mFullSpanItems.contains(position) ? glm.getSpanCount() : mSpanPerItem; |
| } |
| }); |
| } |
| } |
| |
| class Callback { |
| |
| public void onBeforeLayout(RecyclerView.Recycler recycler, RecyclerView.State state) { |
| } |
| |
| public void onAfterLayout(RecyclerView.Recycler recycler, RecyclerView.State state) { |
| } |
| } |
| } |