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