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