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 static com.android.launcher3.views.RecyclerViewFastScroller.FastScrollerLocation.ALL_APPS_SCROLLER; 24 import static com.android.launcher3.views.RecyclerViewFastScroller.FastScrollerLocation.WIDGET_SCROLLER; 25 26 import android.animation.ObjectAnimator; 27 import android.content.Context; 28 import android.content.res.Resources; 29 import android.content.res.TypedArray; 30 import android.graphics.Canvas; 31 import android.graphics.Insets; 32 import android.graphics.Paint; 33 import android.graphics.Point; 34 import android.graphics.Rect; 35 import android.graphics.RectF; 36 import android.text.TextUtils; 37 import android.util.AttributeSet; 38 import android.util.Log; 39 import android.util.Property; 40 import android.view.MotionEvent; 41 import android.view.View; 42 import android.view.ViewConfiguration; 43 import android.view.WindowInsets; 44 import android.widget.TextView; 45 46 import androidx.annotation.NonNull; 47 import androidx.constraintlayout.widget.ConstraintLayout; 48 import androidx.recyclerview.widget.RecyclerView; 49 50 import com.android.launcher3.FastScrollRecyclerView; 51 import com.android.launcher3.Flags; 52 import com.android.launcher3.R; 53 import com.android.launcher3.Utilities; 54 import com.android.launcher3.allapps.LetterListTextView; 55 import com.android.launcher3.graphics.FastScrollThumbDrawable; 56 import com.android.launcher3.util.Themes; 57 58 import java.util.Collections; 59 import java.util.List; 60 61 /** 62 * The track and scrollbar that shows when you scroll the list. 63 */ 64 public class RecyclerViewFastScroller extends View { 65 66 /** FastScrollerLocation describes what RecyclerView the fast scroller is dedicated to. */ 67 public enum FastScrollerLocation { 68 UNKNOWN_SCROLLER(0), 69 ALL_APPS_SCROLLER(1), 70 WIDGET_SCROLLER(2); 71 72 public final int location; 73 FastScrollerLocation(int location)74 FastScrollerLocation(int location) { 75 this.location = location; 76 } 77 } 78 private static final String TAG = "RecyclerViewFastScroller"; 79 private static final boolean DEBUG = false; 80 private static final int FASTSCROLL_THRESHOLD_MILLIS = 40; 81 private static final int SCROLL_DELTA_THRESHOLD_DP = 4; 82 83 // Track is very narrow to target and correctly. This is especially the case if a user is 84 // using a hardware case. Even if x is offset by following amount, we consider it to be valid. 85 private static final int SCROLLBAR_LEFT_OFFSET_TOUCH_DELEGATE_DP = 5; 86 private static final Rect sTempRect = new Rect(); 87 88 private static final Property<RecyclerViewFastScroller, Integer> TRACK_WIDTH = 89 new Property<RecyclerViewFastScroller, Integer>(Integer.class, "width") { 90 91 @Override 92 public Integer get(RecyclerViewFastScroller scrollBar) { 93 return scrollBar.mWidth; 94 } 95 96 @Override 97 public void set(RecyclerViewFastScroller scrollBar, Integer value) { 98 scrollBar.setTrackWidth(value); 99 } 100 }; 101 102 private final static int MAX_TRACK_ALPHA = 30; 103 private final static int SCROLL_BAR_VIS_DURATION = 150; 104 105 private static final List<Rect> SYSTEM_GESTURE_EXCLUSION_RECT = 106 Collections.singletonList(new Rect()); 107 108 private final int mMinWidth; 109 private final int mMaxWidth; 110 private final int mThumbPadding; 111 112 /** Keeps the last known scrolling delta/velocity along y-axis. */ 113 private int mDy = 0; 114 private final float mDeltaThreshold; 115 private final float mScrollbarLeftOffsetTouchDelegate; 116 117 private final ViewConfiguration mConfig; 118 119 // Current width of the track 120 private int mWidth; 121 private ObjectAnimator mWidthAnimator; 122 123 private final Paint mThumbPaint; 124 protected final int mThumbHeight; 125 private final RectF mThumbBounds = new RectF(); 126 private final Point mThumbDrawOffset = new Point(); 127 128 private final Paint mTrackPaint; 129 private final int mThumbColor; 130 private final int mThumbLetterScrollerColor; 131 132 private float mLastTouchY; 133 private boolean mIsDragging; 134 /** 135 * Tracks whether a keyboard hide request has been sent due to downward scrolling. 136 * <p> 137 * Set to true when scrolling down and reset when scrolling up to prevents redundant hide 138 * requests during continuous downward scrolls. 139 */ 140 private boolean mRequestedHideKeyboard; 141 private boolean mIsThumbDetached; 142 private final boolean mCanThumbDetach; 143 private boolean mIgnoreDragGesture; 144 private long mDownTimeStampMillis; 145 146 // This is the offset from the top of the scrollbar when the user first starts touching. To 147 // prevent jumping, this offset is applied as the user scrolls. 148 protected int mTouchOffsetY; 149 protected int mThumbOffsetY; 150 151 // Fast scroller popup 152 private TextView mPopupView; 153 private boolean mPopupVisible; 154 private CharSequence mPopupSectionName; 155 private Insets mSystemGestureInsets; 156 157 protected FastScrollRecyclerView mRv; 158 private RecyclerView.OnScrollListener mOnScrollListener; 159 private final ActivityContext mActivityContext; 160 161 private int mDownX; 162 private int mDownY; 163 private int mLastY; 164 private FastScrollerLocation mFastScrollerLocation; 165 RecyclerViewFastScroller(Context context)166 public RecyclerViewFastScroller(Context context) { 167 this(context, null); 168 } 169 RecyclerViewFastScroller(Context context, AttributeSet attrs)170 public RecyclerViewFastScroller(Context context, AttributeSet attrs) { 171 this(context, attrs, 0); 172 } 173 RecyclerViewFastScroller(Context context, AttributeSet attrs, int defStyleAttr)174 public RecyclerViewFastScroller(Context context, AttributeSet attrs, int defStyleAttr) { 175 super(context, attrs, defStyleAttr); 176 177 mFastScrollerLocation = FastScrollerLocation.UNKNOWN_SCROLLER; 178 mTrackPaint = new Paint(); 179 mTrackPaint.setColor(Themes.getAttrColor(context, android.R.attr.textColorPrimary)); 180 mTrackPaint.setAlpha(MAX_TRACK_ALPHA); 181 182 mThumbColor = Themes.getColorAccent(context); 183 mThumbLetterScrollerColor = context.getColor(R.color.materialColorSurfaceBright); 184 mThumbPaint = new Paint(); 185 mThumbPaint.setAntiAlias(true); 186 mThumbPaint.setColor(mThumbColor); 187 mThumbPaint.setStyle(Paint.Style.FILL); 188 189 Resources res = getResources(); 190 mWidth = mMinWidth = res.getDimensionPixelSize(R.dimen.fastscroll_track_min_width); 191 mMaxWidth = res.getDimensionPixelSize(R.dimen.fastscroll_track_max_width); 192 193 mThumbPadding = res.getDimensionPixelSize(R.dimen.fastscroll_thumb_padding); 194 mThumbHeight = res.getDimensionPixelSize(R.dimen.fastscroll_thumb_height); 195 196 mConfig = ViewConfiguration.get(context); 197 mDeltaThreshold = res.getDisplayMetrics().density * SCROLL_DELTA_THRESHOLD_DP; 198 mScrollbarLeftOffsetTouchDelegate = res.getDisplayMetrics().density 199 * SCROLLBAR_LEFT_OFFSET_TOUCH_DELEGATE_DP; 200 mActivityContext = ActivityContext.lookupContext(context); 201 TypedArray ta = 202 context.obtainStyledAttributes(attrs, R.styleable.RecyclerViewFastScroller, defStyleAttr, 0); 203 mCanThumbDetach = ta.getBoolean(R.styleable.RecyclerViewFastScroller_canThumbDetach, false); 204 ta.recycle(); 205 } 206 207 /** Sets the popup view to show while the scroller is being dragged */ setPopupView(TextView popupView)208 public void setPopupView(TextView popupView) { 209 mPopupView = popupView; 210 mPopupView.setBackground( 211 new FastScrollThumbDrawable(mThumbPaint, Utilities.isRtl(getResources()))); 212 } 213 setRecyclerView(FastScrollRecyclerView rv)214 public void setRecyclerView(FastScrollRecyclerView rv) { 215 if (mRv != null && mOnScrollListener != null) { 216 mRv.removeOnScrollListener(mOnScrollListener); 217 } 218 mRv = rv; 219 220 mRv.addOnScrollListener(mOnScrollListener = new RecyclerView.OnScrollListener() { 221 @Override 222 public void onScrolled(RecyclerView recyclerView, int dx, int dy) { 223 mDy = dy; 224 225 // TODO(winsonc): If we want to animate the section heads while scrolling, we can 226 // initiate that here if the recycler view scroll state is not 227 // RecyclerView.SCROLL_STATE_IDLE. 228 229 mRv.onUpdateScrollbar(dy); 230 } 231 }); 232 } 233 reattachThumbToScroll()234 public void reattachThumbToScroll() { 235 mIsThumbDetached = false; 236 } 237 setThumbOffsetY(int y)238 public void setThumbOffsetY(int y) { 239 if (mThumbOffsetY == y) { 240 return; 241 } 242 updatePopupY(y); 243 mThumbOffsetY = y; 244 invalidate(); 245 } 246 getThumbOffsetY()247 public int getThumbOffsetY() { 248 return mThumbOffsetY; 249 } 250 setTrackWidth(int width)251 private void setTrackWidth(int width) { 252 if (mWidth == width) { 253 return; 254 } 255 mWidth = width; 256 invalidate(); 257 } 258 getThumbHeight()259 public int getThumbHeight() { 260 return mThumbHeight; 261 } 262 isDraggingThumb()263 public boolean isDraggingThumb() { 264 return mIsDragging; 265 } 266 isThumbDetached()267 public boolean isThumbDetached() { 268 return mIsThumbDetached; 269 } 270 271 /** 272 * Handles the touch event and determines whether to show the fast scroller (or updates it if 273 * it is already showing). 274 */ handleTouchEvent(MotionEvent ev, Point offset)275 public boolean handleTouchEvent(MotionEvent ev, Point offset) { 276 int x = (int) ev.getX() - offset.x; 277 int y = (int) ev.getY() - offset.y; 278 279 switch (ev.getAction()) { 280 case MotionEvent.ACTION_DOWN: 281 // Keep track of the down positions 282 mDownX = x; 283 mDownY = mLastY = y; 284 mDownTimeStampMillis = ev.getDownTime(); 285 mRequestedHideKeyboard = false; 286 287 if ((Math.abs(mDy) < mDeltaThreshold && 288 mRv.getScrollState() != SCROLL_STATE_IDLE)) { 289 // now the touch events are being passed to the {@link WidgetCell} until the 290 // touch sequence goes over the touch slop. 291 mRv.stopScroll(); 292 } 293 if (isNearThumb(x, y)) { 294 mTouchOffsetY = mDownY - mThumbOffsetY; 295 } 296 break; 297 case MotionEvent.ACTION_MOVE: 298 boolean isScrollingDown = y > mLastY; 299 mLastY = y; 300 int absDeltaY = Math.abs(y - mDownY); 301 int absDeltaX = Math.abs(x - mDownX); 302 303 // Check if we should start scrolling, but ignore this fastscroll gesture if we have 304 // exceeded some fixed movement 305 mIgnoreDragGesture |= absDeltaY > mConfig.getScaledPagingTouchSlop(); 306 307 if (!mIsDragging && !mIgnoreDragGesture && mRv.supportsFastScrolling()) { 308 if ((isNearThumb(mDownX, mLastY) && ev.getEventTime() - mDownTimeStampMillis 309 > FASTSCROLL_THRESHOLD_MILLIS)) { 310 calcTouchOffsetAndPrepToFastScroll(mDownY, mLastY); 311 } 312 } 313 if (mIsDragging) { 314 if (isScrollingDown) { 315 if (!mRequestedHideKeyboard) { 316 mActivityContext.hideKeyboard(); 317 } 318 mRequestedHideKeyboard = true; 319 } else { 320 mRequestedHideKeyboard = false; 321 } 322 updateFastScrollSectionNameAndThumbOffset(y); 323 } 324 break; 325 case MotionEvent.ACTION_UP: 326 case MotionEvent.ACTION_CANCEL: 327 endFastScrolling(); 328 break; 329 } 330 if (DEBUG) { 331 Log.d(TAG, (ev.getAction() == MotionEvent.ACTION_DOWN ? "\n" : "") 332 + "handleTouchEvent " + MotionEvent.actionToString(ev.getAction()) 333 + " (" + x + "," + y + ")" + " isDragging=" + mIsDragging 334 + " mIgnoreDragGesture=" + mIgnoreDragGesture); 335 336 } 337 return mIsDragging; 338 } 339 calcTouchOffsetAndPrepToFastScroll(int downY, int lastY)340 private void calcTouchOffsetAndPrepToFastScroll(int downY, int lastY) { 341 mIsDragging = true; 342 if (mCanThumbDetach) { 343 mIsThumbDetached = true; 344 } 345 mTouchOffsetY += (lastY - downY); 346 animatePopupVisibility(true); 347 showActiveScrollbar(true); 348 } 349 updateFastScrollSectionNameAndThumbOffset(int y)350 private void updateFastScrollSectionNameAndThumbOffset(int y) { 351 // Update the fastscroller section name at this touch position 352 int bottom = mRv.getScrollbarTrackHeight() - mThumbHeight; 353 float boundedY = (float) Math.max(0, Math.min(bottom, y - mTouchOffsetY)); 354 CharSequence sectionName = mRv.scrollToPositionAtProgress(boundedY / bottom); 355 if (!sectionName.equals(mPopupSectionName)) { 356 mPopupSectionName = sectionName; 357 mPopupView.setText(sectionName); 358 // AllApps haptics are taken care of by AllAppsFastScrollHelper. 359 if (mFastScrollerLocation != ALL_APPS_SCROLLER) { 360 performHapticFeedback(CLOCK_TICK); 361 } 362 } 363 animatePopupVisibility(!TextUtils.isEmpty(sectionName)); 364 mLastTouchY = boundedY; 365 setThumbOffsetY((int) mLastTouchY); 366 updateFastScrollerLetterList(y); 367 } 368 updateFastScrollerLetterList(int y)369 private void updateFastScrollerLetterList(int y) { 370 if (!shouldUseLetterFastScroller()) { 371 return; 372 } 373 ConstraintLayout mLetterList = mRv.getLetterList(); 374 for (int i = 0; i < mLetterList.getChildCount(); i++) { 375 LetterListTextView currentLetter = (LetterListTextView) mLetterList.getChildAt(i); 376 currentLetter.animateBasedOnYPosition(y + mTouchOffsetY); 377 } 378 } 379 380 /** End any active fast scrolling touch handling, if applicable. */ endFastScrolling()381 public void endFastScrolling() { 382 mRv.onFastScrollCompleted(); 383 mTouchOffsetY = 0; 384 mLastTouchY = 0; 385 mIgnoreDragGesture = false; 386 if (mIsDragging) { 387 mIsDragging = false; 388 animatePopupVisibility(false); 389 showActiveScrollbar(false); 390 } 391 } 392 393 @Override onDraw(Canvas canvas)394 public void onDraw(Canvas canvas) { 395 if (mThumbOffsetY < 0 || mRv == null) { 396 return; 397 } 398 int saveCount = canvas.save(); 399 canvas.translate(getWidth() / 2, mRv.getScrollBarTop()); 400 mThumbDrawOffset.set(getWidth() / 2, mRv.getScrollBarTop()); 401 // Draw the track 402 float halfW = mWidth / 2; 403 boolean useLetterFastScroller = shouldUseLetterFastScroller(); 404 if (useLetterFastScroller) { 405 float translateX; 406 if (mIsDragging) { 407 // halfW * 3 is half circle. 408 translateX = halfW * 3; 409 } else { 410 translateX = halfW * 5; 411 } 412 canvas.translate(translateX, mThumbOffsetY); 413 } else { 414 canvas.drawRoundRect(-halfW, 0, halfW, mRv.getScrollbarTrackHeight(), 415 mWidth, mWidth, mTrackPaint); 416 canvas.translate(0, mThumbOffsetY); 417 } 418 mThumbDrawOffset.y += mThumbOffsetY; 419 420 /* Draw half circle */ 421 halfW += mThumbPadding; 422 float r = getScrollThumbRadius(); 423 if (useLetterFastScroller) { 424 mThumbPaint.setColor(mThumbLetterScrollerColor); 425 mThumbBounds.set(0, 0, 0, mThumbHeight); 426 canvas.drawCircle(-halfW, halfW, r * 2, mThumbPaint); 427 } else { 428 mThumbPaint.setColor(mThumbColor); 429 mThumbBounds.set(-halfW, 0, halfW, mThumbHeight); 430 canvas.drawRoundRect(mThumbBounds, r, r, mThumbPaint); 431 } 432 mThumbBounds.roundOut(SYSTEM_GESTURE_EXCLUSION_RECT.get(0)); 433 // swiping very close to the thumb area (not just within it's bound) 434 // will also prevent back gesture 435 SYSTEM_GESTURE_EXCLUSION_RECT.get(0).offset(mThumbDrawOffset.x, mThumbDrawOffset.y); 436 if (mSystemGestureInsets != null) { 437 SYSTEM_GESTURE_EXCLUSION_RECT.get(0).left = 438 SYSTEM_GESTURE_EXCLUSION_RECT.get(0).right - mSystemGestureInsets.right; 439 } 440 setSystemGestureExclusionRects(SYSTEM_GESTURE_EXCLUSION_RECT); 441 canvas.restoreToCount(saveCount); 442 } 443 shouldUseLetterFastScroller()444 boolean shouldUseLetterFastScroller() { 445 return Flags.letterFastScroller() 446 && getScrollerLocation() == FastScrollerLocation.ALL_APPS_SCROLLER; 447 } 448 449 @Override onApplyWindowInsets(WindowInsets insets)450 public WindowInsets onApplyWindowInsets(WindowInsets insets) { 451 mSystemGestureInsets = insets.getSystemGestureInsets(); 452 return super.onApplyWindowInsets(insets); 453 } 454 getScrollThumbRadius()455 private float getScrollThumbRadius() { 456 return mWidth + mThumbPadding + mThumbPadding; 457 } 458 459 /** 460 * Animates the width of the scrollbar. 461 */ showActiveScrollbar(boolean isScrolling)462 private void showActiveScrollbar(boolean isScrolling) { 463 if (mWidthAnimator != null) { 464 mWidthAnimator.cancel(); 465 } 466 467 mWidthAnimator = ObjectAnimator.ofInt(this, TRACK_WIDTH, 468 isScrolling ? mMaxWidth : mMinWidth); 469 mWidthAnimator.setDuration(SCROLL_BAR_VIS_DURATION); 470 mWidthAnimator.start(); 471 } 472 473 /** 474 * Returns whether the specified point is inside the thumb bounds. 475 */ isNearThumb(int x, int y)476 private boolean isNearThumb(int x, int y) { 477 int offset = y - mThumbOffsetY; 478 479 return x >= 0 && x < getWidth() && offset >= 0 && offset <= mThumbHeight; 480 } 481 482 /** 483 * Returns true if AllAppsTransitionController can handle vertical motion 484 * beginning at this point. 485 */ shouldBlockIntercept(int x, int y)486 public boolean shouldBlockIntercept(int x, int y) { 487 return isNearThumb(x, y); 488 } 489 getScrollerLocation()490 public FastScrollerLocation getScrollerLocation() { 491 return mFastScrollerLocation; 492 } 493 setFastScrollerLocation(@onNull FastScrollerLocation location)494 public void setFastScrollerLocation(@NonNull FastScrollerLocation location) { 495 mFastScrollerLocation = location; 496 } 497 animatePopupVisibility(boolean visible)498 private void animatePopupVisibility(boolean visible) { 499 if (mPopupVisible != visible) { 500 mPopupVisible = visible; 501 if (shouldUseLetterFastScroller()) { 502 mRv.getLetterList().animate().alpha(visible ? 1f : 0f) 503 .setDuration(visible ? 200 : 150).start(); 504 } else { 505 mPopupView.animate().cancel(); 506 mPopupView.animate().alpha(visible ? 1f : 0f) 507 .setDuration(visible ? 200 : 150).start(); 508 } 509 } 510 } 511 updatePopupY(int lastTouchY)512 private void updatePopupY(int lastTouchY) { 513 int height = mPopupView.getHeight(); 514 // Aligns the rounded corner of the pop up with the top of the thumb. 515 float top = mRv.getScrollBarTop() + lastTouchY + (getScrollThumbRadius() / 2f) 516 - (height / 2f); 517 top = Utilities.boundToRange(top, 0, 518 getTop() + mRv.getScrollBarTop() + mRv.getScrollbarTrackHeight() - height); 519 mPopupView.setTranslationY(top); 520 } 521 isHitInParent(float x, float y, Point outOffset)522 public boolean isHitInParent(float x, float y, Point outOffset) { 523 if (mThumbOffsetY < 0) { 524 return false; 525 } 526 getHitRect(sTempRect); 527 sTempRect.top += mRv.getScrollBarTop(); 528 if (outOffset != null) { 529 outOffset.set(sTempRect.left, sTempRect.top); 530 } 531 return sTempRect.contains((int) x, (int) y); 532 } 533 534 @Override hasOverlappingRendering()535 public boolean hasOverlappingRendering() { 536 // There is actually some overlap between the track and the thumb. But since the track 537 // alpha is so low, it does not matter. 538 return false; 539 } 540 } 541