1 /*
2  * Copyright (C) 2019 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 package com.android.car.ui.recyclerview;
17 
18 import static com.android.car.ui.utils.CarUiUtils.requireViewByRefId;
19 
20 import static java.lang.annotation.RetentionPolicy.SOURCE;
21 
22 import android.car.drivingstate.CarUxRestrictions;
23 import android.content.Context;
24 import android.content.res.TypedArray;
25 import android.text.TextUtils;
26 import android.util.AttributeSet;
27 import android.util.Log;
28 import android.view.LayoutInflater;
29 import android.view.View;
30 import android.view.ViewGroup;
31 import android.widget.FrameLayout;
32 import android.widget.LinearLayout;
33 
34 import androidx.annotation.IntDef;
35 import androidx.annotation.NonNull;
36 import androidx.annotation.Nullable;
37 import androidx.recyclerview.widget.GridLayoutManager;
38 import androidx.recyclerview.widget.LinearLayoutManager;
39 import androidx.recyclerview.widget.RecyclerView;
40 
41 import com.android.car.ui.R;
42 import com.android.car.ui.recyclerview.decorations.grid.GridDividerItemDecoration;
43 import com.android.car.ui.recyclerview.decorations.grid.GridOffsetItemDecoration;
44 import com.android.car.ui.recyclerview.decorations.linear.LinearDividerItemDecoration;
45 import com.android.car.ui.recyclerview.decorations.linear.LinearOffsetItemDecoration;
46 import com.android.car.ui.recyclerview.decorations.linear.LinearOffsetItemDecoration.OffsetPosition;
47 import com.android.car.ui.toolbar.Toolbar;
48 import com.android.car.ui.utils.CarUxRestrictionsUtil;
49 
50 import java.lang.annotation.Retention;
51 
52 /**
53  * View that extends a {@link RecyclerView} and wraps itself into a {@link LinearLayout} which
54  * could potentially include a scrollbar that has page up and down arrows. Interaction with this
55  * view is similar to a {@code RecyclerView} as it takes the same adapter and the layout manager.
56  */
57 public final class CarUiRecyclerView extends RecyclerView implements
58         Toolbar.OnHeightChangedListener {
59 
60     private static final String TAG = "CarUiRecyclerView";
61 
62     private final UxRestrictionChangedListener mListener = new UxRestrictionChangedListener();
63 
64     private CarUxRestrictionsUtil mCarUxRestrictionsUtil;
65     private boolean mScrollBarEnabled;
66     private String mScrollBarClass;
67     private boolean mFullyInitialized;
68     private float mScrollBarPaddingStart;
69     private float mScrollBarPaddingEnd;
70 
71     private ScrollBar mScrollBar;
72     private int mInitialTopPadding;
73 
74     private GridOffsetItemDecoration mOffsetItemDecoration;
75     private GridDividerItemDecoration mDividerItemDecoration;
76     @CarUiRecyclerViewLayout
77     private int mCarUiRecyclerViewLayout;
78     private int mNumOfColumns;
79     private boolean mInstallingExtScrollBar = false;
80     private int mContainerVisibility = View.VISIBLE;
81     private LinearLayout mContainer;
82 
83     /**
84      * The possible values for setScrollBarPosition. The default value is actually {@link
85      * CarUiRecyclerViewLayout#LINEAR}.
86      */
87     @IntDef({
88             CarUiRecyclerViewLayout.LINEAR,
89             CarUiRecyclerViewLayout.GRID,
90     })
91     @Retention(SOURCE)
92     public @interface CarUiRecyclerViewLayout {
93         /**
94          * Arranges items either horizontally in a single row or vertically in a single column.
95          * This is default.
96          */
97         int LINEAR = 0;
98 
99         /** Arranges items in a Grid. */
100         int GRID = 2;
101     }
102 
103     /**
104      * Interface for a {@link RecyclerView.Adapter} to cap the number of items.
105      *
106      * <p>NOTE: it is still up to the adapter to use maxItems in {@link
107      * RecyclerView.Adapter#getItemCount()}.
108      *
109      * <p>the recommended way would be with:
110      *
111      * <pre>{@code
112      * {@literal@}Override
113      * public int getItemCount() {
114      *   return Math.min(super.getItemCount(), mMaxItems);
115      * }
116      * }</pre>
117      */
118     public interface ItemCap {
119 
120         /**
121          * A value to pass to {@link #setMaxItems(int)} that indicates there should be no limit.
122          */
123         int UNLIMITED = -1;
124 
125         /**
126          * Sets the maximum number of items available in the adapter. A value less than '0' means
127          * the
128          * list should not be capped.
129          */
setMaxItems(int maxItems)130         void setMaxItems(int maxItems);
131     }
132 
CarUiRecyclerView(@onNull Context context)133     public CarUiRecyclerView(@NonNull Context context) {
134         this(context, null);
135     }
136 
CarUiRecyclerView(@onNull Context context, @Nullable AttributeSet attrs)137     public CarUiRecyclerView(@NonNull Context context, @Nullable AttributeSet attrs) {
138         this(context, attrs, R.attr.carUiRecyclerViewStyle);
139     }
140 
CarUiRecyclerView(@onNull Context context, @Nullable AttributeSet attrs, int defStyle)141     public CarUiRecyclerView(@NonNull Context context, @Nullable AttributeSet attrs,
142             int defStyle) {
143         super(context, attrs, defStyle);
144         init(context, attrs, defStyle);
145     }
146 
init(Context context, AttributeSet attrs, int defStyleAttr)147     private void init(Context context, AttributeSet attrs, int defStyleAttr) {
148         mCarUxRestrictionsUtil = CarUxRestrictionsUtil.getInstance(context);
149         TypedArray a = context.obtainStyledAttributes(
150                 attrs,
151                 R.styleable.CarUiRecyclerView,
152                 defStyleAttr,
153                 R.style.Widget_CarUi_CarUiRecyclerView);
154 
155         mScrollBarEnabled = context.getResources().getBoolean(R.bool.car_ui_scrollbar_enable);
156         mFullyInitialized = false;
157 
158         mScrollBarPaddingStart =
159                 context.getResources().getDimension(R.dimen.car_ui_scrollbar_padding_start);
160         mScrollBarPaddingEnd =
161                 context.getResources().getDimension(R.dimen.car_ui_scrollbar_padding_end);
162 
163         mCarUiRecyclerViewLayout =
164                 a.getInt(R.styleable.CarUiRecyclerView_layoutStyle, CarUiRecyclerViewLayout.LINEAR);
165         mNumOfColumns = a.getInt(R.styleable.CarUiRecyclerView_numOfColumns, /* defValue= */ 2);
166         boolean enableDivider =
167                 a.getBoolean(R.styleable.CarUiRecyclerView_enableDivider, /* defValue= */ false);
168 
169         if (mCarUiRecyclerViewLayout == CarUiRecyclerViewLayout.LINEAR) {
170 
171             int linearTopOffset =
172                     a.getInteger(R.styleable.CarUiRecyclerView_startOffset, /* defValue= */ 0);
173             int linearBottomOffset =
174                     a.getInteger(R.styleable.CarUiRecyclerView_endOffset, /* defValue= */ 0);
175 
176             if (enableDivider) {
177                 RecyclerView.ItemDecoration dividerItemDecoration =
178                         new LinearDividerItemDecoration(
179                                 context.getDrawable(R.drawable.car_ui_recyclerview_divider));
180                 addItemDecoration(dividerItemDecoration);
181             }
182             RecyclerView.ItemDecoration topOffsetItemDecoration =
183                     new LinearOffsetItemDecoration(linearTopOffset, OffsetPosition.START);
184 
185             RecyclerView.ItemDecoration bottomOffsetItemDecoration =
186                     new LinearOffsetItemDecoration(linearBottomOffset, OffsetPosition.END);
187 
188             addItemDecoration(topOffsetItemDecoration);
189             addItemDecoration(bottomOffsetItemDecoration);
190             setLayoutManager(new LinearLayoutManager(getContext()));
191         } else {
192             int gridTopOffset =
193                     a.getInteger(R.styleable.CarUiRecyclerView_startOffset, /* defValue= */ 0);
194             int gridBottomOffset =
195                     a.getInteger(R.styleable.CarUiRecyclerView_endOffset, /* defValue= */ 0);
196 
197             if (enableDivider) {
198                 mDividerItemDecoration =
199                         new GridDividerItemDecoration(
200                                 context.getDrawable(R.drawable.car_ui_divider),
201                                 context.getDrawable(R.drawable.car_ui_divider),
202                                 mNumOfColumns);
203                 addItemDecoration(mDividerItemDecoration);
204             }
205 
206             mOffsetItemDecoration =
207                     new GridOffsetItemDecoration(gridTopOffset, mNumOfColumns,
208                             OffsetPosition.START);
209 
210             GridOffsetItemDecoration bottomOffsetItemDecoration =
211                     new GridOffsetItemDecoration(gridBottomOffset, mNumOfColumns,
212                             OffsetPosition.END);
213 
214             addItemDecoration(mOffsetItemDecoration);
215             addItemDecoration(bottomOffsetItemDecoration);
216             setLayoutManager(new GridLayoutManager(getContext(), mNumOfColumns));
217             setNumOfColumns(mNumOfColumns);
218         }
219 
220         if (!mScrollBarEnabled) {
221             a.recycle();
222             mFullyInitialized = true;
223             return;
224         }
225 
226         mScrollBarClass = context.getResources().getString(R.string.car_ui_scrollbar_component);
227         a.recycle();
228         this.getViewTreeObserver()
229                 .addOnGlobalLayoutListener(() -> {
230                     if (mInitialTopPadding == 0) {
231                         mInitialTopPadding = getPaddingTop();
232                     }
233                     mFullyInitialized = true;
234                 });
235     }
236 
237     @Override
onHeightChanged(int height)238     public void onHeightChanged(int height) {
239         setPaddingRelative(getPaddingStart(), mInitialTopPadding + height,
240                 getPaddingEnd(), getPaddingBottom());
241     }
242 
243     /**
244      * Returns {@code true} if the {@link CarUiRecyclerView} is fully drawn. Using a global layout
245      * mListener may not necessarily signify that this view is fully drawn (i.e. when the scrollbar
246      * is enabled).
247      */
fullyInitialized()248     public boolean fullyInitialized() {
249         return mFullyInitialized;
250     }
251 
252     /**
253      * Sets the number of columns in which grid needs to be divided.
254      */
setNumOfColumns(int numberOfColumns)255     public void setNumOfColumns(int numberOfColumns) {
256         mNumOfColumns = numberOfColumns;
257         if (mOffsetItemDecoration != null) {
258             mOffsetItemDecoration.setNumOfColumns(mNumOfColumns);
259         }
260         if (mDividerItemDecoration != null) {
261             mDividerItemDecoration.setNumOfColumns(mNumOfColumns);
262         }
263     }
264 
265     @Override
setVisibility(int visibility)266     public void setVisibility(int visibility) {
267         super.setVisibility(visibility);
268         mContainerVisibility = visibility;
269         if (mContainer != null) {
270             mContainer.setVisibility(visibility);
271         }
272     }
273 
274     @Override
onAttachedToWindow()275     protected void onAttachedToWindow() {
276         super.onAttachedToWindow();
277         mCarUxRestrictionsUtil.register(mListener);
278         if (mInstallingExtScrollBar || !mScrollBarEnabled) {
279             return;
280         }
281         // When CarUiRV is detached from the current parent and attached to the container with
282         // the scrollBar, onAttachedToWindow() will get called immediately when attaching the
283         // CarUiRV to the container. This flag will help us keep track of this state and avoid
284         // recursion. We also want to reset the state of this flag as soon as the container is
285         // successfully attached to the CarUiRV's original parent.
286         mInstallingExtScrollBar = true;
287         installExternalScrollBar();
288         mInstallingExtScrollBar = false;
289     }
290 
291     /**
292      * This method will detach the current recycler view from its parent and attach it to the
293      * container which is a LinearLayout. Later the entire container is attached to the
294      * parent where the recycler view was set with the same layout params.
295      */
installExternalScrollBar()296     private void installExternalScrollBar() {
297         ViewGroup parent = (ViewGroup) getParent();
298         mContainer = new LinearLayout(getContext());
299         LayoutInflater inflater = LayoutInflater.from(getContext());
300         inflater.inflate(R.layout.car_ui_recycler_view, mContainer, true);
301 
302         mContainer.setLayoutParams(getLayoutParams());
303         mContainer.setVisibility(mContainerVisibility);
304         int index = parent.indexOfChild(this);
305         parent.removeView(this);
306         ((FrameLayout) requireViewByRefId(mContainer, R.id.car_ui_recycler_view))
307                 .addView(this,
308                         new FrameLayout.LayoutParams(
309                                 ViewGroup.LayoutParams.MATCH_PARENT,
310                                 ViewGroup.LayoutParams.MATCH_PARENT));
311         setVerticalScrollBarEnabled(false);
312         setHorizontalScrollBarEnabled(false);
313         parent.addView(mContainer, index);
314 
315         createScrollBarFromConfig(requireViewByRefId(mContainer, R.id.car_ui_scroll_bar));
316     }
317 
createScrollBarFromConfig(View scrollView)318     private void createScrollBarFromConfig(View scrollView) {
319         Class<?> cls;
320         try {
321             cls = !TextUtils.isEmpty(mScrollBarClass)
322                     ? getContext().getClassLoader().loadClass(mScrollBarClass)
323                     : DefaultScrollBar.class;
324         } catch (Throwable t) {
325             throw andLog("Error loading scroll bar component: " + mScrollBarClass, t);
326         }
327         try {
328             mScrollBar = (ScrollBar) cls.getDeclaredConstructor().newInstance();
329         } catch (Throwable t) {
330             throw andLog("Error creating scroll bar component: " + mScrollBarClass, t);
331         }
332 
333         mScrollBar.initialize(this, scrollView);
334 
335         mScrollBar.setPadding((int) mScrollBarPaddingStart, (int) mScrollBarPaddingEnd);
336     }
337 
338     @Override
onDetachedFromWindow()339     protected void onDetachedFromWindow() {
340         super.onDetachedFromWindow();
341         mCarUxRestrictionsUtil.unregister(mListener);
342     }
343 
344     /**
345      * Sets the scrollbar's padding start (top) and end (bottom).
346      * This padding is applied in addition to the padding of the inner RecyclerView.
347      */
setScrollBarPadding(int paddingStart, int paddingEnd)348     public void setScrollBarPadding(int paddingStart, int paddingEnd) {
349         if (mScrollBarEnabled) {
350             mScrollBarPaddingStart = paddingStart;
351             mScrollBarPaddingEnd = paddingEnd;
352 
353             if (mScrollBar != null) {
354                 mScrollBar.setPadding(paddingStart, paddingEnd);
355             }
356         }
357     }
358 
359     /**
360      * @deprecated use {#getLayoutManager()}
361      */
362     @Nullable
363     @Deprecated
getEffectiveLayoutManager()364     public LayoutManager getEffectiveLayoutManager() {
365         return super.getLayoutManager();
366     }
367 
andLog(String msg, Throwable t)368     private static RuntimeException andLog(String msg, Throwable t) {
369         Log.e(TAG, msg, t);
370         throw new RuntimeException(msg, t);
371     }
372 
373     private class UxRestrictionChangedListener implements
374             CarUxRestrictionsUtil.OnUxRestrictionsChangedListener {
375 
376         @Override
onRestrictionsChanged(@onNull CarUxRestrictions carUxRestrictions)377         public void onRestrictionsChanged(@NonNull CarUxRestrictions carUxRestrictions) {
378             Adapter<?> adapter = getAdapter();
379             // If the adapter does not implement ItemCap, then the max items on it cannot be
380             // updated.
381             if (!(adapter instanceof ItemCap)) {
382                 return;
383             }
384 
385             int maxItems = ItemCap.UNLIMITED;
386             if ((carUxRestrictions.getActiveRestrictions()
387                     & CarUxRestrictions.UX_RESTRICTIONS_LIMIT_CONTENT)
388                     != 0) {
389                 maxItems = carUxRestrictions.getMaxCumulativeContentItems();
390             }
391 
392             int originalCount = adapter.getItemCount();
393             ((ItemCap) adapter).setMaxItems(maxItems);
394             int newCount = adapter.getItemCount();
395 
396             if (newCount == originalCount) {
397                 return;
398             }
399 
400             if (newCount < originalCount) {
401                 adapter.notifyItemRangeRemoved(newCount, originalCount - newCount);
402             } else {
403                 adapter.notifyItemRangeInserted(originalCount, newCount - originalCount);
404             }
405         }
406     }
407 }
408