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