• 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.RequiresApi;
44 import androidx.recyclerview.widget.RecyclerView;
45 
46 import com.android.launcher3.FastScrollRecyclerView;
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 
123     // Fast scroller popup
124     private TextView mPopupView;
125     private boolean mPopupVisible;
126     private String mPopupSectionName;
127     private Insets mSystemGestureInsets;
128 
129     protected FastScrollRecyclerView mRv;
130     private RecyclerView.OnScrollListener mOnScrollListener;
131 
132     private int mDownX;
133     private int mDownY;
134     private int mLastY;
135 
RecyclerViewFastScroller(Context context)136     public RecyclerViewFastScroller(Context context) {
137         this(context, null);
138     }
139 
RecyclerViewFastScroller(Context context, AttributeSet attrs)140     public RecyclerViewFastScroller(Context context, AttributeSet attrs) {
141         this(context, attrs, 0);
142     }
143 
RecyclerViewFastScroller(Context context, AttributeSet attrs, int defStyleAttr)144     public RecyclerViewFastScroller(Context context, AttributeSet attrs, int defStyleAttr) {
145         super(context, attrs, defStyleAttr);
146 
147         mTrackPaint = new Paint();
148         mTrackPaint.setColor(Themes.getAttrColor(context, android.R.attr.textColorPrimary));
149         mTrackPaint.setAlpha(MAX_TRACK_ALPHA);
150 
151         mThumbPaint = new Paint();
152         mThumbPaint.setAntiAlias(true);
153         mThumbPaint.setColor(Themes.getColorAccent(context));
154         mThumbPaint.setStyle(Paint.Style.FILL);
155 
156         Resources res = getResources();
157         mWidth = mMinWidth = res.getDimensionPixelSize(R.dimen.fastscroll_track_min_width);
158         mMaxWidth = res.getDimensionPixelSize(R.dimen.fastscroll_track_max_width);
159 
160         mThumbPadding = res.getDimensionPixelSize(R.dimen.fastscroll_thumb_padding);
161         mThumbHeight = res.getDimensionPixelSize(R.dimen.fastscroll_thumb_height);
162 
163         mConfig = ViewConfiguration.get(context);
164         mDeltaThreshold = res.getDisplayMetrics().density * SCROLL_DELTA_THRESHOLD_DP;
165         mScrollbarLeftOffsetTouchDelegate = res.getDisplayMetrics().density
166                 * SCROLLBAR_LEFT_OFFSET_TOUCH_DELEGATE_DP;
167 
168         TypedArray ta =
169                 context.obtainStyledAttributes(attrs, R.styleable.RecyclerViewFastScroller, defStyleAttr, 0);
170         mCanThumbDetach = ta.getBoolean(R.styleable.RecyclerViewFastScroller_canThumbDetach, false);
171         ta.recycle();
172     }
173 
174     /** Sets the popup view to show while the scroller is being dragged */
setPopupView(TextView popupView)175     public void setPopupView(TextView popupView) {
176         mPopupView = popupView;
177         mPopupView.setBackground(
178                 new FastScrollThumbDrawable(mThumbPaint, Utilities.isRtl(getResources())));
179     }
180 
setRecyclerView(FastScrollRecyclerView rv)181     public void setRecyclerView(FastScrollRecyclerView rv) {
182         if (mRv != null && mOnScrollListener != null) {
183             mRv.removeOnScrollListener(mOnScrollListener);
184         }
185         mRv = rv;
186 
187         mRv.addOnScrollListener(mOnScrollListener = new RecyclerView.OnScrollListener() {
188             @Override
189             public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
190                 mDy = dy;
191 
192                 // TODO(winsonc): If we want to animate the section heads while scrolling, we can
193                 //                initiate that here if the recycler view scroll state is not
194                 //                RecyclerView.SCROLL_STATE_IDLE.
195 
196                 mRv.onUpdateScrollbar(dy);
197             }
198         });
199     }
200 
reattachThumbToScroll()201     public void reattachThumbToScroll() {
202         mIsThumbDetached = false;
203     }
204 
setThumbOffsetY(int y)205     public void setThumbOffsetY(int y) {
206         if (mThumbOffsetY == y) {
207             return;
208         }
209         updatePopupY(y);
210         mThumbOffsetY = y;
211         invalidate();
212     }
213 
getThumbOffsetY()214     public int getThumbOffsetY() {
215         return mThumbOffsetY;
216     }
217 
setTrackWidth(int width)218     private void setTrackWidth(int width) {
219         if (mWidth == width) {
220             return;
221         }
222         mWidth = width;
223         invalidate();
224     }
225 
getThumbHeight()226     public int getThumbHeight() {
227         return mThumbHeight;
228     }
229 
isDraggingThumb()230     public boolean isDraggingThumb() {
231         return mIsDragging;
232     }
233 
isThumbDetached()234     public boolean isThumbDetached() {
235         return mIsThumbDetached;
236     }
237 
238     /**
239      * Handles the touch event and determines whether to show the fast scroller (or updates it if
240      * it is already showing).
241      */
handleTouchEvent(MotionEvent ev, Point offset)242     public boolean handleTouchEvent(MotionEvent ev, Point offset) {
243         int x = (int) ev.getX() - offset.x;
244         int y = (int) ev.getY() - offset.y;
245 
246         switch (ev.getAction()) {
247             case MotionEvent.ACTION_DOWN:
248                 // Keep track of the down positions
249                 mDownX = x;
250                 mDownY = mLastY = y;
251                 mDownTimeStampMillis = ev.getDownTime();
252 
253                 if ((Math.abs(mDy) < mDeltaThreshold &&
254                         mRv.getScrollState() != SCROLL_STATE_IDLE)) {
255                     // now the touch events are being passed to the {@link WidgetCell} until the
256                     // touch sequence goes over the touch slop.
257                     mRv.stopScroll();
258                 }
259                 if (isNearThumb(x, y)) {
260                     mTouchOffsetY = mDownY - mThumbOffsetY;
261                 }
262                 break;
263             case MotionEvent.ACTION_MOVE:
264                 mLastY = y;
265                 int absDeltaY = Math.abs(y - mDownY);
266                 int absDeltaX = Math.abs(x - mDownX);
267 
268                 // Check if we should start scrolling, but ignore this fastscroll gesture if we have
269                 // exceeded some fixed movement
270                 mIgnoreDragGesture |= absDeltaY > mConfig.getScaledPagingTouchSlop();
271 
272                 if (!mIsDragging && !mIgnoreDragGesture && mRv.supportsFastScrolling()) {
273                     if ((isNearThumb(mDownX, mLastY) && ev.getEventTime() - mDownTimeStampMillis
274                                     > FASTSCROLL_THRESHOLD_MILLIS)) {
275                         calcTouchOffsetAndPrepToFastScroll(mDownY, mLastY);
276                     }
277                 }
278                 if (mIsDragging) {
279                     updateFastScrollSectionNameAndThumbOffset(y);
280                 }
281                 break;
282             case MotionEvent.ACTION_UP:
283             case MotionEvent.ACTION_CANCEL:
284                 endFastScrolling();
285                 break;
286         }
287         if (DEBUG) {
288             Log.d(TAG, (ev.getAction() == MotionEvent.ACTION_DOWN ? "\n" : "")
289                     + "handleTouchEvent " + MotionEvent.actionToString(ev.getAction())
290                     + " (" + x + "," + y + ")" + " isDragging=" + mIsDragging
291                     + " mIgnoreDragGesture=" + mIgnoreDragGesture);
292 
293         }
294         return mIsDragging;
295     }
296 
calcTouchOffsetAndPrepToFastScroll(int downY, int lastY)297     private void calcTouchOffsetAndPrepToFastScroll(int downY, int lastY) {
298         ActivityContext.lookupContext(getContext()).hideKeyboard();
299         mIsDragging = true;
300         if (mCanThumbDetach) {
301             mIsThumbDetached = true;
302         }
303         mTouchOffsetY += (lastY - downY);
304         animatePopupVisibility(true);
305         showActiveScrollbar(true);
306     }
307 
updateFastScrollSectionNameAndThumbOffset(int y)308     private void updateFastScrollSectionNameAndThumbOffset(int y) {
309         // Update the fastscroller section name at this touch position
310         int bottom = mRv.getScrollbarTrackHeight() - mThumbHeight;
311         float boundedY = (float) Math.max(0, Math.min(bottom, y - mTouchOffsetY));
312         String sectionName = mRv.scrollToPositionAtProgress(boundedY / bottom);
313         if (!sectionName.equals(mPopupSectionName)) {
314             mPopupSectionName = sectionName;
315             mPopupView.setText(sectionName);
316             performHapticFeedback(CLOCK_TICK);
317         }
318         animatePopupVisibility(!sectionName.isEmpty());
319         mLastTouchY = boundedY;
320         setThumbOffsetY((int) mLastTouchY);
321     }
322 
323     /** End any active fast scrolling touch handling, if applicable. */
endFastScrolling()324     public void endFastScrolling() {
325         mRv.onFastScrollCompleted();
326         mTouchOffsetY = 0;
327         mLastTouchY = 0;
328         mIgnoreDragGesture = false;
329         if (mIsDragging) {
330             mIsDragging = false;
331             animatePopupVisibility(false);
332             showActiveScrollbar(false);
333         }
334     }
335 
336     @Override
onDraw(Canvas canvas)337     public void onDraw(Canvas canvas) {
338         if (mThumbOffsetY < 0 || mRv == null) {
339             return;
340         }
341         int saveCount = canvas.save();
342         canvas.translate(getWidth() / 2, mRv.getScrollBarTop());
343         mThumbDrawOffset.set(getWidth() / 2, mRv.getScrollBarTop());
344         // Draw the track
345         float halfW = mWidth / 2;
346         canvas.drawRoundRect(-halfW, 0, halfW, mRv.getScrollbarTrackHeight(),
347                 mWidth, mWidth, mTrackPaint);
348 
349         canvas.translate(0, mThumbOffsetY);
350         mThumbDrawOffset.y += mThumbOffsetY;
351         halfW += mThumbPadding;
352         float r = getScrollThumbRadius();
353         mThumbBounds.set(-halfW, 0, halfW, mThumbHeight);
354         canvas.drawRoundRect(mThumbBounds, r, r, mThumbPaint);
355         if (Utilities.ATLEAST_Q) {
356             mThumbBounds.roundOut(SYSTEM_GESTURE_EXCLUSION_RECT.get(0));
357             // swiping very close to the thumb area (not just within it's bound)
358             // will also prevent back gesture
359             SYSTEM_GESTURE_EXCLUSION_RECT.get(0).offset(mThumbDrawOffset.x, mThumbDrawOffset.y);
360             if (Utilities.ATLEAST_Q && mSystemGestureInsets != null) {
361                 SYSTEM_GESTURE_EXCLUSION_RECT.get(0).left =
362                         SYSTEM_GESTURE_EXCLUSION_RECT.get(0).right - mSystemGestureInsets.right;
363             }
364             setSystemGestureExclusionRects(SYSTEM_GESTURE_EXCLUSION_RECT);
365         }
366         canvas.restoreToCount(saveCount);
367     }
368 
369     @Override
370     @RequiresApi(Build.VERSION_CODES.Q)
onApplyWindowInsets(WindowInsets insets)371     public WindowInsets onApplyWindowInsets(WindowInsets insets) {
372         if (Utilities.ATLEAST_Q) {
373             mSystemGestureInsets = insets.getSystemGestureInsets();
374         }
375         return super.onApplyWindowInsets(insets);
376     }
377 
getScrollThumbRadius()378     private float getScrollThumbRadius() {
379         return mWidth + mThumbPadding + mThumbPadding;
380     }
381 
382     /**
383      * Animates the width of the scrollbar.
384      */
showActiveScrollbar(boolean isScrolling)385     private void showActiveScrollbar(boolean isScrolling) {
386         if (mWidthAnimator != null) {
387             mWidthAnimator.cancel();
388         }
389 
390         mWidthAnimator = ObjectAnimator.ofInt(this, TRACK_WIDTH,
391                 isScrolling ? mMaxWidth : mMinWidth);
392         mWidthAnimator.setDuration(SCROLL_BAR_VIS_DURATION);
393         mWidthAnimator.start();
394     }
395 
396     /**
397      * Returns whether the specified point is inside the thumb bounds.
398      */
isNearThumb(int x, int y)399     private boolean isNearThumb(int x, int y) {
400         int offset = y - mThumbOffsetY;
401 
402         return x >= 0 && x < getWidth() && offset >= 0 && offset <= mThumbHeight;
403     }
404 
405     /**
406      * Returns true if AllAppsTransitionController can handle vertical motion
407      * beginning at this point.
408      */
shouldBlockIntercept(int x, int y)409     public boolean shouldBlockIntercept(int x, int y) {
410         return isNearThumb(x, y);
411     }
412 
413     /**
414      * Returns whether the specified x position is near the scroll bar.
415      */
isNearScrollBar(int x)416     public boolean isNearScrollBar(int x) {
417         return x >= (getWidth() - mMaxWidth) / 2 - mScrollbarLeftOffsetTouchDelegate
418                 && x <= (getWidth() + mMaxWidth) / 2;
419     }
420 
animatePopupVisibility(boolean visible)421     private void animatePopupVisibility(boolean visible) {
422         if (mPopupVisible != visible) {
423             mPopupVisible = visible;
424             mPopupView.animate().cancel();
425             mPopupView.animate().alpha(visible ? 1f : 0f).setDuration(visible ? 200 : 150).start();
426         }
427     }
428 
updatePopupY(int lastTouchY)429     private void updatePopupY(int lastTouchY) {
430         int height = mPopupView.getHeight();
431         // Aligns the rounded corner of the pop up with the top of the thumb.
432         float top = mRv.getScrollBarTop() + lastTouchY + (getScrollThumbRadius() / 2f)
433                 - (height / 2f);
434         top = Utilities.boundToRange(top, 0,
435                 getTop() + mRv.getScrollBarTop() + mRv.getScrollbarTrackHeight() - height);
436         mPopupView.setTranslationY(top);
437     }
438 
isHitInParent(float x, float y, Point outOffset)439     public boolean isHitInParent(float x, float y, Point outOffset) {
440         if (mThumbOffsetY < 0) {
441             return false;
442         }
443         getHitRect(sTempRect);
444         sTempRect.top += mRv.getScrollBarTop();
445         if (outOffset != null) {
446             outOffset.set(sTempRect.left, sTempRect.top);
447         }
448         return sTempRect.contains((int) x, (int) y);
449     }
450 
451     @Override
hasOverlappingRendering()452     public boolean hasOverlappingRendering() {
453         // There is actually some overlap between the track and the thumb. But since the track
454         // alpha is so low, it does not matter.
455         return false;
456     }
457 }
458