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