1 /*
2  * Copyright 2018 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package androidx.recyclerview.widget;
18 
19 import static androidx.recyclerview.widget.RecyclerView.VERTICAL;
20 
21 import static org.junit.Assert.assertFalse;
22 import static org.junit.Assert.assertNotNull;
23 import static org.junit.Assert.assertTrue;
24 
25 import android.app.Activity;
26 import android.content.res.Resources;
27 import android.graphics.Color;
28 import android.graphics.drawable.StateListDrawable;
29 import android.view.LayoutInflater;
30 import android.view.MotionEvent;
31 import android.view.View;
32 import android.view.ViewGroup;
33 import android.view.ViewGroup.LayoutParams;
34 import android.widget.TextView;
35 
36 import androidx.recyclerview.R;
37 import androidx.test.annotation.UiThreadTest;
38 import androidx.test.ext.junit.runners.AndroidJUnit4;
39 import androidx.test.filters.LargeTest;
40 import androidx.test.platform.app.InstrumentationRegistry;
41 
42 import org.jspecify.annotations.NonNull;
43 import org.junit.Test;
44 import org.junit.runner.RunWith;
45 
46 import java.util.concurrent.CountDownLatch;
47 import java.util.concurrent.TimeUnit;
48 
49 @LargeTest
50 @RunWith(AndroidJUnit4.class)
51 public class RecyclerViewFastScrollerTest extends BaseRecyclerViewInstrumentationTest {
52     private static final int FLAG_HORIZONTAL = 1;
53     private static final int FLAG_VERTICAL = 1 << 1;
54     private int mScrolledByY = -1000;
55     private int mScrolledByX = -1000;
56     private FastScroller mScroller;
57     private boolean mHide;
58 
setContentView(final int layoutId)59     private void setContentView(final int layoutId) throws Throwable {
60         final Activity activity = mActivityRule.getActivity();
61         mActivityRule.runOnUiThread(new Runnable() {
62             @Override
63             public void run() {
64                 activity.setContentView(layoutId);
65             }
66         });
67     }
68 
69     @Test
xml_fastScrollEnabled_startsInvisibleAndAtTop()70     public void xml_fastScrollEnabled_startsInvisibleAndAtTop() throws Throwable {
71         arrangeWithXml();
72 
73         assertTrue("Expected centerY to start == 0", mScroller.mVerticalThumbCenterY == 0);
74         assertFalse("Expected thumb to start invisible", mScroller.isVisible());
75     }
76 
77     @Test
scrollBy_displaysAndMovesFastScrollerThumb()78     public void scrollBy_displaysAndMovesFastScrollerThumb() throws Throwable {
79         arrangeWithXml();
80 
81         mActivityRule.runOnUiThread(new Runnable() {
82             @Override
83             public void run() {
84                 mRecyclerView.scrollBy(0, 400);
85             }
86         });
87 
88         assertTrue("Expected centerY to be > 0" + mScroller.mVerticalThumbCenterY,
89                 mScroller.mVerticalThumbCenterY > 0);
90         assertTrue("Expected thumb to be visible", mScroller.isVisible());
91     }
92 
93     @Test
ui_dragsThumb_scrollsRecyclerView()94     public void ui_dragsThumb_scrollsRecyclerView() throws Throwable {
95         arrangeWithXml();
96 
97         // RecyclerView#scrollBy(int, int) used to cause the scroller thumb to show up.
98         mActivityRule.runOnUiThread(new Runnable() {
99             @Override
100             public void run() {
101                 mRecyclerView.scrollBy(0, 1);
102                 mRecyclerView.scrollBy(0, -1);
103             }
104         });
105         int[] absoluteCoords = new int[2];
106         mRecyclerView.getLocationOnScreen(absoluteCoords);
107         TouchUtils.drag(InstrumentationRegistry.getInstrumentation(), mRecyclerView.getWidth() - 10,
108                 mRecyclerView.getWidth() - 10, mScroller.mVerticalThumbCenterY + absoluteCoords[1],
109                 mRecyclerView.getHeight() + absoluteCoords[1], 100);
110 
111         assertTrue("Expected dragging thumb to move recyclerView",
112                 mRecyclerView.computeVerticalScrollOffset() > 0);
113     }
114 
115     @Test
properCleanUp()116     public void properCleanUp() throws Throwable {
117         mRecyclerView = new RecyclerView(getActivity());
118         final Activity activity = mActivityRule.getActivity();
119         final CountDownLatch latch = new CountDownLatch(1);
120         mActivityRule.runOnUiThread(new Runnable() {
121             @Override
122             public void run() {
123                 activity.setContentView(
124                         androidx.recyclerview.test.R.layout.fast_scrollbar_test_rv);
125                 mRecyclerView = (RecyclerView) activity.findViewById(
126                         androidx.recyclerview.test.R.id.recycler_view);
127                 LinearLayoutManager layout = new LinearLayoutManager(activity.getBaseContext());
128                 layout.setOrientation(VERTICAL);
129                 mRecyclerView.setLayoutManager(layout);
130                 mRecyclerView.setAdapter(new TestAdapter(50));
131                 Resources res = getActivity().getResources();
132                 mScroller = new FastScroller(mRecyclerView, (StateListDrawable) res.getDrawable(
133                         androidx.recyclerview.test.R.drawable.fast_scroll_thumb_drawable),
134                         res.getDrawable(
135                                 androidx.recyclerview.test.R.drawable
136                                         .fast_scroll_track_drawable),
137                         (StateListDrawable) res.getDrawable(
138                                 androidx.recyclerview.test.R.drawable
139                                         .fast_scroll_thumb_drawable),
140                         res.getDrawable(
141                                 androidx.recyclerview.test.R.drawable
142                                         .fast_scroll_track_drawable),
143                         res.getDimensionPixelSize(R.dimen.fastscroll_default_thickness),
144                         res.getDimensionPixelSize(R.dimen.fastscroll_minimum_range),
145                         res.getDimensionPixelOffset(R.dimen.fastscroll_margin)) {
146                     @Override
147                     public void show() {
148                         // Overriden to avoid animation calls in instrumentation thread
149                     }
150 
151                     @Override
152                     public void hide(int duration) {
153                         latch.countDown();
154                         mHide = true;
155                     }
156                 };
157 
158             }
159         });
160         waitForIdleScroll(mRecyclerView);
161         InstrumentationRegistry.getInstrumentation().waitForIdleSync();
162         mActivityRule.runOnUiThread(new Runnable() {
163             @Override
164             public void run() {
165                 mRecyclerView.scrollBy(0, 400);
166                 mScroller.attachToRecyclerView(new RecyclerView(getActivity()));
167             }
168         });
169         assertFalse(latch.await(2, TimeUnit.SECONDS));
170         assertFalse(mHide);
171     }
172 
173     @Test
inflationTest()174     public void inflationTest() throws Throwable {
175         setContentView(androidx.recyclerview.test.R.layout.fast_scrollbar_test_rv);
176         getInstrumentation().waitForIdleSync();
177         RecyclerView view = (RecyclerView) getActivity().findViewById(
178                 androidx.recyclerview.test.R.id.recycler_view);
179         assertTrue(view.getItemDecorationCount() == 1);
180         assertTrue(view.getItemDecorationAt(0) instanceof FastScroller);
181         FastScroller scroller = (FastScroller) view.getItemDecorationAt(0);
182         assertNotNull(scroller.getHorizontalThumbDrawable());
183         assertNotNull(scroller.getHorizontalTrackDrawable());
184         assertNotNull(scroller.getVerticalThumbDrawable());
185         assertNotNull(scroller.getVerticalTrackDrawable());
186     }
187 
188     @Test
removeFastScrollerSuccessful()189     public void removeFastScrollerSuccessful() throws Throwable {
190         setContentView(androidx.recyclerview.test.R.layout.fast_scrollbar_test_rv);
191         getInstrumentation().waitForIdleSync();
192         final RecyclerView view = (RecyclerView) getActivity().findViewById(
193                 androidx.recyclerview.test.R.id.recycler_view);
194         assertTrue(view.getItemDecorationCount() == 1);
195         mActivityRule.runOnUiThread(new Runnable() {
196             @Override
197             public void run() {
198                 view.removeItemDecorationAt(0);
199                 assertTrue(view.getItemDecorationCount() == 0);
200             }
201         });
202     }
203 
204     @UiThreadTest
205     @Test
initWithBadDrawables()206     public void initWithBadDrawables() throws Throwable {
207         arrangeWithCode();
208 
209         Throwable exception = null;
210         try {
211             mRecyclerView.initFastScroller(null, null, null, null);
212         } catch (Throwable t) {
213             exception = t;
214         }
215         assertTrue(exception instanceof IllegalArgumentException);
216     }
217 
218     @Test
verticalScrollUpdatesFastScrollThumb()219     public void verticalScrollUpdatesFastScrollThumb() throws Throwable {
220         scrollUpdatesFastScrollThumb(FLAG_VERTICAL);
221     }
222 
223     @Test
horizontalScrollUpdatesFastScrollThumb()224     public void horizontalScrollUpdatesFastScrollThumb() throws Throwable {
225         scrollUpdatesFastScrollThumb(FLAG_HORIZONTAL);
226     }
227 
scrollUpdatesFastScrollThumb(int direction)228     private void scrollUpdatesFastScrollThumb(int direction) throws Throwable {
229         arrangeWithCode();
230         mScroller.updateScrollPosition(direction == FLAG_VERTICAL ? 0 : 250,
231                 direction == FLAG_VERTICAL ? 250 : 0);
232         if (direction == FLAG_VERTICAL) {
233             assertTrue("Expected 250 for centerY, got " + mScroller.mVerticalThumbCenterY,
234                     mScroller.mVerticalThumbCenterY == 250);
235             assertTrue("Expected 250 for thumb height, got " + mScroller.mVerticalThumbHeight,
236                     mScroller.mVerticalThumbHeight == 250);
237         } else if (direction == FLAG_HORIZONTAL) {
238             assertTrue("Expected 250 for centerX, got " + mScroller.mHorizontalThumbCenterX,
239                     mScroller.mHorizontalThumbCenterX == 250);
240             assertTrue("Expected 250 for thumb width, got " + mScroller.mHorizontalThumbWidth,
241                     mScroller.mHorizontalThumbWidth == 250);
242         }
243         assertTrue(mScroller.isVisible());
244 
245         mScroller.updateScrollPosition(direction == FLAG_VERTICAL ? 0 : 42,
246                 direction == FLAG_VERTICAL ? 42 : 0);
247         if (direction == FLAG_VERTICAL) {
248             assertTrue("Expected 146 for centerY, got " + mScroller.mVerticalThumbCenterY,
249                     mScroller.mVerticalThumbCenterY == 146);
250             assertTrue("Expected 250 for thumb height, got " + mScroller.mVerticalThumbHeight,
251                     mScroller.mVerticalThumbHeight == 250);
252         } else if (direction == FLAG_HORIZONTAL) {
253             assertTrue("Expected 146 for centerX, got " + mScroller.mHorizontalThumbCenterX,
254                     mScroller.mHorizontalThumbCenterX == 146);
255             assertTrue("Expected 250 for thumb width, got " + mScroller.mHorizontalThumbWidth,
256                     mScroller.mHorizontalThumbWidth == 250);
257         }
258         assertTrue(mScroller.isVisible());
259     }
260 
261     @Test
draggingDoesNotTriggerFastScrollIfNotInThumb()262     public void draggingDoesNotTriggerFastScrollIfNotInThumb() throws Throwable {
263         arrangeWithCode();
264         mScroller.updateScrollPosition(0, 250);
265         final MotionEvent downEvent = MotionEvent.obtain(10, 10, MotionEvent.ACTION_DOWN, 250, 250,
266                 0);
267         assertFalse(mScroller.onInterceptTouchEvent(mRecyclerView, downEvent));
268         final MotionEvent moveEvent = MotionEvent.obtain(10, 10, MotionEvent.ACTION_MOVE, 250, 275,
269                 0);
270         assertFalse(mScroller.onInterceptTouchEvent(mRecyclerView, moveEvent));
271     }
272 
273     @Test
verticalDraggingFastScrollThumbDoesActualScrolling()274     public void verticalDraggingFastScrollThumbDoesActualScrolling() throws Throwable {
275         draggingFastScrollThumbDoesActualScrolling(FLAG_VERTICAL);
276     }
277 
278     @Test
horizontalDraggingFastScrollThumbDoesActualScrolling()279     public void horizontalDraggingFastScrollThumbDoesActualScrolling() throws Throwable {
280         draggingFastScrollThumbDoesActualScrolling(FLAG_HORIZONTAL);
281     }
282 
draggingFastScrollThumbDoesActualScrolling(int direction)283     private void draggingFastScrollThumbDoesActualScrolling(int direction) throws Throwable {
284         arrangeWithCode();
285         mScroller.updateScrollPosition(direction == FLAG_VERTICAL ? 0 : 250,
286                 direction == FLAG_VERTICAL ? 250 : 0);
287         final MotionEvent downEvent = MotionEvent.obtain(10, 10, MotionEvent.ACTION_DOWN,
288                 direction == FLAG_VERTICAL ? 500 : 250, direction == FLAG_VERTICAL ? 250 : 500, 0);
289         assertTrue(mScroller.onInterceptTouchEvent(mRecyclerView, downEvent));
290         assertTrue(mScroller.isDragging());
291         final MotionEvent moveEvent = MotionEvent.obtain(10, 10, MotionEvent.ACTION_MOVE,
292                 direction == FLAG_VERTICAL ? 500 : 221, direction == FLAG_VERTICAL ? 221 : 500, 0);
293         mScroller.onTouchEvent(mRecyclerView, moveEvent);
294         if (direction == FLAG_VERTICAL) {
295             assertTrue("Expected to get -29, but got " + mScrolledByY, mScrolledByY == -29);
296         } else {
297             assertTrue("Expected to get -29, but got " + mScrolledByX, mScrolledByX == -29);
298         }
299     }
300 
arrangeWithXml()301     private void arrangeWithXml() throws Throwable {
302 
303         final TestActivity activity = mActivityRule.getActivity();
304         final TestedFrameLayout testedFrameLayout = activity.getContainer();
305 
306         RecyclerView recyclerView = (RecyclerView) LayoutInflater.from(activity).inflate(
307                 androidx.recyclerview.test.R.layout.fast_scrollbar_test_rv,
308                 testedFrameLayout,
309                 false);
310 
311         LinearLayoutManager layout = new LinearLayoutManager(activity.getBaseContext());
312         layout.setOrientation(VERTICAL);
313         recyclerView.setLayoutManager(layout);
314 
315         recyclerView.setAdapter(new TestAdapter(50));
316 
317         mScroller = (FastScroller) recyclerView.getItemDecorationAt(0);
318 
319         testedFrameLayout.expectLayouts(1);
320         testedFrameLayout.expectDraws(1);
321         setRecyclerView(recyclerView);
322         testedFrameLayout.waitForLayout(2);
323         testedFrameLayout.waitForDraw(2);
324     }
325 
arrangeWithCode()326     private void arrangeWithCode() throws Exception {
327         final int width = 500;
328         final int height = 500;
329 
330         mRecyclerView = new RecyclerView(getActivity()) {
331             @Override
332             public int computeVerticalScrollRange() {
333                 return 1000;
334             }
335 
336             @Override
337             public int computeVerticalScrollExtent() {
338                 return 500;
339             }
340 
341             @Override
342             public int computeVerticalScrollOffset() {
343                 return 250;
344             }
345 
346             @Override
347             public int computeHorizontalScrollRange() {
348                 return 1000;
349             }
350 
351             @Override
352             public int computeHorizontalScrollExtent() {
353                 return 500;
354             }
355 
356             @Override
357             public int computeHorizontalScrollOffset() {
358                 return 250;
359             }
360 
361             @Override
362             public void scrollBy(int x, int y) {
363                 mScrolledByY = y;
364                 mScrolledByX = x;
365             }
366         };
367         mRecyclerView.setAdapter(new TestAdapter(50));
368         mRecyclerView.measure(
369                 View.MeasureSpec.makeMeasureSpec(width, View.MeasureSpec.EXACTLY),
370                 View.MeasureSpec.makeMeasureSpec(height, View.MeasureSpec.EXACTLY));
371         mRecyclerView.layout(0, 0, width, height);
372 
373         Resources res = getActivity().getResources();
374         mScroller = new FastScroller(mRecyclerView, (StateListDrawable) res.getDrawable(
375                 androidx.recyclerview.test.R.drawable.fast_scroll_thumb_drawable),
376                 res.getDrawable(
377                         androidx.recyclerview.test.R.drawable.fast_scroll_track_drawable),
378                 (StateListDrawable) res.getDrawable(
379                         androidx.recyclerview.test.R.drawable.fast_scroll_thumb_drawable),
380                 res.getDrawable(
381                         androidx.recyclerview.test.R.drawable.fast_scroll_track_drawable),
382                 res.getDimensionPixelSize(R.dimen.fastscroll_default_thickness),
383                 res.getDimensionPixelSize(R.dimen.fastscroll_minimum_range),
384                 res.getDimensionPixelOffset(R.dimen.fastscroll_margin)) {
385             @Override
386             public void show() {
387                 // Overriden to avoid animation calls in instrumentation thread
388             }
389 
390             @Override
391             public void hide(int duration) {
392                 mHide = true;
393             }
394         };
395         mRecyclerView.mEnableFastScroller = true;
396 
397         // Draw it once so height/width gets updated
398         mScroller.onDrawOver(null, mRecyclerView, null);
399     }
400 
401     private static class TestAdapter extends RecyclerView.Adapter {
402         private int mItemCount;
403 
404         public static class ViewHolder extends RecyclerView.ViewHolder {
405             public TextView mTextView;
406 
ViewHolder(TextView v)407             ViewHolder(TextView v) {
408                 super(v);
409                 mTextView = v;
410             }
411 
412             @Override
toString()413             public String toString() {
414                 return super.toString() + " '" + mTextView.getText();
415             }
416         }
417 
TestAdapter(int itemCount)418         TestAdapter(int itemCount) {
419             mItemCount = itemCount;
420         }
421 
422         @Override
onCreateViewHolder(@onNull ViewGroup parent, int viewType)423         public RecyclerView.ViewHolder onCreateViewHolder(@NonNull ViewGroup parent,
424                 int viewType) {
425             final ViewHolder h = new ViewHolder(new TextView(parent.getContext()));
426             h.mTextView.setMinimumHeight(128);
427             h.mTextView.setPadding(20, 0, 20, 0);
428             h.mTextView.setFocusable(true);
429             h.mTextView.setBackgroundColor(Color.BLUE);
430             RecyclerView.LayoutParams lp = new RecyclerView.LayoutParams(
431                     LayoutParams.MATCH_PARENT,
432                     ViewGroup.LayoutParams.WRAP_CONTENT);
433             lp.leftMargin = 10;
434             lp.rightMargin = 5;
435             lp.topMargin = 20;
436             lp.bottomMargin = 15;
437             h.mTextView.setLayoutParams(lp);
438             return h;
439         }
440 
441         @Override
onBindViewHolder(RecyclerView.@onNull ViewHolder holder, int position)442         public void onBindViewHolder(RecyclerView.@NonNull ViewHolder holder, int position) {
443             holder.itemView.setTag("pos " + position);
444         }
445 
446         @Override
getItemCount()447         public int getItemCount() {
448             return mItemCount;
449         }
450     }
451 }
452