Files
Lawnchair/src/com/android/launcher3/views/RecyclerViewFastScroller.java
T
Sunny Goyal 77acf12905 Fixing header jump
Linking header position to an empty entry in the recyclerView,
instead of calculating the vertical scroll position. This
allows the header to be in sync with the recyclerView scroll and
item animations

Other simplifications:
> Moving top collapse handle out of header view (it doesn't scroll)
> Removing background clipping logic from full-sheet
> Moving tab bar inside the header view

Bug: 196464142
Test: Verified on device
Change-Id: Iae5a0ae9af7ce258e1b391b8e85c5c270fe56197
2021-08-16 09:45:14 -07:00

461 lines
16 KiB
Java

/*
* Copyright (C) 2017 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.android.launcher3.views;
import static android.view.HapticFeedbackConstants.CLOCK_TICK;
import static androidx.recyclerview.widget.RecyclerView.SCROLL_STATE_IDLE;
import android.animation.ObjectAnimator;
import android.content.Context;
import android.content.res.Resources;
import android.content.res.TypedArray;
import android.graphics.Canvas;
import android.graphics.Insets;
import android.graphics.Paint;
import android.graphics.Point;
import android.graphics.Rect;
import android.graphics.RectF;
import android.os.Build;
import android.util.AttributeSet;
import android.util.Log;
import android.util.Property;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewConfiguration;
import android.view.WindowInsets;
import android.widget.TextView;
import androidx.annotation.RequiresApi;
import androidx.recyclerview.widget.RecyclerView;
import com.android.launcher3.BaseRecyclerView;
import com.android.launcher3.R;
import com.android.launcher3.Utilities;
import com.android.launcher3.graphics.FastScrollThumbDrawable;
import com.android.launcher3.util.Themes;
import java.util.Collections;
import java.util.List;
/**
* The track and scrollbar that shows when you scroll the list.
*/
public class RecyclerViewFastScroller extends View {
private static final String TAG = "RecyclerViewFastScroller";
private static final boolean DEBUG = false;
private static final int FASTSCROLL_THRESHOLD_MILLIS = 40;
private static final int SCROLL_DELTA_THRESHOLD_DP = 4;
// Track is very narrow to target and correctly. This is especially the case if a user is
// using a hardware case. Even if x is offset by following amount, we consider it to be valid.
private static final int SCROLLBAR_LEFT_OFFSET_TOUCH_DELEGATE_DP = 5;
private static final Rect sTempRect = new Rect();
private static final Property<RecyclerViewFastScroller, Integer> TRACK_WIDTH =
new Property<RecyclerViewFastScroller, Integer>(Integer.class, "width") {
@Override
public Integer get(RecyclerViewFastScroller scrollBar) {
return scrollBar.mWidth;
}
@Override
public void set(RecyclerViewFastScroller scrollBar, Integer value) {
scrollBar.setTrackWidth(value);
}
};
private final static int MAX_TRACK_ALPHA = 30;
private final static int SCROLL_BAR_VIS_DURATION = 150;
private static final List<Rect> SYSTEM_GESTURE_EXCLUSION_RECT =
Collections.singletonList(new Rect());
private final int mMinWidth;
private final int mMaxWidth;
private final int mThumbPadding;
/** Keeps the last known scrolling delta/velocity along y-axis. */
private int mDy = 0;
private final float mDeltaThreshold;
private final float mScrollbarLeftOffsetTouchDelegate;
private final ViewConfiguration mConfig;
// Current width of the track
private int mWidth;
private ObjectAnimator mWidthAnimator;
private final Paint mThumbPaint;
protected final int mThumbHeight;
private final RectF mThumbBounds = new RectF();
private final Point mThumbDrawOffset = new Point();
private final Paint mTrackPaint;
private float mLastTouchY;
private boolean mIsDragging;
private boolean mIsThumbDetached;
private final boolean mCanThumbDetach;
private boolean mIgnoreDragGesture;
private boolean mIsRecyclerViewFirstChildInParent = true;
private long mDownTimeStampMillis;
// This is the offset from the top of the scrollbar when the user first starts touching. To
// prevent jumping, this offset is applied as the user scrolls.
protected int mTouchOffsetY;
protected int mThumbOffsetY;
protected int mRvOffsetY;
// Fast scroller popup
private TextView mPopupView;
private boolean mPopupVisible;
private String mPopupSectionName;
private Insets mSystemGestureInsets;
protected BaseRecyclerView mRv;
private RecyclerView.OnScrollListener mOnScrollListener;
private int mDownX;
private int mDownY;
private int mLastY;
public RecyclerViewFastScroller(Context context) {
this(context, null);
}
public RecyclerViewFastScroller(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public RecyclerViewFastScroller(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
mTrackPaint = new Paint();
mTrackPaint.setColor(Themes.getAttrColor(context, android.R.attr.textColorPrimary));
mTrackPaint.setAlpha(MAX_TRACK_ALPHA);
mThumbPaint = new Paint();
mThumbPaint.setAntiAlias(true);
mThumbPaint.setColor(Themes.getColorAccent(context));
mThumbPaint.setStyle(Paint.Style.FILL);
Resources res = getResources();
mWidth = mMinWidth = res.getDimensionPixelSize(R.dimen.fastscroll_track_min_width);
mMaxWidth = res.getDimensionPixelSize(R.dimen.fastscroll_track_max_width);
mThumbPadding = res.getDimensionPixelSize(R.dimen.fastscroll_thumb_padding);
mThumbHeight = res.getDimensionPixelSize(R.dimen.fastscroll_thumb_height);
mConfig = ViewConfiguration.get(context);
mDeltaThreshold = res.getDisplayMetrics().density * SCROLL_DELTA_THRESHOLD_DP;
mScrollbarLeftOffsetTouchDelegate = res.getDisplayMetrics().density
* SCROLLBAR_LEFT_OFFSET_TOUCH_DELEGATE_DP;
TypedArray ta =
context.obtainStyledAttributes(attrs, R.styleable.RecyclerViewFastScroller, defStyleAttr, 0);
mCanThumbDetach = ta.getBoolean(R.styleable.RecyclerViewFastScroller_canThumbDetach, false);
ta.recycle();
}
public void setRecyclerView(BaseRecyclerView rv, TextView popupView) {
if (mRv != null && mOnScrollListener != null) {
mRv.removeOnScrollListener(mOnScrollListener);
}
mRv = rv;
mRv.addOnScrollListener(mOnScrollListener = new RecyclerView.OnScrollListener() {
@Override
public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
mDy = dy;
// TODO(winsonc): If we want to animate the section heads while scrolling, we can
// initiate that here if the recycler view scroll state is not
// RecyclerView.SCROLL_STATE_IDLE.
mRv.onUpdateScrollbar(dy);
}
});
mPopupView = popupView;
mPopupView.setBackground(
new FastScrollThumbDrawable(mThumbPaint, Utilities.isRtl(getResources())));
}
public void reattachThumbToScroll() {
mIsThumbDetached = false;
}
public void setThumbOffsetY(int y) {
if (mThumbOffsetY == y) {
int rvCurrentOffsetY = mRv.getCurrentScrollY();
if (mRvOffsetY != rvCurrentOffsetY) {
mRvOffsetY = mRv.getCurrentScrollY();
}
return;
}
updatePopupY(y);
mThumbOffsetY = y;
invalidate();
mRvOffsetY = mRv.getCurrentScrollY();
}
public int getThumbOffsetY() {
return mThumbOffsetY;
}
private void setTrackWidth(int width) {
if (mWidth == width) {
return;
}
mWidth = width;
invalidate();
}
public int getThumbHeight() {
return mThumbHeight;
}
public boolean isDraggingThumb() {
return mIsDragging;
}
public boolean isThumbDetached() {
return mIsThumbDetached;
}
/**
* Handles the touch event and determines whether to show the fast scroller (or updates it if
* it is already showing).
*/
public boolean handleTouchEvent(MotionEvent ev, Point offset) {
int x = (int) ev.getX() - offset.x;
int y = (int) ev.getY() - offset.y;
switch (ev.getAction()) {
case MotionEvent.ACTION_DOWN:
// Keep track of the down positions
mDownX = x;
mDownY = mLastY = y;
mDownTimeStampMillis = ev.getDownTime();
if ((Math.abs(mDy) < mDeltaThreshold &&
mRv.getScrollState() != SCROLL_STATE_IDLE)) {
// now the touch events are being passed to the {@link WidgetCell} until the
// touch sequence goes over the touch slop.
mRv.stopScroll();
}
if (isNearThumb(x, y)) {
mTouchOffsetY = mDownY - mThumbOffsetY;
}
break;
case MotionEvent.ACTION_MOVE:
mLastY = y;
int absDeltaY = Math.abs(y - mDownY);
int absDeltaX = Math.abs(x - mDownX);
// Check if we should start scrolling, but ignore this fastscroll gesture if we have
// exceeded some fixed movement
mIgnoreDragGesture |= absDeltaY > mConfig.getScaledPagingTouchSlop();
if (!mIsDragging && !mIgnoreDragGesture && mRv.supportsFastScrolling()) {
if ((isNearThumb(mDownX, mLastY) && ev.getEventTime() - mDownTimeStampMillis
> FASTSCROLL_THRESHOLD_MILLIS)) {
calcTouchOffsetAndPrepToFastScroll(mDownY, mLastY);
}
}
if (mIsDragging) {
updateFastScrollSectionNameAndThumbOffset(y);
}
break;
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_CANCEL:
mRv.onFastScrollCompleted();
mTouchOffsetY = 0;
mLastTouchY = 0;
mIgnoreDragGesture = false;
if (mIsDragging) {
mIsDragging = false;
animatePopupVisibility(false);
showActiveScrollbar(false);
}
break;
}
if (DEBUG) {
Log.d(TAG, (ev.getAction() == MotionEvent.ACTION_DOWN ? "\n" : "")
+ "handleTouchEvent " + MotionEvent.actionToString(ev.getAction())
+ " (" + x + "," + y + ")" + " isDragging=" + mIsDragging
+ " mIgnoreDragGesture=" + mIgnoreDragGesture);
}
return mIsDragging;
}
private void calcTouchOffsetAndPrepToFastScroll(int downY, int lastY) {
mIsDragging = true;
if (mCanThumbDetach) {
mIsThumbDetached = true;
}
mTouchOffsetY += (lastY - downY);
animatePopupVisibility(true);
showActiveScrollbar(true);
}
private void updateFastScrollSectionNameAndThumbOffset(int y) {
// Update the fastscroller section name at this touch position
int bottom = mRv.getScrollbarTrackHeight() - mThumbHeight;
float boundedY = (float) Math.max(0, Math.min(bottom, y - mTouchOffsetY));
String sectionName = mRv.scrollToPositionAtProgress(boundedY / bottom);
if (!sectionName.equals(mPopupSectionName)) {
mPopupSectionName = sectionName;
mPopupView.setText(sectionName);
performHapticFeedback(CLOCK_TICK);
}
animatePopupVisibility(!sectionName.isEmpty());
mLastTouchY = boundedY;
setThumbOffsetY((int) mLastTouchY);
}
public void onDraw(Canvas canvas) {
if (mThumbOffsetY < 0) {
return;
}
int saveCount = canvas.save();
canvas.translate(getWidth() / 2, mRv.getScrollBarTop());
mThumbDrawOffset.set(getWidth() / 2, mRv.getScrollBarTop());
// Draw the track
float halfW = mWidth / 2;
canvas.drawRoundRect(-halfW, 0, halfW, mRv.getScrollbarTrackHeight(),
mWidth, mWidth, mTrackPaint);
canvas.translate(0, mThumbOffsetY);
mThumbDrawOffset.y += mThumbOffsetY;
halfW += mThumbPadding;
float r = getScrollThumbRadius();
mThumbBounds.set(-halfW, 0, halfW, mThumbHeight);
canvas.drawRoundRect(mThumbBounds, r, r, mThumbPaint);
if (Utilities.ATLEAST_Q) {
mThumbBounds.roundOut(SYSTEM_GESTURE_EXCLUSION_RECT.get(0));
// swiping very close to the thumb area (not just within it's bound)
// will also prevent back gesture
SYSTEM_GESTURE_EXCLUSION_RECT.get(0).offset(mThumbDrawOffset.x, mThumbDrawOffset.y);
if (Utilities.ATLEAST_Q && mSystemGestureInsets != null) {
SYSTEM_GESTURE_EXCLUSION_RECT.get(0).left =
SYSTEM_GESTURE_EXCLUSION_RECT.get(0).right - mSystemGestureInsets.right;
}
setSystemGestureExclusionRects(SYSTEM_GESTURE_EXCLUSION_RECT);
}
canvas.restoreToCount(saveCount);
}
@Override
@RequiresApi(Build.VERSION_CODES.Q)
public WindowInsets onApplyWindowInsets(WindowInsets insets) {
if (Utilities.ATLEAST_Q) {
mSystemGestureInsets = insets.getSystemGestureInsets();
}
return super.onApplyWindowInsets(insets);
}
private float getScrollThumbRadius() {
return mWidth + mThumbPadding + mThumbPadding;
}
/**
* Animates the width of the scrollbar.
*/
private void showActiveScrollbar(boolean isScrolling) {
if (mWidthAnimator != null) {
mWidthAnimator.cancel();
}
mWidthAnimator = ObjectAnimator.ofInt(this, TRACK_WIDTH,
isScrolling ? mMaxWidth : mMinWidth);
mWidthAnimator.setDuration(SCROLL_BAR_VIS_DURATION);
mWidthAnimator.start();
}
/**
* Returns whether the specified point is inside the thumb bounds.
*/
private boolean isNearThumb(int x, int y) {
int offset = y - mThumbOffsetY;
return x >= 0 && x < getWidth() && offset >= 0 && offset <= mThumbHeight;
}
/**
* Returns true if AllAppsTransitionController can handle vertical motion
* beginning at this point.
*/
public boolean shouldBlockIntercept(int x, int y) {
return isNearThumb(x, y);
}
/**
* Returns whether the specified x position is near the scroll bar.
*/
public boolean isNearScrollBar(int x) {
return x >= (getWidth() - mMaxWidth) / 2 - mScrollbarLeftOffsetTouchDelegate
&& x <= (getWidth() + mMaxWidth) / 2;
}
private void animatePopupVisibility(boolean visible) {
if (mPopupVisible != visible) {
mPopupVisible = visible;
mPopupView.animate().cancel();
mPopupView.animate().alpha(visible ? 1f : 0f).setDuration(visible ? 200 : 150).start();
}
}
private void updatePopupY(int lastTouchY) {
int height = mPopupView.getHeight();
// Aligns the rounded corner of the pop up with the top of the thumb.
float top = mRv.getScrollBarTop() + lastTouchY + (getScrollThumbRadius() / 2f)
- (height / 2f);
top = Utilities.boundToRange(top, 0,
getTop() + mRv.getScrollBarTop() + mRv.getScrollbarTrackHeight() - height);
mPopupView.setTranslationY(top);
}
public boolean isHitInParent(float x, float y, Point outOffset) {
if (mThumbOffsetY < 0) {
return false;
}
getHitRect(sTempRect);
if (mIsRecyclerViewFirstChildInParent) {
sTempRect.top += mRv.getScrollBarTop();
}
if (outOffset != null) {
outOffset.set(sTempRect.left, sTempRect.top);
}
return sTempRect.contains((int) x, (int) y);
}
@Override
public boolean hasOverlappingRendering() {
// There is actually some overlap between the track and the thumb. But since the track
// alpha is so low, it does not matter.
return false;
}
public void setIsRecyclerViewFirstChildInParent(boolean isRecyclerViewFirstChildInParent) {
mIsRecyclerViewFirstChildInParent = isRecyclerViewFirstChildInParent;
}
}