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