• 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 static java.lang.annotation.RetentionPolicy.SOURCE;
20 
21 import android.car.drivingstate.CarUxRestrictions;
22 import android.content.Context;
23 import android.content.res.TypedArray;
24 import android.util.AttributeSet;
25 import android.util.Log;
26 import android.view.View;
27 import android.view.ViewTreeObserver.OnGlobalLayoutListener;
28 
29 import androidx.annotation.IntDef;
30 import androidx.annotation.NonNull;
31 import androidx.annotation.Nullable;
32 import androidx.annotation.VisibleForTesting;
33 import androidx.recyclerview.widget.LinearLayoutManager;
34 import androidx.recyclerview.widget.RecyclerView;
35 
36 import com.android.car.apps.common.CarUxRestrictionsUtil;
37 import com.android.car.apps.common.R;
38 import com.android.car.apps.common.util.ScrollBarUI;
39 
40 import java.lang.annotation.Retention;
41 
42 /**
43  * View that extends a {@link RecyclerView} and creates a nested {@code RecyclerView} with an option
44  * to render a custom scroll bar that has page up and down arrows. Interaction with this view is
45  * similar to a {@code RecyclerView} as it takes the same adapter and the layout manager.
46  */
47 public final class PagedRecyclerView extends RecyclerView {
48 
49     private static final boolean DEBUG = false;
50     private static final String TAG = "PagedRecyclerView";
51 
52     private Context mContext;
53 
54     private final CarUxRestrictionsUtil mCarUxRestrictionsUtil;
55     private final CarUxRestrictionsUtil.OnUxRestrictionsChangedListener mListener;
56 
57     private boolean mScrollBarEnabled;
58     private int mScrollBarContainerWidth;
59     private @ScrollBarPosition int mScrollBarPosition;
60     private boolean mScrollBarAboveRecyclerView;
61     private String mScrollBarClass;
62     private int mScrollBarPaddingStart;
63     private int mScrollBarPaddingEnd;
64     private boolean mFullyInitialized;
65 
66     @Gutter
67     private int mGutter;
68     private int mGutterSize;
69     private RecyclerView mNestedRecyclerView;
70     private Adapter mAdapter;
71     private ScrollBarUI mScrollBarUI;
72 
73     /**
74      * The possible values for @{link #setGutter}. The default value is actually
75      * {@link PagedRecyclerView.Gutter#BOTH}.
76      */
77     @IntDef({
78             Gutter.NONE,
79             Gutter.START,
80             Gutter.END,
81             Gutter.BOTH,
82     })
83 
84     @Retention(SOURCE)
85     public @interface Gutter {
86         /**
87          * No gutter on either side of the list items. The items will span the full width of the
88          * RecyclerView
89          */
90         int NONE = 0;
91 
92         /**
93          * Include a gutter only on the start side (that is, the same side as the scroll bar).
94          */
95         int START = 1;
96 
97         /**
98          * Include a gutter only on the end side (that is, the opposite side of the scroll bar).
99          */
100         int END = 2;
101 
102         /**
103          * Include a gutter on both sides of the list items. This is the default behaviour.
104          */
105         int BOTH = 3;
106     }
107 
108     /**
109      * The possible values for setScrollbarPosition. The default value is actually
110      * {@link PagedRecyclerView.ScrollBarPosition#START}.
111      */
112     @IntDef({
113             ScrollBarPosition.START,
114             ScrollBarPosition.END,
115     })
116 
117     @Retention(SOURCE)
118     public @interface ScrollBarPosition {
119         /**
120          * Position the scrollbar to the left of the screen. This is default.
121          */
122         int START = 0;
123 
124         /**
125          * Position scrollbar to the right of the screen.
126          */
127         int END = 2;
128     }
129 
130     /**
131      * Interface for a {@link RecyclerView.Adapter} to cap the number of items.
132      *
133      * <p>NOTE: it is still up to the adapter to use maxItems in {@link
134      * RecyclerView.Adapter#getItemCount()}.
135      *
136      * <p>the recommended way would be with:
137      *
138      * <pre>{@code
139      * {@literal@}Override
140      * public int getItemCount() {
141      *   return Math.min(super.getItemCount(), mMaxItems);
142      * }
143      * }</pre>
144      */
145     public interface ItemCap {
146         /**
147          * A value to pass to {@link #setMaxItems(int)} that indicates there should be no limit.
148          */
149         int UNLIMITED = -1;
150 
151         /**
152          * Sets the maximum number of items available in the adapter. A value less than '0' means
153          * the list should not be capped.
154          */
setMaxItems(int maxItems)155         void setMaxItems(int maxItems);
156     }
157 
158     /**
159      * Custom layout manager for the outer recyclerview. Since paddings should be applied by the
160      * inner recycler view within its bounds, this layout manager should always have 0 padding.
161      */
162     private class PagedRecyclerViewLayoutManager extends LinearLayoutManager {
PagedRecyclerViewLayoutManager(Context context)163         PagedRecyclerViewLayoutManager(Context context) {
164             super(context);
165         }
166 
167         @Override
getPaddingTop()168         public int getPaddingTop() {
169             return 0;
170         }
171 
172         @Override
getPaddingBottom()173         public int getPaddingBottom() {
174             return 0;
175         }
176 
177         @Override
getPaddingStart()178         public int getPaddingStart() {
179             return 0;
180         }
181 
182         @Override
getPaddingEnd()183         public int getPaddingEnd() {
184             return 0;
185         }
186 
187         @Override
canScrollHorizontally()188         public boolean canScrollHorizontally() {
189             return false;
190         }
191 
192         @Override
canScrollVertically()193         public boolean canScrollVertically() {
194             return false;
195         }
196     }
197 
PagedRecyclerView(@onNull Context context)198     public PagedRecyclerView(@NonNull Context context) {
199         this(context, null, 0);
200     }
201 
PagedRecyclerView(@onNull Context context, @Nullable AttributeSet attrs)202     public PagedRecyclerView(@NonNull Context context, @Nullable AttributeSet attrs) {
203         this(context, attrs, 0);
204     }
205 
PagedRecyclerView(@onNull Context context, @Nullable AttributeSet attrs, int defStyle)206     public PagedRecyclerView(@NonNull Context context, @Nullable AttributeSet attrs, int defStyle) {
207         super(context, attrs, defStyle);
208 
209         mCarUxRestrictionsUtil = CarUxRestrictionsUtil.getInstance(context);
210         mListener = this::updateCarUxRestrictions;
211 
212         init(context, attrs, defStyle);
213     }
214 
init(Context context, AttributeSet attrs, int defStyleAttr)215     private void init(Context context, AttributeSet attrs, int defStyleAttr) {
216         TypedArray a = context.obtainStyledAttributes(
217                 attrs, R.styleable.PagedRecyclerView, defStyleAttr,
218                 R.style.PagedRecyclerView);
219 
220         mScrollBarEnabled = a.getBoolean(R.styleable.PagedRecyclerView_scrollBarEnabled,
221                 /* defValue= */true);
222         mFullyInitialized = false;
223 
224         if (!mScrollBarEnabled) {
225             a.recycle();
226             mFullyInitialized = true;
227             return;
228         }
229 
230         mContext = context;
231         mNestedRecyclerView = new RecyclerView(mContext, attrs,
232                 R.style.PagedRecyclerView_NestedRecyclerView);
233 
234         PagedRecyclerViewLayoutManager layoutManager = new PagedRecyclerViewLayoutManager(context);
235         super.setLayoutManager(layoutManager);
236 
237         PagedRecyclerViewAdapter adapter = new PagedRecyclerViewAdapter();
238         super.setAdapter(adapter);
239 
240         super.setNestedScrollingEnabled(false);
241         super.setClipToPadding(false);
242 
243         // Gutter
244         int defaultGutterSize = getResources().getDimensionPixelSize(R.dimen.car_scroll_bar_margin);
245         mGutter = a.getInt(R.styleable.PagedRecyclerView_gutter, Gutter.BOTH);
246         mGutterSize = defaultGutterSize;
247 
248         int carMargin = mContext.getResources().getDimensionPixelSize(
249                 R.dimen.car_scroll_bar_margin);
250         mScrollBarContainerWidth = a.getDimensionPixelSize(
251                 R.styleable.PagedRecyclerView_scrollBarContainerWidth, carMargin);
252 
253         mScrollBarPosition = a.getInt(R.styleable.PagedRecyclerView_scrollBarPosition,
254                 ScrollBarPosition.START);
255 
256         mScrollBarAboveRecyclerView = a.getBoolean(
257                 R.styleable.PagedRecyclerView_scrollBarAboveRecyclerView, /* defValue= */true);
258 
259         mScrollBarClass = a.getString(R.styleable.PagedRecyclerView_scrollBarCustomClass);
260         a.recycle();
261 
262         // Apply inner RV layout changes after the layout has been calculated for this view.
263         this.getViewTreeObserver().addOnGlobalLayoutListener(new OnGlobalLayoutListener() {
264             @Override
265             public void onGlobalLayout() {
266                 // View holder layout is still pending.
267                 if (PagedRecyclerView.this.findViewHolderForAdapterPosition(0) == null) return;
268 
269                 PagedRecyclerView.this.getViewTreeObserver().removeOnGlobalLayoutListener(this);
270                 initNestedRecyclerView();
271                 setNestedViewLayout();
272 
273                 createScrollBarFromConfig();
274 
275                 mNestedRecyclerView.getViewTreeObserver().addOnGlobalLayoutListener(
276                         new OnGlobalLayoutListener() {
277                             @Override
278                             public void onGlobalLayout() {
279                                 mNestedRecyclerView.getViewTreeObserver()
280                                         .removeOnGlobalLayoutListener(this);
281                                 mFullyInitialized = true;
282                             }
283                         });
284             }
285         });
286     }
287 
288     /**
289      * Returns {@code true} if the {@PagedRecyclerView} is fully drawn. Using a global layout
290      * listener may not necessarily signify that this view is fully drawn (i.e. when the
291      * scrollbar is enabled). This is because the inner views (scrollbar and inner recycler view)
292      * are drawn after the outer views are finished.
293      */
fullyInitialized()294     public boolean fullyInitialized() {
295         return mFullyInitialized;
296     }
297 
298     @Override
onAttachedToWindow()299     protected void onAttachedToWindow() {
300         super.onAttachedToWindow();
301         mCarUxRestrictionsUtil.register(mListener);
302     }
303 
304     @Override
onDetachedFromWindow()305     protected void onDetachedFromWindow() {
306         super.onDetachedFromWindow();
307         mCarUxRestrictionsUtil.unregister(mListener);
308     }
309 
updateCarUxRestrictions(CarUxRestrictions carUxRestrictions)310     private void updateCarUxRestrictions(CarUxRestrictions carUxRestrictions) {
311         // If the adapter does not implement ItemCap, then the max items on it cannot be updated.
312         if (!(mAdapter instanceof ItemCap)) {
313             return;
314         }
315 
316         int maxItems = ItemCap.UNLIMITED;
317         if ((carUxRestrictions.getActiveRestrictions()
318                 & CarUxRestrictions.UX_RESTRICTIONS_LIMIT_CONTENT) != 0) {
319             maxItems = carUxRestrictions.getMaxCumulativeContentItems();
320         }
321 
322         int originalCount = mAdapter.getItemCount();
323         ((ItemCap) mAdapter).setMaxItems(maxItems);
324         int newCount = mAdapter.getItemCount();
325 
326         if (newCount == originalCount) {
327             return;
328         }
329 
330         if (newCount < originalCount) {
331             mAdapter.notifyItemRangeRemoved(
332                     newCount, originalCount - newCount);
333         } else {
334             mAdapter.notifyItemRangeInserted(
335                     originalCount, newCount - originalCount);
336         }
337     }
338 
339     @Override
setClipToPadding(boolean clipToPadding)340     public void setClipToPadding(boolean clipToPadding) {
341         if (mScrollBarEnabled) {
342             mNestedRecyclerView.setClipToPadding(clipToPadding);
343         } else {
344             super.setClipToPadding(clipToPadding);
345         }
346     }
347 
348     @Override
setAdapter(@ullable Adapter adapter)349     public void setAdapter(@Nullable Adapter adapter) {
350         mAdapter = adapter;
351         if (mScrollBarEnabled) {
352             mNestedRecyclerView.setAdapter(adapter);
353         } else {
354             super.setAdapter(adapter);
355         }
356     }
357 
358     @Nullable
359     @Override
getAdapter()360     public Adapter getAdapter() {
361         if (mScrollBarEnabled) {
362             return mNestedRecyclerView.getAdapter();
363         }
364         return super.getAdapter();
365     }
366 
367     @Override
setLayoutManager(@ullable LayoutManager layout)368     public void setLayoutManager(@Nullable LayoutManager layout) {
369         if (mScrollBarEnabled) {
370             mNestedRecyclerView.setLayoutManager(layout);
371         } else {
372             super.setLayoutManager(layout);
373         }
374     }
375 
376     /**
377      * Returns the {@link LayoutManager} for the {@link RecyclerView} displaying the content.
378      *
379      * <p>In cases where the scroll bar is visible and the nested {@link RecyclerView} is
380      * displaying content, {@link #getLayoutManager()} cannot be used because it returns the
381      * {@link LayoutManager} of the outer {@link RecyclerView}. {@link #getLayoutManager()} could
382      * not be overridden to return the effective manager due to interference with accessibility
383      * node tree traversal.
384      */
385     @Nullable
getEffectiveLayoutManager()386     public LayoutManager getEffectiveLayoutManager() {
387         if (mScrollBarEnabled) {
388             return mNestedRecyclerView.getLayoutManager();
389         }
390         return super.getLayoutManager();
391     }
392 
393     @Override
setOnScrollChangeListener(OnScrollChangeListener l)394     public void setOnScrollChangeListener(OnScrollChangeListener l) {
395         if (mScrollBarEnabled) {
396             mNestedRecyclerView.setOnScrollChangeListener(l);
397         } else {
398             super.setOnScrollChangeListener(l);
399         }
400     }
401 
402     @Override
setVerticalFadingEdgeEnabled(boolean verticalFadingEdgeEnabled)403     public void setVerticalFadingEdgeEnabled(boolean verticalFadingEdgeEnabled) {
404         if (mScrollBarEnabled) {
405             mNestedRecyclerView.setVerticalFadingEdgeEnabled(verticalFadingEdgeEnabled);
406         } else {
407             super.setVerticalFadingEdgeEnabled(verticalFadingEdgeEnabled);
408         }
409     }
410 
411     @Override
setFadingEdgeLength(int length)412     public void setFadingEdgeLength(int length) {
413         if (mScrollBarEnabled) {
414             mNestedRecyclerView.setFadingEdgeLength(length);
415         } else {
416             super.setFadingEdgeLength(length);
417         }
418     }
419 
420     @Override
addItemDecoration(@onNull ItemDecoration decor, int index)421     public void addItemDecoration(@NonNull ItemDecoration decor, int index) {
422         if (mScrollBarEnabled) {
423             mNestedRecyclerView.addItemDecoration(decor, index);
424         } else {
425             super.addItemDecoration(decor, index);
426         }
427     }
428 
429     @Override
addItemDecoration(@onNull ItemDecoration decor)430     public void addItemDecoration(@NonNull ItemDecoration decor) {
431         if (mScrollBarEnabled) {
432             mNestedRecyclerView.addItemDecoration(decor);
433         } else {
434             super.addItemDecoration(decor);
435         }
436     }
437 
438     @Override
setItemAnimator(@ullable ItemAnimator animator)439     public void setItemAnimator(@Nullable ItemAnimator animator) {
440         if (mScrollBarEnabled) {
441             mNestedRecyclerView.setItemAnimator(animator);
442         } else {
443             super.setItemAnimator(animator);
444         }
445     }
446 
447     @Override
setPadding(int left, int top, int right, int bottom)448     public void setPadding(int left, int top, int right, int bottom) {
449         if (mScrollBarEnabled) {
450             mNestedRecyclerView.setPadding(left, top, right, bottom);
451             if (mScrollBarUI != null) mScrollBarUI.requestLayout();
452         } else {
453             super.setPadding(left, top, right, bottom);
454         }
455     }
456 
457     @Override
setPaddingRelative(int start, int top, int end, int bottom)458     public void setPaddingRelative(int start, int top, int end, int bottom) {
459         if (mScrollBarEnabled) {
460             mNestedRecyclerView.setPaddingRelative(start, top, end, bottom);
461             if (mScrollBarUI != null) mScrollBarUI.requestLayout();
462         } else {
463             super.setPaddingRelative(start, top, end, bottom);
464         }
465     }
466 
467     @Override
findViewHolderForLayoutPosition(int position)468     public ViewHolder findViewHolderForLayoutPosition(int position) {
469         if (mScrollBarEnabled) {
470             return mNestedRecyclerView.findViewHolderForLayoutPosition(position);
471         } else {
472             return super.findViewHolderForLayoutPosition(position);
473         }
474     }
475 
476     @Override
findContainingViewHolder(View view)477     public ViewHolder findContainingViewHolder(View view) {
478         if (mScrollBarEnabled) {
479             return mNestedRecyclerView.findContainingViewHolder(view);
480         } else {
481             return super.findContainingViewHolder(view);
482         }
483     }
484 
485     @Override
486     @Nullable
findChildViewUnder(float x, float y)487     public View findChildViewUnder(float x, float y) {
488         if (mScrollBarEnabled) {
489             return mNestedRecyclerView.findChildViewUnder(x, y);
490         } else {
491             return super.findChildViewUnder(x, y);
492         }
493     }
494 
495     @Override
addOnScrollListener(@onNull OnScrollListener listener)496     public void addOnScrollListener(@NonNull OnScrollListener listener) {
497         if (mScrollBarEnabled) {
498             mNestedRecyclerView.addOnScrollListener(listener);
499         } else {
500             super.addOnScrollListener(listener);
501         }
502     }
503 
504     @Override
removeOnScrollListener(@onNull OnScrollListener listener)505     public void removeOnScrollListener(@NonNull OnScrollListener listener) {
506         if (mScrollBarEnabled) {
507             mNestedRecyclerView.removeOnScrollListener(listener);
508         } else {
509             super.removeOnScrollListener(listener);
510         }
511     }
512 
513     /**
514      * Calls {@link #layout(int, int, int, int)} for both this RecyclerView and the nested one.
515      */
516     @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
layoutBothForTesting(int l, int t, int r, int b)517     public void layoutBothForTesting(int l, int t, int r, int b) {
518         super.layout(l, t, r, b);
519         mNestedRecyclerView.layout(l, t, r, b);
520     }
521 
522     @Override
getPaddingStart()523     public int getPaddingStart() {
524         return mScrollBarEnabled ? mNestedRecyclerView.getPaddingStart() : super.getPaddingStart();
525     }
526 
527     @Override
getPaddingEnd()528     public int getPaddingEnd() {
529         return mScrollBarEnabled ? mNestedRecyclerView.getPaddingEnd() : super.getPaddingEnd();
530     }
531 
532     @Override
getPaddingTop()533     public int getPaddingTop() {
534         return mScrollBarEnabled ? mNestedRecyclerView.getPaddingTop() : super.getPaddingTop();
535     }
536 
537     @Override
getPaddingBottom()538     public int getPaddingBottom() {
539         return mScrollBarEnabled ? mNestedRecyclerView.getPaddingBottom()
540                 : super.getPaddingBottom();
541     }
542 
543     @Override
setVisibility(int visibility)544     public void setVisibility(int visibility) {
545         super.setVisibility(visibility);
546         if (mScrollBarEnabled) {
547             mNestedRecyclerView.setVisibility(visibility);
548         }
549     }
550 
initNestedRecyclerView()551     private void initNestedRecyclerView() {
552         PagedRecyclerViewAdapter.NestedRowViewHolder vh =
553                 (PagedRecyclerViewAdapter.NestedRowViewHolder)
554                         this.findViewHolderForAdapterPosition(0);
555         if (vh == null) {
556             throw new Error("Outer RecyclerView failed to initialize.");
557         }
558 
559         vh.mFrameLayout.addView(mNestedRecyclerView);
560     }
561 
createScrollBarFromConfig()562     private void createScrollBarFromConfig() {
563         if (DEBUG) Log.d(TAG, "createScrollBarFromConfig");
564         final String clsName = mScrollBarClass == null
565                 ? mContext.getString(R.string.config_scrollBarComponent) : mScrollBarClass;
566         if (clsName == null || clsName.length() == 0) {
567             throw andLog("No scroll bar component configured", null);
568         }
569 
570         Class<?> cls;
571         try {
572             cls = mContext.getClassLoader().loadClass(clsName);
573         } catch (Throwable t) {
574             throw andLog("Error loading scroll bar component: " + clsName, t);
575         }
576         try {
577             mScrollBarUI = (ScrollBarUI) cls.newInstance();
578         } catch (Throwable t) {
579             throw andLog("Error creating scroll bar component: " + clsName, t);
580         }
581 
582         mScrollBarUI.initialize(mContext, mNestedRecyclerView, mScrollBarContainerWidth,
583                 mScrollBarPosition, mScrollBarAboveRecyclerView);
584 
585         mScrollBarUI.setPadding(mScrollBarPaddingStart, mScrollBarPaddingEnd);
586 
587         if (DEBUG) Log.d(TAG, "started " + mScrollBarUI.getClass().getSimpleName());
588     }
589 
590     /**
591      * Sets the scrollbar's padding start (top) and end (bottom).
592      * This padding is applied in addition to the padding of the inner RecyclerView.
593      */
setScrollBarPadding(int paddingStart, int paddingEnd)594     public void setScrollBarPadding(int paddingStart, int paddingEnd) {
595         if (mScrollBarEnabled) {
596             mScrollBarPaddingStart = paddingStart;
597             mScrollBarPaddingEnd = paddingEnd;
598 
599             if (mScrollBarUI != null) {
600                 mScrollBarUI.setPadding(paddingStart, paddingEnd);
601             }
602         }
603     }
604 
605     /**
606      * Set the nested view's layout to the specified value.
607      *
608      * <p>The gutter is the space to the start/end of the list view items and will be equal in size
609      * to the scroll bars. By default, there is a gutter to both the left and right of the list
610      * view items, to account for the scroll bar.
611      */
setNestedViewLayout()612     private void setNestedViewLayout() {
613         int startMargin = 0;
614         int endMargin = 0;
615         if ((mGutter & Gutter.START) != 0) {
616             startMargin = mGutterSize;
617         }
618         if ((mGutter & Gutter.END) != 0) {
619             endMargin = mGutterSize;
620         }
621 
622         MarginLayoutParams layoutParams =
623                 (MarginLayoutParams) mNestedRecyclerView.getLayoutParams();
624 
625         layoutParams.setMarginStart(startMargin);
626         layoutParams.setMarginEnd(endMargin);
627 
628         layoutParams.height = LayoutParams.MATCH_PARENT;
629         layoutParams.width = super.getLayoutManager().getWidth() - startMargin - endMargin;
630         // requestLayout() isn't sufficient because we also need to resolveLayoutParams().
631         mNestedRecyclerView.setLayoutParams(layoutParams);
632 
633         // If there's a gutter, set ClipToPadding to false so that CardView's shadow will still
634         // appear outside of the padding.
635         mNestedRecyclerView.setClipToPadding(startMargin == 0 && endMargin == 0);
636     }
637 
andLog(String msg, Throwable t)638     private RuntimeException andLog(String msg, Throwable t) {
639         Log.e(TAG, msg, t);
640         throw new RuntimeException(msg, t);
641     }
642 }
643