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}