/* * Copyright 2018 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package androidx.recyclerview.widget; import android.animation.Animator; import android.animation.AnimatorListenerAdapter; import android.animation.ValueAnimator; import android.animation.ValueAnimator.AnimatorUpdateListener; import android.graphics.Canvas; import android.graphics.drawable.Drawable; import android.graphics.drawable.StateListDrawable; import android.view.MotionEvent; import androidx.annotation.IntDef; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.VisibleForTesting; import androidx.core.view.ViewCompat; import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; /** * Class responsible to animate and provide a fast scroller. */ @VisibleForTesting class FastScroller extends RecyclerView.ItemDecoration implements RecyclerView.OnItemTouchListener { @IntDef({STATE_HIDDEN, STATE_VISIBLE, STATE_DRAGGING}) @Retention(RetentionPolicy.SOURCE) private @interface State { } // Scroll thumb not showing private static final int STATE_HIDDEN = 0; // Scroll thumb visible and moving along with the scrollbar private static final int STATE_VISIBLE = 1; // Scroll thumb being dragged by user private static final int STATE_DRAGGING = 2; @IntDef({DRAG_X, DRAG_Y, DRAG_NONE}) @Retention(RetentionPolicy.SOURCE) private @interface DragState{ } private static final int DRAG_NONE = 0; private static final int DRAG_X = 1; private static final int DRAG_Y = 2; @IntDef({ANIMATION_STATE_OUT, ANIMATION_STATE_FADING_IN, ANIMATION_STATE_IN, ANIMATION_STATE_FADING_OUT}) @Retention(RetentionPolicy.SOURCE) private @interface AnimationState { } private static final int ANIMATION_STATE_OUT = 0; private static final int ANIMATION_STATE_FADING_IN = 1; private static final int ANIMATION_STATE_IN = 2; private static final int ANIMATION_STATE_FADING_OUT = 3; private static final int SHOW_DURATION_MS = 500; private static final int HIDE_DELAY_AFTER_VISIBLE_MS = 1500; private static final int HIDE_DELAY_AFTER_DRAGGING_MS = 1200; private static final int HIDE_DURATION_MS = 500; private static final int SCROLLBAR_FULL_OPAQUE = 255; private static final int[] PRESSED_STATE_SET = new int[]{android.R.attr.state_pressed}; private static final int[] EMPTY_STATE_SET = new int[]{}; private final int mScrollbarMinimumRange; private final int mMargin; // Final values for the vertical scroll bar @SuppressWarnings("WeakerAccess") /* synthetic access */ final StateListDrawable mVerticalThumbDrawable; @SuppressWarnings("WeakerAccess") /* synthetic access */ final Drawable mVerticalTrackDrawable; private final int mVerticalThumbWidth; private final int mVerticalTrackWidth; // Final values for the horizontal scroll bar private final StateListDrawable mHorizontalThumbDrawable; private final Drawable mHorizontalTrackDrawable; private final int mHorizontalThumbHeight; private final int mHorizontalTrackHeight; // Dynamic values for the vertical scroll bar @VisibleForTesting int mVerticalThumbHeight; @VisibleForTesting int mVerticalThumbCenterY; @VisibleForTesting float mVerticalDragY; // Dynamic values for the horizontal scroll bar @VisibleForTesting int mHorizontalThumbWidth; @VisibleForTesting int mHorizontalThumbCenterX; @VisibleForTesting float mHorizontalDragX; private int mRecyclerViewWidth = 0; private int mRecyclerViewHeight = 0; private RecyclerView mRecyclerView; /** * Whether the document is long/wide enough to require scrolling. If not, we don't show the * relevant scroller. */ private boolean mNeedVerticalScrollbar = false; private boolean mNeedHorizontalScrollbar = false; @State private int mState = STATE_HIDDEN; @DragState private int mDragState = DRAG_NONE; private final int[] mVerticalRange = new int[2]; private final int[] mHorizontalRange = new int[2]; @SuppressWarnings("WeakerAccess") /* synthetic access */ final ValueAnimator mShowHideAnimator = ValueAnimator.ofFloat(0, 1); @SuppressWarnings("WeakerAccess") /* synthetic access */ @AnimationState int mAnimationState = ANIMATION_STATE_OUT; private final Runnable mHideRunnable = new Runnable() { @Override public void run() { hide(HIDE_DURATION_MS); } }; private final RecyclerView.OnScrollListener mOnScrollListener = new RecyclerView.OnScrollListener() { @Override public void onScrolled(RecyclerView recyclerView, int dx, int dy) { updateScrollPosition(recyclerView.computeHorizontalScrollOffset(), recyclerView.computeVerticalScrollOffset()); } }; FastScroller(RecyclerView recyclerView, StateListDrawable verticalThumbDrawable, Drawable verticalTrackDrawable, StateListDrawable horizontalThumbDrawable, Drawable horizontalTrackDrawable, int defaultWidth, int scrollbarMinimumRange, int margin) { mVerticalThumbDrawable = verticalThumbDrawable; mVerticalTrackDrawable = verticalTrackDrawable; mHorizontalThumbDrawable = horizontalThumbDrawable; mHorizontalTrackDrawable = horizontalTrackDrawable; mVerticalThumbWidth = Math.max(defaultWidth, verticalThumbDrawable.getIntrinsicWidth()); mVerticalTrackWidth = Math.max(defaultWidth, verticalTrackDrawable.getIntrinsicWidth()); mHorizontalThumbHeight = Math .max(defaultWidth, horizontalThumbDrawable.getIntrinsicWidth()); mHorizontalTrackHeight = Math .max(defaultWidth, horizontalTrackDrawable.getIntrinsicWidth()); mScrollbarMinimumRange = scrollbarMinimumRange; mMargin = margin; mVerticalThumbDrawable.setAlpha(SCROLLBAR_FULL_OPAQUE); mVerticalTrackDrawable.setAlpha(SCROLLBAR_FULL_OPAQUE); mShowHideAnimator.addListener(new AnimatorListener()); mShowHideAnimator.addUpdateListener(new AnimatorUpdater()); attachToRecyclerView(recyclerView); } public void attachToRecyclerView(@Nullable RecyclerView recyclerView) { if (mRecyclerView == recyclerView) { return; // nothing to do } if (mRecyclerView != null) { destroyCallbacks(); } mRecyclerView = recyclerView; if (mRecyclerView != null) { setupCallbacks(); } } private void setupCallbacks() { mRecyclerView.addItemDecoration(this); mRecyclerView.addOnItemTouchListener(this); mRecyclerView.addOnScrollListener(mOnScrollListener); } private void destroyCallbacks() { mRecyclerView.removeItemDecoration(this); mRecyclerView.removeOnItemTouchListener(this); mRecyclerView.removeOnScrollListener(mOnScrollListener); cancelHide(); } @SuppressWarnings("WeakerAccess") /* synthetic access */ void requestRedraw() { mRecyclerView.invalidate(); } void setState(@State int state) { if (state == STATE_DRAGGING && mState != STATE_DRAGGING) { mVerticalThumbDrawable.setState(PRESSED_STATE_SET); cancelHide(); } if (state == STATE_HIDDEN) { requestRedraw(); } else { show(); } if (mState == STATE_DRAGGING && state != STATE_DRAGGING) { mVerticalThumbDrawable.setState(EMPTY_STATE_SET); resetHideDelay(HIDE_DELAY_AFTER_DRAGGING_MS); } else if (state == STATE_VISIBLE) { resetHideDelay(HIDE_DELAY_AFTER_VISIBLE_MS); } mState = state; } private boolean isLayoutRTL() { return ViewCompat.getLayoutDirection(mRecyclerView) == ViewCompat.LAYOUT_DIRECTION_RTL; } public boolean isDragging() { return mState == STATE_DRAGGING; } @VisibleForTesting boolean isVisible() { return mState == STATE_VISIBLE; } public void show() { switch (mAnimationState) { case ANIMATION_STATE_FADING_OUT: mShowHideAnimator.cancel(); // fall through case ANIMATION_STATE_OUT: mAnimationState = ANIMATION_STATE_FADING_IN; mShowHideAnimator.setFloatValues((float) mShowHideAnimator.getAnimatedValue(), 1); mShowHideAnimator.setDuration(SHOW_DURATION_MS); mShowHideAnimator.setStartDelay(0); mShowHideAnimator.start(); break; } } @VisibleForTesting void hide(int duration) { switch (mAnimationState) { case ANIMATION_STATE_FADING_IN: mShowHideAnimator.cancel(); // fall through case ANIMATION_STATE_IN: mAnimationState = ANIMATION_STATE_FADING_OUT; mShowHideAnimator.setFloatValues((float) mShowHideAnimator.getAnimatedValue(), 0); mShowHideAnimator.setDuration(duration); mShowHideAnimator.start(); break; } } private void cancelHide() { mRecyclerView.removeCallbacks(mHideRunnable); } private void resetHideDelay(int delay) { cancelHide(); mRecyclerView.postDelayed(mHideRunnable, delay); } @Override public void onDrawOver(Canvas canvas, RecyclerView parent, RecyclerView.State state) { if (mRecyclerViewWidth != mRecyclerView.getWidth() || mRecyclerViewHeight != mRecyclerView.getHeight()) { mRecyclerViewWidth = mRecyclerView.getWidth(); mRecyclerViewHeight = mRecyclerView.getHeight(); // This is due to the different events ordering when keyboard is opened or // retracted vs rotate. Hence to avoid corner cases we just disable the // scroller when size changed, and wait until the scroll position is recomputed // before showing it back. setState(STATE_HIDDEN); return; } if (mAnimationState != ANIMATION_STATE_OUT) { if (mNeedVerticalScrollbar) { drawVerticalScrollbar(canvas); } if (mNeedHorizontalScrollbar) { drawHorizontalScrollbar(canvas); } } } private void drawVerticalScrollbar(Canvas canvas) { int viewWidth = mRecyclerViewWidth; int left = viewWidth - mVerticalThumbWidth; int top = mVerticalThumbCenterY - mVerticalThumbHeight / 2; mVerticalThumbDrawable.setBounds(0, 0, mVerticalThumbWidth, mVerticalThumbHeight); mVerticalTrackDrawable .setBounds(0, 0, mVerticalTrackWidth, mRecyclerViewHeight); if (isLayoutRTL()) { mVerticalTrackDrawable.draw(canvas); canvas.translate(mVerticalThumbWidth, top); canvas.scale(-1, 1); mVerticalThumbDrawable.draw(canvas); canvas.scale(1, 1); canvas.translate(-mVerticalThumbWidth, -top); } else { canvas.translate(left, 0); mVerticalTrackDrawable.draw(canvas); canvas.translate(0, top); mVerticalThumbDrawable.draw(canvas); canvas.translate(-left, -top); } } private void drawHorizontalScrollbar(Canvas canvas) { int viewHeight = mRecyclerViewHeight; int top = viewHeight - mHorizontalThumbHeight; int left = mHorizontalThumbCenterX - mHorizontalThumbWidth / 2; mHorizontalThumbDrawable.setBounds(0, 0, mHorizontalThumbWidth, mHorizontalThumbHeight); mHorizontalTrackDrawable .setBounds(0, 0, mRecyclerViewWidth, mHorizontalTrackHeight); canvas.translate(0, top); mHorizontalTrackDrawable.draw(canvas); canvas.translate(left, 0); mHorizontalThumbDrawable.draw(canvas); canvas.translate(-left, -top); } /** * Notify the scroller of external change of the scroll, e.g. through dragging or flinging on * the view itself. * * @param offsetX The new scroll X offset. * @param offsetY The new scroll Y offset. */ void updateScrollPosition(int offsetX, int offsetY) { int verticalContentLength = mRecyclerView.computeVerticalScrollRange(); int verticalVisibleLength = mRecyclerViewHeight; mNeedVerticalScrollbar = verticalContentLength - verticalVisibleLength > 0 && mRecyclerViewHeight >= mScrollbarMinimumRange; int horizontalContentLength = mRecyclerView.computeHorizontalScrollRange(); int horizontalVisibleLength = mRecyclerViewWidth; mNeedHorizontalScrollbar = horizontalContentLength - horizontalVisibleLength > 0 && mRecyclerViewWidth >= mScrollbarMinimumRange; if (!mNeedVerticalScrollbar && !mNeedHorizontalScrollbar) { if (mState != STATE_HIDDEN) { setState(STATE_HIDDEN); } return; } if (mNeedVerticalScrollbar) { float middleScreenPos = offsetY + verticalVisibleLength / 2.0f; mVerticalThumbCenterY = (int) ((verticalVisibleLength * middleScreenPos) / verticalContentLength); mVerticalThumbHeight = Math.min(verticalVisibleLength, (verticalVisibleLength * verticalVisibleLength) / verticalContentLength); } if (mNeedHorizontalScrollbar) { float middleScreenPos = offsetX + horizontalVisibleLength / 2.0f; mHorizontalThumbCenterX = (int) ((horizontalVisibleLength * middleScreenPos) / horizontalContentLength); mHorizontalThumbWidth = Math.min(horizontalVisibleLength, (horizontalVisibleLength * horizontalVisibleLength) / horizontalContentLength); } if (mState == STATE_HIDDEN || mState == STATE_VISIBLE) { setState(STATE_VISIBLE); } } @Override public boolean onInterceptTouchEvent(@NonNull RecyclerView recyclerView, @NonNull MotionEvent ev) { final boolean handled; if (mState == STATE_VISIBLE) { boolean insideVerticalThumb = isPointInsideVerticalThumb(ev.getX(), ev.getY()); boolean insideHorizontalThumb = isPointInsideHorizontalThumb(ev.getX(), ev.getY()); if (ev.getAction() == MotionEvent.ACTION_DOWN && (insideVerticalThumb || insideHorizontalThumb)) { if (insideHorizontalThumb) { mDragState = DRAG_X; mHorizontalDragX = (int) ev.getX(); } else if (insideVerticalThumb) { mDragState = DRAG_Y; mVerticalDragY = (int) ev.getY(); } setState(STATE_DRAGGING); handled = true; } else { handled = false; } } else if (mState == STATE_DRAGGING) { handled = true; } else { handled = false; } return handled; } @Override public void onTouchEvent(@NonNull RecyclerView recyclerView, @NonNull MotionEvent me) { if (mState == STATE_HIDDEN) { return; } if (me.getAction() == MotionEvent.ACTION_DOWN) { boolean insideVerticalThumb = isPointInsideVerticalThumb(me.getX(), me.getY()); boolean insideHorizontalThumb = isPointInsideHorizontalThumb(me.getX(), me.getY()); if (insideVerticalThumb || insideHorizontalThumb) { if (insideHorizontalThumb) { mDragState = DRAG_X; mHorizontalDragX = (int) me.getX(); } else if (insideVerticalThumb) { mDragState = DRAG_Y; mVerticalDragY = (int) me.getY(); } setState(STATE_DRAGGING); } } else if (me.getAction() == MotionEvent.ACTION_UP && mState == STATE_DRAGGING) { mVerticalDragY = 0; mHorizontalDragX = 0; setState(STATE_VISIBLE); mDragState = DRAG_NONE; } else if (me.getAction() == MotionEvent.ACTION_MOVE && mState == STATE_DRAGGING) { show(); if (mDragState == DRAG_X) { horizontalScrollTo(me.getX()); } if (mDragState == DRAG_Y) { verticalScrollTo(me.getY()); } } } @Override public void onRequestDisallowInterceptTouchEvent(boolean disallowIntercept) { } private void verticalScrollTo(float y) { final int[] scrollbarRange = getVerticalRange(); y = Math.max(scrollbarRange[0], Math.min(scrollbarRange[1], y)); if (Math.abs(mVerticalThumbCenterY - y) < 2) { return; } int scrollingBy = scrollTo(mVerticalDragY, y, scrollbarRange, mRecyclerView.computeVerticalScrollRange(), mRecyclerView.computeVerticalScrollOffset(), mRecyclerViewHeight); if (scrollingBy != 0) { mRecyclerView.scrollBy(0, scrollingBy); } mVerticalDragY = y; } private void horizontalScrollTo(float x) { final int[] scrollbarRange = getHorizontalRange(); x = Math.max(scrollbarRange[0], Math.min(scrollbarRange[1], x)); if (Math.abs(mHorizontalThumbCenterX - x) < 2) { return; } int scrollingBy = scrollTo(mHorizontalDragX, x, scrollbarRange, mRecyclerView.computeHorizontalScrollRange(), mRecyclerView.computeHorizontalScrollOffset(), mRecyclerViewWidth); if (scrollingBy != 0) { mRecyclerView.scrollBy(scrollingBy, 0); } mHorizontalDragX = x; } private int scrollTo(float oldDragPos, float newDragPos, int[] scrollbarRange, int scrollRange, int scrollOffset, int viewLength) { int scrollbarLength = scrollbarRange[1] - scrollbarRange[0]; if (scrollbarLength == 0) { return 0; } float percentage = ((newDragPos - oldDragPos) / (float) scrollbarLength); int totalPossibleOffset = scrollRange - viewLength; int scrollingBy = (int) (percentage * totalPossibleOffset); int absoluteOffset = scrollOffset + scrollingBy; if (absoluteOffset < totalPossibleOffset && absoluteOffset >= 0) { return scrollingBy; } else { return 0; } } @VisibleForTesting boolean isPointInsideVerticalThumb(float x, float y) { return (isLayoutRTL() ? x <= mVerticalThumbWidth / 2 : x >= mRecyclerViewWidth - mVerticalThumbWidth) && y >= mVerticalThumbCenterY - mVerticalThumbHeight / 2 && y <= mVerticalThumbCenterY + mVerticalThumbHeight / 2; } @VisibleForTesting boolean isPointInsideHorizontalThumb(float x, float y) { return (y >= mRecyclerViewHeight - mHorizontalThumbHeight) && x >= mHorizontalThumbCenterX - mHorizontalThumbWidth / 2 && x <= mHorizontalThumbCenterX + mHorizontalThumbWidth / 2; } @VisibleForTesting Drawable getHorizontalTrackDrawable() { return mHorizontalTrackDrawable; } @VisibleForTesting Drawable getHorizontalThumbDrawable() { return mHorizontalThumbDrawable; } @VisibleForTesting Drawable getVerticalTrackDrawable() { return mVerticalTrackDrawable; } @VisibleForTesting Drawable getVerticalThumbDrawable() { return mVerticalThumbDrawable; } /** * Gets the (min, max) vertical positions of the vertical scroll bar. */ private int[] getVerticalRange() { mVerticalRange[0] = mMargin; mVerticalRange[1] = mRecyclerViewHeight - mMargin; return mVerticalRange; } /** * Gets the (min, max) horizontal positions of the horizontal scroll bar. */ private int[] getHorizontalRange() { mHorizontalRange[0] = mMargin; mHorizontalRange[1] = mRecyclerViewWidth - mMargin; return mHorizontalRange; } private class AnimatorListener extends AnimatorListenerAdapter { private boolean mCanceled = false; AnimatorListener() { } @Override public void onAnimationEnd(Animator animation) { // Cancel is always followed by a new directive, so don't update state. if (mCanceled) { mCanceled = false; return; } if ((float) mShowHideAnimator.getAnimatedValue() == 0) { mAnimationState = ANIMATION_STATE_OUT; setState(STATE_HIDDEN); } else { mAnimationState = ANIMATION_STATE_IN; requestRedraw(); } } @Override public void onAnimationCancel(Animator animation) { mCanceled = true; } } private class AnimatorUpdater implements AnimatorUpdateListener { AnimatorUpdater() { } @Override public void onAnimationUpdate(ValueAnimator valueAnimator) { int alpha = (int) (SCROLLBAR_FULL_OPAQUE * ((float) valueAnimator.getAnimatedValue())); mVerticalThumbDrawable.setAlpha(alpha); mVerticalTrackDrawable.setAlpha(alpha); requestRedraw(); } } }