• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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 
17 package com.android.car.apps.common.widget;
18 
19 import android.content.Context;
20 import android.content.res.Resources;
21 import android.graphics.PointF;
22 import android.os.Handler;
23 import android.util.Log;
24 import android.view.Gravity;
25 import android.view.LayoutInflater;
26 import android.view.View;
27 import android.view.View.MeasureSpec;
28 import android.view.ViewGroup;
29 import android.view.ViewGroup.LayoutParams;
30 import android.view.animation.AccelerateDecelerateInterpolator;
31 import android.view.animation.Interpolator;
32 import android.widget.FrameLayout;
33 import android.widget.ImageView;
34 
35 import androidx.annotation.IntRange;
36 import androidx.recyclerview.widget.LinearLayoutManager;
37 import androidx.recyclerview.widget.OrientationHelper;
38 import androidx.recyclerview.widget.RecyclerView;
39 
40 import com.android.car.apps.common.R;
41 import com.android.car.apps.common.util.ScrollBarUI;
42 import com.android.car.apps.common.widget.PagedRecyclerView.ScrollBarPosition;
43 
44 /**
45  * Inspired by {@link androidx.car.widget.PagedListView}. Most pagination and scrolling logic has
46  * been ported from the PLV with minor updates.
47  *
48  * The default scroll bar widget for the {@link PagedRecyclerView}.
49  */
50 class CarScrollBar extends ScrollBarUI {
51     private float mButtonDisabledAlpha;
52     private static final String TAG = "CarScrollBar";
53     private PagedSnapHelper mSnapHelper;
54 
55     private ImageView mUpButton;
56     private PaginateButtonClickListener mUpButtonClickListener;
57     private View mScrollView;
58     private View mScrollThumb;
59     private ImageView mDownButton;
60     private PaginateButtonClickListener mDownButtonClickListener;
61 
62     private int mSeparatingMargin;
63     private int mScrollBarThumbWidth;
64 
65     private int mPaddingStart;
66     private int mPaddingEnd;
67 
68     /** The amount of space that the scroll thumb is allowed to roam over. */
69     private int mScrollThumbTrackHeight;
70 
71     private final Interpolator mPaginationInterpolator = new AccelerateDecelerateInterpolator();
72 
73     private int mRowsPerPage = -1;
74     private final Handler mHandler = new Handler();
75 
76     private OrientationHelper mOrientationHelper;
77 
78     /**
79      * When doing a snap, offset the snap by this number of position and then do a smooth scroll to
80      * the final position.
81      */
82     private static final int SNAP_SCROLL_OFFSET_POSITION = 2;
83 
84     /**
85      * The amount of time after settling to wait before autoscrolling to the next page when the user
86      * holds down a pagination button.
87      */
88     private static final int PAGINATION_HOLD_DELAY_MS = 400;
89 
90     @Override
initialize(Context context, RecyclerView recyclerView, int scrollBarContainerWidth, @ScrollBarPosition int scrollBarPosition, boolean scrollBarAboveRecyclerView)91     public void initialize(Context context, RecyclerView recyclerView,
92             int scrollBarContainerWidth, @ScrollBarPosition int scrollBarPosition,
93             boolean scrollBarAboveRecyclerView) {
94 
95         mRecyclerView = recyclerView;
96 
97         LayoutInflater inflater = (LayoutInflater) context.getSystemService(
98                 Context.LAYOUT_INFLATER_SERVICE);
99 
100         FrameLayout parent = (FrameLayout) getRecyclerView().getParent();
101 
102         mScrollView = inflater.inflate(R.layout.car_paged_scrollbar_buttons, parent, false);
103         mScrollView.setLayoutParams(
104                 new LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.MATCH_PARENT));
105 
106         mButtonDisabledAlpha = context.getResources().getFloat(R.dimen.button_disabled_alpha);
107 
108         if (scrollBarAboveRecyclerView) {
109             parent.addView(mScrollView);
110         } else {
111             parent.addView(mScrollView, /* index= */0);
112         }
113 
114         setScrollBarContainerWidth(scrollBarContainerWidth);
115         setScrollBarPosition(scrollBarPosition);
116 
117         getRecyclerView().addOnScrollListener(mRecyclerViewOnScrollListener);
118         getRecyclerView().getRecycledViewPool().setMaxRecycledViews(0, 12);
119 
120         Resources res = context.getResources();
121         mSeparatingMargin = res.getDimensionPixelSize(R.dimen.car_scroll_bar_separator_margin);
122         mScrollBarThumbWidth = res.getDimensionPixelSize(R.dimen.car_scroll_bar_thumb_width);
123 
124         mUpButton = mScrollView.findViewById(R.id.page_up);
125         mUpButtonClickListener = new PaginateButtonClickListener(PaginationListener.PAGE_UP);
126         mUpButton.setOnClickListener(mUpButtonClickListener);
127 
128         mDownButton = mScrollView.findViewById(R.id.page_down);
129         mDownButtonClickListener = new PaginateButtonClickListener(PaginationListener.PAGE_DOWN);
130         mDownButton.setOnClickListener(mDownButtonClickListener);
131 
132         mScrollThumb = mScrollView.findViewById(R.id.scrollbar_thumb);
133 
134         mSnapHelper = new PagedSnapHelper(context);
135         getRecyclerView().setOnFlingListener(null);
136         mSnapHelper.attachToRecyclerView(getRecyclerView());
137 
138         mScrollView.addOnLayoutChangeListener((View v, int left, int top, int right, int bottom,
139                     int oldLeft, int oldTop, int oldRight, int oldBottom) -> {
140             int width = right - left;
141 
142             OrientationHelper orientationHelper =
143                     getOrientationHelper(getRecyclerView().getLayoutManager());
144 
145             // This value will keep track of the top of the current view being laid out.
146             int layoutTop = orientationHelper.getStartAfterPadding() + mPaddingStart;
147 
148             // Lay out the up button at the top of the view.
149             layoutViewCenteredFromTop(mUpButton, layoutTop, width);
150             layoutTop = mUpButton.getBottom();
151 
152             // Lay out the scroll thumb
153             layoutTop += mSeparatingMargin;
154             layoutViewCenteredFromTop(mScrollThumb, layoutTop, width);
155 
156             // Lay out the bottom button at the bottom of the view.
157             int downBottom = orientationHelper.getEndAfterPadding() - mPaddingEnd;
158             layoutViewCenteredFromBottom(mDownButton, downBottom, width);
159 
160             mHandler.post(this::calculateScrollThumbTrackHeight);
161             mHandler.post(() -> updatePaginationButtons(/* animate= */false));
162         });
163     }
164 
165     @Override
requestLayout()166     public void requestLayout() {
167         mScrollView.requestLayout();
168     }
169 
170     @Override
setPadding(int paddingStart, int paddingEnd)171     public void setPadding(int paddingStart, int paddingEnd) {
172         mPaddingStart = paddingStart;
173         mPaddingEnd = paddingEnd;
174         requestLayout();
175     }
176 
177     /**
178      * Sets the listener that will be notified when the up and down buttons have been pressed.
179      *
180      * @param listener The listener to set.
181      */
setPaginationListener(PaginationListener listener)182     void setPaginationListener(PaginationListener listener) {
183         mUpButtonClickListener.setPaginationListener(listener);
184         mDownButtonClickListener.setPaginationListener(listener);
185     }
186 
187     /** Returns {@code true} if the "up" button is pressed */
isUpPressed()188     private boolean isUpPressed() {
189         return mUpButton.isPressed();
190     }
191 
192     /** Returns {@code true} if the "down" button is pressed */
isDownPressed()193     private boolean isDownPressed() {
194         return mDownButton.isPressed();
195     }
196 
197     /**
198      * Sets the width of the container that holds the scrollbar. The scrollbar will be centered
199      * within this width.
200      *
201      * @param width The width of the scrollbar container.
202      */
setScrollBarContainerWidth(int width)203     void setScrollBarContainerWidth(int width) {
204         ViewGroup.LayoutParams layoutParams = mScrollView.getLayoutParams();
205         layoutParams.width = width;
206         mScrollView.requestLayout();
207     }
208 
209     /**
210      * Sets the position of the scrollbar.
211      *
212      * @param position Enum value of the scrollbar position. 0 for Start and 1 for end.
213      */
setScrollBarPosition(@crollBarPosition int position)214     void setScrollBarPosition(@ScrollBarPosition int position) {
215         FrameLayout.LayoutParams layoutParams =
216                 (FrameLayout.LayoutParams) mScrollView.getLayoutParams();
217         if (position == ScrollBarPosition.START) {
218             layoutParams.gravity = Gravity.LEFT;
219         } else {
220             layoutParams.gravity = Gravity.RIGHT;
221         }
222 
223         mScrollView.requestLayout();
224     }
225 
226     /**
227      * Sets whether or not the up button on the scroll bar is clickable.
228      *
229      * @param enabled {@code true} if the up button is enabled.
230      */
setUpEnabled(boolean enabled)231     private void setUpEnabled(boolean enabled) {
232         mUpButton.setEnabled(enabled);
233         mUpButton.setAlpha(enabled ? 1f : mButtonDisabledAlpha);
234     }
235 
236     /**
237      * Sets whether or not the down button on the scroll bar is clickable.
238      *
239      * @param enabled {@code true} if the down button is enabled.
240      */
setDownEnabled(boolean enabled)241     private void setDownEnabled(boolean enabled) {
242         mDownButton.setEnabled(enabled);
243         mDownButton.setAlpha(enabled ? 1f : mButtonDisabledAlpha);
244     }
245 
246     /**
247      * Returns whether or not the down button on the scroll bar is clickable.
248      *
249      * @return {@code true} if the down button is enabled. {@code false} otherwise.
250      */
isDownEnabled()251     private boolean isDownEnabled() {
252         return mDownButton.isEnabled();
253     }
254 
255     /** Listener for when the list should paginate. */
256     interface PaginationListener {
257         int PAGE_UP = 0;
258         int PAGE_DOWN = 1;
259 
260         /** Called when the linked view should be paged in the given direction */
onPaginate(int direction)261         void onPaginate(int direction);
262     }
263 
264     /**
265      * Calculate the amount of space that the scroll bar thumb is allowed to roam. The thumb
266      * is allowed to take up the space between the down bottom and the up or alpha jump
267      * button, depending on if the latter is visible.
268      */
calculateScrollThumbTrackHeight()269     private void calculateScrollThumbTrackHeight() {
270         // Subtracting (2 * mSeparatingMargin) for the top/bottom margin above and below the
271         // scroll bar thumb.
272         mScrollThumbTrackHeight = mDownButton.getTop() - (2 * mSeparatingMargin);
273 
274         // If there's an alpha jump button, then the thumb is laid out starting from below that.
275         mScrollThumbTrackHeight -= mUpButton.getBottom();
276     }
277 
measureScrollThumb()278     private void measureScrollThumb() {
279         int scrollWidth = MeasureSpec.makeMeasureSpec(mScrollBarThumbWidth, MeasureSpec.EXACTLY);
280         int scrollHeight = MeasureSpec.makeMeasureSpec(
281                 mScrollThumb.getLayoutParams().height,
282                 MeasureSpec.EXACTLY);
283         mScrollThumb.measure(scrollWidth, scrollHeight);
284     }
285 
286     /**
287      * An optimization method to only remeasure and lay out the scroll thumb. This method should be
288      * used when the height of the thumb has changed, but no other views need to be remeasured.
289      */
measureAndLayoutScrollThumb()290     private void measureAndLayoutScrollThumb() {
291         measureScrollThumb();
292 
293         // The top value should not change from what it was before; only the height is assumed to
294         // be changing.
295         int layoutTop = mScrollThumb.getTop();
296         layoutViewCenteredFromTop(mScrollThumb, layoutTop, mScrollView.getMeasuredWidth());
297     }
298 
299     /**
300      * Lays out the given View starting from the given {@code top} value downwards and centered
301      * within the given {@code availableWidth}.
302      *
303      * @param  view The view to lay out.
304      * @param  top The top value to start laying out from. This value will be the resulting top
305      *             value of the view.
306      * @param  availableWidth The width in which to center the given view.
307      */
layoutViewCenteredFromTop(View view, int top, int availableWidth)308     private void layoutViewCenteredFromTop(View view, int top, int availableWidth) {
309         int viewWidth = view.getMeasuredWidth();
310         int viewLeft = (availableWidth - viewWidth) / 2;
311         view.layout(viewLeft, top, viewLeft + viewWidth,
312                 top + view.getMeasuredHeight());
313     }
314 
315     /**
316      * Lays out the given View starting from the given {@code bottom} value upwards and centered
317      * within the given {@code availableSpace}.
318      *
319      * @param  view The view to lay out.
320      * @param  bottom The bottom value to start laying out from. This value will be the resulting
321      *                bottom value of the view.
322      * @param  availableWidth The width in which to center the given view.
323      */
layoutViewCenteredFromBottom(View view, int bottom, int availableWidth)324     private void layoutViewCenteredFromBottom(View view, int bottom, int availableWidth) {
325         int viewWidth = view.getMeasuredWidth();
326         int viewLeft = (availableWidth - viewWidth) / 2;
327         view.layout(viewLeft, bottom - view.getMeasuredHeight(),
328                 viewLeft + viewWidth, bottom);
329     }
330 
331     /**
332      * Sets the range, offset and extent of the scroll bar. The range represents the size of a
333      * container for the scrollbar thumb; offset is the distance from the start of the container
334      * to where the thumb should be; and finally, extent is the size of the thumb.
335      *
336      * <p>These values can be expressed in arbitrary units, so long as they share the same units.
337      * The values should also be positive.
338      *
339      * @param range The range of the scrollbar's thumb
340      * @param offset The offset of the scrollbar's thumb
341      * @param extent The extent of the scrollbar's thumb
342      * @param animate Whether or not the thumb should animate from its current position to the
343      *                position specified by the given range, offset and extent.
344      */
setParameters( @ntRangefrom = 0) int range, @IntRange(from = 0) int offset, @IntRange(from = 0) int extent, boolean animate)345     void setParameters(
346             @IntRange(from = 0) int range,
347             @IntRange(from = 0) int offset,
348             @IntRange(from = 0) int extent, boolean animate) {
349         // Not laid out yet, so values cannot be calculated.
350         if (!mScrollView.isLaidOut()) {
351             return;
352         }
353 
354         // If the scroll bars aren't visible, then no need to update.
355         if (mScrollView.getVisibility() == View.GONE || range == 0) {
356             return;
357         }
358 
359         int thumbLength = calculateScrollThumbLength(range, extent);
360         int thumbOffset = calculateScrollThumbOffset(range, offset, thumbLength);
361 
362         // Sets the size of the thumb and request a redraw if needed.
363         ViewGroup.LayoutParams lp = mScrollThumb.getLayoutParams();
364 
365         if (lp.height != thumbLength) {
366             lp.height = thumbLength;
367             mScrollThumb.requestLayout();
368         }
369 
370         moveY(mScrollThumb, thumbOffset, animate);
371     }
372 
373     /**
374      * An optimized version of {@link #setParameters(int, int, int, boolean)} that is meant to be
375      * called if a view is laying itself out. This method will avoid a complete remeasure of
376      * the views in the {@code PagedScrollBarView} if the scroll thumb's height needs to be changed.
377      * Instead, only the thumb itself will be remeasured and laid out.
378      *
379      * <p>These values can be expressed in arbitrary units, so long as they share the same units.
380      *
381      * @param range The range of the scrollbar's thumb
382      * @param offset The offset of the scrollbar's thumb
383      * @param extent The extent of the scrollbar's thumb
384      *
385      * @see #setParameters(int, int, int, boolean)
386      */
setParametersInLayout(int range, int offset, int extent)387     void setParametersInLayout(int range, int offset, int extent) {
388         // If the scroll bars aren't visible, then no need to update.
389         if (mScrollView.getVisibility() == View.GONE || range == 0) {
390             return;
391         }
392 
393         int thumbLength = calculateScrollThumbLength(range, extent);
394         int thumbOffset = calculateScrollThumbOffset(range, offset, thumbLength);
395 
396         // Sets the size of the thumb and request a redraw if needed.
397         ViewGroup.LayoutParams lp = mScrollThumb.getLayoutParams();
398 
399         if (lp.height != thumbLength) {
400             lp.height = thumbLength;
401             measureAndLayoutScrollThumb();
402         }
403 
404         mScrollThumb.setY(thumbOffset);
405     }
406 
407     /**
408      * Calculates and returns how big the scroll bar thumb should be based on the given range and
409      * extent.
410      *
411      * @param range The total amount of space the scroll bar is allowed to roam over.
412      * @param extent The amount of space that the scroll bar takes up relative to the range.
413      * @return The height of the scroll bar thumb in pixels.
414      */
calculateScrollThumbLength(int range, int extent)415     private int calculateScrollThumbLength(int range, int extent) {
416         // Scale the length by the available space that the thumb can fill.
417         return Math.round(((float) extent / range) * mScrollThumbTrackHeight);
418     }
419 
420     /**
421      * Calculates and returns how much the scroll thumb should be offset from the top of where it
422      * has been laid out.
423      *
424      * @param  range The total amount of space the scroll bar is allowed to roam over.
425      * @param  offset The amount the scroll bar should be offset, expressed in the same units as
426      *                the given range.
427      * @param  thumbLength The current length of the thumb in pixels.
428      * @return The amount the thumb should be offset in pixels.
429      */
calculateScrollThumbOffset(int range, int offset, int thumbLength)430     private int calculateScrollThumbOffset(int range, int offset, int thumbLength) {
431         // Ensure that if the user has reached the bottom of the list, then the scroll bar is
432         // aligned to the bottom as well. Otherwise, scale the offset appropriately.
433         // This offset will be a value relative to the parent of this scrollbar, so start by where
434         // the top of mScrollThumb is.
435         return mScrollThumb.getTop() + (isDownEnabled()
436                 ? Math.round(((float) offset / range) * mScrollThumbTrackHeight)
437                 : mScrollThumbTrackHeight - thumbLength);
438     }
439 
440     /** Moves the given view to the specified 'y' position. */
moveY(final View view, float newPosition, boolean animate)441     private void moveY(final View view, float newPosition, boolean animate) {
442         final int duration = animate ? 200 : 0;
443         view.animate()
444                 .y(newPosition)
445                 .setDuration(duration)
446                 .setInterpolator(mPaginationInterpolator)
447                 .start();
448     }
449 
450     /**
451      * Updates the rows number per current page, which is used for calculating how many items we
452      * want to show.
453      */
updateRowsPerPage()454     private void updateRowsPerPage() {
455         RecyclerView.LayoutManager layoutManager = getRecyclerView().getLayoutManager();
456         if (layoutManager == null) {
457             mRowsPerPage = 1;
458             return;
459         }
460 
461         View firstChild = layoutManager.getChildAt(0);
462         if (firstChild == null || firstChild.getHeight() == 0) {
463             mRowsPerPage = 1;
464         } else {
465             mRowsPerPage = Math.max(1, getRecyclerView().getHeight() / firstChild.getHeight());
466         }
467     }
468 
469     private class PaginateButtonClickListener implements View.OnClickListener {
470         private final int mPaginateDirection;
471         private PaginationListener mPaginationListener;
472 
PaginateButtonClickListener(int paginateDirection)473         PaginateButtonClickListener(int paginateDirection) {
474             mPaginateDirection = paginateDirection;
475         }
476 
setPaginationListener(PaginationListener listener)477         public void setPaginationListener(PaginationListener listener) {
478             mPaginationListener = listener;
479         }
480 
481         @Override
onClick(View v)482         public void onClick(View v) {
483             if (mPaginationListener != null) {
484                 mPaginationListener.onPaginate(mPaginateDirection);
485             }
486             if (mPaginateDirection == PaginationListener.PAGE_DOWN) {
487                 pageDown();
488             } else if (mPaginateDirection == PaginationListener.PAGE_UP) {
489                 pageUp();
490             }
491         }
492     }
493 
494     private final RecyclerView.OnScrollListener mRecyclerViewOnScrollListener =
495             new RecyclerView.OnScrollListener() {
496                 @Override
497                 public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
498                     updatePaginationButtons(false);
499                 }
500             };
501 
502     private final Runnable mPaginationRunnable = () -> {
503         boolean upPressed = isUpPressed();
504         boolean downPressed = isDownPressed();
505         if (upPressed && downPressed) {
506             return;
507         }
508         if (upPressed) {
509             pageUp();
510         } else if (downPressed) {
511             pageDown();
512         }
513     };
514 
515     /** Returns the page the given position is on, starting with page 0. */
getPage(int position)516     int getPage(int position) {
517         if (mRowsPerPage == -1) {
518             return -1;
519         }
520         if (mRowsPerPage == 0) {
521             return 0;
522         }
523         return position / mRowsPerPage;
524     }
525 
getOrientationHelper(RecyclerView.LayoutManager layoutManager)526     private OrientationHelper getOrientationHelper(RecyclerView.LayoutManager layoutManager) {
527         if (mOrientationHelper == null || mOrientationHelper.getLayoutManager() != layoutManager) {
528             // PagedRecyclerView is assumed to be a list that always vertically scrolls.
529             mOrientationHelper = OrientationHelper.createVerticalHelper(layoutManager);
530         }
531         return mOrientationHelper;
532     }
533 
534     /**
535      * Scrolls the contents of the RecyclerView up a page. A page is defined as the height of the
536      * {@code PagedRecyclerView}.
537      *
538      * <p>The resulting first item in the list will be snapped to so that it is completely visible.
539      * If this is not possible due to the first item being taller than the containing
540      * {@code PagedRecyclerView}, then the snapping will not occur.
541      */
pageUp()542     void pageUp() {
543         int currentOffset = getRecyclerView().computeVerticalScrollOffset();
544         if (getRecyclerView().getLayoutManager() == null
545                 || getRecyclerView().getChildCount() == 0 || currentOffset == 0) {
546             return;
547         }
548 
549         // Use OrientationHelper to calculate scroll distance in order to match snapping behavior.
550         OrientationHelper orientationHelper =
551                 getOrientationHelper(getRecyclerView().getLayoutManager());
552         int screenSize = orientationHelper.getTotalSpace();
553 
554         int scrollDistance = screenSize;
555         // The iteration order matters. In case where there are 2 items longer than screen size, we
556         // want to focus on upcoming view.
557         for (int i = 0; i < getRecyclerView().getChildCount(); i++) {
558             /*
559              * We treat child View longer than screen size differently:
560              * 1) When it enters screen, next pageUp will align its bottom with parent bottom;
561              * 2) When it leaves screen, next pageUp will align its top with parent top.
562              */
563             View child = getRecyclerView().getChildAt(i);
564             if (child.getHeight() > screenSize) {
565                 if (orientationHelper.getDecoratedEnd(child) < screenSize) {
566                     // Child view bottom is entering screen. Align its bottom with parent bottom.
567                     scrollDistance = screenSize - orientationHelper.getDecoratedEnd(child);
568                 } else if (-screenSize < orientationHelper.getDecoratedStart(child)
569                         && orientationHelper.getDecoratedStart(child) < 0) {
570                     // Child view top is about to enter screen - its distance to parent top
571                     // is less than a full scroll. Align child top with parent top.
572                     scrollDistance = Math.abs(orientationHelper.getDecoratedStart(child));
573                 }
574                 // There can be two items that are longer than the screen. We stop at the first one.
575                 // This is affected by the iteration order.
576                 break;
577             }
578         }
579         // Distance should always be positive. Negate its value to scroll up.
580         getRecyclerView().smoothScrollBy(0, -scrollDistance);
581     }
582 
583     /**
584      * Scrolls the contents of the RecyclerView down a page. A page is defined as the height of the
585      * {@code PagedRecyclerView}.
586      *
587      * <p>This method will attempt to bring the last item in the list as the first item. If the
588      * current first item in the list is taller than the {@code PagedRecyclerView}, then it will be
589      * scrolled the length of a page, but not snapped to.
590      */
pageDown()591     void pageDown() {
592         if (getRecyclerView().getLayoutManager() == null
593                 || getRecyclerView().getChildCount() == 0) {
594             return;
595         }
596 
597         OrientationHelper orientationHelper =
598                 getOrientationHelper(getRecyclerView().getLayoutManager());
599         int screenSize = orientationHelper.getTotalSpace();
600         int scrollDistance = screenSize;
601 
602         // If the last item is partially visible, page down should bring it to the top.
603         View lastChild = getRecyclerView().getChildAt(getRecyclerView().getChildCount() - 1);
604         if (getRecyclerView().getLayoutManager().isViewPartiallyVisible(lastChild,
605                 /* completelyVisible= */ false, /* acceptEndPointInclusion= */ false)) {
606             scrollDistance = orientationHelper.getDecoratedStart(lastChild);
607             if (scrollDistance < 0) {
608                 // Scroll value can be negative if the child is longer than the screen size and the
609                 // visible area of the screen does not show the start of the child.
610                 // Scroll to the next screen if the start value is negative
611                 scrollDistance = screenSize;
612             }
613         }
614 
615         // The iteration order matters. In case where there are 2 items longer than screen size, we
616         // want to focus on upcoming view (the one at the bottom of screen).
617         for (int i = getRecyclerView().getChildCount() - 1; i >= 0; i--) {
618             /* We treat child View longer than screen size differently:
619              * 1) When it enters screen, next pageDown will align its top with parent top;
620              * 2) When it leaves screen, next pageDown will align its bottom with parent bottom.
621              */
622             View child = getRecyclerView().getChildAt(i);
623             if (child.getHeight() > screenSize) {
624                 if (orientationHelper.getDecoratedStart(child) > 0) {
625                     // Child view top is entering screen. Align its top with parent top.
626                     scrollDistance = orientationHelper.getDecoratedStart(child);
627                 } else if (screenSize < orientationHelper.getDecoratedEnd(child)
628                         && orientationHelper.getDecoratedEnd(child) < 2 * screenSize) {
629                     // Child view bottom is about to enter screen - its distance to parent bottom
630                     // is less than a full scroll. Align child bottom with parent bottom.
631                     scrollDistance = orientationHelper.getDecoratedEnd(child) - screenSize;
632                 }
633                 // There can be two items that are longer than the screen. We stop at the first one.
634                 // This is affected by the iteration order.
635                 break;
636             }
637         }
638 
639         getRecyclerView().smoothScrollBy(0, scrollDistance);
640     }
641 
642     /**
643      * Determines if scrollbar should be visible or not and shows/hides it accordingly. If this is
644      * being called as a result of adapter changes, it should be called after the new layout has
645      * been calculated because the method of determining scrollbar visibility uses the current
646      * layout. If this is called after an adapter change but before the new layout, the visibility
647      * determination may not be correct.
648      *
649      * @param animate {@code true} if the scrollbar should animate to its new position.
650      *                {@code false} if no animation is used
651      */
updatePaginationButtons(boolean animate)652     private void updatePaginationButtons(boolean animate) {
653 
654         boolean isAtStart = isAtStart();
655         boolean isAtEnd = isAtEnd();
656         RecyclerView.LayoutManager layoutManager = getRecyclerView().getLayoutManager();
657 
658         if ((isAtStart && isAtEnd) || layoutManager == null || layoutManager.getItemCount() == 0) {
659             mScrollView.setVisibility(View.INVISIBLE);
660         } else {
661             mScrollView.setVisibility(View.VISIBLE);
662         }
663         setUpEnabled(!isAtStart);
664         setDownEnabled(!isAtEnd);
665 
666         if (layoutManager == null) {
667             return;
668         }
669 
670         if (layoutManager.canScrollVertically()) {
671             setParameters(
672                     getRecyclerView().computeVerticalScrollRange(),
673                     getRecyclerView().computeVerticalScrollOffset(),
674                     getRecyclerView().computeVerticalScrollExtent(), animate);
675         } else {
676             setParameters(
677                     getRecyclerView().computeHorizontalScrollRange(),
678                     getRecyclerView().computeHorizontalScrollOffset(),
679                     getRecyclerView().computeHorizontalScrollExtent(), animate);
680         }
681 
682         mScrollView.invalidate();
683     }
684 
685     /** Returns {@code true} if the RecyclerView is completely displaying the first item. */
isAtStart()686     boolean isAtStart() {
687         return mSnapHelper.isAtStart(getRecyclerView().getLayoutManager());
688     }
689 
690     /** Returns {@code true} if the RecyclerView is completely displaying the last item. */
isAtEnd()691     boolean isAtEnd() {
692         return mSnapHelper.isAtEnd(getRecyclerView().getLayoutManager());
693     }
694 
695     /**
696      * Scrolls to the given position in the PagedRecyclerView.
697      *
698      * @param position The position in the list to scroll to.
699      */
scrollToPosition(int position)700     private void scrollToPosition(int position) {
701         RecyclerView.LayoutManager layoutManager = getRecyclerView().getLayoutManager();
702         if (layoutManager == null) {
703             return;
704         }
705 
706         RecyclerView.SmoothScroller smoothScroller = mSnapHelper.createScroller(layoutManager);
707         smoothScroller.setTargetPosition(position);
708 
709         layoutManager.startSmoothScroll(smoothScroller);
710 
711         // Sometimes #scrollToPosition doesn't change the scroll state so we need to make sure
712         // the pagination arrows actually get updated. See b/15801119
713         mHandler.post(() -> updatePaginationButtons(true /*animate*/));
714     }
715 
716     /**
717      * Snap to the given position. This method will snap instantly to a position that's "close" to
718      * the given position and then animate a short decelerate to indicate the direction that the
719      * snap happened.
720      *
721      * @param position The position in the list to scroll to.
722      */
snapToPosition(int position)723     void snapToPosition(int position) {
724         RecyclerView.LayoutManager layoutManager = getRecyclerView().getLayoutManager();
725 
726         if (layoutManager == null) {
727             return;
728         }
729 
730         int startPosition = position;
731         if ((layoutManager instanceof RecyclerView.SmoothScroller.ScrollVectorProvider)) {
732             PointF vector = ((RecyclerView.SmoothScroller.ScrollVectorProvider) layoutManager)
733                     .computeScrollVectorForPosition(position);
734             // A positive value in the vector means scrolling down, so should offset by scrolling to
735             // an item previous in the list.
736             int offsetDirection = (vector == null || vector.y > 0) ? -1 : 1;
737             startPosition += offsetDirection * SNAP_SCROLL_OFFSET_POSITION;
738 
739             // Clamp the start position.
740             startPosition = Math.max(0, Math.min(startPosition, layoutManager.getItemCount() - 1));
741         } else {
742             // If the LayoutManager doesn't implement ScrollVectorProvider (the default for
743             // PagedRecyclerView, LinearLayoutManager does, but if the user has overridden it) then
744             // we cannot compute the direction we need to scroll. So just snap instantly instead.
745             Log.w(TAG, "LayoutManager is not a ScrollVectorProvider, can't do snap animation.");
746         }
747 
748         if (layoutManager instanceof LinearLayoutManager) {
749             ((LinearLayoutManager) layoutManager).scrollToPositionWithOffset(startPosition, 0);
750         } else {
751             layoutManager.scrollToPosition(startPosition);
752         }
753 
754         if (startPosition != position) {
755             // The actual scroll above happens on the next update, so we wait for that to finish
756             // before doing the smooth scroll.
757             mScrollView.post(() -> scrollToPosition(position));
758         }
759     }
760 }
761