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