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