| /* |
| * Copyright 2018 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 androidx.recyclerview.widget; |
| |
| import static org.hamcrest.CoreMatchers.is; |
| import static org.hamcrest.MatcherAssert.assertThat; |
| import static org.hamcrest.core.IsEqual.equalTo; |
| import static org.junit.Assert.assertEquals; |
| import static org.junit.Assert.assertFalse; |
| import static org.junit.Assert.assertNotEquals; |
| import static org.junit.Assert.assertNotNull; |
| import static org.junit.Assert.assertSame; |
| import static org.junit.Assert.assertTrue; |
| import static org.mockito.Matchers.any; |
| import static org.mockito.Matchers.anyInt; |
| import static org.mockito.Matchers.argThat; |
| import static org.mockito.Mockito.mock; |
| import static org.mockito.Mockito.never; |
| import static org.mockito.Mockito.times; |
| import static org.mockito.Mockito.verify; |
| import static org.mockito.Mockito.when; |
| |
| import android.content.Context; |
| import android.os.Build; |
| import android.os.Parcelable; |
| import android.os.SystemClock; |
| import android.support.test.InstrumentationRegistry; |
| import android.support.test.filters.SdkSuppress; |
| import android.support.test.filters.SmallTest; |
| import android.support.test.runner.AndroidJUnit4; |
| import android.view.MotionEvent; |
| import android.view.View; |
| import android.view.ViewGroup; |
| import android.widget.FrameLayout; |
| |
| import androidx.annotation.NonNull; |
| |
| import org.junit.After; |
| import org.junit.Before; |
| import org.junit.Test; |
| import org.junit.runner.RunWith; |
| import org.mockito.ArgumentMatcher; |
| import org.mockito.invocation.InvocationOnMock; |
| import org.mockito.stubbing.Answer; |
| |
| import java.util.ArrayList; |
| import java.util.List; |
| import java.util.concurrent.TimeUnit; |
| |
| @SmallTest |
| @SdkSuppress(minSdkVersion = Build.VERSION_CODES.LOLLIPOP) |
| @RunWith(AndroidJUnit4.class) |
| public class RecyclerViewCacheTest { |
| TimeMockingRecyclerView mRecyclerView; |
| RecyclerView.Recycler mRecycler; |
| |
| private class TimeMockingRecyclerView extends RecyclerView { |
| private long mMockNanoTime = 0; |
| |
| TimeMockingRecyclerView(Context context) { |
| super(context); |
| } |
| |
| public void registerTimePassingMs(long ms) { |
| mMockNanoTime += TimeUnit.MILLISECONDS.toNanos(ms); |
| } |
| |
| @Override |
| long getNanoTime() { |
| return mMockNanoTime; |
| } |
| |
| @Override |
| public int getWindowVisibility() { |
| // Pretend to be visible to avoid being filtered out |
| return View.VISIBLE; |
| } |
| } |
| |
| @Before |
| public void setup() throws Exception { |
| mRecyclerView = new TimeMockingRecyclerView(getContext()); |
| mRecyclerView.onAttachedToWindow(); |
| mRecycler = mRecyclerView.mRecycler; |
| } |
| |
| @After |
| public void teardown() throws Exception { |
| if (mRecyclerView.isAttachedToWindow()) { |
| mRecyclerView.onDetachedFromWindow(); |
| } |
| GapWorker gapWorker = GapWorker.sGapWorker.get(); |
| if (gapWorker != null) { |
| assertTrue(gapWorker.mRecyclerViews.isEmpty()); |
| } |
| } |
| |
| private Context getContext() { |
| return InstrumentationRegistry.getContext(); |
| } |
| |
| private void layout(int width, int height) { |
| mRecyclerView.measure( |
| View.MeasureSpec.makeMeasureSpec(width, View.MeasureSpec.EXACTLY), |
| View.MeasureSpec.makeMeasureSpec(height, View.MeasureSpec.EXACTLY)); |
| mRecyclerView.layout(0, 0, width, height); |
| } |
| |
| @Test |
| public void prefetchReusesCacheItems() { |
| RecyclerView.LayoutManager prefetchingLayoutManager = new RecyclerView.LayoutManager() { |
| @Override |
| public RecyclerView.LayoutParams generateDefaultLayoutParams() { |
| return new RecyclerView.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, |
| ViewGroup.LayoutParams.WRAP_CONTENT); |
| } |
| |
| @Override |
| public void collectAdjacentPrefetchPositions(int dx, int dy, RecyclerView.State state, |
| LayoutPrefetchRegistry prefetchManager) { |
| prefetchManager.addPosition(0, 0); |
| prefetchManager.addPosition(1, 0); |
| prefetchManager.addPosition(2, 0); |
| } |
| |
| @Override |
| public void onLayoutChildren(RecyclerView.Recycler recycler, RecyclerView.State state) { |
| } |
| }; |
| mRecyclerView.setLayoutManager(prefetchingLayoutManager); |
| |
| RecyclerView.Adapter mockAdapter = mock(RecyclerView.Adapter.class); |
| when(mockAdapter.onCreateViewHolder(any(ViewGroup.class), anyInt())) |
| .thenAnswer(new Answer<RecyclerView.ViewHolder>() { |
| @Override |
| public RecyclerView.ViewHolder answer(InvocationOnMock invocation) |
| throws Throwable { |
| return new RecyclerView.ViewHolder(new View(getContext())) {}; |
| } |
| }); |
| when(mockAdapter.getItemCount()).thenReturn(10); |
| mRecyclerView.setAdapter(mockAdapter); |
| |
| layout(320, 320); |
| |
| verify(mockAdapter, never()).onCreateViewHolder(any(ViewGroup.class), anyInt()); |
| verify(mockAdapter, never()).onBindViewHolder( |
| any(RecyclerView.ViewHolder.class), anyInt(), any(List.class)); |
| assertTrue(mRecycler.mCachedViews.isEmpty()); |
| |
| // Prefetch multiple times... |
| for (int i = 0; i < 4; i++) { |
| mRecyclerView.mGapWorker.prefetch(RecyclerView.FOREVER_NS); |
| |
| // ...but should only see the same three items fetched/bound once each |
| verify(mockAdapter, times(3)).onCreateViewHolder(any(ViewGroup.class), anyInt()); |
| verify(mockAdapter, times(3)).onBindViewHolder( |
| any(RecyclerView.ViewHolder.class), anyInt(), any(List.class)); |
| |
| assertTrue(mRecycler.mCachedViews.size() == 3); |
| CacheUtils.verifyCacheContainsPrefetchedPositions(mRecyclerView, 0, 1, 2); |
| } |
| } |
| |
| @Test |
| public void prefetchItemsNotEvictedWithInserts() { |
| mRecyclerView.setLayoutManager(new GridLayoutManager(getContext(), 3)); |
| |
| RecyclerView.Adapter mockAdapter = mock(RecyclerView.Adapter.class); |
| when(mockAdapter.onCreateViewHolder(any(ViewGroup.class), anyInt())) |
| .thenAnswer(new Answer<RecyclerView.ViewHolder>() { |
| @Override |
| public RecyclerView.ViewHolder answer(InvocationOnMock invocation) |
| throws Throwable { |
| View view = new View(getContext()); |
| view.setMinimumWidth(100); |
| view.setMinimumHeight(100); |
| return new RecyclerView.ViewHolder(view) {}; |
| } |
| }); |
| when(mockAdapter.getItemCount()).thenReturn(100); |
| mRecyclerView.setAdapter(mockAdapter); |
| |
| layout(300, 100); |
| |
| assertEquals(2, mRecyclerView.mRecycler.mViewCacheMax); |
| mRecyclerView.mPrefetchRegistry.setPrefetchVector(0, 1); |
| mRecyclerView.mGapWorker.prefetch(RecyclerView.FOREVER_NS); |
| assertEquals(5, mRecyclerView.mRecycler.mViewCacheMax); |
| |
| CacheUtils.verifyCacheContainsPrefetchedPositions(mRecyclerView, 3, 4, 5); |
| |
| // further views recycled, as though from scrolling, shouldn't evict prefetched views: |
| mRecycler.recycleView(mRecycler.getViewForPosition(10)); |
| CacheUtils.verifyCacheContainsPositions(mRecyclerView, 3, 4, 5, 10); |
| |
| mRecycler.recycleView(mRecycler.getViewForPosition(20)); |
| CacheUtils.verifyCacheContainsPositions(mRecyclerView, 3, 4, 5, 10, 20); |
| |
| mRecycler.recycleView(mRecycler.getViewForPosition(30)); |
| CacheUtils.verifyCacheContainsPositions(mRecyclerView, 3, 4, 5, 20, 30); |
| |
| mRecycler.recycleView(mRecycler.getViewForPosition(40)); |
| CacheUtils.verifyCacheContainsPositions(mRecyclerView, 3, 4, 5, 30, 40); |
| |
| // After clearing the cache, the prefetch priorities should be cleared as well: |
| mRecyclerView.mRecycler.recycleAndClearCachedViews(); |
| for (int i : new int[] {3, 4, 5, 50, 60, 70, 80, 90}) { |
| mRecycler.recycleView(mRecycler.getViewForPosition(i)); |
| } |
| |
| // cache only contains most recent positions, no priority for previous prefetches: |
| CacheUtils.verifyCacheContainsPositions(mRecyclerView, 50, 60, 70, 80, 90); |
| } |
| |
| @Test |
| public void prefetchItemsNotEvictedOnScroll() { |
| mRecyclerView.setLayoutManager(new GridLayoutManager(getContext(), 3)); |
| |
| // 100x100 pixel views |
| RecyclerView.Adapter mockAdapter = mock(RecyclerView.Adapter.class); |
| when(mockAdapter.onCreateViewHolder(any(ViewGroup.class), anyInt())) |
| .thenAnswer(new Answer<RecyclerView.ViewHolder>() { |
| @Override |
| public RecyclerView.ViewHolder answer(InvocationOnMock invocation) |
| throws Throwable { |
| View view = new View(getContext()); |
| view.setMinimumWidth(100); |
| view.setMinimumHeight(100); |
| return new RecyclerView.ViewHolder(view) {}; |
| } |
| }); |
| when(mockAdapter.getItemCount()).thenReturn(100); |
| mRecyclerView.setAdapter(mockAdapter); |
| |
| // NOTE: requested cache size must be smaller than span count so two rows cannot fit |
| mRecyclerView.setItemViewCacheSize(2); |
| |
| layout(300, 150); |
| mRecyclerView.scrollBy(0, 75); |
| assertTrue(mRecycler.mCachedViews.isEmpty()); |
| |
| // rows 0, 1, and 2 are all attached and visible. Prefetch row 3: |
| mRecyclerView.mPrefetchRegistry.setPrefetchVector(0, 1); |
| mRecyclerView.mGapWorker.prefetch(RecyclerView.FOREVER_NS); |
| |
| // row 3 is cached: |
| CacheUtils.verifyCacheContainsPositions(mRecyclerView, 9, 10, 11); |
| assertTrue(mRecycler.mCachedViews.size() == 3); |
| |
| // Scroll so 1 falls off (though 3 is still not on screen) |
| mRecyclerView.scrollBy(0, 50); |
| |
| // row 3 is still cached, with a couple other recycled views: |
| CacheUtils.verifyCacheContainsPositions(mRecyclerView, 9, 10, 11); |
| assertTrue(mRecycler.mCachedViews.size() == 5); |
| } |
| |
| @Test |
| public void prefetchIsComputingLayout() { |
| mRecyclerView.setLayoutManager(new LinearLayoutManager(getContext())); |
| |
| // 100x100 pixel views |
| RecyclerView.Adapter mockAdapter = mock(RecyclerView.Adapter.class); |
| when(mockAdapter.onCreateViewHolder(any(ViewGroup.class), anyInt())) |
| .thenAnswer(new Answer<RecyclerView.ViewHolder>() { |
| @Override |
| public RecyclerView.ViewHolder answer(InvocationOnMock invocation) |
| throws Throwable { |
| View view = new View(getContext()); |
| view.setMinimumWidth(100); |
| view.setMinimumHeight(100); |
| assertTrue(mRecyclerView.isComputingLayout()); |
| return new RecyclerView.ViewHolder(view) {}; |
| } |
| }); |
| when(mockAdapter.getItemCount()).thenReturn(100); |
| mRecyclerView.setAdapter(mockAdapter); |
| |
| layout(100, 100); |
| |
| verify(mockAdapter, times(1)).onCreateViewHolder(mRecyclerView, 0); |
| |
| // prefetch an item, should still observe isComputingLayout in that create |
| mRecyclerView.mPrefetchRegistry.setPrefetchVector(0, 1); |
| mRecyclerView.mGapWorker.prefetch(RecyclerView.FOREVER_NS); |
| |
| verify(mockAdapter, times(2)).onCreateViewHolder(mRecyclerView, 0); |
| } |
| |
| @Test |
| public void prefetchAfterOrientationChange() { |
| LinearLayoutManager layout = new LinearLayoutManager(getContext(), |
| LinearLayoutManager.VERTICAL, false); |
| mRecyclerView.setLayoutManager(layout); |
| |
| // 100x100 pixel views |
| mRecyclerView.setAdapter(new RecyclerView.Adapter() { |
| @NonNull |
| @Override |
| public RecyclerView.ViewHolder onCreateViewHolder( |
| @NonNull ViewGroup parent, int viewType) { |
| View view = new View(getContext()); |
| view.setMinimumWidth(100); |
| view.setMinimumHeight(100); |
| assertTrue(mRecyclerView.isComputingLayout()); |
| return new RecyclerView.ViewHolder(view) {}; |
| } |
| |
| @Override |
| public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) {} |
| |
| @Override |
| public int getItemCount() { |
| return 100; |
| } |
| }); |
| |
| layout(100, 100); |
| |
| layout.setOrientation(LinearLayoutManager.HORIZONTAL); |
| |
| // Prefetch an item after changing orientation, before layout - shouldn't crash |
| mRecyclerView.mPrefetchRegistry.setPrefetchVector(1, 1); |
| mRecyclerView.mGapWorker.prefetch(RecyclerView.FOREVER_NS); |
| } |
| |
| @Test |
| public void prefetchDrag() { |
| // event dispatch requires a parent |
| ViewGroup parent = new FrameLayout(getContext()); |
| parent.addView(mRecyclerView); |
| |
| |
| mRecyclerView.setLayoutManager( |
| new LinearLayoutManager(getContext(), LinearLayoutManager.VERTICAL, false)); |
| |
| // 1000x1000 pixel views |
| RecyclerView.Adapter adapter = new RecyclerView.Adapter() { |
| @Override |
| public RecyclerView.ViewHolder onCreateViewHolder( |
| @NonNull ViewGroup parent, int viewType) { |
| mRecyclerView.registerTimePassingMs(5); |
| View view = new View(getContext()); |
| view.setMinimumWidth(1000); |
| view.setMinimumHeight(1000); |
| return new RecyclerView.ViewHolder(view) {}; |
| } |
| |
| @Override |
| public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) { |
| mRecyclerView.registerTimePassingMs(5); |
| } |
| |
| @Override |
| public int getItemCount() { |
| return 100; |
| } |
| }; |
| mRecyclerView.setAdapter(adapter); |
| |
| layout(1000, 1000); |
| |
| long time = SystemClock.uptimeMillis(); |
| mRecyclerView.onTouchEvent( |
| MotionEvent.obtain(time, time, MotionEvent.ACTION_DOWN, 500, 1000, 0)); |
| |
| assertEquals(0, mRecyclerView.mPrefetchRegistry.mPrefetchDx); |
| assertEquals(0, mRecyclerView.mPrefetchRegistry.mPrefetchDy); |
| |
| // Consume slop |
| mRecyclerView.onTouchEvent( |
| MotionEvent.obtain(time, time, MotionEvent.ACTION_MOVE, 50, 500, 0)); |
| |
| // move by 0,30 |
| mRecyclerView.onTouchEvent( |
| MotionEvent.obtain(time, time, MotionEvent.ACTION_MOVE, 50, 470, 0)); |
| assertEquals(0, mRecyclerView.mPrefetchRegistry.mPrefetchDx); |
| assertEquals(30, mRecyclerView.mPrefetchRegistry.mPrefetchDy); |
| |
| // move by 10,15 |
| mRecyclerView.onTouchEvent( |
| MotionEvent.obtain(time, time, MotionEvent.ACTION_MOVE, 40, 455, 0)); |
| assertEquals(10, mRecyclerView.mPrefetchRegistry.mPrefetchDx); |
| assertEquals(15, mRecyclerView.mPrefetchRegistry.mPrefetchDy); |
| |
| // move by 0,0 - IGNORED |
| mRecyclerView.onTouchEvent( |
| MotionEvent.obtain(time, time, MotionEvent.ACTION_MOVE, 40, 455, 0)); |
| assertEquals(10, mRecyclerView.mPrefetchRegistry.mPrefetchDx); // same as prev |
| assertEquals(15, mRecyclerView.mPrefetchRegistry.mPrefetchDy); // same as prev |
| } |
| |
| @Test |
| public void prefetchItemsRespectDeadline() { |
| mRecyclerView.setLayoutManager(new GridLayoutManager(getContext(), 3)); |
| |
| // 100x100 pixel views |
| RecyclerView.Adapter adapter = new RecyclerView.Adapter() { |
| @Override |
| public RecyclerView.ViewHolder onCreateViewHolder( |
| @NonNull ViewGroup parent, int viewType) { |
| mRecyclerView.registerTimePassingMs(5); |
| View view = new View(getContext()); |
| view.setMinimumWidth(100); |
| view.setMinimumHeight(100); |
| return new RecyclerView.ViewHolder(view) {}; |
| } |
| |
| @Override |
| public void onBindViewHolder( |
| @NonNull RecyclerView.ViewHolder holder, int position) { |
| mRecyclerView.registerTimePassingMs(5); |
| } |
| |
| @Override |
| public int getItemCount() { |
| return 100; |
| } |
| }; |
| mRecyclerView.setAdapter(adapter); |
| |
| layout(300, 300); |
| |
| // offset scroll so that no prefetch-able views are directly adjacent to viewport |
| mRecyclerView.scrollBy(0, 50); |
| |
| assertTrue(mRecycler.mCachedViews.size() == 0); |
| assertTrue(mRecyclerView.getRecycledViewPool().getRecycledViewCount(0) == 0); |
| |
| // Should take 15 ms to inflate, bind, inflate, so give 19 to be safe |
| final long deadlineNs = mRecyclerView.getNanoTime() + TimeUnit.MILLISECONDS.toNanos(19); |
| |
| // Timed prefetch |
| mRecyclerView.mPrefetchRegistry.setPrefetchVector(0, 1); |
| mRecyclerView.mGapWorker.prefetch(deadlineNs); |
| |
| // will have enough time to inflate/bind one view, and inflate another |
| assertTrue(mRecycler.mCachedViews.size() == 1); |
| assertTrue(mRecyclerView.getRecycledViewPool().getRecycledViewCount(0) == 1); |
| // Note: order/view below is an implementation detail |
| CacheUtils.verifyCacheContainsPositions(mRecyclerView, 12); |
| |
| |
| // Unbounded prefetch this time |
| mRecyclerView.mGapWorker.prefetch(RecyclerView.FOREVER_NS); |
| |
| // Should finish all work |
| assertTrue(mRecycler.mCachedViews.size() == 3); |
| assertTrue(mRecyclerView.getRecycledViewPool().getRecycledViewCount(0) == 0); |
| CacheUtils.verifyCacheContainsPositions(mRecyclerView, 12, 13, 14); |
| } |
| |
| @Test |
| public void partialPrefetchAvoidsViewRecycledCallback() { |
| mRecyclerView.setLayoutManager(new LinearLayoutManager(getContext())); |
| |
| // 100x100 pixel views |
| RecyclerView.Adapter adapter = new RecyclerView.Adapter() { |
| @Override |
| public RecyclerView.ViewHolder onCreateViewHolder( |
| @NonNull ViewGroup parent, int viewType) { |
| mRecyclerView.registerTimePassingMs(5); |
| View view = new View(getContext()); |
| view.setMinimumWidth(100); |
| view.setMinimumHeight(100); |
| return new RecyclerView.ViewHolder(view) {}; |
| } |
| |
| @Override |
| public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) { |
| mRecyclerView.registerTimePassingMs(5); |
| } |
| |
| @Override |
| public int getItemCount() { |
| return 100; |
| } |
| |
| @Override |
| public void onViewRecycled(@NonNull RecyclerView.ViewHolder holder) { |
| // verify unbound view doesn't get |
| assertNotEquals(RecyclerView.NO_POSITION, holder.getAdapterPosition()); |
| } |
| }; |
| mRecyclerView.setAdapter(adapter); |
| |
| layout(100, 300); |
| |
| // offset scroll so that no prefetch-able views are directly adjacent to viewport |
| mRecyclerView.scrollBy(0, 50); |
| |
| assertTrue(mRecycler.mCachedViews.size() == 0); |
| assertTrue(mRecyclerView.getRecycledViewPool().getRecycledViewCount(0) == 0); |
| |
| // Should take 10 ms to inflate + bind, so just give it 9 so it doesn't have time to bind |
| final long deadlineNs = mRecyclerView.getNanoTime() + TimeUnit.MILLISECONDS.toNanos(9); |
| |
| // Timed prefetch |
| mRecyclerView.mPrefetchRegistry.setPrefetchVector(0, 1); |
| mRecyclerView.mGapWorker.prefetch(deadlineNs); |
| |
| // will have enough time to inflate but not bind one view |
| assertTrue(mRecycler.mCachedViews.size() == 0); |
| assertTrue(mRecyclerView.getRecycledViewPool().getRecycledViewCount(0) == 1); |
| RecyclerView.ViewHolder pooledHolder = mRecyclerView.getRecycledViewPool() |
| .mScrap.get(0).mScrapHeap.get(0); |
| assertEquals(RecyclerView.NO_POSITION, pooledHolder.getAdapterPosition()); |
| } |
| |
| @Test |
| public void prefetchStaggeredItemsPriority() { |
| StaggeredGridLayoutManager sglm = |
| new StaggeredGridLayoutManager(2, StaggeredGridLayoutManager.VERTICAL); |
| mRecyclerView.setLayoutManager(sglm); |
| |
| // first view 50x100 pixels, rest are 100x100 so second column is offset |
| mRecyclerView.setAdapter(new RecyclerView.Adapter() { |
| @NonNull |
| @Override |
| public RecyclerView.ViewHolder onCreateViewHolder( |
| @NonNull ViewGroup parent, int viewType) { |
| return new RecyclerView.ViewHolder(new View(getContext())) {}; |
| } |
| |
| @Override |
| public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) { |
| holder.itemView.setMinimumWidth(100); |
| holder.itemView.setMinimumHeight(position == 0 ? 50 : 100); |
| } |
| |
| @Override |
| public int getItemCount() { |
| return 100; |
| } |
| }); |
| |
| layout(200, 200); |
| |
| /* Each row is 50 pixels: |
| * ------------- * |
| * 0 | 1 * |
| * 2 | 1 * |
| * 2 | 3 * |
| *___4___|___3___* |
| * 4 | 5 * |
| * 6 | 5 * |
| * ... * |
| */ |
| assertEquals(5, mRecyclerView.getChildCount()); |
| assertEquals(0, sglm.getFirstChildPosition()); |
| assertEquals(4, sglm.getLastChildPosition()); |
| |
| // prefetching down shows 5 at 0 pixels away, 6 at 50 pixels away |
| CacheUtils.verifyPositionsPrefetched(mRecyclerView, 0, 10, |
| new Integer[] {5, 0}, new Integer[] {6, 50}); |
| |
| // Prefetch upward shows nothing |
| CacheUtils.verifyPositionsPrefetched(mRecyclerView, 0, -10); |
| |
| mRecyclerView.scrollBy(0, 100); |
| |
| /* Each row is 50 pixels: |
| * ------------- * |
| * 0 | 1 * |
| *___2___|___1___* |
| * 2 | 3 * |
| * 4 | 3 * |
| * 4 | 5 * |
| *___6___|___5___* |
| * 6 | 7 * |
| * 8 | 7 * |
| * ... * |
| */ |
| |
| assertEquals(5, mRecyclerView.getChildCount()); |
| assertEquals(2, sglm.getFirstChildPosition()); |
| assertEquals(6, sglm.getLastChildPosition()); |
| |
| // prefetching down shows 7 at 0 pixels away, 8 at 50 pixels away |
| CacheUtils.verifyPositionsPrefetched(mRecyclerView, 0, 10, |
| new Integer[] {7, 0}, new Integer[] {8, 50}); |
| |
| // prefetching up shows 1 is 0 pixels away, 0 at 50 pixels away |
| CacheUtils.verifyPositionsPrefetched(mRecyclerView, 0, -10, |
| new Integer[] {1, 0}, new Integer[] {0, 50}); |
| } |
| |
| @Test |
| public void prefetchStaggeredPastBoundary() { |
| StaggeredGridLayoutManager sglm = |
| new StaggeredGridLayoutManager(2, StaggeredGridLayoutManager.VERTICAL); |
| mRecyclerView.setLayoutManager(sglm); |
| mRecyclerView.setAdapter(new RecyclerView.Adapter() { |
| @NonNull |
| @Override |
| public RecyclerView.ViewHolder onCreateViewHolder( |
| @NonNull ViewGroup parent, int viewType) { |
| return new RecyclerView.ViewHolder(new View(getContext())) {}; |
| } |
| |
| @Override |
| public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) { |
| holder.itemView.setMinimumWidth(100); |
| holder.itemView.setMinimumHeight(position == 0 ? 100 : 200); |
| } |
| |
| @Override |
| public int getItemCount() { |
| return 2; |
| } |
| }); |
| |
| layout(200, 100); |
| mRecyclerView.scrollBy(0, 50); |
| |
| /* Each row is 50 pixels: |
| * ------------- * |
| *___0___|___1___* |
| * 0 | 1 * |
| *_______|___1___* |
| * | 1 * |
| */ |
| assertEquals(2, mRecyclerView.getChildCount()); |
| assertEquals(0, sglm.getFirstChildPosition()); |
| assertEquals(1, sglm.getLastChildPosition()); |
| |
| // prefetch upward gets nothing |
| CacheUtils.verifyPositionsPrefetched(mRecyclerView, 0, -10); |
| |
| // prefetch downward gets nothing (and doesn't crash...) |
| CacheUtils.verifyPositionsPrefetched(mRecyclerView, 0, 10); |
| } |
| |
| @Test |
| public void prefetchItemsSkipAnimations() { |
| LinearLayoutManager llm = new LinearLayoutManager(getContext()); |
| mRecyclerView.setLayoutManager(llm); |
| final int[] expandedPosition = new int[] {-1}; |
| |
| final RecyclerView.Adapter adapter = new RecyclerView.Adapter() { |
| @Override |
| public RecyclerView.ViewHolder onCreateViewHolder( |
| @NonNull ViewGroup parent, int viewType) { |
| return new RecyclerView.ViewHolder(new View(parent.getContext())) {}; |
| } |
| |
| @Override |
| public void onBindViewHolder(@NonNull RecyclerView.ViewHolder holder, int position) { |
| int height = expandedPosition[0] == position ? 400 : 100; |
| holder.itemView.setLayoutParams(new RecyclerView.LayoutParams(200, height)); |
| } |
| |
| @Override |
| public int getItemCount() { |
| return 10; |
| } |
| }; |
| |
| // make move duration long enough to be able to see the effects |
| RecyclerView.ItemAnimator itemAnimator = mRecyclerView.getItemAnimator(); |
| itemAnimator.setMoveDuration(10000); |
| mRecyclerView.setAdapter(adapter); |
| |
| layout(200, 400); |
| |
| expandedPosition[0] = 1; |
| // insert payload to avoid creating a new view |
| adapter.notifyItemChanged(1, new Object()); |
| |
| layout(200, 400); |
| layout(200, 400); |
| |
| assertTrue(itemAnimator.isRunning()); |
| assertEquals(2, llm.getChildCount()); |
| assertEquals(4, mRecyclerView.getChildCount()); |
| |
| // animating view should be observable as hidden, uncached... |
| CacheUtils.verifyCacheDoesNotContainPositions(mRecyclerView, 2); |
| assertNotNull("Animating view should be found, hidden", |
| mRecyclerView.mChildHelper.findHiddenNonRemovedView(2)); |
| assertTrue(GapWorker.isPrefetchPositionAttached(mRecyclerView, 2)); |
| |
| // ...but must not be removed for prefetch |
| mRecyclerView.mPrefetchRegistry.setPrefetchVector(0, 1); |
| mRecyclerView.mGapWorker.prefetch(RecyclerView.FOREVER_NS); |
| |
| assertEquals("Prefetch must target one view", 1, mRecyclerView.mPrefetchRegistry.mCount); |
| int prefetchTarget = mRecyclerView.mPrefetchRegistry.mPrefetchArray[0]; |
| assertEquals("Prefetch must target view 2", 2, prefetchTarget); |
| |
| // animating view still observable as hidden, uncached |
| CacheUtils.verifyCacheDoesNotContainPositions(mRecyclerView, 2); |
| assertNotNull("Animating view should be found, hidden", |
| mRecyclerView.mChildHelper.findHiddenNonRemovedView(2)); |
| assertTrue(GapWorker.isPrefetchPositionAttached(mRecyclerView, 2)); |
| |
| assertTrue(itemAnimator.isRunning()); |
| assertEquals(2, llm.getChildCount()); |
| assertEquals(4, mRecyclerView.getChildCount()); |
| } |
| |
| @Test |
| public void viewHolderFindsNestedRecyclerViews() { |
| LinearLayoutManager llm = new LinearLayoutManager(getContext()); |
| mRecyclerView.setLayoutManager(llm); |
| |
| RecyclerView.Adapter mockAdapter = mock(RecyclerView.Adapter.class); |
| when(mockAdapter.onCreateViewHolder(any(ViewGroup.class), anyInt())) |
| .thenAnswer(new Answer<RecyclerView.ViewHolder>() { |
| @Override |
| public RecyclerView.ViewHolder answer(InvocationOnMock invocation) |
| throws Throwable { |
| View view = new RecyclerView(getContext()); |
| view.setLayoutParams(new RecyclerView.LayoutParams(100, 100)); |
| return new RecyclerView.ViewHolder(view) {}; |
| } |
| }); |
| when(mockAdapter.getItemCount()).thenReturn(100); |
| mRecyclerView.setAdapter(mockAdapter); |
| |
| layout(100, 200); |
| |
| verify(mockAdapter, times(2)).onCreateViewHolder(any(ViewGroup.class), anyInt()); |
| verify(mockAdapter, times(2)).onBindViewHolder( |
| argThat(new ArgumentMatcher<RecyclerView.ViewHolder>() { |
| @Override |
| public boolean matches(RecyclerView.ViewHolder holder) { |
| return holder.itemView == holder.mNestedRecyclerView.get(); |
| } |
| }), |
| anyInt(), |
| any(List.class)); |
| } |
| |
| class InnerAdapter extends RecyclerView.Adapter<InnerAdapter.ViewHolder> { |
| private static final int INNER_ITEM_COUNT = 20; |
| int mItemsBound = 0; |
| |
| class ViewHolder extends RecyclerView.ViewHolder { |
| ViewHolder(View itemView) { |
| super(itemView); |
| } |
| } |
| |
| InnerAdapter() {} |
| |
| @Override |
| public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { |
| mRecyclerView.registerTimePassingMs(5); |
| View view = new View(parent.getContext()); |
| view.setLayoutParams(new RecyclerView.LayoutParams(100, 100)); |
| return new ViewHolder(view); |
| } |
| |
| @Override |
| public void onBindViewHolder(@NonNull ViewHolder holder, int position) { |
| mRecyclerView.registerTimePassingMs(5); |
| mItemsBound++; |
| } |
| |
| @Override |
| public int getItemCount() { |
| return INNER_ITEM_COUNT; |
| } |
| } |
| |
| class OuterAdapter extends RecyclerView.Adapter<OuterAdapter.ViewHolder> { |
| |
| private boolean mReverseInner; |
| |
| class ViewHolder extends RecyclerView.ViewHolder { |
| private final RecyclerView mRecyclerView; |
| ViewHolder(RecyclerView itemView) { |
| super(itemView); |
| mRecyclerView = itemView; |
| } |
| } |
| |
| ArrayList<InnerAdapter> mAdapters = new ArrayList<>(); |
| ArrayList<Parcelable> mSavedStates = new ArrayList<>(); |
| RecyclerView.RecycledViewPool mSharedPool = new RecyclerView.RecycledViewPool(); |
| |
| OuterAdapter() { |
| this(false); |
| } |
| |
| OuterAdapter(boolean reverseInner) { |
| this(reverseInner, 10); |
| } |
| |
| OuterAdapter(boolean reverseInner, int itemCount) { |
| mReverseInner = reverseInner; |
| for (int i = 0; i < itemCount; i++) { |
| mAdapters.add(new InnerAdapter()); |
| mSavedStates.add(null); |
| } |
| } |
| |
| void addItem() { |
| int index = getItemCount(); |
| mAdapters.add(new InnerAdapter()); |
| mSavedStates.add(null); |
| notifyItemInserted(index); |
| } |
| |
| @Override |
| public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { |
| mRecyclerView.registerTimePassingMs(5); |
| |
| RecyclerView rv = new RecyclerView(parent.getContext()) { |
| @Override |
| public int getWindowVisibility() { |
| // Pretend to be visible to avoid being filtered out |
| return View.VISIBLE; |
| } |
| }; |
| rv.setLayoutManager(new LinearLayoutManager(parent.getContext(), |
| LinearLayoutManager.HORIZONTAL, mReverseInner)); |
| rv.setRecycledViewPool(mSharedPool); |
| rv.setLayoutParams(new RecyclerView.LayoutParams(200, 100)); |
| return new ViewHolder(rv); |
| } |
| |
| @Override |
| public void onBindViewHolder(@NonNull ViewHolder holder, int position) { |
| mRecyclerView.registerTimePassingMs(5); |
| |
| // Tests may rely on bound holders not being shared between inner adapters, |
| // since we force recycle here |
| holder.mRecyclerView.swapAdapter(mAdapters.get(position), true); |
| |
| Parcelable savedState = mSavedStates.get(position); |
| if (savedState != null) { |
| holder.mRecyclerView.getLayoutManager().onRestoreInstanceState(savedState); |
| mSavedStates.set(position, null); |
| } |
| } |
| |
| @Override |
| public void onViewRecycled(@NonNull ViewHolder holder) { |
| mSavedStates.set(holder.getAdapterPosition(), |
| holder.mRecyclerView.getLayoutManager().onSaveInstanceState()); |
| } |
| |
| @Override |
| public int getItemCount() { |
| return mAdapters.size(); |
| } |
| } |
| |
| @Test |
| public void nestedPrefetchSimple() { |
| LinearLayoutManager llm = new LinearLayoutManager(getContext()); |
| assertEquals(2, llm.getInitialPrefetchItemCount()); |
| |
| mRecyclerView.setLayoutManager(llm); |
| mRecyclerView.setAdapter(new OuterAdapter()); |
| |
| layout(200, 200); |
| mRecyclerView.mPrefetchRegistry.setPrefetchVector(0, 1); |
| |
| // prefetch 2 (default) |
| mRecyclerView.mGapWorker.prefetch(RecyclerView.FOREVER_NS); |
| RecyclerView.ViewHolder holder = CacheUtils.peekAtCachedViewForPosition(mRecyclerView, 2); |
| assertNotNull(holder); |
| assertNotNull(holder.mNestedRecyclerView); |
| RecyclerView innerView = holder.mNestedRecyclerView.get(); |
| CacheUtils.verifyCacheContainsPrefetchedPositions(innerView, 0, 1); |
| |
| // prefetch 4 |
| ((LinearLayoutManager) innerView.getLayoutManager()) |
| .setInitialPrefetchItemCount(4); |
| mRecyclerView.mGapWorker.prefetch(RecyclerView.FOREVER_NS); |
| CacheUtils.verifyCacheContainsPrefetchedPositions(innerView, 0, 1, 2, 3); |
| } |
| |
| @Test |
| public void nestedPrefetchNotClearInnerStructureChangeFlag() { |
| LinearLayoutManager llm = new LinearLayoutManager(getContext()); |
| assertEquals(2, llm.getInitialPrefetchItemCount()); |
| |
| mRecyclerView.setLayoutManager(llm); |
| mRecyclerView.setAdapter(new OuterAdapter()); |
| |
| layout(200, 200); |
| mRecyclerView.mPrefetchRegistry.setPrefetchVector(0, 1); |
| |
| // prefetch 2 (default) |
| mRecyclerView.mGapWorker.prefetch(RecyclerView.FOREVER_NS); |
| RecyclerView.ViewHolder holder = CacheUtils.peekAtCachedViewForPosition(mRecyclerView, 2); |
| assertNotNull(holder); |
| assertNotNull(holder.mNestedRecyclerView); |
| RecyclerView innerView = holder.mNestedRecyclerView.get(); |
| RecyclerView.Adapter innerAdapter = innerView.getAdapter(); |
| CacheUtils.verifyCacheContainsPrefetchedPositions(innerView, 0, 1); |
| // mStructureChanged is initially true before first layout pass. |
| assertTrue(innerView.mState.mStructureChanged); |
| assertTrue(innerView.hasPendingAdapterUpdates()); |
| |
| // layout position 2 and clear mStructureChanged |
| mRecyclerView.scrollToPosition(2); |
| layout(200, 200); |
| mRecyclerView.scrollToPosition(0); |
| layout(200, 200); |
| assertFalse(innerView.mState.mStructureChanged); |
| assertFalse(innerView.hasPendingAdapterUpdates()); |
| |
| // notify change on the cached innerView. |
| innerAdapter.notifyDataSetChanged(); |
| assertTrue(innerView.mState.mStructureChanged); |
| assertTrue(innerView.hasPendingAdapterUpdates()); |
| |
| // prefetch again |
| mRecyclerView.mPrefetchRegistry.setPrefetchVector(0, 1); |
| ((LinearLayoutManager) innerView.getLayoutManager()) |
| .setInitialPrefetchItemCount(2); |
| mRecyclerView.mGapWorker.prefetch(RecyclerView.FOREVER_NS); |
| CacheUtils.verifyCacheContainsPrefetchedPositions(innerView, 0, 1); |
| |
| // The re-prefetch is not necessary get the same inner view but we will get same Adapter |
| holder = CacheUtils.peekAtCachedViewForPosition(mRecyclerView, 2); |
| innerView = holder.mNestedRecyclerView.get(); |
| assertSame(innerAdapter, innerView.getAdapter()); |
| // prefetch shouldn't clear the mStructureChanged flag |
| assertTrue(innerView.mState.mStructureChanged); |
| assertTrue(innerView.hasPendingAdapterUpdates()); |
| } |
| |
| @Test |
| public void nestedPrefetchReverseInner() { |
| mRecyclerView.setLayoutManager(new LinearLayoutManager(getContext())); |
| mRecyclerView.setAdapter(new OuterAdapter(/* reverseInner = */ true)); |
| |
| layout(200, 200); |
| mRecyclerView.mPrefetchRegistry.setPrefetchVector(0, 1); |
| |
| mRecyclerView.mGapWorker.prefetch(RecyclerView.FOREVER_NS); |
| RecyclerView.ViewHolder holder = CacheUtils.peekAtCachedViewForPosition(mRecyclerView, 2); |
| |
| // anchor from right side, should see last two positions |
| CacheUtils.verifyCacheContainsPrefetchedPositions(holder.mNestedRecyclerView.get(), 18, 19); |
| } |
| |
| @Test |
| public void nestedPrefetchOffset() { |
| mRecyclerView.setLayoutManager(new LinearLayoutManager(getContext())); |
| mRecyclerView.setAdapter(new OuterAdapter()); |
| |
| layout(200, 200); |
| |
| // Scroll top row by 5.5 items, verify positions 5, 6, 7 showing |
| RecyclerView inner = (RecyclerView) mRecyclerView.getChildAt(0); |
| inner.scrollBy(550, 0); |
| assertEquals(5, RecyclerView.getChildViewHolderInt(inner.getChildAt(0)).mPosition); |
| assertEquals(6, RecyclerView.getChildViewHolderInt(inner.getChildAt(1)).mPosition); |
| assertEquals(7, RecyclerView.getChildViewHolderInt(inner.getChildAt(2)).mPosition); |
| |
| // scroll down 4 rows, up 3 so row 0 is adjacent but uncached |
| mRecyclerView.scrollBy(0, 400); |
| mRecyclerView.scrollBy(0, -300); |
| |
| // top row no longer present |
| CacheUtils.verifyCacheDoesNotContainPositions(mRecyclerView, 0); |
| |
| // prefetch upward, and validate that we've gotten the top row with correct offsets |
| mRecyclerView.mPrefetchRegistry.setPrefetchVector(0, -1); |
| mRecyclerView.mGapWorker.prefetch(RecyclerView.FOREVER_NS); |
| inner = (RecyclerView) CacheUtils.peekAtCachedViewForPosition(mRecyclerView, 0).itemView; |
| CacheUtils.verifyCacheContainsPrefetchedPositions(inner, 5, 6); |
| |
| // prefetch 4 |
| ((LinearLayoutManager) inner.getLayoutManager()).setInitialPrefetchItemCount(4); |
| mRecyclerView.mGapWorker.prefetch(RecyclerView.FOREVER_NS); |
| CacheUtils.verifyCacheContainsPrefetchedPositions(inner, 5, 6, 7, 8); |
| } |
| |
| @Test |
| public void nestedPrefetchNotReset() { |
| mRecyclerView.setLayoutManager(new LinearLayoutManager(getContext())); |
| OuterAdapter outerAdapter = new OuterAdapter(); |
| mRecyclerView.setAdapter(outerAdapter); |
| |
| layout(200, 200); |
| |
| mRecyclerView.mPrefetchRegistry.setPrefetchVector(0, 1); |
| |
| // prefetch row 2, items 0 & 1 |
| assertEquals(0, outerAdapter.mAdapters.get(2).mItemsBound); |
| mRecyclerView.mGapWorker.prefetch(RecyclerView.FOREVER_NS); |
| RecyclerView.ViewHolder holder = CacheUtils.peekAtCachedViewForPosition(mRecyclerView, 2); |
| RecyclerView innerRecyclerView = holder.mNestedRecyclerView.get(); |
| |
| assertNotNull(innerRecyclerView); |
| CacheUtils.verifyCacheContainsPrefetchedPositions(innerRecyclerView, 0, 1); |
| assertEquals(2, outerAdapter.mAdapters.get(2).mItemsBound); |
| |
| // new row comes on, triggers layout... |
| mRecyclerView.scrollBy(0, 50); |
| |
| // ... which shouldn't require new items to be bound, |
| // as prefetch has already done that work |
| assertEquals(2, outerAdapter.mAdapters.get(2).mItemsBound); |
| } |
| |
| static void validateRvChildrenValid(RecyclerView recyclerView, int childCount) { |
| ChildHelper childHelper = recyclerView.mChildHelper; |
| |
| assertEquals(childCount, childHelper.getUnfilteredChildCount()); |
| for (int i = 0; i < childHelper.getUnfilteredChildCount(); i++) { |
| assertFalse(recyclerView.getChildViewHolder( |
| childHelper.getUnfilteredChildAt(i)).isInvalid()); |
| } |
| } |
| |
| @Test |
| public void nestedPrefetchCacheNotTouched() { |
| mRecyclerView.setLayoutManager(new LinearLayoutManager(getContext())); |
| OuterAdapter outerAdapter = new OuterAdapter(); |
| mRecyclerView.setAdapter(outerAdapter); |
| |
| layout(200, 200); |
| mRecyclerView.scrollBy(0, 100); |
| |
| // item 0 is cached |
| assertEquals(2, outerAdapter.mAdapters.get(0).mItemsBound); |
| RecyclerView.ViewHolder holder = CacheUtils.peekAtCachedViewForPosition(mRecyclerView, 0); |
| validateRvChildrenValid(holder.mNestedRecyclerView.get(), 2); |
| |
| // try and prefetch it |
| mRecyclerView.mPrefetchRegistry.setPrefetchVector(0, -1); |
| mRecyclerView.mGapWorker.prefetch(RecyclerView.FOREVER_NS); |
| |
| // make sure cache's inner items aren't rebound unnecessarily |
| assertEquals(2, outerAdapter.mAdapters.get(0).mItemsBound); |
| validateRvChildrenValid(holder.mNestedRecyclerView.get(), 2); |
| } |
| |
| @Test |
| public void nestedRemoveAnimatingView() { |
| mRecyclerView.setLayoutManager(new LinearLayoutManager(getContext())); |
| OuterAdapter outerAdapter = new OuterAdapter(false, 1); |
| mRecyclerView.setAdapter(outerAdapter); |
| mRecyclerView.getItemAnimator().setAddDuration(TimeUnit.MILLISECONDS.toNanos(30)); |
| |
| layout(200, 200); |
| |
| // Insert 3 items - only first one in viewport, so only it animates |
| for (int i = 0; i < 3; i++) { |
| outerAdapter.addItem(); |
| } |
| layout(200, 200); // layout again to kick off animation |
| |
| |
| // item 1 is animating, so scroll it out of viewport |
| mRecyclerView.scrollBy(0, 200); |
| |
| // 2 items attached, 1 cached (pos 0), but item animating pos 1 not accounted for... |
| assertEquals(2, mRecyclerView.mChildHelper.getUnfilteredChildCount()); |
| assertEquals(1, mRecycler.mCachedViews.size()); |
| CacheUtils.verifyCacheContainsPositions(mRecyclerView, 0); |
| assertEquals(0, mRecyclerView.getRecycledViewPool().getRecycledViewCount(0)); |
| |
| // until animation ends |
| mRecyclerView.getItemAnimator().endAnimations(); |
| assertEquals(2, mRecyclerView.mChildHelper.getUnfilteredChildCount()); |
| assertEquals(2, mRecycler.mCachedViews.size()); |
| CacheUtils.verifyCacheContainsPositions(mRecyclerView, 0, 1); |
| assertEquals(0, mRecyclerView.getRecycledViewPool().getRecycledViewCount(0)); |
| |
| for (RecyclerView.ViewHolder viewHolder : mRecycler.mCachedViews) { |
| assertNotNull(viewHolder.mNestedRecyclerView); |
| } |
| } |
| |
| @Test |
| public void nestedExpandCacheCorrectly() { |
| final int DEFAULT_CACHE_SIZE = RecyclerView.Recycler.DEFAULT_CACHE_SIZE; |
| |
| mRecyclerView.setLayoutManager(new LinearLayoutManager(getContext())); |
| OuterAdapter outerAdapter = new OuterAdapter(); |
| mRecyclerView.setAdapter(outerAdapter); |
| |
| layout(200, 200); |
| |
| mRecyclerView.mPrefetchRegistry.setPrefetchVector(0, 1); |
| mRecyclerView.mGapWorker.prefetch(RecyclerView.FOREVER_NS); |
| |
| // after initial prefetch, view cache max expanded by number of inner items prefetched (2) |
| RecyclerView.ViewHolder holder = CacheUtils.peekAtCachedViewForPosition(mRecyclerView, 2); |
| RecyclerView innerView = holder.mNestedRecyclerView.get(); |
| assertTrue(innerView.getLayoutManager().mPrefetchMaxObservedInInitialPrefetch); |
| assertEquals(2, innerView.getLayoutManager().mPrefetchMaxCountObserved); |
| assertEquals(2 + DEFAULT_CACHE_SIZE, innerView.mRecycler.mViewCacheMax); |
| |
| try { |
| // Note: As a hack, we not only must manually dispatch attachToWindow(), but we |
| // also have to be careful to call innerView.mGapWorker below. mRecyclerView.mGapWorker |
| // is registered to the wrong thread, since @setup is called on a different thread |
| // from @Test. Assert this, so this test can be fixed when setup == test thread. |
| assertEquals(1, mRecyclerView.mGapWorker.mRecyclerViews.size()); |
| assertFalse(innerView.isAttachedToWindow()); |
| innerView.onAttachedToWindow(); |
| |
| // bring prefetch view into viewport, at which point it shouldn't have cache expanded... |
| mRecyclerView.scrollBy(0, 100); |
| assertFalse(innerView.getLayoutManager().mPrefetchMaxObservedInInitialPrefetch); |
| assertEquals(0, innerView.getLayoutManager().mPrefetchMaxCountObserved); |
| assertEquals(DEFAULT_CACHE_SIZE, innerView.mRecycler.mViewCacheMax); |
| |
| // until a valid horizontal prefetch caches an item, and expands view count by one |
| innerView.mPrefetchRegistry.setPrefetchVector(1, 0); |
| innerView.mGapWorker.prefetch(RecyclerView.FOREVER_NS); // NB: must be innerView.mGapWorker |
| assertFalse(innerView.getLayoutManager().mPrefetchMaxObservedInInitialPrefetch); |
| assertEquals(1, innerView.getLayoutManager().mPrefetchMaxCountObserved); |
| assertEquals(1 + DEFAULT_CACHE_SIZE, innerView.mRecycler.mViewCacheMax); |
| } finally { |
| if (innerView.isAttachedToWindow()) { |
| innerView.onDetachedFromWindow(); |
| } |
| } |
| } |
| |
| /** |
| * Similar to OuterAdapter above, but uses notifyDataSetChanged() instead of set/swapAdapter |
| * to update data for the inner RecyclerViews when containing ViewHolder is bound. |
| */ |
| class OuterNotifyAdapter extends RecyclerView.Adapter<OuterNotifyAdapter.ViewHolder> { |
| private static final int OUTER_ITEM_COUNT = 10; |
| |
| private boolean mReverseInner; |
| |
| class ViewHolder extends RecyclerView.ViewHolder { |
| private final RecyclerView mRecyclerView; |
| private final InnerAdapter mAdapter; |
| ViewHolder(RecyclerView itemView) { |
| super(itemView); |
| mRecyclerView = itemView; |
| mAdapter = new InnerAdapter(); |
| mRecyclerView.setAdapter(mAdapter); |
| } |
| } |
| |
| ArrayList<Parcelable> mSavedStates = new ArrayList<>(); |
| RecyclerView.RecycledViewPool mSharedPool = new RecyclerView.RecycledViewPool(); |
| |
| OuterNotifyAdapter() { |
| this(false); |
| } |
| |
| OuterNotifyAdapter(boolean reverseInner) { |
| mReverseInner = reverseInner; |
| for (int i = 0; i <= OUTER_ITEM_COUNT; i++) { |
| mSavedStates.add(null); |
| } |
| } |
| |
| @NonNull |
| @Override |
| public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { |
| mRecyclerView.registerTimePassingMs(5); |
| RecyclerView rv = new RecyclerView(parent.getContext()); |
| rv.setLayoutManager(new LinearLayoutManager(parent.getContext(), |
| LinearLayoutManager.HORIZONTAL, mReverseInner)); |
| rv.setRecycledViewPool(mSharedPool); |
| rv.setLayoutParams(new RecyclerView.LayoutParams(200, 100)); |
| return new ViewHolder(rv); |
| } |
| |
| @Override |
| public void onBindViewHolder(@NonNull ViewHolder holder, int position) { |
| mRecyclerView.registerTimePassingMs(5); |
| // if we had actual data to put into our adapter, this is where we'd do it... |
| |
| // ... then notify the adapter that it has new content: |
| holder.mAdapter.notifyDataSetChanged(); |
| |
| Parcelable savedState = mSavedStates.get(position); |
| if (savedState != null) { |
| holder.mRecyclerView.getLayoutManager().onRestoreInstanceState(savedState); |
| mSavedStates.set(position, null); |
| } |
| } |
| |
| @Override |
| public void onViewRecycled(@NonNull ViewHolder holder) { |
| if (holder.getAdapterPosition() >= 0) { |
| mSavedStates.set(holder.getAdapterPosition(), |
| holder.mRecyclerView.getLayoutManager().onSaveInstanceState()); |
| } |
| } |
| |
| @Override |
| public int getItemCount() { |
| return OUTER_ITEM_COUNT; |
| } |
| } |
| |
| @Test |
| public void nestedPrefetchDiscardStaleChildren() { |
| LinearLayoutManager llm = new LinearLayoutManager(getContext()); |
| assertEquals(2, llm.getInitialPrefetchItemCount()); |
| |
| mRecyclerView.setLayoutManager(llm); |
| OuterNotifyAdapter outerAdapter = new OuterNotifyAdapter(); |
| mRecyclerView.setAdapter(outerAdapter); |
| |
| // zero cache, so item we prefetch can't already be ready |
| mRecyclerView.setItemViewCacheSize(0); |
| |
| // layout 3 items, then resize to 2... |
| layout(200, 300); |
| layout(200, 200); |
| |
| // so 1 item is evicted into the RecycledViewPool (bypassing cache) |
| assertEquals(1, mRecycler.mRecyclerPool.getRecycledViewCount(0)); |
| assertEquals(0, mRecycler.mCachedViews.size()); |
| |
| // This is a simple imitation of other behavior (namely, varied types in the outer adapter) |
| // that results in the same initial state to test: items in the pool with attached children |
| for (RecyclerView.ViewHolder holder : mRecycler.mRecyclerPool.mScrap.get(0).mScrapHeap) { |
| // verify that children are attached and valid, since the RVs haven't been rebound |
| assertNotNull(holder.mNestedRecyclerView); |
| assertFalse(holder.mNestedRecyclerView.get().mDataSetHasChangedAfterLayout); |
| validateRvChildrenValid(holder.mNestedRecyclerView.get(), 2); |
| } |
| |
| // prefetch the outer item bind, but without enough time to do any inner binds |
| final long deadlineNs = mRecyclerView.getNanoTime() + TimeUnit.MILLISECONDS.toNanos(9); |
| mRecyclerView.mPrefetchRegistry.setPrefetchVector(0, 1); |
| mRecyclerView.mGapWorker.prefetch(deadlineNs); |
| |
| // 2 is prefetched without children |
| CacheUtils.verifyCacheContainsPrefetchedPositions(mRecyclerView, 2); |
| RecyclerView.ViewHolder holder = CacheUtils.peekAtCachedViewForPosition(mRecyclerView, 2); |
| assertNotNull(holder); |
| assertNotNull(holder.mNestedRecyclerView); |
| assertEquals(0, holder.mNestedRecyclerView.get().mChildHelper.getUnfilteredChildCount()); |
| assertEquals(0, holder.mNestedRecyclerView.get().mRecycler.mCachedViews.size()); |
| |
| // but if we give it more time to bind items, it'll now acquire its inner items |
| mRecyclerView.mGapWorker.prefetch(RecyclerView.FOREVER_NS); |
| CacheUtils.verifyCacheContainsPrefetchedPositions(holder.mNestedRecyclerView.get(), 0, 1); |
| } |
| |
| |
| @Test |
| public void nestedPrefetchDiscardStalePrefetch() { |
| mRecyclerView.setLayoutManager(new LinearLayoutManager(getContext())); |
| OuterNotifyAdapter outerAdapter = new OuterNotifyAdapter(); |
| mRecyclerView.setAdapter(outerAdapter); |
| |
| // zero cache, so item we prefetch can't already be ready |
| mRecyclerView.setItemViewCacheSize(0); |
| |
| // layout as 2x2, starting on row index 2, with empty cache |
| layout(200, 200); |
| mRecyclerView.scrollBy(0, 200); |
| |
| // no views cached, or previously used (so we can trust number in mItemsBound) |
| mRecycler.mRecyclerPool.clear(); |
| assertEquals(0, mRecycler.mRecyclerPool.getRecycledViewCount(0)); |
| assertEquals(0, mRecycler.mCachedViews.size()); |
| |
| // prefetch the outer item and its inner children |
| mRecyclerView.mPrefetchRegistry.setPrefetchVector(0, 1); |
| mRecyclerView.mGapWorker.prefetch(RecyclerView.FOREVER_NS); |
| |
| // 4 is prefetched with 2 inner children, first two binds |
| CacheUtils.verifyCacheContainsPrefetchedPositions(mRecyclerView, 4); |
| RecyclerView.ViewHolder holder = CacheUtils.peekAtCachedViewForPosition(mRecyclerView, 4); |
| assertNotNull(holder); |
| assertNotNull(holder.mNestedRecyclerView); |
| RecyclerView innerRecyclerView = holder.mNestedRecyclerView.get(); |
| assertEquals(0, innerRecyclerView.mChildHelper.getUnfilteredChildCount()); |
| assertEquals(2, innerRecyclerView.mRecycler.mCachedViews.size()); |
| assertEquals(2, ((InnerAdapter) innerRecyclerView.getAdapter()).mItemsBound); |
| |
| // notify data set changed, so any previously prefetched items invalid, and re-prefetch |
| innerRecyclerView.getAdapter().notifyDataSetChanged(); |
| mRecyclerView.mPrefetchRegistry.setPrefetchVector(0, 1); |
| mRecyclerView.mGapWorker.prefetch(RecyclerView.FOREVER_NS); |
| |
| // 4 is prefetched again... |
| CacheUtils.verifyCacheContainsPrefetchedPositions(mRecyclerView, 4); |
| |
| // reusing the same instance with 2 inner children... |
| assertSame(holder, CacheUtils.peekAtCachedViewForPosition(mRecyclerView, 4)); |
| assertSame(innerRecyclerView, holder.mNestedRecyclerView.get()); |
| assertEquals(0, innerRecyclerView.mChildHelper.getUnfilteredChildCount()); |
| assertEquals(2, innerRecyclerView.mRecycler.mCachedViews.size()); |
| |
| // ... but there should be two new binds |
| assertEquals(4, ((InnerAdapter) innerRecyclerView.getAdapter()).mItemsBound); |
| } |
| |
| @Test |
| public void setRecycledViewPool_followedByTwoSetAdapters_clearsRecycledViewPool() { |
| RecyclerView.ViewHolder viewHolder = new RecyclerView.ViewHolder(new View(getContext())) {}; |
| viewHolder.mItemViewType = 123; |
| RecyclerView.Adapter adapter = mock(RecyclerView.Adapter.class); |
| RecyclerView recyclerView = new RecyclerView(getContext()); |
| RecyclerView.RecycledViewPool recycledViewPool = new RecyclerView.RecycledViewPool(); |
| recycledViewPool.putRecycledView(viewHolder); |
| |
| recyclerView.setRecycledViewPool(recycledViewPool); |
| recyclerView.setAdapter(adapter); |
| recyclerView.setAdapter(adapter); |
| |
| assertThat(recycledViewPool.getRecycledViewCount(123), is(equalTo(0))); |
| } |
| |
| @Test |
| public void setRecycledViewPool_followedByTwoSwapAdapters_doesntClearRecycledViewPool() { |
| RecyclerView.ViewHolder viewHolder = new RecyclerView.ViewHolder(new View(getContext())) {}; |
| viewHolder.mItemViewType = 123; |
| RecyclerView.Adapter adapter = mock(RecyclerView.Adapter.class); |
| RecyclerView recyclerView = new RecyclerView(getContext()); |
| RecyclerView.RecycledViewPool recycledViewPool = new RecyclerView.RecycledViewPool(); |
| recycledViewPool.putRecycledView(viewHolder); |
| |
| recyclerView.setRecycledViewPool(recycledViewPool); |
| recyclerView.swapAdapter(adapter, false); |
| recyclerView.swapAdapter(adapter, false); |
| |
| assertThat(recycledViewPool.getRecycledViewCount(123), is(equalTo(1))); |
| } |
| } |