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