1 /*
2  * Copyright (C) 2019 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 package com.android.car.ui.recyclerview;
17 
18 import static com.android.car.ui.utils.CarUiUtils.requireViewByRefId;
19 
20 import android.content.res.Resources;
21 import android.os.Handler;
22 import android.view.View;
23 import android.view.ViewGroup;
24 import android.view.animation.AccelerateDecelerateInterpolator;
25 import android.view.animation.Interpolator;
26 
27 import androidx.annotation.IntRange;
28 import androidx.annotation.NonNull;
29 import androidx.recyclerview.widget.OrientationHelper;
30 import androidx.recyclerview.widget.RecyclerView;
31 
32 import com.android.car.ui.R;
33 import com.android.car.ui.utils.CarUiUtils;
34 
35 /**
36  * The default scroll bar widget for the {@link CarUiRecyclerView}.
37  *
38  * <p>Inspired by {@link androidx.car.widget.PagedListView}. Most pagination and scrolling logic has
39  * been ported from the PLV with minor updates.
40  */
41 class DefaultScrollBar implements ScrollBar {
42 
43     private float mButtonDisabledAlpha;
44     private CarUiSnapHelper mSnapHelper;
45 
46     private View mScrollView;
47     private View mScrollTrack;
48     private View mScrollThumb;
49     private View mUpButton;
50     private View mDownButton;
51 
52     private RecyclerView mRecyclerView;
53 
54     private final Interpolator mPaginationInterpolator = new AccelerateDecelerateInterpolator();
55 
56     private final Handler mHandler = new Handler();
57 
58     private OrientationHelper mOrientationHelper;
59 
60     @Override
initialize(RecyclerView rv, View scrollView)61     public void initialize(RecyclerView rv, View scrollView) {
62         mRecyclerView = rv;
63 
64         mScrollView = scrollView;
65 
66         Resources res = rv.getContext().getResources();
67 
68         mButtonDisabledAlpha = CarUiUtils.getFloat(res, R.dimen.car_ui_button_disabled_alpha);
69 
70         getRecyclerView().addOnScrollListener(mRecyclerViewOnScrollListener);
71         getRecyclerView().getRecycledViewPool().setMaxRecycledViews(0, 12);
72 
73         mUpButton = requireViewByRefId(mScrollView, R.id.car_ui_scrollbar_page_up);
74         View.OnClickListener paginateUpButtonOnClickListener = v -> pageUp();
75         mUpButton.setOnClickListener(paginateUpButtonOnClickListener);
76         mUpButton.setOnTouchListener(
77                 new OnContinuousScrollListener(rv.getContext(), paginateUpButtonOnClickListener));
78 
79         mDownButton = requireViewByRefId(mScrollView, R.id.car_ui_scrollbar_page_down);
80         View.OnClickListener paginateDownButtonOnClickListener = v -> pageDown();
81         mDownButton.setOnClickListener(paginateDownButtonOnClickListener);
82         mDownButton.setOnTouchListener(
83                 new OnContinuousScrollListener(rv.getContext(), paginateDownButtonOnClickListener));
84 
85         mScrollTrack = requireViewByRefId(mScrollView, R.id.car_ui_scrollbar_track);
86         mScrollThumb = requireViewByRefId(mScrollView, R.id.car_ui_scrollbar_thumb);
87 
88         mSnapHelper = new CarUiSnapHelper(rv.getContext());
89         getRecyclerView().setOnFlingListener(null);
90         mSnapHelper.attachToRecyclerView(getRecyclerView());
91 
92         // enables fast scrolling.
93         FastScroller fastScroller = new FastScroller(mRecyclerView, mScrollTrack, mScrollView);
94         fastScroller.enable();
95 
96         mScrollView.setVisibility(View.INVISIBLE);
97         mScrollView.addOnLayoutChangeListener(
98                 (View v,
99                         int left,
100                         int top,
101                         int right,
102                         int bottom,
103                         int oldLeft,
104                         int oldTop,
105                         int oldRight,
106                         int oldBottom) -> mHandler.post(this::updatePaginationButtons));
107     }
108 
getRecyclerView()109     public RecyclerView getRecyclerView() {
110         return mRecyclerView;
111     }
112 
113     @Override
requestLayout()114     public void requestLayout() {
115         mScrollView.requestLayout();
116     }
117 
118     @Override
setPadding(int paddingStart, int paddingEnd)119     public void setPadding(int paddingStart, int paddingEnd) {
120         mScrollView.setPadding(mScrollView.getPaddingLeft(), paddingStart,
121                 mScrollView.getPaddingRight(), paddingEnd);
122     }
123 
124     /**
125      * Sets whether or not the up button on the scroll bar is clickable.
126      *
127      * @param enabled {@code true} if the up button is enabled.
128      */
setUpEnabled(boolean enabled)129     private void setUpEnabled(boolean enabled) {
130         mUpButton.setEnabled(enabled);
131         mUpButton.setAlpha(enabled ? 1f : mButtonDisabledAlpha);
132     }
133 
134     /**
135      * Sets whether or not the down button on the scroll bar is clickable.
136      *
137      * @param enabled {@code true} if the down button is enabled.
138      */
setDownEnabled(boolean enabled)139     private void setDownEnabled(boolean enabled) {
140         mDownButton.setEnabled(enabled);
141         mDownButton.setAlpha(enabled ? 1f : mButtonDisabledAlpha);
142     }
143 
144     /**
145      * Returns whether or not the down button on the scroll bar is clickable.
146      *
147      * @return {@code true} if the down button is enabled. {@code false} otherwise.
148      */
isDownEnabled()149     private boolean isDownEnabled() {
150         return mDownButton.isEnabled();
151     }
152 
153     /**
154      * Sets the range, offset and extent of the scroll bar. The range represents the size of a
155      * container for the scrollbar thumb; offset is the distance from the start of the container to
156      * where the thumb should be; and finally, extent is the size of the thumb.
157      *
158      * <p>These values can be expressed in arbitrary units, so long as they share the same units.
159      * The
160      * values should also be positive.
161      *
162      * @param range  The range of the scrollbar's thumb
163      * @param offset The offset of the scrollbar's thumb
164      * @param extent The extent of the scrollbar's thumb
165      */
setParameters( @ntRangefrom = 0) int range, @IntRange(from = 0) int offset, @IntRange(from = 0) int extent)166     private void setParameters(
167             @IntRange(from = 0) int range,
168             @IntRange(from = 0) int offset,
169             @IntRange(from = 0) int extent) {
170         // Not laid out yet, so values cannot be calculated.
171         if (!mScrollView.isLaidOut()) {
172             return;
173         }
174 
175         // If the scroll bars aren't visible, then no need to update.
176         if (mScrollView.getVisibility() == View.GONE || range == 0) {
177             return;
178         }
179 
180         int thumbLength = calculateScrollThumbLength(range, extent);
181         int thumbOffset = calculateScrollThumbOffset(range, offset, thumbLength);
182 
183         // Sets the size of the thumb and request a redraw if needed.
184         ViewGroup.LayoutParams lp = mScrollThumb.getLayoutParams();
185 
186         if (lp.height != thumbLength) {
187             lp.height = thumbLength;
188             mScrollThumb.requestLayout();
189         }
190 
191         moveY(mScrollThumb, thumbOffset);
192     }
193 
194     /**
195      * Calculates and returns how big the scroll bar thumb should be based on the given range and
196      * extent.
197      *
198      * @param range  The total amount of space the scroll bar is allowed to roam over.
199      * @param extent The amount of space that the scroll bar takes up relative to the range.
200      * @return The height of the scroll bar thumb in pixels.
201      */
calculateScrollThumbLength(int range, int extent)202     private int calculateScrollThumbLength(int range, int extent) {
203         // Scale the length by the available space that the thumb can fill.
204         return Math.round(((float) extent / range) * mScrollTrack.getHeight());
205     }
206 
207     /**
208      * Calculates and returns how much the scroll thumb should be offset from the top of where it
209      * has
210      * been laid out.
211      *
212      * @param range       The total amount of space the scroll bar is allowed to roam over.
213      * @param offset      The amount the scroll bar should be offset, expressed in the same units as
214      *                    the
215      *                    given range.
216      * @param thumbLength The current length of the thumb in pixels.
217      * @return The amount the thumb should be offset in pixels.
218      */
calculateScrollThumbOffset(int range, int offset, int thumbLength)219     private int calculateScrollThumbOffset(int range, int offset, int thumbLength) {
220         // Ensure that if the user has reached the bottom of the list, then the scroll bar is
221         // aligned to the bottom as well. Otherwise, scale the offset appropriately.
222         // This offset will be a value relative to the parent of this scrollbar, so start by where
223         // the top of scrollbar track is.
224         return mScrollTrack.getTop()
225                 + (isDownEnabled()
226                 ? Math.round(((float) offset / range) * mScrollTrack.getHeight())
227                 : mScrollTrack.getHeight() - thumbLength);
228     }
229 
230     /** Moves the given view to the specified 'y' position. */
moveY(final View view, float newPosition)231     private void moveY(final View view, float newPosition) {
232         view.animate()
233                 .y(newPosition)
234                 .setDuration(/* duration= */ 0)
235                 .setInterpolator(mPaginationInterpolator)
236                 .start();
237     }
238 
239     private final RecyclerView.OnScrollListener mRecyclerViewOnScrollListener =
240             new RecyclerView.OnScrollListener() {
241                 @Override
242                 public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) {
243                     updatePaginationButtons();
244                 }
245             };
246 
getOrientationHelper(RecyclerView.LayoutManager layoutManager)247     private OrientationHelper getOrientationHelper(RecyclerView.LayoutManager layoutManager) {
248         if (mOrientationHelper == null || mOrientationHelper.getLayoutManager() != layoutManager) {
249             // CarUiRecyclerView is assumed to be a list that always vertically scrolls.
250             mOrientationHelper = OrientationHelper.createVerticalHelper(layoutManager);
251         }
252         return mOrientationHelper;
253     }
254 
255     /**
256      * Scrolls the contents of the RecyclerView up a page. A page is defined as the height of the
257      * {@code CarUiRecyclerView}.
258      *
259      * <p>The resulting first item in the list will be snapped to so that it is completely visible.
260      * If
261      * this is not possible due to the first item being taller than the containing {@code
262      * CarUiRecyclerView}, then the snapping will not occur.
263      */
pageUp()264     void pageUp() {
265         int currentOffset = getRecyclerView().computeVerticalScrollOffset();
266         if (getRecyclerView().getLayoutManager() == null
267                 || getRecyclerView().getChildCount() == 0
268                 || currentOffset == 0) {
269             return;
270         }
271 
272         // Use OrientationHelper to calculate scroll distance in order to match snapping behavior.
273         OrientationHelper orientationHelper =
274                 getOrientationHelper(getRecyclerView().getLayoutManager());
275         int screenSize = orientationHelper.getTotalSpace();
276         int scrollDistance = screenSize;
277         // The iteration order matters. In case where there are 2 items longer than screen size, we
278         // want to focus on upcoming view.
279         for (int i = 0; i < getRecyclerView().getChildCount(); i++) {
280             /*
281              * We treat child View longer than screen size differently:
282              * 1) When it enters screen, next pageUp will align its bottom with parent bottom;
283              * 2) When it leaves screen, next pageUp will align its top with parent top.
284              */
285             View child = getRecyclerView().getChildAt(i);
286             if (child.getHeight() > screenSize) {
287                 if (orientationHelper.getDecoratedEnd(child) < screenSize) {
288                     // Child view bottom is entering screen. Align its bottom with parent bottom.
289                     scrollDistance = screenSize - orientationHelper.getDecoratedEnd(child);
290                 } else if (-screenSize < orientationHelper.getDecoratedStart(child)
291                         && orientationHelper.getDecoratedStart(child) < 0) {
292                     // Child view top is about to enter screen - its distance to parent top
293                     // is less than a full scroll. Align child top with parent top.
294                     scrollDistance = Math.abs(orientationHelper.getDecoratedStart(child));
295                 }
296                 // There can be two items that are longer than the screen. We stop at the first one.
297                 // This is affected by the iteration order.
298                 break;
299             }
300         }
301         // Distance should always be positive. Negate its value to scroll up.
302         mRecyclerView.smoothScrollBy(0, -scrollDistance);
303     }
304 
305     /**
306      * Scrolls the contents of the RecyclerView down a page. A page is defined as the height of the
307      * {@code CarUiRecyclerView}.
308      *
309      * <p>This method will attempt to bring the last item in the list as the first item. If the
310      * current first item in the list is taller than the {@code CarUiRecyclerView}, then it will be
311      * scrolled the length of a page, but not snapped to.
312      */
pageDown()313     void pageDown() {
314         if (getRecyclerView().getLayoutManager() == null
315                 || getRecyclerView().getChildCount() == 0) {
316             return;
317         }
318 
319         OrientationHelper orientationHelper =
320                 getOrientationHelper(getRecyclerView().getLayoutManager());
321         int screenSize = orientationHelper.getTotalSpace();
322         int scrollDistance = screenSize;
323 
324         // If the last item is partially visible, page down should bring it to the top.
325         View lastChild = getRecyclerView().getChildAt(getRecyclerView().getChildCount() - 1);
326         if (getRecyclerView().getLayoutManager().isViewPartiallyVisible(lastChild,
327                 /* completelyVisible= */ false, /* acceptEndPointInclusion= */ false)) {
328             scrollDistance = orientationHelper.getDecoratedStart(lastChild);
329             if (scrollDistance <= 0) {
330                 // - Scroll value is zero if the top of last item is aligned with top of the screen;
331                 // - Scroll value can be negative if the child is longer than the screen size and
332                 //   the visible area of the screen does not show the start of the child.
333                 // Scroll to the next screen in both cases.
334                 scrollDistance = screenSize;
335             }
336         }
337 
338         // The iteration order matters. In case where there are 2 items longer than screen size, we
339         // want to focus on upcoming view (the one at the bottom of screen).
340         for (int i = getRecyclerView().getChildCount() - 1; i >= 0; i--) {
341             /* We treat child View longer than screen size differently:
342              * 1) When it enters screen, next pageDown will align its top with parent top;
343              * 2) When it leaves screen, next pageDown will align its bottom with parent bottom.
344              */
345             View child = getRecyclerView().getChildAt(i);
346             if (child.getHeight() > screenSize) {
347                 if (orientationHelper.getDecoratedStart(child) > 0) {
348                     // Child view top is entering screen. Align its top with parent top.
349                     scrollDistance = orientationHelper.getDecoratedStart(child);
350                 } else if (screenSize < orientationHelper.getDecoratedEnd(child)
351                         && orientationHelper.getDecoratedEnd(child) < 2 * screenSize) {
352                     // Child view bottom is about to enter screen - its distance to parent bottom
353                     // is less than a full scroll. Align child bottom with parent bottom.
354                     scrollDistance = orientationHelper.getDecoratedEnd(child) - screenSize;
355                 }
356                 // There can be two items that are longer than the screen. We stop at the first one.
357                 // This is affected by the iteration order.
358                 break;
359             }
360         }
361 
362         mRecyclerView.smoothScrollBy(0, scrollDistance);
363     }
364 
365     /**
366      * Determines if scrollbar should be visible or not and shows/hides it accordingly. If this is
367      * being called as a result of adapter changes, it should be called after the new layout has
368      * been
369      * calculated because the method of determining scrollbar visibility uses the current layout.
370      * If
371      * this is called after an adapter change but before the new layout, the visibility
372      * determination
373      * may not be correct.
374      */
updatePaginationButtons()375     private void updatePaginationButtons() {
376 
377         boolean isAtStart = isAtStart();
378         boolean isAtEnd = isAtEnd();
379         RecyclerView.LayoutManager layoutManager = getRecyclerView().getLayoutManager();
380 
381         // enable/disable the button before the view is shown. So there is no flicker.
382         setUpEnabled(!isAtStart);
383         setDownEnabled(!isAtEnd);
384         if ((isAtStart && isAtEnd) || layoutManager == null || layoutManager.getItemCount() == 0) {
385             mScrollView.setVisibility(View.INVISIBLE);
386         } else {
387             mScrollView.setVisibility(View.VISIBLE);
388         }
389 
390         if (layoutManager == null) {
391             return;
392         }
393 
394         if (layoutManager.canScrollVertically()) {
395             setParameters(
396                     getRecyclerView().computeVerticalScrollRange(),
397                     getRecyclerView().computeVerticalScrollOffset(),
398                     getRecyclerView().computeVerticalScrollExtent());
399         } else {
400             setParameters(
401                     getRecyclerView().computeHorizontalScrollRange(),
402                     getRecyclerView().computeHorizontalScrollOffset(),
403                     getRecyclerView().computeHorizontalScrollExtent());
404         }
405 
406         mScrollView.invalidate();
407     }
408 
409     /** Returns {@code true} if the RecyclerView is completely displaying the first item. */
isAtStart()410     boolean isAtStart() {
411         return mSnapHelper.isAtStart(getRecyclerView().getLayoutManager());
412     }
413 
414     /** Returns {@code true} if the RecyclerView is completely displaying the last item. */
isAtEnd()415     boolean isAtEnd() {
416         return mSnapHelper.isAtEnd(getRecyclerView().getLayoutManager());
417     }
418 }
419