| /* |
| * 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 |
| private final StateListDrawable mVerticalThumbDrawable; |
| private 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]; |
| private final ValueAnimator mShowHideAnimator = ValueAnimator.ofFloat(0, 1); |
| @AnimationState private 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(); |
| } |
| |
| private void requestRedraw() { |
| mRecyclerView.invalidate(); |
| } |
| |
| private 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; |
| } |
| |
| @VisibleForTesting boolean isHidden() { |
| return mState == STATE_HIDDEN; |
| } |
| |
| |
| 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; |
| } |
| } |
| |
| public void hide() { |
| hide(0); |
| } |
| |
| @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; |
| |
| @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 { |
| |
| @Override |
| public void onAnimationUpdate(ValueAnimator valueAnimator) { |
| int alpha = (int) (SCROLLBAR_FULL_OPAQUE * ((float) valueAnimator.getAnimatedValue())); |
| mVerticalThumbDrawable.setAlpha(alpha); |
| mVerticalTrackDrawable.setAlpha(alpha); |
| requestRedraw(); |
| } |
| } |
| } |