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