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