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