1 /*
2  * Copyright (C) 2017 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.car.widget;
18 
19 import static java.lang.annotation.RetentionPolicy.SOURCE;
20 
21 import android.content.Context;
22 import android.content.res.TypedArray;
23 import android.graphics.Canvas;
24 import android.graphics.Paint;
25 import android.graphics.PointF;
26 import android.graphics.Rect;
27 import android.graphics.drawable.Drawable;
28 import android.os.Bundle;
29 import android.os.Handler;
30 import android.os.Parcelable;
31 import android.util.AttributeSet;
32 import android.util.Log;
33 import android.util.SparseArray;
34 import android.view.LayoutInflater;
35 import android.view.View;
36 import android.view.ViewGroup;
37 import android.widget.FrameLayout;
38 
39 import androidx.annotation.ColorRes;
40 import androidx.annotation.IdRes;
41 import androidx.annotation.IntDef;
42 import androidx.annotation.NonNull;
43 import androidx.annotation.Nullable;
44 import androidx.annotation.UiThread;
45 import androidx.annotation.VisibleForTesting;
46 import androidx.car.R;
47 import androidx.recyclerview.widget.GridLayoutManager;
48 import androidx.recyclerview.widget.LinearLayoutManager;
49 import androidx.recyclerview.widget.OrientationHelper;
50 import androidx.recyclerview.widget.RecyclerView;
51 
52 import java.lang.annotation.Retention;
53 
54 /**
55  * View that wraps a {@link RecyclerView} and a scroll bar that has
56  * page up and down arrows. Interaction with this view is similar to a {@code RecyclerView} as it
57  * takes the same adapter.
58  *
59  * <p>By default, this PagedListView utilizes a vertical {@link LinearLayoutManager} to display
60  * its items.
61  */
62 public class PagedListView extends FrameLayout {
63     /**
64      * The key used to save the state of this PagedListView's super class in
65      * {@link #onSaveInstanceState()}.
66      */
67     private static final String SAVED_SUPER_STATE_KEY = "PagedListViewSuperState";
68 
69     /**
70      * The key used to save the state of {@link #mRecyclerView} so that it can be restored
71      * on configuration change. The actual saving of state will be controlled by the LayoutManager
72      * of the RecyclerView; this value simply ensures the state is passed on to the LayoutManager.
73      */
74     private static final String SAVED_RECYCLER_VIEW_STATE_KEY = "RecyclerViewState";
75 
76     /** Default maximum number of clicks allowed on a list */
77     public static final int DEFAULT_MAX_CLICKS = 6;
78 
79     /**
80      * Value to pass to {@link #setMaxPages(int)} to indicate there is no restriction on the
81      * maximum number of pages to show.
82      */
83     public static final int UNLIMITED_PAGES = -1;
84 
85     /**
86      * The amount of time after settling to wait before autoscrolling to the next page when the user
87      * holds down a pagination button.
88      */
89     private static final int PAGINATION_HOLD_DELAY_MS = 400;
90 
91     /**
92      * When doing a snap, offset the snap by this number of position and then do a smooth scroll to
93      * the final position.
94      */
95     private static final int SNAP_SCROLL_OFFSET_POSITION = 2;
96 
97     private static final String TAG = "PagedListView";
98     private static final int INVALID_RESOURCE_ID = -1;
99 
100     private RecyclerView mRecyclerView;
101     private PagedSnapHelper mSnapHelper;
102     private final Handler mHandler = new Handler();
103     private boolean mScrollBarEnabled;
104     @VisibleForTesting
105     PagedScrollBarView mScrollBarView;
106 
107     /**
108      * AlphaJumpOverlayView that will be null until the first time you tap the alpha jump button, at
109      * which point we'll construct it and add it to the view hierarchy as a child of this frame
110      * layout.
111      */
112     @Nullable private AlphaJumpOverlayView mAlphaJumpView;
113 
114     private int mRowsPerPage = -1;
115     private RecyclerView.Adapter<? extends RecyclerView.ViewHolder> mAdapter;
116 
117     /** Maximum number of pages to show. */
118     private int mMaxPages;
119 
120     private OnScrollListener mOnScrollListener;
121 
122     /** Number of visible rows per page */
123     private int mDefaultMaxPages = DEFAULT_MAX_CLICKS;
124 
125     /** Used to check if there are more items added to the list. */
126     private int mLastItemCount;
127 
128     private boolean mNeedsFocus;
129 
130     private OrientationHelper mOrientationHelper;
131 
132     @Gutter
133     private int mGutter;
134     private int mGutterSize;
135 
136     /**
137      * Interface for a {@link RecyclerView.Adapter} to cap the number of
138      * items.
139      *
140      * <p>NOTE: it is still up to the adapter to use maxItems in {@link
141      * RecyclerView.Adapter#getItemCount()}.
142      *
143      * <p>the recommended way would be with:
144      *
145      * <pre>{@code
146      * {@literal@}Override
147      * public int getItemCount() {
148      *   return Math.min(super.getItemCount(), mMaxItems);
149      * }
150      * }</pre>
151      */
152     public interface ItemCap {
153         /**
154          * A value to pass to {@link #setMaxItems(int)} that indicates there should be no limit.
155          */
156         int UNLIMITED = -1;
157 
158         /**
159          * Sets the maximum number of items available in the adapter. A value less than '0' means
160          * the list should not be capped.
161          */
setMaxItems(int maxItems)162         void setMaxItems(int maxItems);
163     }
164 
165     /**
166      * Interface for controlling visibility of item dividers for individual items based on the
167      * item's position.
168      *
169      * <p> NOTE: interface takes effect only when dividers are enabled.
170      */
171     public interface DividerVisibilityManager {
172         /**
173          * Given an item position, returns whether the divider below that item should be hidden.
174          *
175          * @param position item position inside the adapter.
176          * @return true if divider is to be hidden, false if divider should be shown.
177          */
shouldHideDivider(int position)178         boolean shouldHideDivider(int position);
179     }
180 
181     /**
182      * The possible values for @{link #setGutter}. The default value is actually
183      * {@link Gutter#BOTH}.
184      */
185     @IntDef({
186             Gutter.NONE,
187             Gutter.START,
188             Gutter.END,
189             Gutter.BOTH,
190     })
191     @Retention(SOURCE)
192     public @interface Gutter {
193         /**
194          * No gutter on either side of the list items. The items will span the full width of the
195          * {@link PagedListView}.
196          */
197         int NONE = 0;
198 
199         /**
200          * Include a gutter only on the start side (that is, the same side as the scroll bar).
201          */
202         int START = 1;
203 
204         /**
205          * Include a gutter only on the end side (that is, the opposite side of the scroll bar).
206          */
207         int END = 2;
208 
209         /**
210          * Include a gutter on both sides of the list items. This is the default behaviour.
211          */
212         int BOTH = 3;
213     }
214 
215     /**
216      * Interface for a {@link RecyclerView.Adapter} to set the position
217      * offset for the adapter to load the data.
218      *
219      * <p>For example in the adapter, if the positionOffset is 20, then for position 0 it will show
220      * the item in position 20 instead, for position 1 it will show the item in position 21 instead
221      * and so on.
222      */
223     public interface ItemPositionOffset {
224         /** Sets the position offset for the adapter. */
setPositionOffset(int positionOffset)225         void setPositionOffset(int positionOffset);
226     }
227 
PagedListView(Context context)228     public PagedListView(Context context) {
229         super(context);
230         init(context, null /* attrs */);
231     }
232 
PagedListView(Context context, AttributeSet attrs)233     public PagedListView(Context context, AttributeSet attrs) {
234         super(context, attrs);
235         init(context, attrs);
236     }
237 
PagedListView(Context context, AttributeSet attrs, int defStyleAttrs)238     public PagedListView(Context context, AttributeSet attrs, int defStyleAttrs) {
239         super(context, attrs, defStyleAttrs);
240         init(context, attrs);
241     }
242 
PagedListView(Context context, AttributeSet attrs, int defStyleAttrs, int defStyleRes)243     public PagedListView(Context context, AttributeSet attrs, int defStyleAttrs, int defStyleRes) {
244         super(context, attrs, defStyleAttrs, defStyleRes);
245         init(context, attrs);
246     }
247 
init(Context context, AttributeSet attrs)248     private void init(Context context, AttributeSet attrs) {
249         LayoutInflater.from(context).inflate(R.layout.car_paged_recycler_view,
250                 this /* root */, true /* attachToRoot */);
251 
252         TypedArray a = context.obtainStyledAttributes(
253                 attrs, R.styleable.PagedListView, R.attr.pagedListViewStyle, 0 /* defStyleRes */);
254         mRecyclerView = findViewById(R.id.recycler_view);
255 
256         mMaxPages = getDefaultMaxPages();
257 
258         RecyclerView.LayoutManager layoutManager =
259                 new LinearLayoutManager(context, LinearLayoutManager.VERTICAL, false);
260         mRecyclerView.setLayoutManager(layoutManager);
261 
262         mSnapHelper = new PagedSnapHelper(context);
263         mSnapHelper.attachToRecyclerView(mRecyclerView);
264 
265         mRecyclerView.addOnScrollListener(mRecyclerViewOnScrollListener);
266         mRecyclerView.getRecycledViewPool().setMaxRecycledViews(0, 12);
267 
268         if (a.getBoolean(R.styleable.PagedListView_verticallyCenterListContent, false)) {
269             // Setting the height of wrap_content allows the RecyclerView to center itself.
270             mRecyclerView.getLayoutParams().height = ViewGroup.LayoutParams.WRAP_CONTENT;
271         }
272 
273         int defaultGutterSize = getResources().getDimensionPixelSize(R.dimen.car_margin);
274         mGutterSize = a.getDimensionPixelSize(R.styleable.PagedListView_gutterSize,
275                 defaultGutterSize);
276 
277         if (a.hasValue(R.styleable.PagedListView_gutter)) {
278             int gutter = a.getInt(R.styleable.PagedListView_gutter, Gutter.BOTH);
279             setGutter(gutter);
280         } else if (a.hasValue(R.styleable.PagedListView_offsetScrollBar)) {
281             boolean offsetScrollBar =
282                     a.getBoolean(R.styleable.PagedListView_offsetScrollBar, false);
283             if (offsetScrollBar) {
284                 setGutter(Gutter.START);
285             }
286         } else {
287             setGutter(Gutter.BOTH);
288         }
289 
290         if (a.getBoolean(R.styleable.PagedListView_showPagedListViewDivider, true)) {
291             int dividerStartMargin = a.getDimensionPixelSize(
292                     R.styleable.PagedListView_dividerStartMargin, 0);
293             int dividerEndMargin = a.getDimensionPixelSize(
294                     R.styleable.PagedListView_dividerEndMargin, 0);
295             int dividerStartId = a.getResourceId(
296                     R.styleable.PagedListView_alignDividerStartTo, INVALID_RESOURCE_ID);
297             int dividerEndId = a.getResourceId(
298                     R.styleable.PagedListView_alignDividerEndTo, INVALID_RESOURCE_ID);
299 
300             int listDividerColor = a.getResourceId(R.styleable.PagedListView_listDividerColor,
301                     R.color.car_list_divider);
302 
303             mRecyclerView.addItemDecoration(new DividerDecoration(context, dividerStartMargin,
304                     dividerEndMargin, dividerStartId, dividerEndId, listDividerColor));
305         }
306 
307         int itemSpacing = a.getDimensionPixelSize(R.styleable.PagedListView_itemSpacing, 0);
308         if (itemSpacing > 0) {
309             mRecyclerView.addItemDecoration(new ItemSpacingDecoration(itemSpacing));
310         }
311 
312         int listContentTopMargin =
313                 a.getDimensionPixelSize(R.styleable.PagedListView_listContentTopOffset, 0);
314         if (listContentTopMargin > 0) {
315             mRecyclerView.addItemDecoration(new TopOffsetDecoration(listContentTopMargin));
316         }
317 
318         // Set focusable false explicitly to handle the behavior change in Android O where
319         // clickable view becomes focusable by default.
320         setFocusable(false);
321 
322         mScrollBarEnabled = a.getBoolean(R.styleable.PagedListView_scrollBarEnabled, true);
323         mScrollBarView = findViewById(R.id.paged_scroll_view);
324         mScrollBarView.setPaginationListener(new PagedScrollBarView.PaginationListener() {
325             @Override
326             public void onPaginate(int direction) {
327                 switch (direction) {
328                     case PagedScrollBarView.PaginationListener.PAGE_UP:
329                         pageUp();
330                         if (mOnScrollListener != null) {
331                             mOnScrollListener.onScrollUpButtonClicked();
332                         }
333                         break;
334                     case PagedScrollBarView.PaginationListener.PAGE_DOWN:
335                         pageDown();
336                         if (mOnScrollListener != null) {
337                             mOnScrollListener.onScrollDownButtonClicked();
338                         }
339                         break;
340                     default:
341                         Log.e(TAG, "Unknown pagination direction (" + direction + ")");
342                 }
343             }
344 
345             @Override
346             public void onAlphaJump() {
347                 showAlphaJump();
348             }
349         });
350 
351         Drawable upButtonIcon = a.getDrawable(R.styleable.PagedListView_upButtonIcon);
352         if (upButtonIcon != null) {
353             setUpButtonIcon(upButtonIcon);
354         }
355 
356         Drawable downButtonIcon = a.getDrawable(R.styleable.PagedListView_downButtonIcon);
357         if (downButtonIcon != null) {
358             setDownButtonIcon(downButtonIcon);
359         }
360 
361         // Using getResourceId() over getColor() because setScrollbarColor() expects a color resId.
362         int scrollBarColor = a.getResourceId(R.styleable.PagedListView_scrollBarColor, -1);
363         if (scrollBarColor != -1) {
364             setScrollbarColor(scrollBarColor);
365         }
366 
367         mScrollBarView.setVisibility(mScrollBarEnabled ? VISIBLE : GONE);
368 
369         if (mScrollBarEnabled) {
370             // Use the top margin that is defined in the layout as the default value.
371             int topMargin = a.getDimensionPixelSize(
372                     R.styleable.PagedListView_scrollBarTopMargin,
373                     ((MarginLayoutParams) mScrollBarView.getLayoutParams()).topMargin);
374             setScrollBarTopMargin(topMargin);
375         } else {
376             MarginLayoutParams params = (MarginLayoutParams) mRecyclerView.getLayoutParams();
377             params.setMarginStart(0);
378         }
379 
380         if (a.hasValue(R.styleable.PagedListView_scrollBarContainerWidth)) {
381             int carMargin = getResources().getDimensionPixelSize(R.dimen.car_margin);
382             int scrollBarContainerWidth = a.getDimensionPixelSize(
383                     R.styleable.PagedListView_scrollBarContainerWidth, carMargin);
384             setScrollBarContainerWidth(scrollBarContainerWidth);
385         }
386 
387         if (a.hasValue(R.styleable.PagedListView_dayNightStyle)) {
388             @DayNightStyle int dayNightStyle =
389                     a.getInt(R.styleable.PagedListView_dayNightStyle, DayNightStyle.AUTO);
390             setDayNightStyle(dayNightStyle);
391         } else {
392             setDayNightStyle(DayNightStyle.AUTO);
393         }
394 
395         a.recycle();
396     }
397 
398     @Override
onDetachedFromWindow()399     protected void onDetachedFromWindow() {
400         super.onDetachedFromWindow();
401         mHandler.removeCallbacks(mUpdatePaginationRunnable);
402     }
403 
404     /**
405      * Returns the position of the given View in the list.
406      *
407      * @param v The View to check for.
408      * @return The position or -1 if the given View is {@code null} or not in the list.
409      */
positionOf(@ullable View v)410     public int positionOf(@Nullable View v) {
411         if (v == null || v.getParent() != mRecyclerView
412                 || mRecyclerView.getLayoutManager() == null) {
413             return -1;
414         }
415         return mRecyclerView.getLayoutManager().getPosition(v);
416     }
417 
418     /**
419      * Set the gutter to the specified value.
420      *
421      * <p>The gutter is the space to the start/end of the list view items and will be equal in size
422      * to the scroll bars. By default, there is a gutter to both the left and right of the list
423      * view items, to account for the scroll bar.
424      *
425      * @param gutter A {@link Gutter} value that identifies which sides to apply the gutter to.
426      */
setGutter(@utter int gutter)427     public void setGutter(@Gutter int gutter) {
428         mGutter = gutter;
429 
430         int startMargin = 0;
431         int endMargin = 0;
432         if ((mGutter & Gutter.START) != 0) {
433             startMargin = mGutterSize;
434         }
435         if ((mGutter & Gutter.END) != 0) {
436             endMargin = mGutterSize;
437         }
438         MarginLayoutParams layoutParams = (MarginLayoutParams) mRecyclerView.getLayoutParams();
439         layoutParams.setMarginStart(startMargin);
440         layoutParams.setMarginEnd(endMargin);
441         // requestLayout() isn't sufficient because we also need to resolveLayoutParams().
442         mRecyclerView.setLayoutParams(layoutParams);
443 
444         // If there's a gutter, set ClipToPadding to false so that CardView's shadow will still
445         // appear outside of the padding.
446         mRecyclerView.setClipToPadding(startMargin == 0 && endMargin == 0);
447 
448     }
449 
450     /**
451      * Sets the size of the gutter that appears at the start, end or both sizes of the items in
452      * the {@code PagedListView}.
453      *
454      * @param gutterSize The size of the gutter in pixels.
455      * @see #setGutter(int)
456      */
setGutterSize(int gutterSize)457     public void setGutterSize(int gutterSize) {
458         mGutterSize = gutterSize;
459 
460         // Call setGutter to reset the gutter.
461         setGutter(mGutter);
462     }
463 
464     /**
465      * Sets the width of the container that holds the scrollbar. The scrollbar will be centered
466      * within this width.
467      *
468      * @param width The width of the scrollbar container.
469      */
setScrollBarContainerWidth(int width)470     public void setScrollBarContainerWidth(int width) {
471         ViewGroup.LayoutParams layoutParams = mScrollBarView.getLayoutParams();
472         layoutParams.width = width;
473         mScrollBarView.requestLayout();
474     }
475 
476     /**
477      * Sets the top margin above the scroll bar. By default, this margin is 0.
478      *
479      * @param topMargin The top margin.
480      */
setScrollBarTopMargin(int topMargin)481     public void setScrollBarTopMargin(int topMargin) {
482         MarginLayoutParams params = (MarginLayoutParams) mScrollBarView.getLayoutParams();
483         params.topMargin = topMargin;
484         mScrollBarView.requestLayout();
485     }
486 
487     /**
488      * Sets an offset above the first item in the {@code PagedListView}. This offset is scrollable
489      * with the contents of the list.
490      *
491      * @param offset The top offset to add.
492      */
setListContentTopOffset(int offset)493     public void setListContentTopOffset(int offset) {
494         TopOffsetDecoration existing = null;
495         for (int i = 0, count = mRecyclerView.getItemDecorationCount(); i < count; i++) {
496             RecyclerView.ItemDecoration itemDecoration = mRecyclerView.getItemDecorationAt(i);
497             if (itemDecoration instanceof TopOffsetDecoration) {
498                 existing = (TopOffsetDecoration) itemDecoration;
499                 break;
500             }
501         }
502 
503         if (offset == 0 && existing != null) {
504             mRecyclerView.removeItemDecoration(existing);
505         } else if (existing == null) {
506             mRecyclerView.addItemDecoration(new TopOffsetDecoration(offset));
507         } else {
508             existing.setTopOffset(offset);
509         }
510         mRecyclerView.invalidateItemDecorations();
511     }
512 
513     @NonNull
getRecyclerView()514     public RecyclerView getRecyclerView() {
515         return mRecyclerView;
516     }
517 
518     /**
519      * Scrolls to the given position in the PagedListView.
520      *
521      * @param position The position in the list to scroll to.
522      */
scrollToPosition(int position)523     public void scrollToPosition(int position) {
524         RecyclerView.LayoutManager layoutManager = mRecyclerView.getLayoutManager();
525         if (layoutManager == null) {
526             return;
527         }
528 
529         RecyclerView.SmoothScroller smoothScroller = mSnapHelper.createScroller(layoutManager);
530         smoothScroller.setTargetPosition(position);
531 
532         layoutManager.startSmoothScroll(smoothScroller);
533 
534         // Sometimes #scrollToPosition doesn't change the scroll state so we need to make sure
535         // the pagination arrows actually get updated. See b/15801119
536         mHandler.post(mUpdatePaginationRunnable);
537     }
538 
539     /**
540      * Snap to the given position. This method will snap instantly to a position that's "close" to
541      * the given position and then animate a short decelerate to indicate the direction that the
542      * snap happened.
543      *
544      * @param position The position in the list to scroll to.
545      */
snapToPosition(int position)546     public void snapToPosition(int position) {
547         RecyclerView.LayoutManager layoutManager = mRecyclerView.getLayoutManager();
548 
549         if (layoutManager == null) {
550             return;
551         }
552 
553         int startPosition = position;
554         if ((layoutManager instanceof RecyclerView.SmoothScroller.ScrollVectorProvider)) {
555             PointF vector = ((RecyclerView.SmoothScroller.ScrollVectorProvider) layoutManager)
556                     .computeScrollVectorForPosition(position);
557             // A positive value in the vector means scrolling down, so should offset by scrolling to
558             // an item previous in the list.
559             int offsetDirection = (vector == null || vector.y > 0) ? -1 : 1;
560             startPosition += offsetDirection * SNAP_SCROLL_OFFSET_POSITION;
561 
562             // Clamp the start position.
563             startPosition = Math.max(0, Math.min(startPosition, layoutManager.getItemCount() - 1));
564         } else {
565             // If the LayoutManager doesn't implement ScrollVectorProvider (the default for
566             // PagedListView, LinearLayoutManager does, but if the user has overridden it) then we
567             // cannot compute the direction we need to scroll. So just snap instantly instead.
568             Log.w(TAG, "LayoutManager is not a ScrollVectorProvider, can't do snap animation.");
569         }
570 
571         if (layoutManager instanceof LinearLayoutManager) {
572             ((LinearLayoutManager) layoutManager).scrollToPositionWithOffset(startPosition, 0);
573         } else {
574             layoutManager.scrollToPosition(startPosition);
575         }
576 
577         if (startPosition != position) {
578             // The actual scroll above happens on the next update, so we wait for that to finish
579             // before doing the smooth scroll.
580             post(() -> scrollToPosition(position));
581         }
582     }
583 
584     /** Sets the icon to be used for the up button. */
setUpButtonIcon(Drawable icon)585     public void setUpButtonIcon(Drawable icon) {
586         mScrollBarView.setUpButtonIcon(icon);
587     }
588 
589     /** Sets the icon to be used for the down button. */
setDownButtonIcon(Drawable icon)590     public void setDownButtonIcon(Drawable icon) {
591         mScrollBarView.setDownButtonIcon(icon);
592     }
593 
594     /**
595      * Sets the adapter for the list.
596      *
597      * <p>The given Adapter can implement {@link ItemCap} if it wishes to control the behavior of
598      * a max number of items. Otherwise, methods in the PagedListView to limit the content, such as
599      * {@link #setMaxPages(int)}, will do nothing.
600      */
setAdapter( @onNull RecyclerView.Adapter<? extends RecyclerView.ViewHolder> adapter)601     public void setAdapter(
602             @NonNull RecyclerView.Adapter<? extends RecyclerView.ViewHolder> adapter) {
603         mAdapter = adapter;
604         mRecyclerView.setAdapter(adapter);
605 
606         updateMaxItems();
607         updateAlphaJump();
608     }
609 
610     /**
611      * Sets {@link DividerVisibilityManager} on all {@code DividerDecoration} item decorations.
612      *
613      * @param dvm {@code DividerVisibilityManager} to be set.
614      */
setDividerVisibilityManager(DividerVisibilityManager dvm)615     public void setDividerVisibilityManager(DividerVisibilityManager dvm) {
616         int decorCount = mRecyclerView.getItemDecorationCount();
617         for (int i = 0; i < decorCount; i++) {
618             RecyclerView.ItemDecoration decor = mRecyclerView.getItemDecorationAt(i);
619             if (decor instanceof DividerDecoration) {
620                 ((DividerDecoration) decor).setVisibilityManager(dvm);
621             }
622         }
623         mRecyclerView.invalidateItemDecorations();
624     }
625 
626     @Nullable
627     @SuppressWarnings("unchecked")
getAdapter()628     public RecyclerView.Adapter<? extends RecyclerView.ViewHolder> getAdapter() {
629         return mRecyclerView.getAdapter();
630     }
631 
632     /**
633      * Sets the maximum number of the pages that can be shown in the PagedListView. The size of a
634      * page is defined as the number of items that fit completely on the screen at once.
635      *
636      * <p>Passing {@link #UNLIMITED_PAGES} will remove any restrictions on a maximum number
637      * of pages.
638      *
639      * <p>Note that for any restriction on maximum pages to work, the adapter passed to this
640      * PagedListView needs to implement {@link ItemCap}.
641      *
642      * @param maxPages The maximum number of pages that fit on the screen. Should be positive or
643      * {@link #UNLIMITED_PAGES}.
644      */
setMaxPages(int maxPages)645     public void setMaxPages(int maxPages) {
646         mMaxPages = Math.max(UNLIMITED_PAGES, maxPages);
647         updateMaxItems();
648     }
649 
650     /**
651      * Returns the maximum number of pages allowed in the PagedListView. This number is set by
652      * {@link #setMaxPages(int)}. If that method has not been called, then this value should match
653      * the default value.
654      *
655      * @return The maximum number of pages to be shown or {@link #UNLIMITED_PAGES} if there is
656      * no limit.
657      */
getMaxPages()658     public int getMaxPages() {
659         return mMaxPages;
660     }
661 
662     /**
663      * Gets the number of rows per page. Default value of mRowsPerPage is -1. If the first child of
664      * PagedLayoutManager is null or the height of the first child is 0, it will return 1.
665      */
getRowsPerPage()666     public int getRowsPerPage() {
667         return mRowsPerPage;
668     }
669 
670     /** Resets the maximum number of pages to be shown to be the default. */
resetMaxPages()671     public void resetMaxPages() {
672         mMaxPages = getDefaultMaxPages();
673         updateMaxItems();
674     }
675 
676     /**
677      * Adds an {@link RecyclerView.ItemDecoration} to this PagedListView.
678      *
679      * @param decor The decoration to add.
680      * @see RecyclerView#addItemDecoration(RecyclerView.ItemDecoration)
681      */
addItemDecoration(@onNull RecyclerView.ItemDecoration decor)682     public void addItemDecoration(@NonNull RecyclerView.ItemDecoration decor) {
683         mRecyclerView.addItemDecoration(decor);
684     }
685 
686     /**
687      * Removes the given {@link RecyclerView.ItemDecoration} from this
688      * PagedListView.
689      *
690      * <p>The decoration will function the same as the item decoration for a {@link RecyclerView}.
691      *
692      * @param decor The decoration to remove.
693      * @see RecyclerView#removeItemDecoration(RecyclerView.ItemDecoration)
694      */
removeItemDecoration(@onNull RecyclerView.ItemDecoration decor)695     public void removeItemDecoration(@NonNull RecyclerView.ItemDecoration decor) {
696         mRecyclerView.removeItemDecoration(decor);
697     }
698 
699     /**
700      * Sets spacing between each item in the list. The spacing will not be added before the first
701      * item and after the last.
702      *
703      * @param itemSpacing the spacing between each item.
704      */
setItemSpacing(int itemSpacing)705     public void setItemSpacing(int itemSpacing) {
706         ItemSpacingDecoration existing = null;
707         for (int i = 0, count = mRecyclerView.getItemDecorationCount(); i < count; i++) {
708             RecyclerView.ItemDecoration itemDecoration = mRecyclerView.getItemDecorationAt(i);
709             if (itemDecoration instanceof ItemSpacingDecoration) {
710                 existing = (ItemSpacingDecoration) itemDecoration;
711                 break;
712             }
713         }
714 
715         if (itemSpacing == 0 && existing != null) {
716             mRecyclerView.removeItemDecoration(existing);
717         } else if (existing == null) {
718             mRecyclerView.addItemDecoration(new ItemSpacingDecoration(itemSpacing));
719         } else {
720             existing.setItemSpacing(itemSpacing);
721         }
722         mRecyclerView.invalidateItemDecorations();
723     }
724 
725     /**
726      * Sets the color of scrollbar.
727      *
728      * <p>Custom color ignores {@link DayNightStyle}. Calling {@link #resetScrollbarColor} resets to
729      * default color.
730      *
731      * @param color Resource identifier of the color.
732      */
setScrollbarColor(@olorRes int color)733     public void setScrollbarColor(@ColorRes int color) {
734         mScrollBarView.setThumbColor(color);
735     }
736 
737     /**
738      * Resets the color of scrollbar to default.
739      */
resetScrollbarColor()740     public void resetScrollbarColor() {
741         mScrollBarView.resetThumbColor();
742     }
743 
744     /**
745      * Adds an {@link RecyclerView.OnItemTouchListener} to this
746      * PagedListView.
747      *
748      * <p>The listener will function the same as the listener for a regular {@link RecyclerView}.
749      *
750      * @param touchListener The touch listener to add.
751      * @see RecyclerView#addOnItemTouchListener(RecyclerView.OnItemTouchListener)
752      */
addOnItemTouchListener(@onNull RecyclerView.OnItemTouchListener touchListener)753     public void addOnItemTouchListener(@NonNull RecyclerView.OnItemTouchListener touchListener) {
754         mRecyclerView.addOnItemTouchListener(touchListener);
755     }
756 
757     /**
758      * Removes the given {@link RecyclerView.OnItemTouchListener} from
759      * the PagedListView.
760      *
761      * @param touchListener The touch listener to remove.
762      * @see RecyclerView#removeOnItemTouchListener(RecyclerView.OnItemTouchListener)
763      */
removeOnItemTouchListener(@onNull RecyclerView.OnItemTouchListener touchListener)764     public void removeOnItemTouchListener(@NonNull RecyclerView.OnItemTouchListener touchListener) {
765         mRecyclerView.removeOnItemTouchListener(touchListener);
766     }
767 
768     /**
769      * Sets how this {@link PagedListView} responds to day/night configuration changes. By
770      * default, the PagedListView is darker in the day and lighter at night.
771      *
772      * @param dayNightStyle A value from {@link DayNightStyle}.
773      * @see DayNightStyle
774      */
setDayNightStyle(@ayNightStyle int dayNightStyle)775     public void setDayNightStyle(@DayNightStyle int dayNightStyle) {
776         // Update the scrollbar
777         mScrollBarView.setDayNightStyle(dayNightStyle);
778 
779         int decorCount = mRecyclerView.getItemDecorationCount();
780         for (int i = 0; i < decorCount; i++) {
781             RecyclerView.ItemDecoration decor = mRecyclerView.getItemDecorationAt(i);
782             if (decor instanceof DividerDecoration) {
783                 ((DividerDecoration) decor).updateDividerColor();
784             }
785         }
786     }
787 
788     /**
789      * Sets the {@link OnScrollListener} that will be notified of scroll events within the
790      * PagedListView.
791      *
792      * @param listener The scroll listener to set.
793      */
setOnScrollListener(OnScrollListener listener)794     public void setOnScrollListener(OnScrollListener listener) {
795         mOnScrollListener = listener;
796     }
797 
798     /** Returns the page the given position is on, starting with page 0. */
getPage(int position)799     public int getPage(int position) {
800         if (mRowsPerPage == -1) {
801             return -1;
802         }
803         if (mRowsPerPage == 0) {
804             return 0;
805         }
806         return position / mRowsPerPage;
807     }
808 
getOrientationHelper(RecyclerView.LayoutManager layoutManager)809     private OrientationHelper getOrientationHelper(RecyclerView.LayoutManager layoutManager) {
810         if (mOrientationHelper == null || mOrientationHelper.getLayoutManager() != layoutManager) {
811             // PagedListView is assumed to be a list that always vertically scrolls.
812             mOrientationHelper = OrientationHelper.createVerticalHelper(layoutManager);
813         }
814         return mOrientationHelper;
815     }
816 
817     /**
818      * Scrolls the contents of the RecyclerView up a page. A page is defined as the height of the
819      * {@code PagedListView}.
820      *
821      * <p>The resulting first item in the list will be snapped to so that it is completely visible.
822      * If this is not possible due to the first item being taller than the containing
823      * {@code PagedListView}, then the snapping will not occur.
824      */
pageUp()825     public void pageUp() {
826         if (mRecyclerView.getLayoutManager() == null || mRecyclerView.getChildCount() == 0) {
827             return;
828         }
829 
830         // Use OrientationHelper to calculate scroll distance in order to match snapping behavior.
831         OrientationHelper orientationHelper =
832                 getOrientationHelper(mRecyclerView.getLayoutManager());
833 
834         int screenSize = mRecyclerView.getHeight();
835         int scrollDistance = screenSize;
836         // The iteration order matters. In case where there are 2 items longer than screen size, we
837         // want to focus on upcoming view.
838         for (int i = 0; i < mRecyclerView.getChildCount(); i++) {
839             /*
840              * We treat child View longer than screen size differently:
841              * 1) When it enters screen, next pageUp will align its bottom with parent bottom;
842              * 2) When it leaves screen, next pageUp will align its top with parent top.
843              */
844             View child = mRecyclerView.getChildAt(i);
845             if (child.getHeight() > screenSize) {
846                 if (orientationHelper.getDecoratedEnd(child) < screenSize) {
847                     // Child view bottom is entering screen. Align its bottom with parent bottom.
848                     scrollDistance = screenSize - orientationHelper.getDecoratedEnd(child);
849                 } else if (-screenSize < orientationHelper.getDecoratedStart(child)
850                         && orientationHelper.getDecoratedStart(child) < 0) {
851                     // Child view top is about to enter screen - its distance to parent top
852                     // is less than a full scroll. Align child top with parent top.
853                     scrollDistance = Math.abs(orientationHelper.getDecoratedStart(child));
854                 }
855                 // There can be two items that are longer than the screen. We stop at the first one.
856                 // This is affected by the iteration order.
857                 break;
858             }
859         }
860         // Distance should always be positive. Negate its value to scroll up.
861         mRecyclerView.smoothScrollBy(0, -scrollDistance);
862     }
863 
864     /**
865      * Scrolls the contents of the RecyclerView down a page. A page is defined as the height of the
866      * {@code PagedListView}.
867      *
868      * <p>This method will attempt to bring the last item in the list as the first item. If the
869      * current first item in the list is taller than the {@code PagedListView}, then it will be
870      * scrolled the length of a page, but not snapped to.
871      */
pageDown()872     public void pageDown() {
873         if (mRecyclerView.getLayoutManager() == null || mRecyclerView.getChildCount() == 0) {
874             return;
875         }
876 
877         OrientationHelper orientationHelper =
878                 getOrientationHelper(mRecyclerView.getLayoutManager());
879         int screenSize = mRecyclerView.getHeight();
880         int scrollDistance = screenSize;
881 
882         // If the last item is partially visible, page down should bring it to the top.
883         View lastChild = mRecyclerView.getChildAt(mRecyclerView.getChildCount() - 1);
884         if (mRecyclerView.getLayoutManager().isViewPartiallyVisible(lastChild,
885                 /* completelyVisible= */ false, /* acceptEndPointInclusion= */ false)) {
886             scrollDistance = orientationHelper.getDecoratedStart(lastChild);
887             if (scrollDistance < 0) {
888                 // Scroll value can be negative if the child is longer than the screen size and the
889                 // visible area of the screen does not show the start of the child.
890                 // Scroll to the next screen if the start value is negative
891                 scrollDistance = screenSize;
892             }
893         }
894 
895         // The iteration order matters. In case where there are 2 items longer than screen size, we
896         // want to focus on upcoming view (the one at the bottom of screen).
897         for (int i = mRecyclerView.getChildCount() - 1; i >= 0; i--) {
898             /* We treat child View longer than screen size differently:
899              * 1) When it enters screen, next pageDown will align its top with parent top;
900              * 2) When it leaves screen, next pageDown will align its bottom with parent bottom.
901              */
902             View child = mRecyclerView.getChildAt(i);
903             if (child.getHeight() > screenSize) {
904                 if (orientationHelper.getDecoratedStart(child) > 0) {
905                     // Child view top is entering screen. Align its top with parent top.
906                     scrollDistance = orientationHelper.getDecoratedStart(child);
907                 } else if (screenSize < orientationHelper.getDecoratedEnd(child)
908                         && orientationHelper.getDecoratedEnd(child) < 2 * screenSize) {
909                     // Child view bottom is about to enter screen - its distance to parent bottom
910                     // is less than a full scroll. Align child bottom with parent bottom.
911                     scrollDistance = orientationHelper.getDecoratedEnd(child) - screenSize;
912                 }
913                 // There can be two items that are longer than the screen. We stop at the first one.
914                 // This is affected by the iteration order.
915                 break;
916             }
917         }
918 
919         mRecyclerView.smoothScrollBy(0, scrollDistance);
920     }
921 
922     /**
923      * Sets the default number of pages that this PagedListView is limited to.
924      *
925      * @param newDefault The default number of pages. Should be positive.
926      */
setDefaultMaxPages(int newDefault)927     public void setDefaultMaxPages(int newDefault) {
928         if (newDefault < 0) {
929             return;
930         }
931         mDefaultMaxPages = newDefault;
932         resetMaxPages();
933     }
934 
935     /** Returns the default number of pages the list should have */
getDefaultMaxPages()936     private int getDefaultMaxPages() {
937         // assume list shown in response to a click, so, reduce number of clicks by one
938         return mDefaultMaxPages - 1;
939     }
940 
941     @Override
onLayout(boolean changed, int left, int top, int right, int bottom)942     public void onLayout(boolean changed, int left, int top, int right, int bottom) {
943         // if a late item is added to the top of the layout after the layout is stabilized, causing
944         // the former top item to be pushed to the 2nd page, the focus will still be on the former
945         // top item. Since our car layout manager tries to scroll the viewport so that the focused
946         // item is visible, the view port will be on the 2nd page. That means the newly added item
947         // will not be visible, on the first page.
948 
949         // what we want to do is: if the formerly focused item is the first one in the list, any
950         // item added above it will make the focus to move to the new first item.
951         // if the focus is not on the formerly first item, then we don't need to do anything. Let
952         // the layout manager do the job and scroll the viewport so the currently focused item
953         // is visible.
954         RecyclerView.LayoutManager layoutManager = mRecyclerView.getLayoutManager();
955 
956         if (layoutManager == null) {
957             return;
958         }
959 
960         // we need to calculate whether we want to request focus here, before the super call,
961         // because after the super call, the first born might be changed.
962         View focusedChild = layoutManager.getFocusedChild();
963         View firstBorn = layoutManager.getChildAt(0);
964 
965         super.onLayout(changed, left, top, right, bottom);
966 
967         if (mAdapter != null) {
968             int itemCount = mAdapter.getItemCount();
969             if (Log.isLoggable(TAG, Log.DEBUG)) {
970                 Log.d(TAG, String.format(
971                         "onLayout hasFocus: %s, mLastItemCount: %s, itemCount: %s, "
972                                 + "focusedChild: %s, firstBorn: %s, isInTouchMode: %s, "
973                                 + "mNeedsFocus: %s",
974                         hasFocus(),
975                         mLastItemCount,
976                         itemCount,
977                         focusedChild,
978                         firstBorn,
979                         isInTouchMode(),
980                         mNeedsFocus));
981             }
982             updateMaxItems();
983             // This is a workaround for missing focus because isInTouchMode() is not always
984             // returning the right value.
985             // This is okay for the Engine release since focus is always showing.
986             // However, in Tala and Fender, we want to show focus only when the user uses
987             // hardware controllers, so we need to revisit this logic. b/22990605.
988             if (mNeedsFocus && itemCount > 0) {
989                 if (focusedChild == null) {
990                     requestFocus();
991                 }
992                 mNeedsFocus = false;
993             }
994             if (itemCount > mLastItemCount && focusedChild == firstBorn) {
995                 requestFocus();
996             }
997             mLastItemCount = itemCount;
998         }
999 
1000         if (!mScrollBarEnabled) {
1001             // Don't change the visibility of the ScrollBar unless it's enabled.
1002             return;
1003         }
1004 
1005         boolean isAtStart = isAtStart();
1006         boolean isAtEnd = isAtEnd();
1007 
1008         if ((isAtStart && isAtEnd) || layoutManager.getItemCount() == 0) {
1009             mScrollBarView.setVisibility(View.INVISIBLE);
1010             return;
1011         }
1012 
1013         mScrollBarView.setVisibility(View.VISIBLE);
1014         mScrollBarView.setUpEnabled(!isAtStart);
1015         mScrollBarView.setDownEnabled(!isAtEnd);
1016 
1017         if (mRecyclerView.getLayoutManager().canScrollVertically()) {
1018             mScrollBarView.setParametersInLayout(
1019                     mRecyclerView.computeVerticalScrollRange(),
1020                     mRecyclerView.computeVerticalScrollOffset(),
1021                     mRecyclerView.computeVerticalScrollExtent());
1022         } else {
1023             mScrollBarView.setParametersInLayout(
1024                     mRecyclerView.computeHorizontalScrollRange(),
1025                     mRecyclerView.computeHorizontalScrollOffset(),
1026                     mRecyclerView.computeHorizontalScrollExtent());
1027         }
1028     }
1029 
1030     /**
1031      * Determines if scrollbar should be visible or not and shows/hides it accordingly. If this is
1032      * being called as a result of adapter changes, it should be called after the new layout has
1033      * been calculated because the method of determining scrollbar visibility uses the current
1034      * layout. If this is called after an adapter change but before the new layout, the visibility
1035      * determination may not be correct.
1036      *
1037      * @param animate {@code true} if the scrollbar should animate to its new position.
1038      *                {@code false} if no animation is used
1039      */
updatePaginationButtons(boolean animate)1040     private void updatePaginationButtons(boolean animate) {
1041         if (!mScrollBarEnabled) {
1042             // Don't change the visibility of the ScrollBar unless it's enabled.
1043             return;
1044         }
1045 
1046         boolean isAtStart = isAtStart();
1047         boolean isAtEnd = isAtEnd();
1048         RecyclerView.LayoutManager layoutManager = mRecyclerView.getLayoutManager();
1049 
1050         if ((isAtStart && isAtEnd) || layoutManager == null || layoutManager.getItemCount() == 0) {
1051             mScrollBarView.setVisibility(View.INVISIBLE);
1052         } else {
1053             mScrollBarView.setVisibility(View.VISIBLE);
1054         }
1055         mScrollBarView.setUpEnabled(!isAtStart);
1056         mScrollBarView.setDownEnabled(!isAtEnd);
1057 
1058         if (layoutManager == null) {
1059             return;
1060         }
1061 
1062         if (mRecyclerView.getLayoutManager().canScrollVertically()) {
1063             mScrollBarView.setParameters(
1064                     mRecyclerView.computeVerticalScrollRange(),
1065                     mRecyclerView.computeVerticalScrollOffset(),
1066                     mRecyclerView.computeVerticalScrollExtent(), animate);
1067         } else {
1068             mScrollBarView.setParameters(
1069                     mRecyclerView.computeHorizontalScrollRange(),
1070                     mRecyclerView.computeHorizontalScrollOffset(),
1071                     mRecyclerView.computeHorizontalScrollExtent(), animate);
1072         }
1073 
1074         invalidate();
1075     }
1076 
1077     /** Returns {@code true} if the RecyclerView is completely displaying the first item. */
isAtStart()1078     public boolean isAtStart() {
1079         return mSnapHelper.isAtStart(mRecyclerView.getLayoutManager());
1080     }
1081 
1082     /** Returns {@code true} if the RecyclerView is completely displaying the last item. */
isAtEnd()1083     public boolean isAtEnd() {
1084         return mSnapHelper.isAtEnd(mRecyclerView.getLayoutManager());
1085     }
1086 
1087     @UiThread
updateMaxItems()1088     private void updateMaxItems() {
1089         if (mAdapter == null) {
1090             return;
1091         }
1092 
1093         // Ensure mRowsPerPage regardless of if the adapter implements ItemCap.
1094         updateRowsPerPage();
1095 
1096         // If the adapter does not implement ItemCap, then the max items on it cannot be updated.
1097         if (!(mAdapter instanceof ItemCap)) {
1098             return;
1099         }
1100 
1101         final int originalCount = mAdapter.getItemCount();
1102         ((ItemCap) mAdapter).setMaxItems(calculateMaxItemCount());
1103         final int newCount = mAdapter.getItemCount();
1104         if (newCount == originalCount) {
1105             return;
1106         }
1107 
1108         if (newCount < originalCount) {
1109             mAdapter.notifyItemRangeRemoved(newCount, originalCount - newCount);
1110         } else {
1111             mAdapter.notifyItemRangeInserted(originalCount, newCount - originalCount);
1112         }
1113     }
1114 
calculateMaxItemCount()1115     private int calculateMaxItemCount() {
1116         RecyclerView.LayoutManager layoutManager = mRecyclerView.getLayoutManager();
1117         if (layoutManager == null) {
1118             return -1;
1119         }
1120 
1121         View firstChild = layoutManager.getChildAt(0);
1122         if (firstChild == null || firstChild.getHeight() == 0) {
1123             return -1;
1124         } else {
1125             return (mMaxPages < 0) ? -1 : mRowsPerPage * mMaxPages;
1126         }
1127     }
1128 
1129     /**
1130      * Updates the rows number per current page, which is used for calculating how many items we
1131      * want to show.
1132      */
updateRowsPerPage()1133     private void updateRowsPerPage() {
1134         RecyclerView.LayoutManager layoutManager = mRecyclerView.getLayoutManager();
1135         if (layoutManager == null) {
1136             mRowsPerPage = 1;
1137             return;
1138         }
1139 
1140         View firstChild = layoutManager.getChildAt(0);
1141         if (firstChild == null || firstChild.getHeight() == 0) {
1142             mRowsPerPage = 1;
1143         } else {
1144             mRowsPerPage = Math.max(1, (getHeight() - getPaddingTop()) / firstChild.getHeight());
1145         }
1146     }
1147 
1148     @Override
onSaveInstanceState()1149     public Parcelable onSaveInstanceState() {
1150         Bundle bundle = new Bundle();
1151         bundle.putParcelable(SAVED_SUPER_STATE_KEY, super.onSaveInstanceState());
1152 
1153         SparseArray<Parcelable> recyclerViewState = new SparseArray<>();
1154         mRecyclerView.saveHierarchyState(recyclerViewState);
1155         bundle.putSparseParcelableArray(SAVED_RECYCLER_VIEW_STATE_KEY, recyclerViewState);
1156 
1157         return bundle;
1158     }
1159 
1160     @Override
onRestoreInstanceState(Parcelable state)1161     public void onRestoreInstanceState(Parcelable state) {
1162         if (!(state instanceof Bundle)) {
1163             super.onRestoreInstanceState(state);
1164             return;
1165         }
1166 
1167         Bundle bundle = (Bundle) state;
1168         mRecyclerView.restoreHierarchyState(
1169                 bundle.getSparseParcelableArray(SAVED_RECYCLER_VIEW_STATE_KEY));
1170 
1171         super.onRestoreInstanceState(bundle.getParcelable(SAVED_SUPER_STATE_KEY));
1172     }
1173 
1174     @Override
dispatchSaveInstanceState(SparseArray<Parcelable> container)1175     protected void dispatchSaveInstanceState(SparseArray<Parcelable> container) {
1176         // There is the possibility of multiple PagedListViews on a page. This means that the ids
1177         // of the child Views of PagedListView are no longer unique, and onSaveInstanceState()
1178         // cannot be used as is. As a result, PagedListViews needs to manually dispatch the instance
1179         // states. Call dispatchFreezeSelfOnly() so that no child views have onSaveInstanceState()
1180         // called by the system.
1181         dispatchFreezeSelfOnly(container);
1182     }
1183 
1184     @Override
dispatchRestoreInstanceState(SparseArray<Parcelable> container)1185     protected void dispatchRestoreInstanceState(SparseArray<Parcelable> container) {
1186         // Prevent onRestoreInstanceState() from being called on child Views. Instead, PagedListView
1187         // will manually handle passing the state. See the comment in dispatchSaveInstanceState()
1188         // for more information.
1189         dispatchThawSelfOnly(container);
1190     }
1191 
updateAlphaJump()1192     private void updateAlphaJump() {
1193         boolean supportsAlphaJump = (mAdapter instanceof IAlphaJumpAdapter);
1194         mScrollBarView.setShowAlphaJump(supportsAlphaJump);
1195     }
1196 
showAlphaJump()1197     private void showAlphaJump() {
1198         if (mAlphaJumpView == null && mAdapter instanceof IAlphaJumpAdapter) {
1199             mAlphaJumpView = new AlphaJumpOverlayView(getContext());
1200             mAlphaJumpView.init(this, (IAlphaJumpAdapter) mAdapter);
1201             addView(mAlphaJumpView);
1202         }
1203 
1204         mAlphaJumpView.setVisibility(View.VISIBLE);
1205     }
1206 
1207     private final RecyclerView.OnScrollListener mRecyclerViewOnScrollListener =
1208             new RecyclerView.OnScrollListener() {
1209                 @Override
1210                 public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
1211                     if (mOnScrollListener != null) {
1212                         mOnScrollListener.onScrolled(recyclerView, dx, dy);
1213 
1214                         if (!isAtStart() && isAtEnd()) {
1215                             mOnScrollListener.onReachBottom();
1216                         }
1217                     }
1218                     updatePaginationButtons(false);
1219                 }
1220 
1221                 @Override
1222                 public void onScrollStateChanged(RecyclerView recyclerView, int newState) {
1223                     if (mOnScrollListener != null) {
1224                         mOnScrollListener.onScrollStateChanged(recyclerView, newState);
1225                     }
1226                     if (newState == RecyclerView.SCROLL_STATE_IDLE) {
1227                         mHandler.postDelayed(mPaginationRunnable, PAGINATION_HOLD_DELAY_MS);
1228                     }
1229                 }
1230             };
1231 
1232     private final Runnable mPaginationRunnable =
1233             new Runnable() {
1234                 @Override
1235                 public void run() {
1236                     boolean upPressed = mScrollBarView.isUpPressed();
1237                     boolean downPressed = mScrollBarView.isDownPressed();
1238                     if (upPressed && downPressed) {
1239                         return;
1240                     }
1241                     if (upPressed) {
1242                         pageUp();
1243                     } else if (downPressed) {
1244                         pageDown();
1245                     }
1246                 }
1247             };
1248 
1249     private final Runnable mUpdatePaginationRunnable =
1250             () -> updatePaginationButtons(true /*animate*/);
1251 
1252     /** Used to listen for {@code PagedListView} scroll events. */
1253     public abstract static class OnScrollListener {
1254         /**
1255          * Called when the {@code PagedListView} has been scrolled so that the last item is
1256          * completely visible.
1257          */
onReachBottom()1258         public void onReachBottom() {}
1259         /** Called when scroll up button is clicked */
onScrollUpButtonClicked()1260         public void onScrollUpButtonClicked() {}
1261         /** Called when scroll down button is clicked */
onScrollDownButtonClicked()1262         public void onScrollDownButtonClicked() {}
1263 
1264         /**
1265          * Called when RecyclerView.OnScrollListener#onScrolled is called. See
1266          * RecyclerView.OnScrollListener
1267          */
onScrolled(RecyclerView recyclerView, int dx, int dy)1268         public void onScrolled(RecyclerView recyclerView, int dx, int dy) {}
1269 
1270         /** See RecyclerView.OnScrollListener */
onScrollStateChanged(RecyclerView recyclerView, int newState)1271         public void onScrollStateChanged(RecyclerView recyclerView, int newState) {}
1272     }
1273 
1274     /**
1275      * A {@link RecyclerView.ItemDecoration} that will add spacing
1276      * between each item in the RecyclerView that it is added to.
1277      */
1278     private static class ItemSpacingDecoration extends RecyclerView.ItemDecoration {
1279         private int mItemSpacing;
1280 
ItemSpacingDecoration(int itemSpacing)1281         private ItemSpacingDecoration(int itemSpacing) {
1282             mItemSpacing = itemSpacing;
1283         }
1284 
1285         @Override
getItemOffsets(Rect outRect, View view, RecyclerView parent, RecyclerView.State state)1286         public void getItemOffsets(Rect outRect, View view, RecyclerView parent,
1287                 RecyclerView.State state) {
1288             super.getItemOffsets(outRect, view, parent, state);
1289             int position = parent.getChildAdapterPosition(view);
1290 
1291             // Skip offset for last item except for GridLayoutManager.
1292             if (position == state.getItemCount() - 1
1293                     && !(parent.getLayoutManager() instanceof GridLayoutManager)) {
1294                 return;
1295             }
1296 
1297             outRect.bottom = mItemSpacing;
1298         }
1299 
1300         /**
1301          * @param itemSpacing sets spacing between each item.
1302          */
setItemSpacing(int itemSpacing)1303         public void setItemSpacing(int itemSpacing) {
1304             mItemSpacing = itemSpacing;
1305         }
1306     }
1307 
1308     /**
1309      * A {@link RecyclerView.ItemDecoration} that will draw a dividing
1310      * line between each item in the RecyclerView that it is added to.
1311      */
1312     private static class DividerDecoration extends RecyclerView.ItemDecoration {
1313         private final Context mContext;
1314         private final Paint mPaint;
1315         private final int mDividerHeight;
1316         private final int mDividerStartMargin;
1317         private final int mDividerEndMargin;
1318         @IdRes private final int mDividerStartId;
1319         @IdRes private final int mDividerEndId;
1320         @ColorRes private final int mListDividerColor;
1321         private DividerVisibilityManager mVisibilityManager;
1322 
1323         /**
1324          * @param dividerStartMargin The start offset of the dividing line. This offset will be
1325          *     relative to {@code dividerStartId} if that value is given.
1326          * @param dividerStartId A child view id whose starting edge will be used as the starting
1327          *     edge of the dividing line. If this value is {@link #INVALID_RESOURCE_ID}, the top
1328          *     container of each child view will be used.
1329          * @param dividerEndId A child view id whose ending edge will be used as the starting edge
1330          *     of the dividing lin.e If this value is {@link #INVALID_RESOURCE_ID}, then the top
1331          *     container view of each child will be used.
1332          */
DividerDecoration(Context context, int dividerStartMargin, int dividerEndMargin, @IdRes int dividerStartId, @IdRes int dividerEndId, @ColorRes int listDividerColor)1333         private DividerDecoration(Context context, int dividerStartMargin,
1334                 int dividerEndMargin, @IdRes int dividerStartId, @IdRes int dividerEndId,
1335                 @ColorRes int listDividerColor) {
1336             mContext = context;
1337             mDividerStartMargin = dividerStartMargin;
1338             mDividerEndMargin = dividerEndMargin;
1339             mDividerStartId = dividerStartId;
1340             mDividerEndId = dividerEndId;
1341             mListDividerColor = listDividerColor;
1342 
1343             mPaint = new Paint();
1344             mPaint.setColor(mContext.getColor(listDividerColor));
1345             mDividerHeight = mContext.getResources().getDimensionPixelSize(
1346                     R.dimen.car_list_divider_height);
1347         }
1348 
1349         /** Updates the list divider color which may have changed due to a day night transition. */
updateDividerColor()1350         public void updateDividerColor() {
1351             mPaint.setColor(mContext.getColor(mListDividerColor));
1352         }
1353 
1354         /** Sets {@link DividerVisibilityManager} on the DividerDecoration.*/
setVisibilityManager(DividerVisibilityManager dvm)1355         public void setVisibilityManager(DividerVisibilityManager dvm) {
1356             mVisibilityManager = dvm;
1357         }
1358 
1359         @Override
onDrawOver(Canvas c, RecyclerView parent, RecyclerView.State state)1360         public void onDrawOver(Canvas c, RecyclerView parent, RecyclerView.State state) {
1361             boolean usesGridLayoutManager = parent.getLayoutManager() instanceof GridLayoutManager;
1362             for (int i = 0; i < parent.getChildCount(); i++) {
1363                 View container = parent.getChildAt(i);
1364                 int itemPosition = parent.getChildAdapterPosition(container);
1365 
1366                 if (hideDividerForAdapterPosition(itemPosition)) {
1367                     continue;
1368                 }
1369 
1370                 View nextVerticalContainer;
1371                 if (usesGridLayoutManager) {
1372                     // Find an item in next row to calculate vertical space.
1373                     int lastItem = GridLayoutManagerUtils.getLastIndexOnSameRow(i, parent);
1374                     nextVerticalContainer = parent.getChildAt(lastItem + 1);
1375                 } else {
1376                     nextVerticalContainer = parent.getChildAt(i + 1);
1377                 }
1378                 if (nextVerticalContainer == null) {
1379                     // Skip drawing divider for the last row in GridLayoutManager, or the last
1380                     // item (presumably in LinearLayoutManager).
1381                     continue;
1382                 }
1383                 int spacing = nextVerticalContainer.getTop() - container.getBottom();
1384                 drawDivider(c, container, spacing);
1385             }
1386         }
1387 
1388         /**
1389          * Draws a divider under {@code container}.
1390          *
1391          * @param spacing between {@code container} and next view.
1392          */
drawDivider(Canvas c, View container, int spacing)1393         private void drawDivider(Canvas c, View container, int spacing) {
1394             View startChild =
1395                     mDividerStartId != INVALID_RESOURCE_ID
1396                             ? container.findViewById(mDividerStartId)
1397                             : container;
1398 
1399             View endChild =
1400                     mDividerEndId != INVALID_RESOURCE_ID
1401                             ? container.findViewById(mDividerEndId)
1402                             : container;
1403 
1404             if (startChild == null || endChild == null) {
1405                 return;
1406             }
1407 
1408             Rect containerRect = new Rect();
1409             container.getGlobalVisibleRect(containerRect);
1410 
1411             Rect startRect = new Rect();
1412             startChild.getGlobalVisibleRect(startRect);
1413 
1414             Rect endRect = new Rect();
1415             endChild.getGlobalVisibleRect(endRect);
1416 
1417             int left = container.getLeft() + mDividerStartMargin
1418                     + (startRect.left - containerRect.left);
1419             int right = container.getRight()  - mDividerEndMargin
1420                     - (endRect.right - containerRect.right);
1421             // "(spacing + divider height) / 2" aligns the center of divider to that of spacing
1422             // between two items.
1423             // When spacing is an odd value (e.g. created by other decoration), space under divider
1424             // is greater by 1dp.
1425             int bottom = container.getBottom() + (spacing + mDividerHeight) / 2;
1426             int top = bottom - mDividerHeight;
1427 
1428             c.drawRect(left, top, right, bottom, mPaint);
1429         }
1430 
1431         @Override
getItemOffsets(Rect outRect, View view, RecyclerView parent, RecyclerView.State state)1432         public void getItemOffsets(Rect outRect, View view, RecyclerView parent,
1433                 RecyclerView.State state) {
1434             super.getItemOffsets(outRect, view, parent, state);
1435             int pos = parent.getChildAdapterPosition(view);
1436             if (hideDividerForAdapterPosition(pos)) {
1437                 return;
1438             }
1439             // Add an bottom offset to all items that should have divider, even when divider is not
1440             // drawn for the bottom item(s).
1441             // With GridLayoutManager it's difficult to tell whether a view is in the last row.
1442             // This is to keep expected behavior consistent.
1443             outRect.bottom = mDividerHeight;
1444         }
1445 
hideDividerForAdapterPosition(int position)1446         private boolean hideDividerForAdapterPosition(int position) {
1447             return mVisibilityManager != null && mVisibilityManager.shouldHideDivider(position);
1448         }
1449     }
1450 
1451     /**
1452      * A {@link RecyclerView.ItemDecoration} that will add a top offset
1453      * to the first item in the RecyclerView it is added to.
1454      */
1455     private static class TopOffsetDecoration extends RecyclerView.ItemDecoration {
1456         private int mTopOffset;
1457 
TopOffsetDecoration(int topOffset)1458         private TopOffsetDecoration(int topOffset) {
1459             mTopOffset = topOffset;
1460         }
1461 
1462         @Override
getItemOffsets(Rect outRect, View view, RecyclerView parent, RecyclerView.State state)1463         public void getItemOffsets(Rect outRect, View view, RecyclerView parent,
1464                 RecyclerView.State state) {
1465             super.getItemOffsets(outRect, view, parent, state);
1466             int position = parent.getChildAdapterPosition(view);
1467             if (parent.getLayoutManager() instanceof GridLayoutManager
1468                     && position < GridLayoutManagerUtils.getFirstRowItemCount(parent)) {
1469                 // For GridLayoutManager, top offset should be set for all items in the first row.
1470                 // Otherwise the top items will be visually uneven.
1471                 outRect.top = mTopOffset;
1472             } else if (position == 0) {
1473                 // Only set the offset for the first item.
1474                 outRect.top = mTopOffset;
1475             }
1476         }
1477 
1478         /**
1479          * @param topOffset sets spacing between each item.
1480          */
setTopOffset(int topOffset)1481         public void setTopOffset(int topOffset) {
1482             mTopOffset = topOffset;
1483         }
1484     }
1485 }
1486