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