blob: 80d639964ef83d66529036921f16badab6bde89e [file] [log] [blame]
Ben Lin743fe592017-03-01 14:36:15 -08001/*
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}