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 android.content.res.Resources; 21 import android.os.Handler; 22 import android.view.View; 23 import android.view.ViewGroup; 24 import android.view.animation.AccelerateDecelerateInterpolator; 25 import android.view.animation.Interpolator; 26 import android.widget.ImageView; 27 28 import androidx.annotation.IntRange; 29 import androidx.annotation.VisibleForTesting; 30 import androidx.recyclerview.widget.OrientationHelper; 31 import androidx.recyclerview.widget.RecyclerView; 32 33 import com.android.car.ui.R; 34 import com.android.car.ui.utils.CarUiUtils; 35 36 /** 37 * The default scroll bar widget for the {@link CarUiRecyclerView}. 38 * 39 * <p>Inspired by {@link androidx.car.widget.PagedListView}. Most pagination and scrolling logic has 40 * been ported from the PLV with minor updates. 41 */ 42 class DefaultScrollBar implements ScrollBar { 43 44 @VisibleForTesting 45 int mPaddingStart; 46 @VisibleForTesting 47 int mPaddingEnd; 48 49 private float mButtonDisabledAlpha; 50 private CarUiSnapHelper mSnapHelper; 51 52 private ImageView mUpButton; 53 private View mScrollView; 54 private View mScrollThumb; 55 private ImageView mDownButton; 56 57 private int mSeparatingMargin; 58 59 private RecyclerView mRecyclerView; 60 61 /** The amount of space that the scroll thumb is allowed to roam over. */ 62 private int mScrollThumbTrackHeight; 63 64 private final Interpolator mPaginationInterpolator = new AccelerateDecelerateInterpolator(); 65 66 private final int mRowsPerPage = -1; 67 private final Handler mHandler = new Handler(); 68 69 private OrientationHelper mOrientationHelper; 70 71 @Override initialize(RecyclerView rv, View scrollView)72 public void initialize(RecyclerView rv, View scrollView) { 73 mRecyclerView = rv; 74 75 mScrollView = scrollView; 76 77 Resources res = rv.getContext().getResources(); 78 79 mButtonDisabledAlpha = CarUiUtils.getFloat(res, R.dimen.car_ui_button_disabled_alpha); 80 81 getRecyclerView().addOnScrollListener(mRecyclerViewOnScrollListener); 82 getRecyclerView().getRecycledViewPool().setMaxRecycledViews(0, 12); 83 84 mSeparatingMargin = res.getDimensionPixelSize(R.dimen.car_ui_scrollbar_separator_margin); 85 86 mUpButton = requireViewByRefId(mScrollView, R.id.page_up); 87 PaginateButtonClickListener upButtonClickListener = 88 new PaginateButtonClickListener(PaginationListener.PAGE_UP); 89 mUpButton.setOnClickListener(upButtonClickListener); 90 91 mDownButton = requireViewByRefId(mScrollView, R.id.page_down); 92 PaginateButtonClickListener downButtonClickListener = 93 new PaginateButtonClickListener(PaginationListener.PAGE_DOWN); 94 mDownButton.setOnClickListener(downButtonClickListener); 95 96 mScrollThumb = requireViewByRefId(mScrollView, R.id.scrollbar_thumb); 97 98 mSnapHelper = new CarUiSnapHelper(rv.getContext()); 99 getRecyclerView().setOnFlingListener(null); 100 mSnapHelper.attachToRecyclerView(getRecyclerView()); 101 102 mScrollView.addOnLayoutChangeListener( 103 (View v, 104 int left, 105 int top, 106 int right, 107 int bottom, 108 int oldLeft, 109 int oldTop, 110 int oldRight, 111 int oldBottom) -> { 112 int width = right - left; 113 114 OrientationHelper orientationHelper = 115 getOrientationHelper(getRecyclerView().getLayoutManager()); 116 117 // This value will keep track of the top of the current view being laid out. 118 int layoutTop = orientationHelper.getStartAfterPadding() + mPaddingStart; 119 120 // Lay out the up button at the top of the view. 121 layoutViewCenteredFromTop(mUpButton, layoutTop, width); 122 layoutTop = mUpButton.getBottom(); 123 124 // Lay out the scroll thumb 125 layoutTop += mSeparatingMargin; 126 layoutViewCenteredFromTop(mScrollThumb, layoutTop, width); 127 128 // Lay out the bottom button at the bottom of the view. 129 int downBottom = orientationHelper.getEndAfterPadding() - mPaddingEnd; 130 layoutViewCenteredFromBottom(mDownButton, downBottom, width); 131 132 mHandler.post(this::calculateScrollThumbTrackHeight); 133 mHandler.post(() -> updatePaginationButtons(/* animate= */ false)); 134 }); 135 } 136 getRecyclerView()137 public RecyclerView getRecyclerView() { 138 return mRecyclerView; 139 } 140 141 @Override requestLayout()142 public void requestLayout() { 143 mScrollView.requestLayout(); 144 } 145 146 @Override setPadding(int paddingStart, int paddingEnd)147 public void setPadding(int paddingStart, int paddingEnd) { 148 this.mPaddingStart = paddingStart; 149 this.mPaddingEnd = paddingEnd; 150 requestLayout(); 151 } 152 153 /** 154 * Sets whether or not the up button on the scroll bar is clickable. 155 * 156 * @param enabled {@code true} if the up button is enabled. 157 */ setUpEnabled(boolean enabled)158 private void setUpEnabled(boolean enabled) { 159 mUpButton.setEnabled(enabled); 160 mUpButton.setAlpha(enabled ? 1f : mButtonDisabledAlpha); 161 } 162 163 /** 164 * Sets whether or not the down button on the scroll bar is clickable. 165 * 166 * @param enabled {@code true} if the down button is enabled. 167 */ setDownEnabled(boolean enabled)168 private void setDownEnabled(boolean enabled) { 169 mDownButton.setEnabled(enabled); 170 mDownButton.setAlpha(enabled ? 1f : mButtonDisabledAlpha); 171 } 172 173 /** 174 * Returns whether or not the down button on the scroll bar is clickable. 175 * 176 * @return {@code true} if the down button is enabled. {@code false} otherwise. 177 */ isDownEnabled()178 private boolean isDownEnabled() { 179 return mDownButton.isEnabled(); 180 } 181 182 /** Listener for when the list should paginate. */ 183 interface PaginationListener { 184 int PAGE_UP = 0; 185 int PAGE_DOWN = 1; 186 187 /** Called when the linked view should be paged in the given direction */ onPaginate(int direction)188 void onPaginate(int direction); 189 } 190 191 /** 192 * Calculate the amount of space that the scroll bar thumb is allowed to roam. The thumb is 193 * allowed to take up the space between the down bottom and the up or alpha jump button, 194 * depending 195 * on if the latter is visible. 196 */ calculateScrollThumbTrackHeight()197 private void calculateScrollThumbTrackHeight() { 198 // Subtracting (2 * mSeparatingMargin) for the top/bottom margin above and below the 199 // scroll bar thumb. 200 mScrollThumbTrackHeight = mDownButton.getTop() - (2 * mSeparatingMargin); 201 202 // If there's an alpha jump button, then the thumb is laid out starting from below that. 203 mScrollThumbTrackHeight -= mUpButton.getBottom(); 204 } 205 206 /** 207 * Lays out the given View starting from the given {@code top} value downwards and centered 208 * within the given {@code availableWidth}. 209 * 210 * @param view The view to lay out. 211 * @param top The top value to start laying out from. This value will be the resulting top value 212 * of the view. 213 * @param availableWidth The width in which to center the given view. 214 */ layoutViewCenteredFromTop(View view, int top, int availableWidth)215 private static void layoutViewCenteredFromTop(View view, int top, int availableWidth) { 216 int viewWidth = view.getMeasuredWidth(); 217 int viewLeft = (availableWidth - viewWidth) / 2; 218 view.layout(viewLeft, top, viewLeft + viewWidth, top + view.getMeasuredHeight()); 219 } 220 221 /** 222 * Lays out the given View starting from the given {@code bottom} value upwards and centered 223 * within the given {@code availableSpace}. 224 * 225 * @param view The view to lay out. 226 * @param bottom The bottom value to start laying out from. This value will be the resulting 227 * bottom value of the view. 228 * @param availableWidth The width in which to center the given view. 229 */ layoutViewCenteredFromBottom(View view, int bottom, int availableWidth)230 private static void layoutViewCenteredFromBottom(View view, int bottom, int availableWidth) { 231 int viewWidth = view.getMeasuredWidth(); 232 int viewLeft = (availableWidth - viewWidth) / 2; 233 view.layout(viewLeft, bottom - view.getMeasuredHeight(), viewLeft + viewWidth, bottom); 234 } 235 236 /** 237 * Sets the range, offset and extent of the scroll bar. The range represents the size of a 238 * container for the scrollbar thumb; offset is the distance from the start of the container to 239 * where the thumb should be; and finally, extent is the size of the thumb. 240 * 241 * <p>These values can be expressed in arbitrary units, so long as they share the same units. 242 * The 243 * values should also be positive. 244 * 245 * @param range The range of the scrollbar's thumb 246 * @param offset The offset of the scrollbar's thumb 247 * @param extent The extent of the scrollbar's thumb 248 * @param animate Whether or not the thumb should animate from its current position to the 249 * position specified by the given range, offset and extent. 250 */ setParameters( @ntRangefrom = 0) int range, @IntRange(from = 0) int offset, @IntRange(from = 0) int extent, boolean animate)251 private void setParameters( 252 @IntRange(from = 0) int range, 253 @IntRange(from = 0) int offset, 254 @IntRange(from = 0) int extent, 255 boolean animate) { 256 // Not laid out yet, so values cannot be calculated. 257 if (!mScrollView.isLaidOut()) { 258 return; 259 } 260 261 // If the scroll bars aren't visible, then no need to update. 262 if (mScrollView.getVisibility() == View.GONE || range == 0) { 263 return; 264 } 265 266 int thumbLength = calculateScrollThumbLength(range, extent); 267 int thumbOffset = calculateScrollThumbOffset(range, offset, thumbLength); 268 269 // Sets the size of the thumb and request a redraw if needed. 270 ViewGroup.LayoutParams lp = mScrollThumb.getLayoutParams(); 271 272 if (lp.height != thumbLength) { 273 lp.height = thumbLength; 274 mScrollThumb.requestLayout(); 275 } 276 277 moveY(mScrollThumb, thumbOffset, animate); 278 } 279 280 /** 281 * Calculates and returns how big the scroll bar thumb should be based on the given range and 282 * extent. 283 * 284 * @param range The total amount of space the scroll bar is allowed to roam over. 285 * @param extent The amount of space that the scroll bar takes up relative to the range. 286 * @return The height of the scroll bar thumb in pixels. 287 */ calculateScrollThumbLength(int range, int extent)288 private int calculateScrollThumbLength(int range, int extent) { 289 // Scale the length by the available space that the thumb can fill. 290 return Math.round(((float) extent / range) * mScrollThumbTrackHeight); 291 } 292 293 /** 294 * Calculates and returns how much the scroll thumb should be offset from the top of where it 295 * has 296 * been laid out. 297 * 298 * @param range The total amount of space the scroll bar is allowed to roam over. 299 * @param offset The amount the scroll bar should be offset, expressed in the same units as the 300 * given range. 301 * @param thumbLength The current length of the thumb in pixels. 302 * @return The amount the thumb should be offset in pixels. 303 */ calculateScrollThumbOffset(int range, int offset, int thumbLength)304 private int calculateScrollThumbOffset(int range, int offset, int thumbLength) { 305 // Ensure that if the user has reached the bottom of the list, then the scroll bar is 306 // aligned to the bottom as well. Otherwise, scale the offset appropriately. 307 // This offset will be a value relative to the parent of this scrollbar, so start by where 308 // the top of mScrollThumb is. 309 return mScrollThumb.getTop() 310 + (isDownEnabled() 311 ? Math.round(((float) offset / range) * mScrollThumbTrackHeight) 312 : mScrollThumbTrackHeight - thumbLength); 313 } 314 315 /** Moves the given view to the specified 'y' position. */ moveY(final View view, float newPosition, boolean animate)316 private void moveY(final View view, float newPosition, boolean animate) { 317 final int duration = animate ? 200 : 0; 318 view.animate() 319 .y(newPosition) 320 .setDuration(duration) 321 .setInterpolator(mPaginationInterpolator) 322 .start(); 323 } 324 325 private class PaginateButtonClickListener implements View.OnClickListener { 326 private final int mPaginateDirection; 327 private PaginationListener mPaginationListener; 328 PaginateButtonClickListener(int paginateDirection)329 PaginateButtonClickListener(int paginateDirection) { 330 this.mPaginateDirection = paginateDirection; 331 } 332 333 @Override onClick(View v)334 public void onClick(View v) { 335 if (mPaginationListener != null) { 336 mPaginationListener.onPaginate(mPaginateDirection); 337 } 338 if (mPaginateDirection == PaginationListener.PAGE_DOWN) { 339 pageDown(); 340 } else if (mPaginateDirection == PaginationListener.PAGE_UP) { 341 pageUp(); 342 } 343 } 344 } 345 346 private final RecyclerView.OnScrollListener mRecyclerViewOnScrollListener = 347 new RecyclerView.OnScrollListener() { 348 @Override 349 public void onScrolled(RecyclerView recyclerView, int dx, int dy) { 350 updatePaginationButtons(false); 351 } 352 }; 353 354 /** Returns the page the given position is on, starting with page 0. */ getPage(int position)355 int getPage(int position) { 356 if (mRowsPerPage == -1) { 357 return -1; 358 } 359 if (mRowsPerPage == 0) { 360 return 0; 361 } 362 return position / mRowsPerPage; 363 } 364 getOrientationHelper(RecyclerView.LayoutManager layoutManager)365 private OrientationHelper getOrientationHelper(RecyclerView.LayoutManager layoutManager) { 366 if (mOrientationHelper == null || mOrientationHelper.getLayoutManager() != layoutManager) { 367 // CarUiRecyclerView is assumed to be a list that always vertically scrolls. 368 mOrientationHelper = OrientationHelper.createVerticalHelper(layoutManager); 369 } 370 return mOrientationHelper; 371 } 372 373 /** 374 * Scrolls the contents of the RecyclerView up a page. A page is defined as the height of the 375 * {@code CarUiRecyclerView}. 376 * 377 * <p>The resulting first item in the list will be snapped to so that it is completely visible. 378 * If 379 * this is not possible due to the first item being taller than the containing {@code 380 * CarUiRecyclerView}, then the snapping will not occur. 381 */ pageUp()382 void pageUp() { 383 int currentOffset = getRecyclerView().computeVerticalScrollOffset(); 384 if (getRecyclerView().getLayoutManager() == null 385 || getRecyclerView().getChildCount() == 0 386 || currentOffset == 0) { 387 return; 388 } 389 390 // Use OrientationHelper to calculate scroll distance in order to match snapping behavior. 391 OrientationHelper orientationHelper = 392 getOrientationHelper(getRecyclerView().getLayoutManager()); 393 int screenSize = orientationHelper.getTotalSpace(); 394 int scrollDistance = screenSize; 395 // The iteration order matters. In case where there are 2 items longer than screen size, we 396 // want to focus on upcoming view. 397 for (int i = 0; i < getRecyclerView().getChildCount(); i++) { 398 /* 399 * We treat child View longer than screen size differently: 400 * 1) When it enters screen, next pageUp will align its bottom with parent bottom; 401 * 2) When it leaves screen, next pageUp will align its top with parent top. 402 */ 403 View child = getRecyclerView().getChildAt(i); 404 if (child.getHeight() > screenSize) { 405 if (orientationHelper.getDecoratedEnd(child) < screenSize) { 406 // Child view bottom is entering screen. Align its bottom with parent bottom. 407 scrollDistance = screenSize - orientationHelper.getDecoratedEnd(child); 408 } else if (-screenSize < orientationHelper.getDecoratedStart(child) 409 && orientationHelper.getDecoratedStart(child) < 0) { 410 // Child view top is about to enter screen - its distance to parent top 411 // is less than a full scroll. Align child top with parent top. 412 scrollDistance = Math.abs(orientationHelper.getDecoratedStart(child)); 413 } 414 // There can be two items that are longer than the screen. We stop at the first one. 415 // This is affected by the iteration order. 416 break; 417 } 418 } 419 // Distance should always be positive. Negate its value to scroll up. 420 mSnapHelper.smoothScrollBy(-scrollDistance); 421 } 422 423 /** 424 * Scrolls the contents of the RecyclerView down a page. A page is defined as the height of the 425 * {@code CarUiRecyclerView}. 426 * 427 * <p>This method will attempt to bring the last item in the list as the first item. If the 428 * current first item in the list is taller than the {@code CarUiRecyclerView}, then it will be 429 * scrolled the length of a page, but not snapped to. 430 */ pageDown()431 void pageDown() { 432 if (getRecyclerView().getLayoutManager() == null 433 || getRecyclerView().getChildCount() == 0) { 434 return; 435 } 436 437 OrientationHelper orientationHelper = 438 getOrientationHelper(getRecyclerView().getLayoutManager()); 439 int screenSize = orientationHelper.getTotalSpace(); 440 int scrollDistance = screenSize; 441 442 // If the last item is partially visible, page down should bring it to the top. 443 View lastChild = getRecyclerView().getChildAt(getRecyclerView().getChildCount() - 1); 444 if (getRecyclerView().getLayoutManager().isViewPartiallyVisible(lastChild, 445 /* completelyVisible= */ false, /* acceptEndPointInclusion= */ false)) { 446 scrollDistance = orientationHelper.getDecoratedStart(lastChild); 447 if (scrollDistance <= 0) { 448 // - Scroll value is zero if the top of last item is aligned with top of the screen; 449 // - Scroll value can be negative if the child is longer than the screen size and 450 // the visible area of the screen does not show the start of the child. 451 // Scroll to the next screen in both cases. 452 scrollDistance = screenSize; 453 } 454 } 455 456 // The iteration order matters. In case where there are 2 items longer than screen size, we 457 // want to focus on upcoming view (the one at the bottom of screen). 458 for (int i = getRecyclerView().getChildCount() - 1; i >= 0; i--) { 459 /* We treat child View longer than screen size differently: 460 * 1) When it enters screen, next pageDown will align its top with parent top; 461 * 2) When it leaves screen, next pageDown will align its bottom with parent bottom. 462 */ 463 View child = getRecyclerView().getChildAt(i); 464 if (child.getHeight() > screenSize) { 465 if (orientationHelper.getDecoratedStart(child) > 0) { 466 // Child view top is entering screen. Align its top with parent top. 467 scrollDistance = orientationHelper.getDecoratedStart(child); 468 } else if (screenSize < orientationHelper.getDecoratedEnd(child) 469 && orientationHelper.getDecoratedEnd(child) < 2 * screenSize) { 470 // Child view bottom is about to enter screen - its distance to parent bottom 471 // is less than a full scroll. Align child bottom with parent bottom. 472 scrollDistance = orientationHelper.getDecoratedEnd(child) - screenSize; 473 } 474 // There can be two items that are longer than the screen. We stop at the first one. 475 // This is affected by the iteration order. 476 break; 477 } 478 } 479 480 mSnapHelper.smoothScrollBy(scrollDistance); 481 } 482 483 /** 484 * Determines if scrollbar should be visible or not and shows/hides it accordingly. If this is 485 * being called as a result of adapter changes, it should be called after the new layout has 486 * been 487 * calculated because the method of determining scrollbar visibility uses the current layout. 488 * If 489 * this is called after an adapter change but before the new layout, the visibility 490 * determination 491 * may not be correct. 492 * 493 * @param animate {@code true} if the scrollbar should animate to its new position. {@code 494 * false} 495 * if no animation is used 496 */ updatePaginationButtons(boolean animate)497 private void updatePaginationButtons(boolean animate) { 498 499 boolean isAtStart = isAtStart(); 500 boolean isAtEnd = isAtEnd(); 501 RecyclerView.LayoutManager layoutManager = getRecyclerView().getLayoutManager(); 502 503 if ((isAtStart && isAtEnd) || layoutManager == null || layoutManager.getItemCount() == 0) { 504 mScrollView.setVisibility(View.INVISIBLE); 505 } else { 506 mScrollView.setVisibility(View.VISIBLE); 507 } 508 setUpEnabled(!isAtStart); 509 setDownEnabled(!isAtEnd); 510 511 if (layoutManager == null) { 512 return; 513 } 514 515 if (layoutManager.canScrollVertically()) { 516 setParameters( 517 getRecyclerView().computeVerticalScrollRange(), 518 getRecyclerView().computeVerticalScrollOffset(), 519 getRecyclerView().computeVerticalScrollExtent(), 520 animate); 521 } else { 522 setParameters( 523 getRecyclerView().computeHorizontalScrollRange(), 524 getRecyclerView().computeHorizontalScrollOffset(), 525 getRecyclerView().computeHorizontalScrollExtent(), 526 animate); 527 } 528 529 mScrollView.invalidate(); 530 } 531 532 /** Returns {@code true} if the RecyclerView is completely displaying the first item. */ isAtStart()533 boolean isAtStart() { 534 return mSnapHelper.isAtStart(getRecyclerView().getLayoutManager()); 535 } 536 537 /** Returns {@code true} if the RecyclerView is completely displaying the last item. */ isAtEnd()538 boolean isAtEnd() { 539 return mSnapHelper.isAtEnd(getRecyclerView().getLayoutManager()); 540 } 541 } 542