1 /* 2 * Copyright 2018 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 androidx.recyclerview.widget; 18 19 import android.animation.Animator; 20 import android.animation.AnimatorListenerAdapter; 21 import android.animation.ValueAnimator; 22 import android.animation.ValueAnimator.AnimatorUpdateListener; 23 import android.graphics.Canvas; 24 import android.graphics.drawable.Drawable; 25 import android.graphics.drawable.StateListDrawable; 26 import android.view.MotionEvent; 27 28 import androidx.annotation.IntDef; 29 import androidx.annotation.NonNull; 30 import androidx.annotation.Nullable; 31 import androidx.annotation.VisibleForTesting; 32 import androidx.core.view.ViewCompat; 33 34 import java.lang.annotation.Retention; 35 import java.lang.annotation.RetentionPolicy; 36 37 /** 38 * Class responsible to animate and provide a fast scroller. 39 */ 40 @VisibleForTesting 41 class FastScroller extends RecyclerView.ItemDecoration implements RecyclerView.OnItemTouchListener { 42 @IntDef({STATE_HIDDEN, STATE_VISIBLE, STATE_DRAGGING}) 43 @Retention(RetentionPolicy.SOURCE) 44 private @interface State { } 45 // Scroll thumb not showing 46 private static final int STATE_HIDDEN = 0; 47 // Scroll thumb visible and moving along with the scrollbar 48 private static final int STATE_VISIBLE = 1; 49 // Scroll thumb being dragged by user 50 private static final int STATE_DRAGGING = 2; 51 52 @IntDef({DRAG_X, DRAG_Y, DRAG_NONE}) 53 @Retention(RetentionPolicy.SOURCE) 54 private @interface DragState{ } 55 private static final int DRAG_NONE = 0; 56 private static final int DRAG_X = 1; 57 private static final int DRAG_Y = 2; 58 59 @IntDef({ANIMATION_STATE_OUT, ANIMATION_STATE_FADING_IN, ANIMATION_STATE_IN, 60 ANIMATION_STATE_FADING_OUT}) 61 @Retention(RetentionPolicy.SOURCE) 62 private @interface AnimationState { } 63 private static final int ANIMATION_STATE_OUT = 0; 64 private static final int ANIMATION_STATE_FADING_IN = 1; 65 private static final int ANIMATION_STATE_IN = 2; 66 private static final int ANIMATION_STATE_FADING_OUT = 3; 67 68 private static final int SHOW_DURATION_MS = 500; 69 private static final int HIDE_DELAY_AFTER_VISIBLE_MS = 1500; 70 private static final int HIDE_DELAY_AFTER_DRAGGING_MS = 1200; 71 private static final int HIDE_DURATION_MS = 500; 72 private static final int SCROLLBAR_FULL_OPAQUE = 255; 73 74 private static final int[] PRESSED_STATE_SET = new int[]{android.R.attr.state_pressed}; 75 private static final int[] EMPTY_STATE_SET = new int[]{}; 76 77 private final int mScrollbarMinimumRange; 78 private final int mMargin; 79 80 // Final values for the vertical scroll bar 81 private final StateListDrawable mVerticalThumbDrawable; 82 private final Drawable mVerticalTrackDrawable; 83 private final int mVerticalThumbWidth; 84 private final int mVerticalTrackWidth; 85 86 // Final values for the horizontal scroll bar 87 private final StateListDrawable mHorizontalThumbDrawable; 88 private final Drawable mHorizontalTrackDrawable; 89 private final int mHorizontalThumbHeight; 90 private final int mHorizontalTrackHeight; 91 92 // Dynamic values for the vertical scroll bar 93 @VisibleForTesting int mVerticalThumbHeight; 94 @VisibleForTesting int mVerticalThumbCenterY; 95 @VisibleForTesting float mVerticalDragY; 96 97 // Dynamic values for the horizontal scroll bar 98 @VisibleForTesting int mHorizontalThumbWidth; 99 @VisibleForTesting int mHorizontalThumbCenterX; 100 @VisibleForTesting float mHorizontalDragX; 101 102 private int mRecyclerViewWidth = 0; 103 private int mRecyclerViewHeight = 0; 104 105 private RecyclerView mRecyclerView; 106 /** 107 * Whether the document is long/wide enough to require scrolling. If not, we don't show the 108 * relevant scroller. 109 */ 110 private boolean mNeedVerticalScrollbar = false; 111 private boolean mNeedHorizontalScrollbar = false; 112 @State private int mState = STATE_HIDDEN; 113 @DragState private int mDragState = DRAG_NONE; 114 115 private final int[] mVerticalRange = new int[2]; 116 private final int[] mHorizontalRange = new int[2]; 117 private final ValueAnimator mShowHideAnimator = ValueAnimator.ofFloat(0, 1); 118 @AnimationState private int mAnimationState = ANIMATION_STATE_OUT; 119 private final Runnable mHideRunnable = new Runnable() { 120 @Override 121 public void run() { 122 hide(HIDE_DURATION_MS); 123 } 124 }; 125 private final RecyclerView.OnScrollListener 126 mOnScrollListener = new RecyclerView.OnScrollListener() { 127 @Override 128 public void onScrolled(RecyclerView recyclerView, int dx, int dy) { 129 updateScrollPosition(recyclerView.computeHorizontalScrollOffset(), 130 recyclerView.computeVerticalScrollOffset()); 131 } 132 }; 133 134 FastScroller(RecyclerView recyclerView, StateListDrawable verticalThumbDrawable, 135 Drawable verticalTrackDrawable, StateListDrawable horizontalThumbDrawable, 136 Drawable horizontalTrackDrawable, int defaultWidth, int scrollbarMinimumRange, 137 int margin) { 138 mVerticalThumbDrawable = verticalThumbDrawable; 139 mVerticalTrackDrawable = verticalTrackDrawable; 140 mHorizontalThumbDrawable = horizontalThumbDrawable; 141 mHorizontalTrackDrawable = horizontalTrackDrawable; 142 mVerticalThumbWidth = Math.max(defaultWidth, verticalThumbDrawable.getIntrinsicWidth()); 143 mVerticalTrackWidth = Math.max(defaultWidth, verticalTrackDrawable.getIntrinsicWidth()); 144 mHorizontalThumbHeight = Math 145 .max(defaultWidth, horizontalThumbDrawable.getIntrinsicWidth()); 146 mHorizontalTrackHeight = Math 147 .max(defaultWidth, horizontalTrackDrawable.getIntrinsicWidth()); 148 mScrollbarMinimumRange = scrollbarMinimumRange; 149 mMargin = margin; 150 mVerticalThumbDrawable.setAlpha(SCROLLBAR_FULL_OPAQUE); 151 mVerticalTrackDrawable.setAlpha(SCROLLBAR_FULL_OPAQUE); 152 153 mShowHideAnimator.addListener(new AnimatorListener()); 154 mShowHideAnimator.addUpdateListener(new AnimatorUpdater()); 155 156 attachToRecyclerView(recyclerView); 157 } 158 159 public void attachToRecyclerView(@Nullable RecyclerView recyclerView) { 160 if (mRecyclerView == recyclerView) { 161 return; // nothing to do 162 } 163 if (mRecyclerView != null) { 164 destroyCallbacks(); 165 } 166 mRecyclerView = recyclerView; 167 if (mRecyclerView != null) { 168 setupCallbacks(); 169 } 170 } 171 172 private void setupCallbacks() { 173 mRecyclerView.addItemDecoration(this); 174 mRecyclerView.addOnItemTouchListener(this); 175 mRecyclerView.addOnScrollListener(mOnScrollListener); 176 } 177 178 private void destroyCallbacks() { 179 mRecyclerView.removeItemDecoration(this); 180 mRecyclerView.removeOnItemTouchListener(this); 181 mRecyclerView.removeOnScrollListener(mOnScrollListener); 182 cancelHide(); 183 } 184 185 private void requestRedraw() { 186 mRecyclerView.invalidate(); 187 } 188 189 private void setState(@State int state) { 190 if (state == STATE_DRAGGING && mState != STATE_DRAGGING) { 191 mVerticalThumbDrawable.setState(PRESSED_STATE_SET); 192 cancelHide(); 193 } 194 195 if (state == STATE_HIDDEN) { 196 requestRedraw(); 197 } else { 198 show(); 199 } 200 201 if (mState == STATE_DRAGGING && state != STATE_DRAGGING) { 202 mVerticalThumbDrawable.setState(EMPTY_STATE_SET); 203 resetHideDelay(HIDE_DELAY_AFTER_DRAGGING_MS); 204 } else if (state == STATE_VISIBLE) { 205 resetHideDelay(HIDE_DELAY_AFTER_VISIBLE_MS); 206 } 207 mState = state; 208 } 209 210 private boolean isLayoutRTL() { 211 return ViewCompat.getLayoutDirection(mRecyclerView) == ViewCompat.LAYOUT_DIRECTION_RTL; 212 } 213 214 public boolean isDragging() { 215 return mState == STATE_DRAGGING; 216 } 217 218 @VisibleForTesting boolean isVisible() { 219 return mState == STATE_VISIBLE; 220 } 221 222 @VisibleForTesting boolean isHidden() { 223 return mState == STATE_HIDDEN; 224 } 225 226 227 public void show() { 228 switch (mAnimationState) { 229 case ANIMATION_STATE_FADING_OUT: 230 mShowHideAnimator.cancel(); 231 // fall through 232 case ANIMATION_STATE_OUT: 233 mAnimationState = ANIMATION_STATE_FADING_IN; 234 mShowHideAnimator.setFloatValues((float) mShowHideAnimator.getAnimatedValue(), 1); 235 mShowHideAnimator.setDuration(SHOW_DURATION_MS); 236 mShowHideAnimator.setStartDelay(0); 237 mShowHideAnimator.start(); 238 break; 239 } 240 } 241 242 public void hide() { 243 hide(0); 244 } 245 246 @VisibleForTesting 247 void hide(int duration) { 248 switch (mAnimationState) { 249 case ANIMATION_STATE_FADING_IN: 250 mShowHideAnimator.cancel(); 251 // fall through 252 case ANIMATION_STATE_IN: 253 mAnimationState = ANIMATION_STATE_FADING_OUT; 254 mShowHideAnimator.setFloatValues((float) mShowHideAnimator.getAnimatedValue(), 0); 255 mShowHideAnimator.setDuration(duration); 256 mShowHideAnimator.start(); 257 break; 258 } 259 } 260 261 private void cancelHide() { 262 mRecyclerView.removeCallbacks(mHideRunnable); 263 } 264 265 private void resetHideDelay(int delay) { 266 cancelHide(); 267 mRecyclerView.postDelayed(mHideRunnable, delay); 268 } 269 270 @Override 271 public void onDrawOver(Canvas canvas, RecyclerView parent, RecyclerView.State state) { 272 if (mRecyclerViewWidth != mRecyclerView.getWidth() 273 || mRecyclerViewHeight != mRecyclerView.getHeight()) { 274 mRecyclerViewWidth = mRecyclerView.getWidth(); 275 mRecyclerViewHeight = mRecyclerView.getHeight(); 276 // This is due to the different events ordering when keyboard is opened or 277 // retracted vs rotate. Hence to avoid corner cases we just disable the 278 // scroller when size changed, and wait until the scroll position is recomputed 279 // before showing it back. 280 setState(STATE_HIDDEN); 281 return; 282 } 283 284 if (mAnimationState != ANIMATION_STATE_OUT) { 285 if (mNeedVerticalScrollbar) { 286 drawVerticalScrollbar(canvas); 287 } 288 if (mNeedHorizontalScrollbar) { 289 drawHorizontalScrollbar(canvas); 290 } 291 } 292 } 293 294 private void drawVerticalScrollbar(Canvas canvas) { 295 int viewWidth = mRecyclerViewWidth; 296 297 int left = viewWidth - mVerticalThumbWidth; 298 int top = mVerticalThumbCenterY - mVerticalThumbHeight / 2; 299 mVerticalThumbDrawable.setBounds(0, 0, mVerticalThumbWidth, mVerticalThumbHeight); 300 mVerticalTrackDrawable 301 .setBounds(0, 0, mVerticalTrackWidth, mRecyclerViewHeight); 302 303 if (isLayoutRTL()) { 304 mVerticalTrackDrawable.draw(canvas); 305 canvas.translate(mVerticalThumbWidth, top); 306 canvas.scale(-1, 1); 307 mVerticalThumbDrawable.draw(canvas); 308 canvas.scale(1, 1); 309 canvas.translate(-mVerticalThumbWidth, -top); 310 } else { 311 canvas.translate(left, 0); 312 mVerticalTrackDrawable.draw(canvas); 313 canvas.translate(0, top); 314 mVerticalThumbDrawable.draw(canvas); 315 canvas.translate(-left, -top); 316 } 317 } 318 319 private void drawHorizontalScrollbar(Canvas canvas) { 320 int viewHeight = mRecyclerViewHeight; 321 322 int top = viewHeight - mHorizontalThumbHeight; 323 int left = mHorizontalThumbCenterX - mHorizontalThumbWidth / 2; 324 mHorizontalThumbDrawable.setBounds(0, 0, mHorizontalThumbWidth, mHorizontalThumbHeight); 325 mHorizontalTrackDrawable 326 .setBounds(0, 0, mRecyclerViewWidth, mHorizontalTrackHeight); 327 328 canvas.translate(0, top); 329 mHorizontalTrackDrawable.draw(canvas); 330 canvas.translate(left, 0); 331 mHorizontalThumbDrawable.draw(canvas); 332 canvas.translate(-left, -top); 333 } 334 335 /** 336 * Notify the scroller of external change of the scroll, e.g. through dragging or flinging on 337 * the view itself. 338 * 339 * @param offsetX The new scroll X offset. 340 * @param offsetY The new scroll Y offset. 341 */ 342 void updateScrollPosition(int offsetX, int offsetY) { 343 int verticalContentLength = mRecyclerView.computeVerticalScrollRange(); 344 int verticalVisibleLength = mRecyclerViewHeight; 345 mNeedVerticalScrollbar = verticalContentLength - verticalVisibleLength > 0 346 && mRecyclerViewHeight >= mScrollbarMinimumRange; 347 348 int horizontalContentLength = mRecyclerView.computeHorizontalScrollRange(); 349 int horizontalVisibleLength = mRecyclerViewWidth; 350 mNeedHorizontalScrollbar = horizontalContentLength - horizontalVisibleLength > 0 351 && mRecyclerViewWidth >= mScrollbarMinimumRange; 352 353 if (!mNeedVerticalScrollbar && !mNeedHorizontalScrollbar) { 354 if (mState != STATE_HIDDEN) { 355 setState(STATE_HIDDEN); 356 } 357 return; 358 } 359 360 if (mNeedVerticalScrollbar) { 361 float middleScreenPos = offsetY + verticalVisibleLength / 2.0f; 362 mVerticalThumbCenterY = 363 (int) ((verticalVisibleLength * middleScreenPos) / verticalContentLength); 364 mVerticalThumbHeight = Math.min(verticalVisibleLength, 365 (verticalVisibleLength * verticalVisibleLength) / verticalContentLength); 366 } 367 368 if (mNeedHorizontalScrollbar) { 369 float middleScreenPos = offsetX + horizontalVisibleLength / 2.0f; 370 mHorizontalThumbCenterX = 371 (int) ((horizontalVisibleLength * middleScreenPos) / horizontalContentLength); 372 mHorizontalThumbWidth = Math.min(horizontalVisibleLength, 373 (horizontalVisibleLength * horizontalVisibleLength) / horizontalContentLength); 374 } 375 376 if (mState == STATE_HIDDEN || mState == STATE_VISIBLE) { 377 setState(STATE_VISIBLE); 378 } 379 } 380 381 @Override 382 public boolean onInterceptTouchEvent(@NonNull RecyclerView recyclerView, 383 @NonNull MotionEvent ev) { 384 final boolean handled; 385 if (mState == STATE_VISIBLE) { 386 boolean insideVerticalThumb = isPointInsideVerticalThumb(ev.getX(), ev.getY()); 387 boolean insideHorizontalThumb = isPointInsideHorizontalThumb(ev.getX(), ev.getY()); 388 if (ev.getAction() == MotionEvent.ACTION_DOWN 389 && (insideVerticalThumb || insideHorizontalThumb)) { 390 if (insideHorizontalThumb) { 391 mDragState = DRAG_X; 392 mHorizontalDragX = (int) ev.getX(); 393 } else if (insideVerticalThumb) { 394 mDragState = DRAG_Y; 395 mVerticalDragY = (int) ev.getY(); 396 } 397 398 setState(STATE_DRAGGING); 399 handled = true; 400 } else { 401 handled = false; 402 } 403 } else if (mState == STATE_DRAGGING) { 404 handled = true; 405 } else { 406 handled = false; 407 } 408 return handled; 409 } 410 411 @Override 412 public void onTouchEvent(@NonNull RecyclerView recyclerView, @NonNull MotionEvent me) { 413 if (mState == STATE_HIDDEN) { 414 return; 415 } 416 417 if (me.getAction() == MotionEvent.ACTION_DOWN) { 418 boolean insideVerticalThumb = isPointInsideVerticalThumb(me.getX(), me.getY()); 419 boolean insideHorizontalThumb = isPointInsideHorizontalThumb(me.getX(), me.getY()); 420 if (insideVerticalThumb || insideHorizontalThumb) { 421 if (insideHorizontalThumb) { 422 mDragState = DRAG_X; 423 mHorizontalDragX = (int) me.getX(); 424 } else if (insideVerticalThumb) { 425 mDragState = DRAG_Y; 426 mVerticalDragY = (int) me.getY(); 427 } 428 setState(STATE_DRAGGING); 429 } 430 } else if (me.getAction() == MotionEvent.ACTION_UP && mState == STATE_DRAGGING) { 431 mVerticalDragY = 0; 432 mHorizontalDragX = 0; 433 setState(STATE_VISIBLE); 434 mDragState = DRAG_NONE; 435 } else if (me.getAction() == MotionEvent.ACTION_MOVE && mState == STATE_DRAGGING) { 436 show(); 437 if (mDragState == DRAG_X) { 438 horizontalScrollTo(me.getX()); 439 } 440 if (mDragState == DRAG_Y) { 441 verticalScrollTo(me.getY()); 442 } 443 } 444 } 445 446 @Override 447 public void onRequestDisallowInterceptTouchEvent(boolean disallowIntercept) { } 448 449 private void verticalScrollTo(float y) { 450 final int[] scrollbarRange = getVerticalRange(); 451 y = Math.max(scrollbarRange[0], Math.min(scrollbarRange[1], y)); 452 if (Math.abs(mVerticalThumbCenterY - y) < 2) { 453 return; 454 } 455 int scrollingBy = scrollTo(mVerticalDragY, y, scrollbarRange, 456 mRecyclerView.computeVerticalScrollRange(), 457 mRecyclerView.computeVerticalScrollOffset(), mRecyclerViewHeight); 458 if (scrollingBy != 0) { 459 mRecyclerView.scrollBy(0, scrollingBy); 460 } 461 mVerticalDragY = y; 462 } 463 464 private void horizontalScrollTo(float x) { 465 final int[] scrollbarRange = getHorizontalRange(); 466 x = Math.max(scrollbarRange[0], Math.min(scrollbarRange[1], x)); 467 if (Math.abs(mHorizontalThumbCenterX - x) < 2) { 468 return; 469 } 470 471 int scrollingBy = scrollTo(mHorizontalDragX, x, scrollbarRange, 472 mRecyclerView.computeHorizontalScrollRange(), 473 mRecyclerView.computeHorizontalScrollOffset(), mRecyclerViewWidth); 474 if (scrollingBy != 0) { 475 mRecyclerView.scrollBy(scrollingBy, 0); 476 } 477 478 mHorizontalDragX = x; 479 } 480 481 private int scrollTo(float oldDragPos, float newDragPos, int[] scrollbarRange, int scrollRange, 482 int scrollOffset, int viewLength) { 483 int scrollbarLength = scrollbarRange[1] - scrollbarRange[0]; 484 if (scrollbarLength == 0) { 485 return 0; 486 } 487 float percentage = ((newDragPos - oldDragPos) / (float) scrollbarLength); 488 int totalPossibleOffset = scrollRange - viewLength; 489 int scrollingBy = (int) (percentage * totalPossibleOffset); 490 int absoluteOffset = scrollOffset + scrollingBy; 491 if (absoluteOffset < totalPossibleOffset && absoluteOffset >= 0) { 492 return scrollingBy; 493 } else { 494 return 0; 495 } 496 } 497 498 @VisibleForTesting 499 boolean isPointInsideVerticalThumb(float x, float y) { 500 return (isLayoutRTL() ? x <= mVerticalThumbWidth / 2 501 : x >= mRecyclerViewWidth - mVerticalThumbWidth) 502 && y >= mVerticalThumbCenterY - mVerticalThumbHeight / 2 503 && y <= mVerticalThumbCenterY + mVerticalThumbHeight / 2; 504 } 505 506 @VisibleForTesting 507 boolean isPointInsideHorizontalThumb(float x, float y) { 508 return (y >= mRecyclerViewHeight - mHorizontalThumbHeight) 509 && x >= mHorizontalThumbCenterX - mHorizontalThumbWidth / 2 510 && x <= mHorizontalThumbCenterX + mHorizontalThumbWidth / 2; 511 } 512 513 @VisibleForTesting 514 Drawable getHorizontalTrackDrawable() { 515 return mHorizontalTrackDrawable; 516 } 517 518 @VisibleForTesting 519 Drawable getHorizontalThumbDrawable() { 520 return mHorizontalThumbDrawable; 521 } 522 523 @VisibleForTesting 524 Drawable getVerticalTrackDrawable() { 525 return mVerticalTrackDrawable; 526 } 527 528 @VisibleForTesting 529 Drawable getVerticalThumbDrawable() { 530 return mVerticalThumbDrawable; 531 } 532 533 /** 534 * Gets the (min, max) vertical positions of the vertical scroll bar. 535 */ 536 private int[] getVerticalRange() { 537 mVerticalRange[0] = mMargin; 538 mVerticalRange[1] = mRecyclerViewHeight - mMargin; 539 return mVerticalRange; 540 } 541 542 /** 543 * Gets the (min, max) horizontal positions of the horizontal scroll bar. 544 */ 545 private int[] getHorizontalRange() { 546 mHorizontalRange[0] = mMargin; 547 mHorizontalRange[1] = mRecyclerViewWidth - mMargin; 548 return mHorizontalRange; 549 } 550 551 private class AnimatorListener extends AnimatorListenerAdapter { 552 553 private boolean mCanceled = false; 554 555 @Override 556 public void onAnimationEnd(Animator animation) { 557 // Cancel is always followed by a new directive, so don't update state. 558 if (mCanceled) { 559 mCanceled = false; 560 return; 561 } 562 if ((float) mShowHideAnimator.getAnimatedValue() == 0) { 563 mAnimationState = ANIMATION_STATE_OUT; 564 setState(STATE_HIDDEN); 565 } else { 566 mAnimationState = ANIMATION_STATE_IN; 567 requestRedraw(); 568 } 569 } 570 571 @Override 572 public void onAnimationCancel(Animator animation) { 573 mCanceled = true; 574 } 575 } 576 577 private class AnimatorUpdater implements AnimatorUpdateListener { 578 579 @Override 580 public void onAnimationUpdate(ValueAnimator valueAnimator) { 581 int alpha = (int) (SCROLLBAR_FULL_OPAQUE * ((float) valueAnimator.getAnimatedValue())); 582 mVerticalThumbDrawable.setAlpha(alpha); 583 mVerticalTrackDrawable.setAlpha(alpha); 584 requestRedraw(); 585 } 586 } 587 } 588