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