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 android.animation.ObjectAnimator; 20 import android.content.Context; 21 import android.content.res.Resources; 22 import android.content.res.TypedArray; 23 import android.graphics.Canvas; 24 import android.graphics.Paint; 25 import android.graphics.Point; 26 import android.graphics.Rect; 27 import android.graphics.RectF; 28 import android.util.AttributeSet; 29 import android.util.Property; 30 import android.view.MotionEvent; 31 import android.view.View; 32 import android.view.ViewConfiguration; 33 import android.widget.TextView; 34 35 import androidx.recyclerview.widget.RecyclerView; 36 37 import com.android.launcher3.BaseRecyclerView; 38 import com.android.launcher3.R; 39 import com.android.launcher3.Utilities; 40 import com.android.launcher3.graphics.FastScrollThumbDrawable; 41 import com.android.launcher3.util.Themes; 42 43 import java.util.Collections; 44 import java.util.List; 45 46 /** 47 * The track and scrollbar that shows when you scroll the list. 48 */ 49 public class RecyclerViewFastScroller extends View { 50 51 private static final int SCROLL_DELTA_THRESHOLD_DP = 4; 52 private static final Rect sTempRect = new Rect(); 53 54 private static final Property<RecyclerViewFastScroller, Integer> TRACK_WIDTH = 55 new Property<RecyclerViewFastScroller, Integer>(Integer.class, "width") { 56 57 @Override 58 public Integer get(RecyclerViewFastScroller scrollBar) { 59 return scrollBar.mWidth; 60 } 61 62 @Override 63 public void set(RecyclerViewFastScroller scrollBar, Integer value) { 64 scrollBar.setTrackWidth(value); 65 } 66 }; 67 68 private final static int MAX_TRACK_ALPHA = 30; 69 private final static int SCROLL_BAR_VIS_DURATION = 150; 70 71 private static final List<Rect> SYSTEM_GESTURE_EXCLUSION_RECT = 72 Collections.singletonList(new Rect()); 73 74 private final int mMinWidth; 75 private final int mMaxWidth; 76 private final int mThumbPadding; 77 78 /** Keeps the last known scrolling delta/velocity along y-axis. */ 79 private int mDy = 0; 80 private final float mDeltaThreshold; 81 82 private final ViewConfiguration mConfig; 83 84 // Current width of the track 85 private int mWidth; 86 private ObjectAnimator mWidthAnimator; 87 88 private final Paint mThumbPaint; 89 protected final int mThumbHeight; 90 private final RectF mThumbBounds = new RectF(); 91 private final Point mThumbDrawOffset = new Point(); 92 93 private final Paint mTrackPaint; 94 95 private float mLastTouchY; 96 private boolean mIsDragging; 97 private boolean mIsThumbDetached; 98 private final boolean mCanThumbDetach; 99 private boolean mIgnoreDragGesture; 100 101 // This is the offset from the top of the scrollbar when the user first starts touching. To 102 // prevent jumping, this offset is applied as the user scrolls. 103 protected int mTouchOffsetY; 104 protected int mThumbOffsetY; 105 106 // Fast scroller popup 107 private TextView mPopupView; 108 private boolean mPopupVisible; 109 private String mPopupSectionName; 110 111 protected BaseRecyclerView mRv; 112 private RecyclerView.OnScrollListener mOnScrollListener; 113 114 private int mDownX; 115 private int mDownY; 116 private int mLastY; 117 RecyclerViewFastScroller(Context context)118 public RecyclerViewFastScroller(Context context) { 119 this(context, null); 120 } 121 RecyclerViewFastScroller(Context context, AttributeSet attrs)122 public RecyclerViewFastScroller(Context context, AttributeSet attrs) { 123 this(context, attrs, 0); 124 } 125 RecyclerViewFastScroller(Context context, AttributeSet attrs, int defStyleAttr)126 public RecyclerViewFastScroller(Context context, AttributeSet attrs, int defStyleAttr) { 127 super(context, attrs, defStyleAttr); 128 129 mTrackPaint = new Paint(); 130 mTrackPaint.setColor(Themes.getAttrColor(context, android.R.attr.textColorPrimary)); 131 mTrackPaint.setAlpha(MAX_TRACK_ALPHA); 132 133 mThumbPaint = new Paint(); 134 mThumbPaint.setAntiAlias(true); 135 mThumbPaint.setColor(Themes.getColorAccent(context)); 136 mThumbPaint.setStyle(Paint.Style.FILL); 137 138 Resources res = getResources(); 139 mWidth = mMinWidth = res.getDimensionPixelSize(R.dimen.fastscroll_track_min_width); 140 mMaxWidth = res.getDimensionPixelSize(R.dimen.fastscroll_track_max_width); 141 142 mThumbPadding = res.getDimensionPixelSize(R.dimen.fastscroll_thumb_padding); 143 mThumbHeight = res.getDimensionPixelSize(R.dimen.fastscroll_thumb_height); 144 145 mConfig = ViewConfiguration.get(context); 146 mDeltaThreshold = res.getDisplayMetrics().density * SCROLL_DELTA_THRESHOLD_DP; 147 148 TypedArray ta = 149 context.obtainStyledAttributes(attrs, R.styleable.RecyclerViewFastScroller, defStyleAttr, 0); 150 mCanThumbDetach = ta.getBoolean(R.styleable.RecyclerViewFastScroller_canThumbDetach, false); 151 ta.recycle(); 152 } 153 setRecyclerView(BaseRecyclerView rv, TextView popupView)154 public void setRecyclerView(BaseRecyclerView rv, TextView popupView) { 155 if (mRv != null && mOnScrollListener != null) { 156 mRv.removeOnScrollListener(mOnScrollListener); 157 } 158 mRv = rv; 159 160 mRv.addOnScrollListener(mOnScrollListener = new RecyclerView.OnScrollListener() { 161 @Override 162 public void onScrolled(RecyclerView recyclerView, int dx, int dy) { 163 mDy = dy; 164 165 // TODO(winsonc): If we want to animate the section heads while scrolling, we can 166 // initiate that here if the recycler view scroll state is not 167 // RecyclerView.SCROLL_STATE_IDLE. 168 169 mRv.onUpdateScrollbar(dy); 170 } 171 }); 172 173 mPopupView = popupView; 174 mPopupView.setBackground( 175 new FastScrollThumbDrawable(mThumbPaint, Utilities.isRtl(getResources()))); 176 } 177 reattachThumbToScroll()178 public void reattachThumbToScroll() { 179 mIsThumbDetached = false; 180 } 181 setThumbOffsetY(int y)182 public void setThumbOffsetY(int y) { 183 if (mThumbOffsetY == y) { 184 return; 185 } 186 updatePopupY(y); 187 mThumbOffsetY = y; 188 invalidate(); 189 } 190 getThumbOffsetY()191 public int getThumbOffsetY() { 192 return mThumbOffsetY; 193 } 194 setTrackWidth(int width)195 private void setTrackWidth(int width) { 196 if (mWidth == width) { 197 return; 198 } 199 mWidth = width; 200 invalidate(); 201 } 202 getThumbHeight()203 public int getThumbHeight() { 204 return mThumbHeight; 205 } 206 isDraggingThumb()207 public boolean isDraggingThumb() { 208 return mIsDragging; 209 } 210 isThumbDetached()211 public boolean isThumbDetached() { 212 return mIsThumbDetached; 213 } 214 215 /** 216 * Handles the touch event and determines whether to show the fast scroller (or updates it if 217 * it is already showing). 218 */ handleTouchEvent(MotionEvent ev, Point offset)219 public boolean handleTouchEvent(MotionEvent ev, Point offset) { 220 int x = (int) ev.getX() - offset.x; 221 int y = (int) ev.getY() - offset.y; 222 switch (ev.getAction()) { 223 case MotionEvent.ACTION_DOWN: 224 // Keep track of the down positions 225 mDownX = x; 226 mDownY = mLastY = y; 227 228 if ((Math.abs(mDy) < mDeltaThreshold && 229 mRv.getScrollState() != RecyclerView.SCROLL_STATE_IDLE)) { 230 // now the touch events are being passed to the {@link WidgetCell} until the 231 // touch sequence goes over the touch slop. 232 mRv.stopScroll(); 233 } 234 if (isNearThumb(x, y)) { 235 mTouchOffsetY = mDownY - mThumbOffsetY; 236 } else if (mRv.supportsFastScrolling() 237 && isNearScrollBar(mDownX)) { 238 calcTouchOffsetAndPrepToFastScroll(mDownY, mLastY); 239 updateFastScrollSectionNameAndThumbOffset(y); 240 } 241 break; 242 case MotionEvent.ACTION_MOVE: 243 mLastY = y; 244 245 // Check if we should start scrolling, but ignore this fastscroll gesture if we have 246 // exceeded some fixed movement 247 mIgnoreDragGesture |= Math.abs(y - mDownY) > mConfig.getScaledPagingTouchSlop(); 248 if (!mIsDragging && !mIgnoreDragGesture && mRv.supportsFastScrolling() && 249 isNearThumb(mDownX, mLastY) && 250 Math.abs(y - mDownY) > mConfig.getScaledTouchSlop()) { 251 calcTouchOffsetAndPrepToFastScroll(mDownY, mLastY); 252 } 253 if (mIsDragging) { 254 updateFastScrollSectionNameAndThumbOffset(y); 255 } 256 break; 257 case MotionEvent.ACTION_UP: 258 case MotionEvent.ACTION_CANCEL: 259 mRv.onFastScrollCompleted(); 260 mTouchOffsetY = 0; 261 mLastTouchY = 0; 262 mIgnoreDragGesture = false; 263 if (mIsDragging) { 264 mIsDragging = false; 265 animatePopupVisibility(false); 266 showActiveScrollbar(false); 267 } 268 break; 269 } 270 return mIsDragging; 271 } 272 calcTouchOffsetAndPrepToFastScroll(int downY, int lastY)273 private void calcTouchOffsetAndPrepToFastScroll(int downY, int lastY) { 274 mIsDragging = true; 275 if (mCanThumbDetach) { 276 mIsThumbDetached = true; 277 } 278 mTouchOffsetY += (lastY - downY); 279 animatePopupVisibility(true); 280 showActiveScrollbar(true); 281 } 282 updateFastScrollSectionNameAndThumbOffset(int y)283 private void updateFastScrollSectionNameAndThumbOffset(int y) { 284 // Update the fastscroller section name at this touch position 285 int bottom = mRv.getScrollbarTrackHeight() - mThumbHeight; 286 float boundedY = (float) Math.max(0, Math.min(bottom, y - mTouchOffsetY)); 287 String sectionName = mRv.scrollToPositionAtProgress(boundedY / bottom); 288 if (!sectionName.equals(mPopupSectionName)) { 289 mPopupSectionName = sectionName; 290 mPopupView.setText(sectionName); 291 } 292 animatePopupVisibility(!sectionName.isEmpty()); 293 mLastTouchY = boundedY; 294 setThumbOffsetY((int) mLastTouchY); 295 } 296 onDraw(Canvas canvas)297 public void onDraw(Canvas canvas) { 298 if (mThumbOffsetY < 0) { 299 return; 300 } 301 int saveCount = canvas.save(); 302 canvas.translate(getWidth() / 2, mRv.getScrollBarTop()); 303 mThumbDrawOffset.set(getWidth() / 2, mRv.getScrollBarTop()); 304 // Draw the track 305 float halfW = mWidth / 2; 306 canvas.drawRoundRect(-halfW, 0, halfW, mRv.getScrollbarTrackHeight(), 307 mWidth, mWidth, mTrackPaint); 308 309 canvas.translate(0, mThumbOffsetY); 310 mThumbDrawOffset.y += mThumbOffsetY; 311 halfW += mThumbPadding; 312 float r = getScrollThumbRadius(); 313 mThumbBounds.set(-halfW, 0, halfW, mThumbHeight); 314 canvas.drawRoundRect(mThumbBounds, r, r, mThumbPaint); 315 if (Utilities.ATLEAST_Q) { 316 mThumbBounds.roundOut(SYSTEM_GESTURE_EXCLUSION_RECT.get(0)); 317 SYSTEM_GESTURE_EXCLUSION_RECT.get(0).offset(mThumbDrawOffset.x, mThumbDrawOffset.y); 318 setSystemGestureExclusionRects(SYSTEM_GESTURE_EXCLUSION_RECT); 319 } 320 canvas.restoreToCount(saveCount); 321 } 322 getScrollThumbRadius()323 private float getScrollThumbRadius() { 324 return mWidth + mThumbPadding + mThumbPadding; 325 } 326 327 /** 328 * Animates the width of the scrollbar. 329 */ showActiveScrollbar(boolean isScrolling)330 private void showActiveScrollbar(boolean isScrolling) { 331 if (mWidthAnimator != null) { 332 mWidthAnimator.cancel(); 333 } 334 335 mWidthAnimator = ObjectAnimator.ofInt(this, TRACK_WIDTH, 336 isScrolling ? mMaxWidth : mMinWidth); 337 mWidthAnimator.setDuration(SCROLL_BAR_VIS_DURATION); 338 mWidthAnimator.start(); 339 } 340 341 /** 342 * Returns whether the specified point is inside the thumb bounds. 343 */ isNearThumb(int x, int y)344 private boolean isNearThumb(int x, int y) { 345 int offset = y - mThumbOffsetY; 346 347 return x >= 0 && x < getWidth() && offset >= 0 && offset <= mThumbHeight; 348 } 349 350 /** 351 * Returns true if AllAppsTransitionController can handle vertical motion 352 * beginning at this point. 353 */ shouldBlockIntercept(int x, int y)354 public boolean shouldBlockIntercept(int x, int y) { 355 return isNearThumb(x, y); 356 } 357 358 /** 359 * Returns whether the specified x position is near the scroll bar. 360 */ isNearScrollBar(int x)361 public boolean isNearScrollBar(int x) { 362 return x >= (getWidth() - mMaxWidth) / 2 && x <= (getWidth() + mMaxWidth) / 2; 363 } 364 animatePopupVisibility(boolean visible)365 private void animatePopupVisibility(boolean visible) { 366 if (mPopupVisible != visible) { 367 mPopupVisible = visible; 368 mPopupView.animate().cancel(); 369 mPopupView.animate().alpha(visible ? 1f : 0f).setDuration(visible ? 200 : 150).start(); 370 } 371 } 372 updatePopupY(int lastTouchY)373 private void updatePopupY(int lastTouchY) { 374 if (!mPopupVisible) { 375 return; 376 } 377 int height = mPopupView.getHeight(); 378 // Aligns the rounded corner of the pop up with the top of the thumb. 379 float top = mRv.getScrollBarTop() + lastTouchY + (getScrollThumbRadius() / 2f) 380 - (height / 2f); 381 top = Utilities.boundToRange(top, 0, 382 getTop() + mRv.getScrollBarTop() + mRv.getScrollbarTrackHeight() - height); 383 mPopupView.setTranslationY(top); 384 } 385 isHitInParent(float x, float y, Point outOffset)386 public boolean isHitInParent(float x, float y, Point outOffset) { 387 if (mThumbOffsetY < 0) { 388 return false; 389 } 390 getHitRect(sTempRect); 391 sTempRect.top += mRv.getScrollBarTop(); 392 if (outOffset != null) { 393 outOffset.set(sTempRect.left, sTempRect.top); 394 } 395 return sTempRect.contains((int) x, (int) y); 396 } 397 398 @Override hasOverlappingRendering()399 public boolean hasOverlappingRendering() { 400 // There is actually some overlap between the track and the thumb. But since the track 401 // alpha is so low, it does not matter. 402 return false; 403 } 404 } 405