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 import android.widget.ImageView;
27 
28 import androidx.annotation.IntRange;
29 import androidx.annotation.VisibleForTesting;
30 import androidx.recyclerview.widget.OrientationHelper;
31 import androidx.recyclerview.widget.RecyclerView;
32 
33 import com.android.car.ui.R;
34 import com.android.car.ui.utils.CarUiUtils;
35 
36 /**
37  * The default scroll bar widget for the {@link CarUiRecyclerView}.
38  *
39  * <p>Inspired by {@link androidx.car.widget.PagedListView}. Most pagination and scrolling logic has
40  * been ported from the PLV with minor updates.
41  */
42 class DefaultScrollBar implements ScrollBar {
43 
44     @VisibleForTesting
45     int mPaddingStart;
46     @VisibleForTesting
47     int mPaddingEnd;
48 
49     private float mButtonDisabledAlpha;
50     private CarUiSnapHelper mSnapHelper;
51 
52     private ImageView mUpButton;
53     private View mScrollView;
54     private View mScrollThumb;
55     private ImageView mDownButton;
56 
57     private int mSeparatingMargin;
58 
59     private RecyclerView mRecyclerView;
60 
61     /** The amount of space that the scroll thumb is allowed to roam over. */
62     private int mScrollThumbTrackHeight;
63 
64     private final Interpolator mPaginationInterpolator = new AccelerateDecelerateInterpolator();
65 
66     private final int mRowsPerPage = -1;
67     private final Handler mHandler = new Handler();
68 
69     private OrientationHelper mOrientationHelper;
70 
71     @Override
initialize(RecyclerView rv, View scrollView)72     public void initialize(RecyclerView rv, View scrollView) {
73         mRecyclerView = rv;
74 
75         mScrollView = scrollView;
76 
77         Resources res = rv.getContext().getResources();
78 
79         mButtonDisabledAlpha = CarUiUtils.getFloat(res, R.dimen.car_ui_button_disabled_alpha);
80 
81         getRecyclerView().addOnScrollListener(mRecyclerViewOnScrollListener);
82         getRecyclerView().getRecycledViewPool().setMaxRecycledViews(0, 12);
83 
84         mSeparatingMargin = res.getDimensionPixelSize(R.dimen.car_ui_scrollbar_separator_margin);
85 
86         mUpButton = requireViewByRefId(mScrollView, R.id.page_up);
87         PaginateButtonClickListener upButtonClickListener =
88                 new PaginateButtonClickListener(PaginationListener.PAGE_UP);
89         mUpButton.setOnClickListener(upButtonClickListener);
90 
91         mDownButton = requireViewByRefId(mScrollView, R.id.page_down);
92         PaginateButtonClickListener downButtonClickListener =
93                 new PaginateButtonClickListener(PaginationListener.PAGE_DOWN);
94         mDownButton.setOnClickListener(downButtonClickListener);
95 
96         mScrollThumb = requireViewByRefId(mScrollView, R.id.scrollbar_thumb);
97 
98         mSnapHelper = new CarUiSnapHelper(rv.getContext());
99         getRecyclerView().setOnFlingListener(null);
100         mSnapHelper.attachToRecyclerView(getRecyclerView());
101 
102         mScrollView.addOnLayoutChangeListener(
103                 (View v,
104                         int left,
105                         int top,
106                         int right,
107                         int bottom,
108                         int oldLeft,
109                         int oldTop,
110                         int oldRight,
111                         int oldBottom) -> {
112                     int width = right - left;
113 
114                     OrientationHelper orientationHelper =
115                             getOrientationHelper(getRecyclerView().getLayoutManager());
116 
117                     // This value will keep track of the top of the current view being laid out.
118                     int layoutTop = orientationHelper.getStartAfterPadding() + mPaddingStart;
119 
120                     // Lay out the up button at the top of the view.
121                     layoutViewCenteredFromTop(mUpButton, layoutTop, width);
122                     layoutTop = mUpButton.getBottom();
123 
124                     // Lay out the scroll thumb
125                     layoutTop += mSeparatingMargin;
126                     layoutViewCenteredFromTop(mScrollThumb, layoutTop, width);
127 
128                     // Lay out the bottom button at the bottom of the view.
129                     int downBottom = orientationHelper.getEndAfterPadding() - mPaddingEnd;
130                     layoutViewCenteredFromBottom(mDownButton, downBottom, width);
131 
132                     mHandler.post(this::calculateScrollThumbTrackHeight);
133                     mHandler.post(() -> updatePaginationButtons(/* animate= */ false));
134                 });
135     }
136 
getRecyclerView()137     public RecyclerView getRecyclerView() {
138         return mRecyclerView;
139     }
140 
141     @Override
requestLayout()142     public void requestLayout() {
143         mScrollView.requestLayout();
144     }
145 
146     @Override
setPadding(int paddingStart, int paddingEnd)147     public void setPadding(int paddingStart, int paddingEnd) {
148         this.mPaddingStart = paddingStart;
149         this.mPaddingEnd = paddingEnd;
150         requestLayout();
151     }
152 
153     /**
154      * Sets whether or not the up button on the scroll bar is clickable.
155      *
156      * @param enabled {@code true} if the up button is enabled.
157      */
setUpEnabled(boolean enabled)158     private void setUpEnabled(boolean enabled) {
159         mUpButton.setEnabled(enabled);
160         mUpButton.setAlpha(enabled ? 1f : mButtonDisabledAlpha);
161     }
162 
163     /**
164      * Sets whether or not the down button on the scroll bar is clickable.
165      *
166      * @param enabled {@code true} if the down button is enabled.
167      */
setDownEnabled(boolean enabled)168     private void setDownEnabled(boolean enabled) {
169         mDownButton.setEnabled(enabled);
170         mDownButton.setAlpha(enabled ? 1f : mButtonDisabledAlpha);
171     }
172 
173     /**
174      * Returns whether or not the down button on the scroll bar is clickable.
175      *
176      * @return {@code true} if the down button is enabled. {@code false} otherwise.
177      */
isDownEnabled()178     private boolean isDownEnabled() {
179         return mDownButton.isEnabled();
180     }
181 
182     /** Listener for when the list should paginate. */
183     interface PaginationListener {
184         int PAGE_UP = 0;
185         int PAGE_DOWN = 1;
186 
187         /** Called when the linked view should be paged in the given direction */
onPaginate(int direction)188         void onPaginate(int direction);
189     }
190 
191     /**
192      * Calculate the amount of space that the scroll bar thumb is allowed to roam. The thumb is
193      * allowed to take up the space between the down bottom and the up or alpha jump button,
194      * depending
195      * on if the latter is visible.
196      */
calculateScrollThumbTrackHeight()197     private void calculateScrollThumbTrackHeight() {
198         // Subtracting (2 * mSeparatingMargin) for the top/bottom margin above and below the
199         // scroll bar thumb.
200         mScrollThumbTrackHeight = mDownButton.getTop() - (2 * mSeparatingMargin);
201 
202         // If there's an alpha jump button, then the thumb is laid out starting from below that.
203         mScrollThumbTrackHeight -= mUpButton.getBottom();
204     }
205 
206     /**
207      * Lays out the given View starting from the given {@code top} value downwards and centered
208      * within the given {@code availableWidth}.
209      *
210      * @param view The view to lay out.
211      * @param top The top value to start laying out from. This value will be the resulting top value
212      * of the view.
213      * @param availableWidth The width in which to center the given view.
214      */
layoutViewCenteredFromTop(View view, int top, int availableWidth)215     private static void layoutViewCenteredFromTop(View view, int top, int availableWidth) {
216         int viewWidth = view.getMeasuredWidth();
217         int viewLeft = (availableWidth - viewWidth) / 2;
218         view.layout(viewLeft, top, viewLeft + viewWidth, top + view.getMeasuredHeight());
219     }
220 
221     /**
222      * Lays out the given View starting from the given {@code bottom} value upwards and centered
223      * within the given {@code availableSpace}.
224      *
225      * @param view The view to lay out.
226      * @param bottom The bottom value to start laying out from. This value will be the resulting
227      * bottom value of the view.
228      * @param availableWidth The width in which to center the given view.
229      */
layoutViewCenteredFromBottom(View view, int bottom, int availableWidth)230     private static void layoutViewCenteredFromBottom(View view, int bottom, int availableWidth) {
231         int viewWidth = view.getMeasuredWidth();
232         int viewLeft = (availableWidth - viewWidth) / 2;
233         view.layout(viewLeft, bottom - view.getMeasuredHeight(), viewLeft + viewWidth, bottom);
234     }
235 
236     /**
237      * Sets the range, offset and extent of the scroll bar. The range represents the size of a
238      * container for the scrollbar thumb; offset is the distance from the start of the container to
239      * where the thumb should be; and finally, extent is the size of the thumb.
240      *
241      * <p>These values can be expressed in arbitrary units, so long as they share the same units.
242      * The
243      * values should also be positive.
244      *
245      * @param range The range of the scrollbar's thumb
246      * @param offset The offset of the scrollbar's thumb
247      * @param extent The extent of the scrollbar's thumb
248      * @param animate Whether or not the thumb should animate from its current position to the
249      * position specified by the given range, offset and extent.
250      */
setParameters( @ntRangefrom = 0) int range, @IntRange(from = 0) int offset, @IntRange(from = 0) int extent, boolean animate)251     private void setParameters(
252             @IntRange(from = 0) int range,
253             @IntRange(from = 0) int offset,
254             @IntRange(from = 0) int extent,
255             boolean animate) {
256         // Not laid out yet, so values cannot be calculated.
257         if (!mScrollView.isLaidOut()) {
258             return;
259         }
260 
261         // If the scroll bars aren't visible, then no need to update.
262         if (mScrollView.getVisibility() == View.GONE || range == 0) {
263             return;
264         }
265 
266         int thumbLength = calculateScrollThumbLength(range, extent);
267         int thumbOffset = calculateScrollThumbOffset(range, offset, thumbLength);
268 
269         // Sets the size of the thumb and request a redraw if needed.
270         ViewGroup.LayoutParams lp = mScrollThumb.getLayoutParams();
271 
272         if (lp.height != thumbLength) {
273             lp.height = thumbLength;
274             mScrollThumb.requestLayout();
275         }
276 
277         moveY(mScrollThumb, thumbOffset, animate);
278     }
279 
280     /**
281      * Calculates and returns how big the scroll bar thumb should be based on the given range and
282      * extent.
283      *
284      * @param range The total amount of space the scroll bar is allowed to roam over.
285      * @param extent The amount of space that the scroll bar takes up relative to the range.
286      * @return The height of the scroll bar thumb in pixels.
287      */
calculateScrollThumbLength(int range, int extent)288     private int calculateScrollThumbLength(int range, int extent) {
289         // Scale the length by the available space that the thumb can fill.
290         return Math.round(((float) extent / range) * mScrollThumbTrackHeight);
291     }
292 
293     /**
294      * Calculates and returns how much the scroll thumb should be offset from the top of where it
295      * has
296      * been laid out.
297      *
298      * @param range The total amount of space the scroll bar is allowed to roam over.
299      * @param offset The amount the scroll bar should be offset, expressed in the same units as the
300      * given range.
301      * @param thumbLength The current length of the thumb in pixels.
302      * @return The amount the thumb should be offset in pixels.
303      */
calculateScrollThumbOffset(int range, int offset, int thumbLength)304     private int calculateScrollThumbOffset(int range, int offset, int thumbLength) {
305         // Ensure that if the user has reached the bottom of the list, then the scroll bar is
306         // aligned to the bottom as well. Otherwise, scale the offset appropriately.
307         // This offset will be a value relative to the parent of this scrollbar, so start by where
308         // the top of mScrollThumb is.
309         return mScrollThumb.getTop()
310                 + (isDownEnabled()
311                 ? Math.round(((float) offset / range) * mScrollThumbTrackHeight)
312                 : mScrollThumbTrackHeight - thumbLength);
313     }
314 
315     /** Moves the given view to the specified 'y' position. */
moveY(final View view, float newPosition, boolean animate)316     private void moveY(final View view, float newPosition, boolean animate) {
317         final int duration = animate ? 200 : 0;
318         view.animate()
319                 .y(newPosition)
320                 .setDuration(duration)
321                 .setInterpolator(mPaginationInterpolator)
322                 .start();
323     }
324 
325     private class PaginateButtonClickListener implements View.OnClickListener {
326         private final int mPaginateDirection;
327         private PaginationListener mPaginationListener;
328 
PaginateButtonClickListener(int paginateDirection)329         PaginateButtonClickListener(int paginateDirection) {
330             this.mPaginateDirection = paginateDirection;
331         }
332 
333         @Override
onClick(View v)334         public void onClick(View v) {
335             if (mPaginationListener != null) {
336                 mPaginationListener.onPaginate(mPaginateDirection);
337             }
338             if (mPaginateDirection == PaginationListener.PAGE_DOWN) {
339                 pageDown();
340             } else if (mPaginateDirection == PaginationListener.PAGE_UP) {
341                 pageUp();
342             }
343         }
344     }
345 
346     private final RecyclerView.OnScrollListener mRecyclerViewOnScrollListener =
347             new RecyclerView.OnScrollListener() {
348                 @Override
349                 public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
350                     updatePaginationButtons(false);
351                 }
352             };
353 
354     /** Returns the page the given position is on, starting with page 0. */
getPage(int position)355     int getPage(int position) {
356         if (mRowsPerPage == -1) {
357             return -1;
358         }
359         if (mRowsPerPage == 0) {
360             return 0;
361         }
362         return position / mRowsPerPage;
363     }
364 
getOrientationHelper(RecyclerView.LayoutManager layoutManager)365     private OrientationHelper getOrientationHelper(RecyclerView.LayoutManager layoutManager) {
366         if (mOrientationHelper == null || mOrientationHelper.getLayoutManager() != layoutManager) {
367             // CarUiRecyclerView is assumed to be a list that always vertically scrolls.
368             mOrientationHelper = OrientationHelper.createVerticalHelper(layoutManager);
369         }
370         return mOrientationHelper;
371     }
372 
373     /**
374      * Scrolls the contents of the RecyclerView up a page. A page is defined as the height of the
375      * {@code CarUiRecyclerView}.
376      *
377      * <p>The resulting first item in the list will be snapped to so that it is completely visible.
378      * If
379      * this is not possible due to the first item being taller than the containing {@code
380      * CarUiRecyclerView}, then the snapping will not occur.
381      */
pageUp()382     void pageUp() {
383         int currentOffset = getRecyclerView().computeVerticalScrollOffset();
384         if (getRecyclerView().getLayoutManager() == null
385                 || getRecyclerView().getChildCount() == 0
386                 || currentOffset == 0) {
387             return;
388         }
389 
390         // Use OrientationHelper to calculate scroll distance in order to match snapping behavior.
391         OrientationHelper orientationHelper =
392                 getOrientationHelper(getRecyclerView().getLayoutManager());
393         int screenSize = orientationHelper.getTotalSpace();
394         int scrollDistance = screenSize;
395         // The iteration order matters. In case where there are 2 items longer than screen size, we
396         // want to focus on upcoming view.
397         for (int i = 0; i < getRecyclerView().getChildCount(); i++) {
398             /*
399              * We treat child View longer than screen size differently:
400              * 1) When it enters screen, next pageUp will align its bottom with parent bottom;
401              * 2) When it leaves screen, next pageUp will align its top with parent top.
402              */
403             View child = getRecyclerView().getChildAt(i);
404             if (child.getHeight() > screenSize) {
405                 if (orientationHelper.getDecoratedEnd(child) < screenSize) {
406                     // Child view bottom is entering screen. Align its bottom with parent bottom.
407                     scrollDistance = screenSize - orientationHelper.getDecoratedEnd(child);
408                 } else if (-screenSize < orientationHelper.getDecoratedStart(child)
409                         && orientationHelper.getDecoratedStart(child) < 0) {
410                     // Child view top is about to enter screen - its distance to parent top
411                     // is less than a full scroll. Align child top with parent top.
412                     scrollDistance = Math.abs(orientationHelper.getDecoratedStart(child));
413                 }
414                 // There can be two items that are longer than the screen. We stop at the first one.
415                 // This is affected by the iteration order.
416                 break;
417             }
418         }
419         // Distance should always be positive. Negate its value to scroll up.
420         mSnapHelper.smoothScrollBy(-scrollDistance);
421     }
422 
423     /**
424      * Scrolls the contents of the RecyclerView down a page. A page is defined as the height of the
425      * {@code CarUiRecyclerView}.
426      *
427      * <p>This method will attempt to bring the last item in the list as the first item. If the
428      * current first item in the list is taller than the {@code CarUiRecyclerView}, then it will be
429      * scrolled the length of a page, but not snapped to.
430      */
pageDown()431     void pageDown() {
432         if (getRecyclerView().getLayoutManager() == null
433                 || getRecyclerView().getChildCount() == 0) {
434             return;
435         }
436 
437         OrientationHelper orientationHelper =
438                 getOrientationHelper(getRecyclerView().getLayoutManager());
439         int screenSize = orientationHelper.getTotalSpace();
440         int scrollDistance = screenSize;
441 
442         // If the last item is partially visible, page down should bring it to the top.
443         View lastChild = getRecyclerView().getChildAt(getRecyclerView().getChildCount() - 1);
444         if (getRecyclerView().getLayoutManager().isViewPartiallyVisible(lastChild,
445                 /* completelyVisible= */ false, /* acceptEndPointInclusion= */ false)) {
446             scrollDistance = orientationHelper.getDecoratedStart(lastChild);
447             if (scrollDistance <= 0) {
448                 // - Scroll value is zero if the top of last item is aligned with top of the screen;
449                 // - Scroll value can be negative if the child is longer than the screen size and
450                 //   the visible area of the screen does not show the start of the child.
451                 // Scroll to the next screen in both cases.
452                 scrollDistance = screenSize;
453             }
454         }
455 
456         // The iteration order matters. In case where there are 2 items longer than screen size, we
457         // want to focus on upcoming view (the one at the bottom of screen).
458         for (int i = getRecyclerView().getChildCount() - 1; i >= 0; i--) {
459             /* We treat child View longer than screen size differently:
460              * 1) When it enters screen, next pageDown will align its top with parent top;
461              * 2) When it leaves screen, next pageDown will align its bottom with parent bottom.
462              */
463             View child = getRecyclerView().getChildAt(i);
464             if (child.getHeight() > screenSize) {
465                 if (orientationHelper.getDecoratedStart(child) > 0) {
466                     // Child view top is entering screen. Align its top with parent top.
467                     scrollDistance = orientationHelper.getDecoratedStart(child);
468                 } else if (screenSize < orientationHelper.getDecoratedEnd(child)
469                         && orientationHelper.getDecoratedEnd(child) < 2 * screenSize) {
470                     // Child view bottom is about to enter screen - its distance to parent bottom
471                     // is less than a full scroll. Align child bottom with parent bottom.
472                     scrollDistance = orientationHelper.getDecoratedEnd(child) - screenSize;
473                 }
474                 // There can be two items that are longer than the screen. We stop at the first one.
475                 // This is affected by the iteration order.
476                 break;
477             }
478         }
479 
480         mSnapHelper.smoothScrollBy(scrollDistance);
481     }
482 
483     /**
484      * Determines if scrollbar should be visible or not and shows/hides it accordingly. If this is
485      * being called as a result of adapter changes, it should be called after the new layout has
486      * been
487      * calculated because the method of determining scrollbar visibility uses the current layout.
488      * If
489      * this is called after an adapter change but before the new layout, the visibility
490      * determination
491      * may not be correct.
492      *
493      * @param animate {@code true} if the scrollbar should animate to its new position. {@code
494      * false}
495      * if no animation is used
496      */
updatePaginationButtons(boolean animate)497     private void updatePaginationButtons(boolean animate) {
498 
499         boolean isAtStart = isAtStart();
500         boolean isAtEnd = isAtEnd();
501         RecyclerView.LayoutManager layoutManager = getRecyclerView().getLayoutManager();
502 
503         if ((isAtStart && isAtEnd) || layoutManager == null || layoutManager.getItemCount() == 0) {
504             mScrollView.setVisibility(View.INVISIBLE);
505         } else {
506             mScrollView.setVisibility(View.VISIBLE);
507         }
508         setUpEnabled(!isAtStart);
509         setDownEnabled(!isAtEnd);
510 
511         if (layoutManager == null) {
512             return;
513         }
514 
515         if (layoutManager.canScrollVertically()) {
516             setParameters(
517                     getRecyclerView().computeVerticalScrollRange(),
518                     getRecyclerView().computeVerticalScrollOffset(),
519                     getRecyclerView().computeVerticalScrollExtent(),
520                     animate);
521         } else {
522             setParameters(
523                     getRecyclerView().computeHorizontalScrollRange(),
524                     getRecyclerView().computeHorizontalScrollOffset(),
525                     getRecyclerView().computeHorizontalScrollExtent(),
526                     animate);
527         }
528 
529         mScrollView.invalidate();
530     }
531 
532     /** Returns {@code true} if the RecyclerView is completely displaying the first item. */
isAtStart()533     boolean isAtStart() {
534         return mSnapHelper.isAtStart(getRecyclerView().getLayoutManager());
535     }
536 
537     /** Returns {@code true} if the RecyclerView is completely displaying the last item. */
isAtEnd()538     boolean isAtEnd() {
539         return mSnapHelper.isAtEnd(getRecyclerView().getLayoutManager());
540     }
541 }
542