1 /*
2  * Copyright (C) 2019 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 package com.android.car.ui.recyclerview;
17 
18 import static com.android.car.ui.utils.CarUiUtils.findViewByRefId;
19 import static com.android.car.ui.utils.RotaryConstants.ROTARY_HORIZONTALLY_SCROLLABLE;
20 import static com.android.car.ui.utils.RotaryConstants.ROTARY_VERTICALLY_SCROLLABLE;
21 
22 import static java.lang.annotation.RetentionPolicy.SOURCE;
23 
24 import android.car.drivingstate.CarUxRestrictions;
25 import android.content.Context;
26 import android.content.res.TypedArray;
27 import android.graphics.Rect;
28 import android.os.Handler;
29 import android.os.Looper;
30 import android.os.Parcelable;
31 import android.text.TextUtils;
32 import android.util.AttributeSet;
33 import android.util.Log;
34 import android.view.InputDevice;
35 import android.view.LayoutInflater;
36 import android.view.MotionEvent;
37 import android.view.View;
38 import android.view.ViewGroup;
39 import android.widget.FrameLayout;
40 import android.widget.LinearLayout;
41 
42 import androidx.annotation.IntDef;
43 import androidx.annotation.NonNull;
44 import androidx.annotation.Nullable;
45 import androidx.recyclerview.widget.GridLayoutManager;
46 import androidx.recyclerview.widget.LinearLayoutManager;
47 import androidx.recyclerview.widget.RecyclerView;
48 
49 import com.android.car.ui.R;
50 import com.android.car.ui.recyclerview.decorations.grid.GridDividerItemDecoration;
51 import com.android.car.ui.recyclerview.decorations.grid.GridOffsetItemDecoration;
52 import com.android.car.ui.recyclerview.decorations.linear.LinearDividerItemDecoration;
53 import com.android.car.ui.recyclerview.decorations.linear.LinearOffsetItemDecoration;
54 import com.android.car.ui.recyclerview.decorations.linear.LinearOffsetItemDecoration.OffsetPosition;
55 import com.android.car.ui.toolbar.Toolbar;
56 import com.android.car.ui.utils.CarUxRestrictionsUtil;
57 
58 import java.lang.annotation.Retention;
59 import java.util.Objects;
60 
61 /**
62  * View that extends a {@link RecyclerView} and wraps itself into a {@link LinearLayout} which
63  * could potentially include a scrollbar that has page up and down arrows. Interaction with this
64  * view is similar to a {@code RecyclerView} as it takes the same adapter and the layout manager.
65  */
66 public final class CarUiRecyclerView extends RecyclerView implements
67         Toolbar.OnHeightChangedListener {
68 
69     private static final String TAG = "CarUiRecyclerView";
70 
71     private final CarUxRestrictionsUtil.OnUxRestrictionsChangedListener mListener =
72             new UxRestrictionChangedListener();
73 
74     @NonNull
75     private final CarUxRestrictionsUtil mCarUxRestrictionsUtil;
76     private boolean mScrollBarEnabled;
77     @Nullable
78     private String mScrollBarClass;
79     private int mScrollBarPaddingTop;
80     private int mScrollBarPaddingBottom;
81     private boolean mHasScrolledToTop = false;
82 
83     @Nullable
84     private ScrollBar mScrollBar;
85     private int mInitialTopPadding;
86 
87     @Nullable
88     private GridOffsetItemDecoration mOffsetItemDecoration;
89     @NonNull
90     private GridDividerItemDecoration mDividerItemDecorationGrid;
91     @NonNull
92     private RecyclerView.ItemDecoration mDividerItemDecorationLinear;
93     private int mNumOfColumns;
94     private boolean mInstallingExtScrollBar = false;
95     private int mContainerVisibility = View.VISIBLE;
96     @Nullable
97     private Rect mContainerPadding;
98     @Nullable
99     private Rect mContainerPaddingRelative;
100     @Nullable
101     private LinearLayout mContainer;
102 
103 
104     /**
105      * The possible values for setScrollBarPosition. The default value is actually {@link
106      * CarUiRecyclerViewLayout#LINEAR}.
107      */
108     @IntDef({
109             CarUiRecyclerViewLayout.LINEAR,
110             CarUiRecyclerViewLayout.GRID,
111     })
112     @Retention(SOURCE)
113     public @interface CarUiRecyclerViewLayout {
114         /**
115          * Arranges items either horizontally in a single row or vertically in a single column.
116          * This is default.
117          */
118         int LINEAR = 0;
119 
120         /** Arranges items in a Grid. */
121         int GRID = 2;
122     }
123 
124     /**
125      * Interface for a {@link RecyclerView.Adapter} to cap the number of items.
126      *
127      * <p>NOTE: it is still up to the adapter to use maxItems in {@link
128      * RecyclerView.Adapter#getItemCount()}.
129      *
130      * <p>the recommended way would be with:
131      *
132      * <pre>{@code
133      * {@literal@}Override
134      * public int getItemCount() {
135      *   return Math.min(super.getItemCount(), mMaxItems);
136      * }
137      * }</pre>
138      */
139     public interface ItemCap {
140 
141         /**
142          * A value to pass to {@link #setMaxItems(int)} that indicates there should be no limit.
143          */
144         int UNLIMITED = -1;
145 
146         /**
147          * Sets the maximum number of items available in the adapter. A value less than '0' means
148          * the
149          * list should not be capped.
150          */
setMaxItems(int maxItems)151         void setMaxItems(int maxItems);
152     }
153 
CarUiRecyclerView(@onNull Context context)154     public CarUiRecyclerView(@NonNull Context context) {
155         this(context, null);
156     }
157 
CarUiRecyclerView(@onNull Context context, @Nullable AttributeSet attrs)158     public CarUiRecyclerView(@NonNull Context context, @Nullable AttributeSet attrs) {
159         this(context, attrs, R.attr.carUiRecyclerViewStyle);
160     }
161 
CarUiRecyclerView(@onNull Context context, @Nullable AttributeSet attrs, int defStyle)162     public CarUiRecyclerView(@NonNull Context context, @Nullable AttributeSet attrs,
163             int defStyle) {
164         super(context, attrs, defStyle);
165         mCarUxRestrictionsUtil = CarUxRestrictionsUtil.getInstance(context);
166         init(context, attrs, defStyle);
167     }
168 
init(Context context, AttributeSet attrs, int defStyleAttr)169     private void init(Context context, AttributeSet attrs, int defStyleAttr) {
170         initRotaryScroll(context, attrs, defStyleAttr);
171         setClipToPadding(false);
172         TypedArray a = context.obtainStyledAttributes(
173                 attrs,
174                 R.styleable.CarUiRecyclerView,
175                 defStyleAttr,
176                 R.style.Widget_CarUi_CarUiRecyclerView);
177 
178         mScrollBarEnabled = context.getResources().getBoolean(R.bool.car_ui_scrollbar_enable);
179 
180         mScrollBarPaddingTop = context.getResources()
181                 .getDimensionPixelSize(R.dimen.car_ui_scrollbar_padding_top);
182         mScrollBarPaddingBottom = context.getResources()
183                 .getDimensionPixelSize(R.dimen.car_ui_scrollbar_padding_bottom);
184 
185         @CarUiRecyclerViewLayout int carUiRecyclerViewLayout =
186                 a.getInt(R.styleable.CarUiRecyclerView_layoutStyle, CarUiRecyclerViewLayout.LINEAR);
187         mNumOfColumns = a.getInt(R.styleable.CarUiRecyclerView_numOfColumns, /* defValue= */ 2);
188         boolean enableDivider =
189                 a.getBoolean(R.styleable.CarUiRecyclerView_enableDivider, /* defValue= */ false);
190 
191         mDividerItemDecorationLinear = new LinearDividerItemDecoration(
192                 context.getDrawable(R.drawable.car_ui_recyclerview_divider));
193 
194         mDividerItemDecorationGrid =
195                 new GridDividerItemDecoration(
196                         context.getDrawable(R.drawable.car_ui_divider),
197                         context.getDrawable(R.drawable.car_ui_divider),
198                         mNumOfColumns);
199 
200         int topOffset = a.getInteger(R.styleable.CarUiRecyclerView_topOffset, /* defValue= */0);
201         int bottomOffset = a.getInteger(
202                 R.styleable.CarUiRecyclerView_bottomOffset, /* defValue= */0);
203         if (carUiRecyclerViewLayout == CarUiRecyclerViewLayout.LINEAR) {
204 
205             if (enableDivider) {
206                 addItemDecoration(mDividerItemDecorationLinear);
207             }
208             RecyclerView.ItemDecoration topOffsetItemDecoration =
209                     new LinearOffsetItemDecoration(topOffset, OffsetPosition.START);
210 
211             RecyclerView.ItemDecoration bottomOffsetItemDecoration =
212                     new LinearOffsetItemDecoration(bottomOffset, OffsetPosition.END);
213 
214             addItemDecoration(topOffsetItemDecoration);
215             addItemDecoration(bottomOffsetItemDecoration);
216             setLayoutManager(new LinearLayoutManager(getContext()));
217         } else {
218 
219             if (enableDivider) {
220                 addItemDecoration(mDividerItemDecorationGrid);
221             }
222 
223             mOffsetItemDecoration =
224                     new GridOffsetItemDecoration(topOffset, mNumOfColumns,
225                             OffsetPosition.START);
226 
227             GridOffsetItemDecoration bottomOffsetItemDecoration =
228                     new GridOffsetItemDecoration(bottomOffset, mNumOfColumns,
229                             OffsetPosition.END);
230 
231             addItemDecoration(mOffsetItemDecoration);
232             addItemDecoration(bottomOffsetItemDecoration);
233             setLayoutManager(new GridLayoutManager(getContext(), mNumOfColumns));
234             setNumOfColumns(mNumOfColumns);
235         }
236 
237         a.recycle();
238         if (!mScrollBarEnabled) {
239             return;
240         }
241 
242         setVerticalScrollBarEnabled(false);
243         setHorizontalScrollBarEnabled(false);
244 
245         mScrollBarClass = context.getResources().getString(R.string.car_ui_scrollbar_component);
246         this.getViewTreeObserver()
247                 .addOnGlobalLayoutListener(() -> {
248                     if (!mHasScrolledToTop && getLayoutManager() != null) {
249                         // Scroll to the top after the first global layout, so that
250                         // we can set padding for the insets and still have the
251                         // recyclerview start at the top.
252                         new Handler(Objects.requireNonNull(Looper.myLooper())).post(() ->
253                                 getLayoutManager().scrollToPosition(0));
254                         mHasScrolledToTop = true;
255                     }
256 
257                     if (mInitialTopPadding == 0) {
258                         mInitialTopPadding = getPaddingTop();
259                     }
260                 });
261     }
262 
263     /**
264      * If this view's content description isn't set to opt out of scrolling via the rotary
265      * controller, initialize it accordingly.
266      */
initRotaryScroll(Context context, AttributeSet attrs, int defStyleAttr)267     private void initRotaryScroll(Context context, AttributeSet attrs, int defStyleAttr) {
268         CharSequence contentDescription = getContentDescription();
269         if (contentDescription == null) {
270             TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.RecyclerView,
271                     defStyleAttr, /* defStyleRes= */ 0);
272             int orientation = a.getInt(R.styleable.RecyclerView_android_orientation,
273                     LinearLayout.VERTICAL);
274             setContentDescription(
275                     orientation == LinearLayout.HORIZONTAL
276                             ? ROTARY_HORIZONTALLY_SCROLLABLE
277                             : ROTARY_VERTICALLY_SCROLLABLE);
278         } else if (!ROTARY_HORIZONTALLY_SCROLLABLE.contentEquals(contentDescription)
279                 && !ROTARY_VERTICALLY_SCROLLABLE.contentEquals(contentDescription)) {
280             return;
281         }
282 
283         // Convert SOURCE_ROTARY_ENCODER scroll events into SOURCE_MOUSE scroll events that
284         // RecyclerView knows how to handle.
285         setOnGenericMotionListener((v, event) -> {
286             if (event.getAction() == MotionEvent.ACTION_SCROLL) {
287                 if (event.getSource() == InputDevice.SOURCE_ROTARY_ENCODER) {
288                     MotionEvent mouseEvent = MotionEvent.obtain(event);
289                     mouseEvent.setSource(InputDevice.SOURCE_MOUSE);
290                     CarUiRecyclerView.super.onGenericMotionEvent(mouseEvent);
291                     return true;
292                 }
293             }
294             return false;
295         });
296 
297         // Mark this view as focusable. This view will be focused when no focusable elements are
298         // visible.
299         setFocusable(true);
300 
301         // Focus this view before descendants so that the RotaryService can focus this view when it
302         // wants to.
303         setDescendantFocusability(ViewGroup.FOCUS_BEFORE_DESCENDANTS);
304 
305         // Disable the default focus highlight. No highlight should appear when this view is
306         // focused.
307         setDefaultFocusHighlightEnabled(false);
308     }
309 
310     @Override
onRestoreInstanceState(Parcelable state)311     protected void onRestoreInstanceState(Parcelable state) {
312         super.onRestoreInstanceState(state);
313 
314         // If we're restoring an existing RecyclerView, we don't want
315         // to do the initial scroll to top
316         mHasScrolledToTop = true;
317     }
318 
319     @Override
requestLayout()320     public void requestLayout() {
321         super.requestLayout();
322         if (mScrollBar != null) {
323             mScrollBar.requestLayout();
324         }
325     }
326 
327     @Override
onHeightChanged(int height)328     public void onHeightChanged(int height) {
329         setPaddingRelative(getPaddingStart(), mInitialTopPadding + height,
330                 getPaddingEnd(), getPaddingBottom());
331     }
332 
333     /**
334      * Sets the number of columns in which grid needs to be divided.
335      */
setNumOfColumns(int numberOfColumns)336     public void setNumOfColumns(int numberOfColumns) {
337         mNumOfColumns = numberOfColumns;
338         if (mOffsetItemDecoration != null) {
339             mOffsetItemDecoration.setNumOfColumns(mNumOfColumns);
340         }
341         if (mDividerItemDecorationGrid != null) {
342             mDividerItemDecorationGrid.setNumOfColumns(mNumOfColumns);
343         }
344     }
345 
346     @Override
setVisibility(int visibility)347     public void setVisibility(int visibility) {
348         super.setVisibility(visibility);
349         mContainerVisibility = visibility;
350         if (mContainer != null) {
351             mContainer.setVisibility(visibility);
352         }
353     }
354 
355     @Override
onAttachedToWindow()356     protected void onAttachedToWindow() {
357         super.onAttachedToWindow();
358         mCarUxRestrictionsUtil.register(mListener);
359         if (mInstallingExtScrollBar || !mScrollBarEnabled) {
360             return;
361         }
362         // When CarUiRV is detached from the current parent and attached to the container with
363         // the scrollBar, onAttachedToWindow() will get called immediately when attaching the
364         // CarUiRV to the container. This flag will help us keep track of this state and avoid
365         // recursion. We also want to reset the state of this flag as soon as the container is
366         // successfully attached to the CarUiRV's original parent.
367         mInstallingExtScrollBar = true;
368         installExternalScrollBar();
369         mInstallingExtScrollBar = false;
370     }
371 
372     /**
373      * This method will detach the current recycler view from its parent and attach it to the
374      * container which is a LinearLayout. Later the entire container is attached to the
375      * parent where the recycler view was set with the same layout params.
376      */
installExternalScrollBar()377     private void installExternalScrollBar() {
378         mContainer = new LinearLayout(getContext());
379         LayoutInflater inflater = LayoutInflater.from(getContext());
380         inflater.inflate(R.layout.car_ui_recycler_view, mContainer, true);
381         mContainer.setVisibility(mContainerVisibility);
382 
383         if (mContainerPadding != null) {
384             mContainer.setPadding(mContainerPadding.left, mContainerPadding.top,
385                     mContainerPadding.right, mContainerPadding.bottom);
386         } else if (mContainerPaddingRelative != null) {
387             mContainer.setPaddingRelative(mContainerPaddingRelative.left,
388                     mContainerPaddingRelative.top, mContainerPaddingRelative.right,
389                     mContainerPaddingRelative.bottom);
390         } else {
391             mContainer.setPadding(getPaddingLeft(), /* top= */ 0,
392                     getPaddingRight(), /* bottom= */ 0);
393             setPadding(/* left= */ 0, getPaddingTop(),
394                     /* right= */ 0, getPaddingBottom());
395         }
396 
397         mContainer.setLayoutParams(getLayoutParams());
398         ViewGroup parent = (ViewGroup) getParent();
399         int index = parent.indexOfChild(this);
400         parent.removeViewInLayout(this);
401 
402         FrameLayout.LayoutParams params = new FrameLayout.LayoutParams(
403                 ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT);
404         ((CarUiRecyclerViewContainer) Objects.requireNonNull(
405                 findViewByRefId(mContainer, R.id.car_ui_recycler_view)))
406                 .addRecyclerView(this, params);
407         parent.addView(mContainer, index);
408 
409         createScrollBarFromConfig(findViewByRefId(mContainer, R.id.car_ui_scroll_bar));
410     }
411 
createScrollBarFromConfig(View scrollView)412     private void createScrollBarFromConfig(View scrollView) {
413         Class<?> cls;
414         try {
415             cls = !TextUtils.isEmpty(mScrollBarClass)
416                     ? getContext().getClassLoader().loadClass(mScrollBarClass)
417                     : DefaultScrollBar.class;
418         } catch (Throwable t) {
419             throw andLog("Error loading scroll bar component: " + mScrollBarClass, t);
420         }
421         try {
422             mScrollBar = (ScrollBar) cls.getDeclaredConstructor().newInstance();
423         } catch (Throwable t) {
424             throw andLog("Error creating scroll bar component: " + mScrollBarClass, t);
425         }
426 
427         mScrollBar.initialize(this, scrollView);
428 
429         setScrollBarPadding(mScrollBarPaddingTop, mScrollBarPaddingBottom);
430     }
431 
432     @Override
onDetachedFromWindow()433     protected void onDetachedFromWindow() {
434         super.onDetachedFromWindow();
435         mCarUxRestrictionsUtil.unregister(mListener);
436     }
437 
438     @Override
setPadding(int left, int top, int right, int bottom)439     public void setPadding(int left, int top, int right, int bottom) {
440         mContainerPaddingRelative = null;
441         if (mScrollBarEnabled) {
442             super.setPadding(0, top, 0, bottom);
443             mContainerPadding = new Rect(left, 0, right, 0);
444             if (mContainer != null) {
445                 mContainer.setPadding(left, 0, right, 0);
446             }
447             setScrollBarPadding(mScrollBarPaddingTop, mScrollBarPaddingBottom);
448         } else {
449             super.setPadding(left, top, right, bottom);
450         }
451     }
452 
453     @Override
setPaddingRelative(int start, int top, int end, int bottom)454     public void setPaddingRelative(int start, int top, int end, int bottom) {
455         mContainerPadding = null;
456         if (mScrollBarEnabled) {
457             super.setPaddingRelative(0, top, 0, bottom);
458             mContainerPaddingRelative = new Rect(start, 0, end, 0);
459             if (mContainer != null) {
460                 mContainer.setPaddingRelative(start, 0, end, 0);
461             }
462             setScrollBarPadding(mScrollBarPaddingTop, mScrollBarPaddingBottom);
463         } else {
464             super.setPaddingRelative(start, top, end, bottom);
465         }
466     }
467 
468     /**
469      * Sets the scrollbar's padding top and bottom.
470      * This padding is applied in addition to the padding of the RecyclerView.
471      */
setScrollBarPadding(int paddingTop, int paddingBottom)472     public void setScrollBarPadding(int paddingTop, int paddingBottom) {
473         if (mScrollBarEnabled) {
474             mScrollBarPaddingTop = paddingTop;
475             mScrollBarPaddingBottom = paddingBottom;
476 
477             if (mScrollBar != null) {
478                 mScrollBar.setPadding(paddingTop + getPaddingTop(),
479                         paddingBottom + getPaddingBottom());
480             }
481         }
482     }
483 
484     /**
485      * Sets divider item decoration for linear layout.
486      */
setLinearDividerItemDecoration(boolean enableDividers)487     public void setLinearDividerItemDecoration(boolean enableDividers) {
488         if (enableDividers) {
489             addItemDecoration(mDividerItemDecorationLinear);
490             return;
491         }
492         removeItemDecoration(mDividerItemDecorationLinear);
493     }
494 
495     /**
496      * Sets divider item decoration for grid layout.
497      */
setGridDividerItemDecoration(boolean enableDividers)498     public void setGridDividerItemDecoration(boolean enableDividers) {
499         if (enableDividers) {
500             addItemDecoration(mDividerItemDecorationGrid);
501             return;
502         }
503         removeItemDecoration(mDividerItemDecorationGrid);
504     }
505 
andLog(String msg, Throwable t)506     private static RuntimeException andLog(String msg, Throwable t) {
507         Log.e(TAG, msg, t);
508         throw new RuntimeException(msg, t);
509     }
510 
511     private class UxRestrictionChangedListener implements
512             CarUxRestrictionsUtil.OnUxRestrictionsChangedListener {
513 
514         @Override
onRestrictionsChanged(@onNull CarUxRestrictions carUxRestrictions)515         public void onRestrictionsChanged(@NonNull CarUxRestrictions carUxRestrictions) {
516             Adapter<?> adapter = getAdapter();
517             // If the adapter does not implement ItemCap, then the max items on it cannot be
518             // updated.
519             if (!(adapter instanceof ItemCap)) {
520                 return;
521             }
522 
523             int maxItems = ItemCap.UNLIMITED;
524             if ((carUxRestrictions.getActiveRestrictions()
525                     & CarUxRestrictions.UX_RESTRICTIONS_LIMIT_CONTENT)
526                     != 0) {
527                 maxItems = carUxRestrictions.getMaxCumulativeContentItems();
528             }
529 
530             int originalCount = adapter.getItemCount();
531             ((ItemCap) adapter).setMaxItems(maxItems);
532             int newCount = adapter.getItemCount();
533 
534             if (newCount == originalCount) {
535                 return;
536             }
537 
538             if (newCount < originalCount) {
539                 adapter.notifyItemRangeRemoved(newCount, originalCount - newCount);
540             } else {
541                 adapter.notifyItemRangeInserted(originalCount, newCount - originalCount);
542             }
543         }
544     }
545 }
546