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 
17 package com.android.launcher3.views;
18 
19 import android.animation.ObjectAnimator;
20 import android.content.Context;
21 import android.content.res.Resources;
22 import android.content.res.TypedArray;
23 import android.graphics.Canvas;
24 import android.graphics.Paint;
25 import android.graphics.Point;
26 import android.graphics.Rect;
27 import android.graphics.RectF;
28 import android.util.AttributeSet;
29 import android.util.Property;
30 import android.view.MotionEvent;
31 import android.view.View;
32 import android.view.ViewConfiguration;
33 import android.widget.TextView;
34 
35 import androidx.recyclerview.widget.RecyclerView;
36 
37 import com.android.launcher3.BaseRecyclerView;
38 import com.android.launcher3.R;
39 import com.android.launcher3.Utilities;
40 import com.android.launcher3.graphics.FastScrollThumbDrawable;
41 import com.android.launcher3.util.Themes;
42 
43 import java.util.Collections;
44 import java.util.List;
45 
46 /**
47  * The track and scrollbar that shows when you scroll the list.
48  */
49 public class RecyclerViewFastScroller extends View {
50 
51     private static final int SCROLL_DELTA_THRESHOLD_DP = 4;
52     private static final Rect sTempRect = new Rect();
53 
54     private static final Property<RecyclerViewFastScroller, Integer> TRACK_WIDTH =
55             new Property<RecyclerViewFastScroller, Integer>(Integer.class, "width") {
56 
57                 @Override
58                 public Integer get(RecyclerViewFastScroller scrollBar) {
59                     return scrollBar.mWidth;
60                 }
61 
62                 @Override
63                 public void set(RecyclerViewFastScroller scrollBar, Integer value) {
64                     scrollBar.setTrackWidth(value);
65                 }
66             };
67 
68     private final static int MAX_TRACK_ALPHA = 30;
69     private final static int SCROLL_BAR_VIS_DURATION = 150;
70     private static final float FAST_SCROLL_OVERLAY_Y_OFFSET_FACTOR = 0.75f;
71 
72     private static final List<Rect> SYSTEM_GESTURE_EXCLUSION_RECT =
73             Collections.singletonList(new Rect());
74 
75     private final int mMinWidth;
76     private final int mMaxWidth;
77     private final int mThumbPadding;
78 
79     /** Keeps the last known scrolling delta/velocity along y-axis. */
80     private int mDy = 0;
81     private final float mDeltaThreshold;
82 
83     private final ViewConfiguration mConfig;
84 
85     // Current width of the track
86     private int mWidth;
87     private ObjectAnimator mWidthAnimator;
88 
89     private final Paint mThumbPaint;
90     protected final int mThumbHeight;
91     private final RectF mThumbBounds = new RectF();
92     private final Point mThumbDrawOffset = new Point();
93 
94     private final Paint mTrackPaint;
95 
96     private float mLastTouchY;
97     private boolean mIsDragging;
98     private boolean mIsThumbDetached;
99     private final boolean mCanThumbDetach;
100     private boolean mIgnoreDragGesture;
101 
102     // This is the offset from the top of the scrollbar when the user first starts touching.  To
103     // prevent jumping, this offset is applied as the user scrolls.
104     protected int mTouchOffsetY;
105     protected int mThumbOffsetY;
106 
107     // Fast scroller popup
108     private TextView mPopupView;
109     private boolean mPopupVisible;
110     private String mPopupSectionName;
111 
112     protected BaseRecyclerView mRv;
113     private RecyclerView.OnScrollListener mOnScrollListener;
114 
115     private int mDownX;
116     private int mDownY;
117     private int mLastY;
118 
RecyclerViewFastScroller(Context context)119     public RecyclerViewFastScroller(Context context) {
120         this(context, null);
121     }
122 
RecyclerViewFastScroller(Context context, AttributeSet attrs)123     public RecyclerViewFastScroller(Context context, AttributeSet attrs) {
124         this(context, attrs, 0);
125     }
126 
RecyclerViewFastScroller(Context context, AttributeSet attrs, int defStyleAttr)127     public RecyclerViewFastScroller(Context context, AttributeSet attrs, int defStyleAttr) {
128         super(context, attrs, defStyleAttr);
129 
130         mTrackPaint = new Paint();
131         mTrackPaint.setColor(Themes.getAttrColor(context, android.R.attr.textColorPrimary));
132         mTrackPaint.setAlpha(MAX_TRACK_ALPHA);
133 
134         mThumbPaint = new Paint();
135         mThumbPaint.setAntiAlias(true);
136         mThumbPaint.setColor(Themes.getColorAccent(context));
137         mThumbPaint.setStyle(Paint.Style.FILL);
138 
139         Resources res = getResources();
140         mWidth = mMinWidth = res.getDimensionPixelSize(R.dimen.fastscroll_track_min_width);
141         mMaxWidth = res.getDimensionPixelSize(R.dimen.fastscroll_track_max_width);
142 
143         mThumbPadding = res.getDimensionPixelSize(R.dimen.fastscroll_thumb_padding);
144         mThumbHeight = res.getDimensionPixelSize(R.dimen.fastscroll_thumb_height);
145 
146         mConfig = ViewConfiguration.get(context);
147         mDeltaThreshold = res.getDisplayMetrics().density * SCROLL_DELTA_THRESHOLD_DP;
148 
149         TypedArray ta =
150                 context.obtainStyledAttributes(attrs, R.styleable.RecyclerViewFastScroller, defStyleAttr, 0);
151         mCanThumbDetach = ta.getBoolean(R.styleable.RecyclerViewFastScroller_canThumbDetach, false);
152         ta.recycle();
153     }
154 
setRecyclerView(BaseRecyclerView rv, TextView popupView)155     public void setRecyclerView(BaseRecyclerView rv, TextView popupView) {
156         if (mRv != null && mOnScrollListener != null) {
157             mRv.removeOnScrollListener(mOnScrollListener);
158         }
159         mRv = rv;
160 
161         mRv.addOnScrollListener(mOnScrollListener = new RecyclerView.OnScrollListener() {
162             @Override
163             public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
164                 mDy = dy;
165 
166                 // TODO(winsonc): If we want to animate the section heads while scrolling, we can
167                 //                initiate that here if the recycler view scroll state is not
168                 //                RecyclerView.SCROLL_STATE_IDLE.
169 
170                 mRv.onUpdateScrollbar(dy);
171             }
172         });
173 
174         mPopupView = popupView;
175         mPopupView.setBackground(
176                 new FastScrollThumbDrawable(mThumbPaint, Utilities.isRtl(getResources())));
177     }
178 
reattachThumbToScroll()179     public void reattachThumbToScroll() {
180         mIsThumbDetached = false;
181     }
182 
setThumbOffsetY(int y)183     public void setThumbOffsetY(int y) {
184         if (mThumbOffsetY == y) {
185             return;
186         }
187         updatePopupY((int) y);
188         mThumbOffsetY = y;
189         invalidate();
190     }
191 
getThumbOffsetY()192     public int getThumbOffsetY() {
193         return mThumbOffsetY;
194     }
195 
setTrackWidth(int width)196     private void setTrackWidth(int width) {
197         if (mWidth == width) {
198             return;
199         }
200         mWidth = width;
201         invalidate();
202     }
203 
getThumbHeight()204     public int getThumbHeight() {
205         return mThumbHeight;
206     }
207 
isDraggingThumb()208     public boolean isDraggingThumb() {
209         return mIsDragging;
210     }
211 
isThumbDetached()212     public boolean isThumbDetached() {
213         return mIsThumbDetached;
214     }
215 
216     /**
217      * Handles the touch event and determines whether to show the fast scroller (or updates it if
218      * it is already showing).
219      */
handleTouchEvent(MotionEvent ev, Point offset)220     public boolean handleTouchEvent(MotionEvent ev, Point offset) {
221         int x = (int) ev.getX() - offset.x;
222         int y = (int) ev.getY() - offset.y;
223         switch (ev.getAction()) {
224             case MotionEvent.ACTION_DOWN:
225                 // Keep track of the down positions
226                 mDownX = x;
227                 mDownY = mLastY = y;
228 
229                 if ((Math.abs(mDy) < mDeltaThreshold &&
230                         mRv.getScrollState() != RecyclerView.SCROLL_STATE_IDLE)) {
231                     // now the touch events are being passed to the {@link WidgetCell} until the
232                     // touch sequence goes over the touch slop.
233                     mRv.stopScroll();
234                 }
235                 if (isNearThumb(x, y)) {
236                     mTouchOffsetY = mDownY - mThumbOffsetY;
237                 } else if (mRv.supportsFastScrolling()
238                         && isNearScrollBar(mDownX)) {
239                     calcTouchOffsetAndPrepToFastScroll(mDownY, mLastY);
240                     updateFastScrollSectionNameAndThumbOffset(mLastY, y);
241                 }
242                 break;
243             case MotionEvent.ACTION_MOVE:
244                 mLastY = y;
245 
246                 // Check if we should start scrolling, but ignore this fastscroll gesture if we have
247                 // exceeded some fixed movement
248                 mIgnoreDragGesture |= Math.abs(y - mDownY) > mConfig.getScaledPagingTouchSlop();
249                 if (!mIsDragging && !mIgnoreDragGesture && mRv.supportsFastScrolling() &&
250                         isNearThumb(mDownX, mLastY) &&
251                         Math.abs(y - mDownY) > mConfig.getScaledTouchSlop()) {
252                     calcTouchOffsetAndPrepToFastScroll(mDownY, mLastY);
253                 }
254                 if (mIsDragging) {
255                     updateFastScrollSectionNameAndThumbOffset(mLastY, y);
256                 }
257                 break;
258             case MotionEvent.ACTION_UP:
259             case MotionEvent.ACTION_CANCEL:
260                 mRv.onFastScrollCompleted();
261                 mTouchOffsetY = 0;
262                 mLastTouchY = 0;
263                 mIgnoreDragGesture = false;
264                 if (mIsDragging) {
265                     mIsDragging = false;
266                     animatePopupVisibility(false);
267                     showActiveScrollbar(false);
268                 }
269                 break;
270         }
271         return mIsDragging;
272     }
273 
calcTouchOffsetAndPrepToFastScroll(int downY, int lastY)274     private void calcTouchOffsetAndPrepToFastScroll(int downY, int lastY) {
275         mIsDragging = true;
276         if (mCanThumbDetach) {
277             mIsThumbDetached = true;
278         }
279         mTouchOffsetY += (lastY - downY);
280         animatePopupVisibility(true);
281         showActiveScrollbar(true);
282     }
283 
updateFastScrollSectionNameAndThumbOffset(int lastY, int y)284     private void updateFastScrollSectionNameAndThumbOffset(int lastY, int y) {
285         // Update the fastscroller section name at this touch position
286         int bottom = mRv.getScrollbarTrackHeight() - mThumbHeight;
287         float boundedY = (float) Math.max(0, Math.min(bottom, y - mTouchOffsetY));
288         String sectionName = mRv.scrollToPositionAtProgress(boundedY / bottom);
289         if (!sectionName.equals(mPopupSectionName)) {
290             mPopupSectionName = sectionName;
291             mPopupView.setText(sectionName);
292         }
293         animatePopupVisibility(!sectionName.isEmpty());
294         mLastTouchY = boundedY;
295         setThumbOffsetY((int) mLastTouchY);
296     }
297 
onDraw(Canvas canvas)298     public void onDraw(Canvas canvas) {
299         if (mThumbOffsetY < 0) {
300             return;
301         }
302         int saveCount = canvas.save();
303         canvas.translate(getWidth() / 2, mRv.getScrollBarTop());
304         mThumbDrawOffset.set(getWidth() / 2, mRv.getScrollBarTop());
305         // Draw the track
306         float halfW = mWidth / 2;
307         canvas.drawRoundRect(-halfW, 0, halfW, mRv.getScrollbarTrackHeight(),
308                 mWidth, mWidth, mTrackPaint);
309 
310         canvas.translate(0, mThumbOffsetY);
311         mThumbDrawOffset.y += mThumbOffsetY;
312         halfW += mThumbPadding;
313         float r = getScrollThumbRadius();
314         mThumbBounds.set(-halfW, 0, halfW, mThumbHeight);
315         canvas.drawRoundRect(mThumbBounds, r, r, mThumbPaint);
316         if (Utilities.ATLEAST_Q) {
317             mThumbBounds.roundOut(SYSTEM_GESTURE_EXCLUSION_RECT.get(0));
318             SYSTEM_GESTURE_EXCLUSION_RECT.get(0).offset(mThumbDrawOffset.x, mThumbDrawOffset.y);
319             setSystemGestureExclusionRects(SYSTEM_GESTURE_EXCLUSION_RECT);
320         }
321         canvas.restoreToCount(saveCount);
322     }
323 
getScrollThumbRadius()324     private float getScrollThumbRadius() {
325         return mWidth + mThumbPadding + mThumbPadding;
326     }
327 
328     /**
329      * Animates the width of the scrollbar.
330      */
showActiveScrollbar(boolean isScrolling)331     private void showActiveScrollbar(boolean isScrolling) {
332         if (mWidthAnimator != null) {
333             mWidthAnimator.cancel();
334         }
335 
336         mWidthAnimator = ObjectAnimator.ofInt(this, TRACK_WIDTH,
337                 isScrolling ? mMaxWidth : mMinWidth);
338         mWidthAnimator.setDuration(SCROLL_BAR_VIS_DURATION);
339         mWidthAnimator.start();
340     }
341 
342     /**
343      * Returns whether the specified point is inside the thumb bounds.
344      */
isNearThumb(int x, int y)345     private boolean isNearThumb(int x, int y) {
346         int offset = y - mThumbOffsetY;
347 
348         return x >= 0 && x < getWidth() && offset >= 0 && offset <= mThumbHeight;
349     }
350 
351     /**
352      * Returns true if AllAppsTransitionController can handle vertical motion
353      * beginning at this point.
354      */
shouldBlockIntercept(int x, int y)355     public boolean shouldBlockIntercept(int x, int y) {
356         return isNearThumb(x, y);
357     }
358 
359     /**
360      * Returns whether the specified x position is near the scroll bar.
361      */
isNearScrollBar(int x)362     public boolean isNearScrollBar(int x) {
363         return x >= (getWidth() - mMaxWidth) / 2 && x <= (getWidth() + mMaxWidth) / 2;
364     }
365 
animatePopupVisibility(boolean visible)366     private void animatePopupVisibility(boolean visible) {
367         if (mPopupVisible != visible) {
368             mPopupVisible = visible;
369             mPopupView.animate().cancel();
370             mPopupView.animate().alpha(visible ? 1f : 0f).setDuration(visible ? 200 : 150).start();
371         }
372     }
373 
updatePopupY(int lastTouchY)374     private void updatePopupY(int lastTouchY) {
375         if (!mPopupVisible) {
376             return;
377         }
378         int height = mPopupView.getHeight();
379         // Aligns the rounded corner of the pop up with the top of the thumb.
380         float top = mRv.getScrollBarTop() + lastTouchY + (getScrollThumbRadius() / 2f)
381                 - (height / 2f);
382         top = Utilities.boundToRange(top, 0,
383                 getTop() + mRv.getScrollBarTop() + mRv.getScrollbarTrackHeight() - height);
384         mPopupView.setTranslationY(top);
385     }
386 
isHitInParent(float x, float y, Point outOffset)387     public boolean isHitInParent(float x, float y, Point outOffset) {
388         if (mThumbOffsetY < 0) {
389             return false;
390         }
391         getHitRect(sTempRect);
392         sTempRect.top += mRv.getScrollBarTop();
393         if (outOffset != null) {
394             outOffset.set(sTempRect.left, sTempRect.top);
395         }
396         return sTempRect.contains((int) x, (int) y);
397     }
398 
399     @Override
hasOverlappingRendering()400     public boolean hasOverlappingRendering() {
401         // There is actually some overlap between the track and the thumb. But since the track
402         // alpha is so low, it does not matter.
403         return false;
404     }
405 }
406