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 android.content.Context; 20 import android.content.res.Resources; 21 import android.graphics.PointF; 22 import android.os.Handler; 23 import android.util.Log; 24 import android.view.Gravity; 25 import android.view.LayoutInflater; 26 import android.view.View; 27 import android.view.View.MeasureSpec; 28 import android.view.ViewGroup; 29 import android.view.ViewGroup.LayoutParams; 30 import android.view.animation.AccelerateDecelerateInterpolator; 31 import android.view.animation.Interpolator; 32 import android.widget.FrameLayout; 33 import android.widget.ImageView; 34 35 import androidx.annotation.IntRange; 36 import androidx.recyclerview.widget.LinearLayoutManager; 37 import androidx.recyclerview.widget.OrientationHelper; 38 import androidx.recyclerview.widget.RecyclerView; 39 40 import com.android.car.apps.common.R; 41 import com.android.car.apps.common.util.ScrollBarUI; 42 import com.android.car.apps.common.widget.PagedRecyclerView.ScrollBarPosition; 43 44 /** 45 * Inspired by {@link androidx.car.widget.PagedListView}. Most pagination and scrolling logic has 46 * been ported from the PLV with minor updates. 47 * 48 * The default scroll bar widget for the {@link PagedRecyclerView}. 49 */ 50 class CarScrollBar extends ScrollBarUI { 51 private float mButtonDisabledAlpha; 52 private static final String TAG = "CarScrollBar"; 53 private PagedSnapHelper mSnapHelper; 54 55 private ImageView mUpButton; 56 private PaginateButtonClickListener mUpButtonClickListener; 57 private View mScrollView; 58 private View mScrollThumb; 59 private ImageView mDownButton; 60 private PaginateButtonClickListener mDownButtonClickListener; 61 62 private int mSeparatingMargin; 63 private int mScrollBarThumbWidth; 64 65 private int mPaddingStart; 66 private int mPaddingEnd; 67 68 /** The amount of space that the scroll thumb is allowed to roam over. */ 69 private int mScrollThumbTrackHeight; 70 71 private final Interpolator mPaginationInterpolator = new AccelerateDecelerateInterpolator(); 72 73 private int mRowsPerPage = -1; 74 private final Handler mHandler = new Handler(); 75 76 private OrientationHelper mOrientationHelper; 77 78 /** 79 * When doing a snap, offset the snap by this number of position and then do a smooth scroll to 80 * the final position. 81 */ 82 private static final int SNAP_SCROLL_OFFSET_POSITION = 2; 83 84 /** 85 * The amount of time after settling to wait before autoscrolling to the next page when the user 86 * holds down a pagination button. 87 */ 88 private static final int PAGINATION_HOLD_DELAY_MS = 400; 89 90 @Override initialize(Context context, RecyclerView recyclerView, int scrollBarContainerWidth, @ScrollBarPosition int scrollBarPosition, boolean scrollBarAboveRecyclerView)91 public void initialize(Context context, RecyclerView recyclerView, 92 int scrollBarContainerWidth, @ScrollBarPosition int scrollBarPosition, 93 boolean scrollBarAboveRecyclerView) { 94 95 mRecyclerView = recyclerView; 96 97 LayoutInflater inflater = (LayoutInflater) context.getSystemService( 98 Context.LAYOUT_INFLATER_SERVICE); 99 100 FrameLayout parent = (FrameLayout) getRecyclerView().getParent(); 101 102 mScrollView = inflater.inflate(R.layout.car_paged_scrollbar_buttons, parent, false); 103 mScrollView.setLayoutParams( 104 new LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.MATCH_PARENT)); 105 106 mButtonDisabledAlpha = context.getResources().getFloat(R.dimen.button_disabled_alpha); 107 108 if (scrollBarAboveRecyclerView) { 109 parent.addView(mScrollView); 110 } else { 111 parent.addView(mScrollView, /* index= */0); 112 } 113 114 setScrollBarContainerWidth(scrollBarContainerWidth); 115 setScrollBarPosition(scrollBarPosition); 116 117 getRecyclerView().addOnScrollListener(mRecyclerViewOnScrollListener); 118 getRecyclerView().getRecycledViewPool().setMaxRecycledViews(0, 12); 119 120 Resources res = context.getResources(); 121 mSeparatingMargin = res.getDimensionPixelSize(R.dimen.car_scroll_bar_separator_margin); 122 mScrollBarThumbWidth = res.getDimensionPixelSize(R.dimen.car_scroll_bar_thumb_width); 123 124 mUpButton = mScrollView.findViewById(R.id.page_up); 125 mUpButtonClickListener = new PaginateButtonClickListener(PaginationListener.PAGE_UP); 126 mUpButton.setOnClickListener(mUpButtonClickListener); 127 128 mDownButton = mScrollView.findViewById(R.id.page_down); 129 mDownButtonClickListener = new PaginateButtonClickListener(PaginationListener.PAGE_DOWN); 130 mDownButton.setOnClickListener(mDownButtonClickListener); 131 132 mScrollThumb = mScrollView.findViewById(R.id.scrollbar_thumb); 133 134 mSnapHelper = new PagedSnapHelper(context); 135 getRecyclerView().setOnFlingListener(null); 136 mSnapHelper.attachToRecyclerView(getRecyclerView()); 137 138 mScrollView.addOnLayoutChangeListener((View v, int left, int top, int right, int bottom, 139 int oldLeft, int oldTop, int oldRight, int oldBottom) -> { 140 int width = right - left; 141 142 OrientationHelper orientationHelper = 143 getOrientationHelper(getRecyclerView().getLayoutManager()); 144 145 // This value will keep track of the top of the current view being laid out. 146 int layoutTop = orientationHelper.getStartAfterPadding() + mPaddingStart; 147 148 // Lay out the up button at the top of the view. 149 layoutViewCenteredFromTop(mUpButton, layoutTop, width); 150 layoutTop = mUpButton.getBottom(); 151 152 // Lay out the scroll thumb 153 layoutTop += mSeparatingMargin; 154 layoutViewCenteredFromTop(mScrollThumb, layoutTop, width); 155 156 // Lay out the bottom button at the bottom of the view. 157 int downBottom = orientationHelper.getEndAfterPadding() - mPaddingEnd; 158 layoutViewCenteredFromBottom(mDownButton, downBottom, width); 159 160 mHandler.post(this::calculateScrollThumbTrackHeight); 161 mHandler.post(() -> updatePaginationButtons(/* animate= */false)); 162 }); 163 } 164 165 @Override requestLayout()166 public void requestLayout() { 167 mScrollView.requestLayout(); 168 } 169 170 @Override setPadding(int paddingStart, int paddingEnd)171 public void setPadding(int paddingStart, int paddingEnd) { 172 mPaddingStart = paddingStart; 173 mPaddingEnd = paddingEnd; 174 requestLayout(); 175 } 176 177 /** 178 * Sets the listener that will be notified when the up and down buttons have been pressed. 179 * 180 * @param listener The listener to set. 181 */ setPaginationListener(PaginationListener listener)182 void setPaginationListener(PaginationListener listener) { 183 mUpButtonClickListener.setPaginationListener(listener); 184 mDownButtonClickListener.setPaginationListener(listener); 185 } 186 187 /** Returns {@code true} if the "up" button is pressed */ isUpPressed()188 private boolean isUpPressed() { 189 return mUpButton.isPressed(); 190 } 191 192 /** Returns {@code true} if the "down" button is pressed */ isDownPressed()193 private boolean isDownPressed() { 194 return mDownButton.isPressed(); 195 } 196 197 /** 198 * Sets the width of the container that holds the scrollbar. The scrollbar will be centered 199 * within this width. 200 * 201 * @param width The width of the scrollbar container. 202 */ setScrollBarContainerWidth(int width)203 void setScrollBarContainerWidth(int width) { 204 ViewGroup.LayoutParams layoutParams = mScrollView.getLayoutParams(); 205 layoutParams.width = width; 206 mScrollView.requestLayout(); 207 } 208 209 /** 210 * Sets the position of the scrollbar. 211 * 212 * @param position Enum value of the scrollbar position. 0 for Start and 1 for end. 213 */ setScrollBarPosition(@crollBarPosition int position)214 void setScrollBarPosition(@ScrollBarPosition int position) { 215 FrameLayout.LayoutParams layoutParams = 216 (FrameLayout.LayoutParams) mScrollView.getLayoutParams(); 217 if (position == ScrollBarPosition.START) { 218 layoutParams.gravity = Gravity.LEFT; 219 } else { 220 layoutParams.gravity = Gravity.RIGHT; 221 } 222 223 mScrollView.requestLayout(); 224 } 225 226 /** 227 * Sets whether or not the up button on the scroll bar is clickable. 228 * 229 * @param enabled {@code true} if the up button is enabled. 230 */ setUpEnabled(boolean enabled)231 private void setUpEnabled(boolean enabled) { 232 mUpButton.setEnabled(enabled); 233 mUpButton.setAlpha(enabled ? 1f : mButtonDisabledAlpha); 234 } 235 236 /** 237 * Sets whether or not the down button on the scroll bar is clickable. 238 * 239 * @param enabled {@code true} if the down button is enabled. 240 */ setDownEnabled(boolean enabled)241 private void setDownEnabled(boolean enabled) { 242 mDownButton.setEnabled(enabled); 243 mDownButton.setAlpha(enabled ? 1f : mButtonDisabledAlpha); 244 } 245 246 /** 247 * Returns whether or not the down button on the scroll bar is clickable. 248 * 249 * @return {@code true} if the down button is enabled. {@code false} otherwise. 250 */ isDownEnabled()251 private boolean isDownEnabled() { 252 return mDownButton.isEnabled(); 253 } 254 255 /** Listener for when the list should paginate. */ 256 interface PaginationListener { 257 int PAGE_UP = 0; 258 int PAGE_DOWN = 1; 259 260 /** Called when the linked view should be paged in the given direction */ onPaginate(int direction)261 void onPaginate(int direction); 262 } 263 264 /** 265 * Calculate the amount of space that the scroll bar thumb is allowed to roam. The thumb 266 * is allowed to take up the space between the down bottom and the up or alpha jump 267 * button, depending on if the latter is visible. 268 */ calculateScrollThumbTrackHeight()269 private void calculateScrollThumbTrackHeight() { 270 // Subtracting (2 * mSeparatingMargin) for the top/bottom margin above and below the 271 // scroll bar thumb. 272 mScrollThumbTrackHeight = mDownButton.getTop() - (2 * mSeparatingMargin); 273 274 // If there's an alpha jump button, then the thumb is laid out starting from below that. 275 mScrollThumbTrackHeight -= mUpButton.getBottom(); 276 } 277 measureScrollThumb()278 private void measureScrollThumb() { 279 int scrollWidth = MeasureSpec.makeMeasureSpec(mScrollBarThumbWidth, MeasureSpec.EXACTLY); 280 int scrollHeight = MeasureSpec.makeMeasureSpec( 281 mScrollThumb.getLayoutParams().height, 282 MeasureSpec.EXACTLY); 283 mScrollThumb.measure(scrollWidth, scrollHeight); 284 } 285 286 /** 287 * An optimization method to only remeasure and lay out the scroll thumb. This method should be 288 * used when the height of the thumb has changed, but no other views need to be remeasured. 289 */ measureAndLayoutScrollThumb()290 private void measureAndLayoutScrollThumb() { 291 measureScrollThumb(); 292 293 // The top value should not change from what it was before; only the height is assumed to 294 // be changing. 295 int layoutTop = mScrollThumb.getTop(); 296 layoutViewCenteredFromTop(mScrollThumb, layoutTop, mScrollView.getMeasuredWidth()); 297 } 298 299 /** 300 * Lays out the given View starting from the given {@code top} value downwards and centered 301 * within the given {@code availableWidth}. 302 * 303 * @param view The view to lay out. 304 * @param top The top value to start laying out from. This value will be the resulting top 305 * value of the view. 306 * @param availableWidth The width in which to center the given view. 307 */ layoutViewCenteredFromTop(View view, int top, int availableWidth)308 private void layoutViewCenteredFromTop(View view, int top, int availableWidth) { 309 int viewWidth = view.getMeasuredWidth(); 310 int viewLeft = (availableWidth - viewWidth) / 2; 311 view.layout(viewLeft, top, viewLeft + viewWidth, 312 top + view.getMeasuredHeight()); 313 } 314 315 /** 316 * Lays out the given View starting from the given {@code bottom} value upwards and centered 317 * within the given {@code availableSpace}. 318 * 319 * @param view The view to lay out. 320 * @param bottom The bottom value to start laying out from. This value will be the resulting 321 * bottom value of the view. 322 * @param availableWidth The width in which to center the given view. 323 */ layoutViewCenteredFromBottom(View view, int bottom, int availableWidth)324 private void layoutViewCenteredFromBottom(View view, int bottom, int availableWidth) { 325 int viewWidth = view.getMeasuredWidth(); 326 int viewLeft = (availableWidth - viewWidth) / 2; 327 view.layout(viewLeft, bottom - view.getMeasuredHeight(), 328 viewLeft + viewWidth, bottom); 329 } 330 331 /** 332 * Sets the range, offset and extent of the scroll bar. The range represents the size of a 333 * container for the scrollbar thumb; offset is the distance from the start of the container 334 * to where the thumb should be; and finally, extent is the size of the thumb. 335 * 336 * <p>These values can be expressed in arbitrary units, so long as they share the same units. 337 * The values should also be positive. 338 * 339 * @param range The range of the scrollbar's thumb 340 * @param offset The offset of the scrollbar's thumb 341 * @param extent The extent of the scrollbar's thumb 342 * @param animate Whether or not the thumb should animate from its current position to the 343 * position specified by the given range, offset and extent. 344 */ setParameters( @ntRangefrom = 0) int range, @IntRange(from = 0) int offset, @IntRange(from = 0) int extent, boolean animate)345 void setParameters( 346 @IntRange(from = 0) int range, 347 @IntRange(from = 0) int offset, 348 @IntRange(from = 0) int extent, boolean animate) { 349 // Not laid out yet, so values cannot be calculated. 350 if (!mScrollView.isLaidOut()) { 351 return; 352 } 353 354 // If the scroll bars aren't visible, then no need to update. 355 if (mScrollView.getVisibility() == View.GONE || range == 0) { 356 return; 357 } 358 359 int thumbLength = calculateScrollThumbLength(range, extent); 360 int thumbOffset = calculateScrollThumbOffset(range, offset, thumbLength); 361 362 // Sets the size of the thumb and request a redraw if needed. 363 ViewGroup.LayoutParams lp = mScrollThumb.getLayoutParams(); 364 365 if (lp.height != thumbLength) { 366 lp.height = thumbLength; 367 mScrollThumb.requestLayout(); 368 } 369 370 moveY(mScrollThumb, thumbOffset, animate); 371 } 372 373 /** 374 * An optimized version of {@link #setParameters(int, int, int, boolean)} that is meant to be 375 * called if a view is laying itself out. This method will avoid a complete remeasure of 376 * the views in the {@code PagedScrollBarView} if the scroll thumb's height needs to be changed. 377 * Instead, only the thumb itself will be remeasured and laid out. 378 * 379 * <p>These values can be expressed in arbitrary units, so long as they share the same units. 380 * 381 * @param range The range of the scrollbar's thumb 382 * @param offset The offset of the scrollbar's thumb 383 * @param extent The extent of the scrollbar's thumb 384 * 385 * @see #setParameters(int, int, int, boolean) 386 */ setParametersInLayout(int range, int offset, int extent)387 void setParametersInLayout(int range, int offset, int extent) { 388 // If the scroll bars aren't visible, then no need to update. 389 if (mScrollView.getVisibility() == View.GONE || range == 0) { 390 return; 391 } 392 393 int thumbLength = calculateScrollThumbLength(range, extent); 394 int thumbOffset = calculateScrollThumbOffset(range, offset, thumbLength); 395 396 // Sets the size of the thumb and request a redraw if needed. 397 ViewGroup.LayoutParams lp = mScrollThumb.getLayoutParams(); 398 399 if (lp.height != thumbLength) { 400 lp.height = thumbLength; 401 measureAndLayoutScrollThumb(); 402 } 403 404 mScrollThumb.setY(thumbOffset); 405 } 406 407 /** 408 * Calculates and returns how big the scroll bar thumb should be based on the given range and 409 * extent. 410 * 411 * @param range The total amount of space the scroll bar is allowed to roam over. 412 * @param extent The amount of space that the scroll bar takes up relative to the range. 413 * @return The height of the scroll bar thumb in pixels. 414 */ calculateScrollThumbLength(int range, int extent)415 private int calculateScrollThumbLength(int range, int extent) { 416 // Scale the length by the available space that the thumb can fill. 417 return Math.round(((float) extent / range) * mScrollThumbTrackHeight); 418 } 419 420 /** 421 * Calculates and returns how much the scroll thumb should be offset from the top of where it 422 * has been laid out. 423 * 424 * @param range The total amount of space the scroll bar is allowed to roam over. 425 * @param offset The amount the scroll bar should be offset, expressed in the same units as 426 * the given range. 427 * @param thumbLength The current length of the thumb in pixels. 428 * @return The amount the thumb should be offset in pixels. 429 */ calculateScrollThumbOffset(int range, int offset, int thumbLength)430 private int calculateScrollThumbOffset(int range, int offset, int thumbLength) { 431 // Ensure that if the user has reached the bottom of the list, then the scroll bar is 432 // aligned to the bottom as well. Otherwise, scale the offset appropriately. 433 // This offset will be a value relative to the parent of this scrollbar, so start by where 434 // the top of mScrollThumb is. 435 return mScrollThumb.getTop() + (isDownEnabled() 436 ? Math.round(((float) offset / range) * mScrollThumbTrackHeight) 437 : mScrollThumbTrackHeight - thumbLength); 438 } 439 440 /** Moves the given view to the specified 'y' position. */ moveY(final View view, float newPosition, boolean animate)441 private void moveY(final View view, float newPosition, boolean animate) { 442 final int duration = animate ? 200 : 0; 443 view.animate() 444 .y(newPosition) 445 .setDuration(duration) 446 .setInterpolator(mPaginationInterpolator) 447 .start(); 448 } 449 450 /** 451 * Updates the rows number per current page, which is used for calculating how many items we 452 * want to show. 453 */ updateRowsPerPage()454 private void updateRowsPerPage() { 455 RecyclerView.LayoutManager layoutManager = getRecyclerView().getLayoutManager(); 456 if (layoutManager == null) { 457 mRowsPerPage = 1; 458 return; 459 } 460 461 View firstChild = layoutManager.getChildAt(0); 462 if (firstChild == null || firstChild.getHeight() == 0) { 463 mRowsPerPage = 1; 464 } else { 465 mRowsPerPage = Math.max(1, getRecyclerView().getHeight() / firstChild.getHeight()); 466 } 467 } 468 469 private class PaginateButtonClickListener implements View.OnClickListener { 470 private final int mPaginateDirection; 471 private PaginationListener mPaginationListener; 472 PaginateButtonClickListener(int paginateDirection)473 PaginateButtonClickListener(int paginateDirection) { 474 mPaginateDirection = paginateDirection; 475 } 476 setPaginationListener(PaginationListener listener)477 public void setPaginationListener(PaginationListener listener) { 478 mPaginationListener = listener; 479 } 480 481 @Override onClick(View v)482 public void onClick(View v) { 483 if (mPaginationListener != null) { 484 mPaginationListener.onPaginate(mPaginateDirection); 485 } 486 if (mPaginateDirection == PaginationListener.PAGE_DOWN) { 487 pageDown(); 488 } else if (mPaginateDirection == PaginationListener.PAGE_UP) { 489 pageUp(); 490 } 491 } 492 } 493 494 private final RecyclerView.OnScrollListener mRecyclerViewOnScrollListener = 495 new RecyclerView.OnScrollListener() { 496 @Override 497 public void onScrolled(RecyclerView recyclerView, int dx, int dy) { 498 updatePaginationButtons(false); 499 } 500 }; 501 502 private final Runnable mPaginationRunnable = () -> { 503 boolean upPressed = isUpPressed(); 504 boolean downPressed = isDownPressed(); 505 if (upPressed && downPressed) { 506 return; 507 } 508 if (upPressed) { 509 pageUp(); 510 } else if (downPressed) { 511 pageDown(); 512 } 513 }; 514 515 /** Returns the page the given position is on, starting with page 0. */ getPage(int position)516 int getPage(int position) { 517 if (mRowsPerPage == -1) { 518 return -1; 519 } 520 if (mRowsPerPage == 0) { 521 return 0; 522 } 523 return position / mRowsPerPage; 524 } 525 getOrientationHelper(RecyclerView.LayoutManager layoutManager)526 private OrientationHelper getOrientationHelper(RecyclerView.LayoutManager layoutManager) { 527 if (mOrientationHelper == null || mOrientationHelper.getLayoutManager() != layoutManager) { 528 // PagedRecyclerView is assumed to be a list that always vertically scrolls. 529 mOrientationHelper = OrientationHelper.createVerticalHelper(layoutManager); 530 } 531 return mOrientationHelper; 532 } 533 534 /** 535 * Scrolls the contents of the RecyclerView up a page. A page is defined as the height of the 536 * {@code PagedRecyclerView}. 537 * 538 * <p>The resulting first item in the list will be snapped to so that it is completely visible. 539 * If this is not possible due to the first item being taller than the containing 540 * {@code PagedRecyclerView}, then the snapping will not occur. 541 */ pageUp()542 void pageUp() { 543 int currentOffset = getRecyclerView().computeVerticalScrollOffset(); 544 if (getRecyclerView().getLayoutManager() == null 545 || getRecyclerView().getChildCount() == 0 || currentOffset == 0) { 546 return; 547 } 548 549 // Use OrientationHelper to calculate scroll distance in order to match snapping behavior. 550 OrientationHelper orientationHelper = 551 getOrientationHelper(getRecyclerView().getLayoutManager()); 552 int screenSize = orientationHelper.getTotalSpace(); 553 554 int scrollDistance = screenSize; 555 // The iteration order matters. In case where there are 2 items longer than screen size, we 556 // want to focus on upcoming view. 557 for (int i = 0; i < getRecyclerView().getChildCount(); i++) { 558 /* 559 * We treat child View longer than screen size differently: 560 * 1) When it enters screen, next pageUp will align its bottom with parent bottom; 561 * 2) When it leaves screen, next pageUp will align its top with parent top. 562 */ 563 View child = getRecyclerView().getChildAt(i); 564 if (child.getHeight() > screenSize) { 565 if (orientationHelper.getDecoratedEnd(child) < screenSize) { 566 // Child view bottom is entering screen. Align its bottom with parent bottom. 567 scrollDistance = screenSize - orientationHelper.getDecoratedEnd(child); 568 } else if (-screenSize < orientationHelper.getDecoratedStart(child) 569 && orientationHelper.getDecoratedStart(child) < 0) { 570 // Child view top is about to enter screen - its distance to parent top 571 // is less than a full scroll. Align child top with parent top. 572 scrollDistance = Math.abs(orientationHelper.getDecoratedStart(child)); 573 } 574 // There can be two items that are longer than the screen. We stop at the first one. 575 // This is affected by the iteration order. 576 break; 577 } 578 } 579 // Distance should always be positive. Negate its value to scroll up. 580 getRecyclerView().smoothScrollBy(0, -scrollDistance); 581 } 582 583 /** 584 * Scrolls the contents of the RecyclerView down a page. A page is defined as the height of the 585 * {@code PagedRecyclerView}. 586 * 587 * <p>This method will attempt to bring the last item in the list as the first item. If the 588 * current first item in the list is taller than the {@code PagedRecyclerView}, then it will be 589 * scrolled the length of a page, but not snapped to. 590 */ pageDown()591 void pageDown() { 592 if (getRecyclerView().getLayoutManager() == null 593 || getRecyclerView().getChildCount() == 0) { 594 return; 595 } 596 597 OrientationHelper orientationHelper = 598 getOrientationHelper(getRecyclerView().getLayoutManager()); 599 int screenSize = orientationHelper.getTotalSpace(); 600 int scrollDistance = screenSize; 601 602 // If the last item is partially visible, page down should bring it to the top. 603 View lastChild = getRecyclerView().getChildAt(getRecyclerView().getChildCount() - 1); 604 if (getRecyclerView().getLayoutManager().isViewPartiallyVisible(lastChild, 605 /* completelyVisible= */ false, /* acceptEndPointInclusion= */ false)) { 606 scrollDistance = orientationHelper.getDecoratedStart(lastChild); 607 if (scrollDistance < 0) { 608 // Scroll value can be negative if the child is longer than the screen size and the 609 // visible area of the screen does not show the start of the child. 610 // Scroll to the next screen if the start value is negative 611 scrollDistance = screenSize; 612 } 613 } 614 615 // The iteration order matters. In case where there are 2 items longer than screen size, we 616 // want to focus on upcoming view (the one at the bottom of screen). 617 for (int i = getRecyclerView().getChildCount() - 1; i >= 0; i--) { 618 /* We treat child View longer than screen size differently: 619 * 1) When it enters screen, next pageDown will align its top with parent top; 620 * 2) When it leaves screen, next pageDown will align its bottom with parent bottom. 621 */ 622 View child = getRecyclerView().getChildAt(i); 623 if (child.getHeight() > screenSize) { 624 if (orientationHelper.getDecoratedStart(child) > 0) { 625 // Child view top is entering screen. Align its top with parent top. 626 scrollDistance = orientationHelper.getDecoratedStart(child); 627 } else if (screenSize < orientationHelper.getDecoratedEnd(child) 628 && orientationHelper.getDecoratedEnd(child) < 2 * screenSize) { 629 // Child view bottom is about to enter screen - its distance to parent bottom 630 // is less than a full scroll. Align child bottom with parent bottom. 631 scrollDistance = orientationHelper.getDecoratedEnd(child) - screenSize; 632 } 633 // There can be two items that are longer than the screen. We stop at the first one. 634 // This is affected by the iteration order. 635 break; 636 } 637 } 638 639 getRecyclerView().smoothScrollBy(0, scrollDistance); 640 } 641 642 /** 643 * Determines if scrollbar should be visible or not and shows/hides it accordingly. If this is 644 * being called as a result of adapter changes, it should be called after the new layout has 645 * been calculated because the method of determining scrollbar visibility uses the current 646 * layout. If this is called after an adapter change but before the new layout, the visibility 647 * determination may not be correct. 648 * 649 * @param animate {@code true} if the scrollbar should animate to its new position. 650 * {@code false} if no animation is used 651 */ updatePaginationButtons(boolean animate)652 private void updatePaginationButtons(boolean animate) { 653 654 boolean isAtStart = isAtStart(); 655 boolean isAtEnd = isAtEnd(); 656 RecyclerView.LayoutManager layoutManager = getRecyclerView().getLayoutManager(); 657 658 if ((isAtStart && isAtEnd) || layoutManager == null || layoutManager.getItemCount() == 0) { 659 mScrollView.setVisibility(View.INVISIBLE); 660 } else { 661 mScrollView.setVisibility(View.VISIBLE); 662 } 663 setUpEnabled(!isAtStart); 664 setDownEnabled(!isAtEnd); 665 666 if (layoutManager == null) { 667 return; 668 } 669 670 if (layoutManager.canScrollVertically()) { 671 setParameters( 672 getRecyclerView().computeVerticalScrollRange(), 673 getRecyclerView().computeVerticalScrollOffset(), 674 getRecyclerView().computeVerticalScrollExtent(), animate); 675 } else { 676 setParameters( 677 getRecyclerView().computeHorizontalScrollRange(), 678 getRecyclerView().computeHorizontalScrollOffset(), 679 getRecyclerView().computeHorizontalScrollExtent(), animate); 680 } 681 682 mScrollView.invalidate(); 683 } 684 685 /** Returns {@code true} if the RecyclerView is completely displaying the first item. */ isAtStart()686 boolean isAtStart() { 687 return mSnapHelper.isAtStart(getRecyclerView().getLayoutManager()); 688 } 689 690 /** Returns {@code true} if the RecyclerView is completely displaying the last item. */ isAtEnd()691 boolean isAtEnd() { 692 return mSnapHelper.isAtEnd(getRecyclerView().getLayoutManager()); 693 } 694 695 /** 696 * Scrolls to the given position in the PagedRecyclerView. 697 * 698 * @param position The position in the list to scroll to. 699 */ scrollToPosition(int position)700 private void scrollToPosition(int position) { 701 RecyclerView.LayoutManager layoutManager = getRecyclerView().getLayoutManager(); 702 if (layoutManager == null) { 703 return; 704 } 705 706 RecyclerView.SmoothScroller smoothScroller = mSnapHelper.createScroller(layoutManager); 707 smoothScroller.setTargetPosition(position); 708 709 layoutManager.startSmoothScroll(smoothScroller); 710 711 // Sometimes #scrollToPosition doesn't change the scroll state so we need to make sure 712 // the pagination arrows actually get updated. See b/15801119 713 mHandler.post(() -> updatePaginationButtons(true /*animate*/)); 714 } 715 716 /** 717 * Snap to the given position. This method will snap instantly to a position that's "close" to 718 * the given position and then animate a short decelerate to indicate the direction that the 719 * snap happened. 720 * 721 * @param position The position in the list to scroll to. 722 */ snapToPosition(int position)723 void snapToPosition(int position) { 724 RecyclerView.LayoutManager layoutManager = getRecyclerView().getLayoutManager(); 725 726 if (layoutManager == null) { 727 return; 728 } 729 730 int startPosition = position; 731 if ((layoutManager instanceof RecyclerView.SmoothScroller.ScrollVectorProvider)) { 732 PointF vector = ((RecyclerView.SmoothScroller.ScrollVectorProvider) layoutManager) 733 .computeScrollVectorForPosition(position); 734 // A positive value in the vector means scrolling down, so should offset by scrolling to 735 // an item previous in the list. 736 int offsetDirection = (vector == null || vector.y > 0) ? -1 : 1; 737 startPosition += offsetDirection * SNAP_SCROLL_OFFSET_POSITION; 738 739 // Clamp the start position. 740 startPosition = Math.max(0, Math.min(startPosition, layoutManager.getItemCount() - 1)); 741 } else { 742 // If the LayoutManager doesn't implement ScrollVectorProvider (the default for 743 // PagedRecyclerView, LinearLayoutManager does, but if the user has overridden it) then 744 // we cannot compute the direction we need to scroll. So just snap instantly instead. 745 Log.w(TAG, "LayoutManager is not a ScrollVectorProvider, can't do snap animation."); 746 } 747 748 if (layoutManager instanceof LinearLayoutManager) { 749 ((LinearLayoutManager) layoutManager).scrollToPositionWithOffset(startPosition, 0); 750 } else { 751 layoutManager.scrollToPosition(startPosition); 752 } 753 754 if (startPosition != position) { 755 // The actual scroll above happens on the next update, so we wait for that to finish 756 // before doing the smooth scroll. 757 mScrollView.post(() -> scrollToPosition(position)); 758 } 759 } 760 } 761