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