Android自定义ViewGroup的滚动与惯性滚动效果实现的不同组合方式

ViewGroup的滚动与惯性滚动

前言

之前的文章在讲完 ViewGroup 的布局与测量之后直接上实战了,其实并没有细说到 ViewGroup 的滚动和一些触摸的细节问题,今天我补上准备单独讲一讲。有兴趣可以查看之前的对应专栏【传送门】

关于 ViewGroup 的滚动我们一般有两种思路,一个是 scrollTo / scrollBy 一个是 setTranslationX / setTranslationY 。

他们的区别是:使用 scrollTo 或 scrollBy 进行滚动时,滚动的是视图的内容,而视图本身的位置是不变的。通常用于创建像 ScrollView 或 ListView 这样的滚动容器,其中视图的子元素需要在用户滚动时在屏幕上移动。

使用 setTranslationX / setTranslationY 会导致视图本身的位置发生变化,并且不会影响到视图的子视图或布局参数。这通常用于动画,因为它允许视图在其布局容器中以其实际边界以外的方式移动。

例如本文的示例,就是类似 ScrollView 与 ViewPager 的那种效果所以我选用 scrollTo 的方式,而在之前的文章【Android自定义ViewGroup嵌套与交互实战,幕布全屏滚动效果】 中,由于它内部还有其他的动画如缩放平移等,所以我选择使用 setTranslationX / setTranslationY 的方式来处理会更加的方便。

然后我们需要了解实现滚动的方式,自己手撕实现、GestureDetector、VelocityTracker、Scroller、属性动画等方式的实现。

接下来就一起看看都怎么实现的吧!

话不多说,Let's go

一、ViewGroup处理滑动 onTouch + Scroller

最基础的滑动我们可以直接重写 onTouch 并且内部通过 Scroller 实现 scrollTo / scrollBy 的方式实现滚动,代码如下:

ini 复制代码
public class ViewGroup7 extends ViewGroup implements View.OnTouchListener {

    private int mScreenHeight;
    private int mLastY;
    private int mStart;
    private int mEnd;
    private Scroller mScroller;

    public ViewGroup7(Context context) {
        this(context, null);
    }

    public ViewGroup7(Context context, @Nullable AttributeSet attrs) {
        this(context, attrs, 0);
    }

    public ViewGroup7(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);

        mScreenHeight = ScreenUtils.getScreenHeith(context);
        mScroller = new Scroller(context);

        setOnTouchListener(this);
    }

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        int count = getChildCount();
        for (int i = 0; i < count; i++) {
            View childView = getChildAt(i);
            measureChild(childView, widthMeasureSpec, heightMeasureSpec);
        }

    }

    @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
        int childCount = getChildCount();
        //设置ViewGroup的高度
        MarginLayoutParams mlp = (MarginLayoutParams) getLayoutParams();
        mlp.height = mScreenHeight * childCount;
        setLayoutParams(mlp);

        for (int i = 0; i < childCount; i++) {
            View child = getChildAt(i);

            if (child.getVisibility() != View.GONE) {
                child.layout(l, i * mScreenHeight, r, (i + 1) * mScreenHeight);
            }
        }
    }

    @Override
    public void computeScroll() {
        super.computeScroll();

        if (mScroller.computeScrollOffset()) {
            scrollTo(0, mScroller.getCurrY());
            postInvalidate();
        }
    }

    @Override
    public boolean onTouch(View v, MotionEvent event) {
        int y = (int) event.getY();
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                mLastY = y;
                mStart = getScrollY();
                break;
            case MotionEvent.ACTION_MOVE:
                //当停止动画的时候,它会马上滚动到终点,然后向动画设置为结束。
                if (!mScroller.isFinished()) {
                    mScroller.abortAnimation();
                }
                int dy = mLastY - y;
                if (getScrollY() < 0) {
                    dy = 0;
                }
                //开始滚动
                scrollBy(0, dy);
                mLastY = y;
                break;
            case MotionEvent.ACTION_UP:
                mEnd = getScrollY();
                int dScrollY = mEnd - mStart;
                if (dScrollY > 0) {
                    if (dScrollY < mScreenHeight / 3) {
                        mScroller.startScroll(0, getScrollY(), 0, -dScrollY);
                    } else {
                        mScroller.startScroll(0, getScrollY(), 0, mScreenHeight - dScrollY);
                    }
                } else {
                    if (-dScrollY < mScreenHeight / 3) {
                        mScroller.startScroll(0, getScrollY(), 0, -dScrollY);
                    } else {
                        mScroller.startScroll(0, getScrollY(), 0, -mScreenHeight - dScrollY);
                    }
                }
                invalidate();
                break;

        }

        return true;
    }
}

可以看到滚动的效果如图:

使用 Scroller 是另一种有效的方法来实现平滑滚动动画。Scroller 是一个专用于处理滚动效果的工具类,与 scrollTo 直接跳到指定位置不同,Scroller 可以使滚动过程看起来更自然和平滑。

Scroller 本质上是一个辅助计算滚动动作的工具类,它负责计算和提供每个时间点上的滚动位置 currX 和 currY。在滚动过程中,Scroller 会根据设置的持续时间和插值方法,计算出当前时间点上的滚动位置。

所以说其实 Scroller 的 startScroll 方法本身也只是计算并赋值了当前的 CurrY 中,具体的滚动方法还是要重写 computeScroll 在内部取出 Scroller 赋值的 CurrY 然后再通过 scrollTo / scrollBy 的方式实现具体的滚动,通过不停的刷新从而视觉看起来滚动了。

二、ViewGroup处理滑动 GestureDetector + Scroller

上面的代码我们是通过实现 onTouch 的方式自己手动计算并滚动的,而 GestureDetector 类是我们的手势辅助类,算是一个场景化的快速实现类,它可以算是谷歌帮我们封装好的常用工具类,我们可以通过它实现点击,滚动,惯性等等操作。

我们改造上面的方式,使用 GestureDetector 做滚动操作就更简单,代码如下:

scss 复制代码
public class ViewGroup7 extends LinearLayout {

    private int mScreenHeight;
    private GestureDetector mGestureDetector;
    private Scroller mScroller;
    private int mStart;
    private int mEnd;

    //...
   
    @SuppressLint("ClickableViewAccessibility")
    public ViewGroup7(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);

        mGestureDetector = new GestureDetector(context, new GestureDetector.SimpleOnGestureListener() {

            @Override
            public boolean onDown(MotionEvent e) {
                mStart = getScrollY();
                return true;
            }

            @Override
            public boolean onSingleTapUp(MotionEvent e) {
                //如果是按下抬起,可以触发 onSingleTapUp ,但是如果是按下滑动,则不会触发
                doEventUp();
                return true;
            }

            public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
                // 处理滑动事件
                if (!mScroller.isFinished()) {
                    mScroller.abortAnimation();
                }
                scrollBy(0, (int) distanceY);
                return true;

            }

        });

        setFocusable(true);
        setClickable(true);
        setEnabled(true);
        setLongClickable(true);

    }


    @Override
    public void computeScroll() {
        super.computeScroll();

        if (mScroller.computeScrollOffset()) { // 检查是否滚动操作完成
            scrollTo(0, mScroller.getCurrY());

            if (mScroller.getCurrX() == getScrollX() && mScroller.getCurrY() == getScrollY()) {
                postInvalidate();
            }

        }
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {

        boolean result = mGestureDetector.onTouchEvent(event); //全部事件都交给 GestureDetector 处理

        if (!result) {
            if (event.getAction() == MotionEvent.ACTION_UP) {
                // 手指抬起时并且 GestureDetector 没有触发onSingleTapUp, 那么你可以在这里处理
                doEventUp();
            }
            return true;
        }
        return result;

    }

    private void doEventUp() {
        mEnd = getScrollY();

        int dScrollY = mEnd - mStart;
        if (dScrollY > 0) {
            if (dScrollY < mScreenHeight / 3) {
                mScroller.startScroll(0, getScrollY(), 0, -dScrollY);

            } else {
                mScroller.startScroll(0, getScrollY(), 0, mScreenHeight - dScrollY);
            }
        } else {
            if (-dScrollY < mScreenHeight / 3) {
                mScroller.startScroll(0, getScrollY(), 0, -dScrollY);
            } else {
                mScroller.startScroll(0, getScrollY(), 0, -mScreenHeight - dScrollY);
            }
        }
        invalidate(); // 必须调用此方法来触发重绘
    }

}

需要注意的是 GestureDetector 中的 Up 事件,只有点击的时候抬起手指才会触发,而滚动结束之后抬起手指是不会触发的,所以我们在 onTouchEvent 的时候单独处理了手指抬起的事件,做了兼容处理。

三、ViewGroup处理滑动 GestureDetector + Animator

前面的方式,不管是 OnTouch 还是 GestureDetector 我们都用 Scroller 来做的滚动,如果不想使用 Scroller ,我们直接自己写属性动画,在动画监听中直接滚动也可以做到类似的效果,代码如下:

java 复制代码
public class ViewGroup7 extends LinearLayout {

    private int mScreenHeight;
    private GestureDetector mGestureDetector;
    private ValueAnimator mValueAnimator;
    private int mStart;
    private int mEnd;

   //...

    @SuppressLint("ClickableViewAccessibility")
    public ViewGroup7(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);

        mGestureDetector = new GestureDetector(context, new GestureDetector.SimpleOnGestureListener() {

            @Override
            public boolean onDown(MotionEvent e) {
                mStart = getScrollY();
                return true;
            }

            @Override
            public boolean onSingleTapUp(MotionEvent e) {
                //如果是按下抬起,可以触发 onSingleTapUp ,但是如果是按下滑动,则不会触发
                doEventUp();
                return true;
            }

            public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
                // 处理滑动事件
                if (mValueAnimator != null && mValueAnimator.isRunning()) {
                    mValueAnimator.cancel();
                }

                scrollBy(0, (int) distanceY);
                return true;

            }

        });

        setFocusable(true);
        setClickable(true);
        setEnabled(true);
        setLongClickable(true);

    }


    @Override
    public boolean onTouchEvent(MotionEvent event) {

        boolean result = mGestureDetector.onTouchEvent(event); //全部事件都交给 GestureDetector 处理

        if (!result) {
            if (event.getAction() == MotionEvent.ACTION_UP) {
                // 手指抬起时并且 GestureDetector 没有触发onSingleTapUp, 那么你可以在这里处理
                doEventUp();
            }
            return true;
        }
        return result;

    }

    private void doEventUp() {
        if (mValueAnimator != null && mValueAnimator.isRunning()) {
            mValueAnimator.cancel();
        }

        mEnd = getScrollY();
        int dScrollY = mEnd - mStart;
        YYLogUtils.w("doEventUp执行了 dScrollY:" + dScrollY);

        int startY = getScrollY();
        int endY = dScrollY > 0 ?
                (dScrollY < mScreenHeight / 3 ? startY - dScrollY : startY + mScreenHeight - dScrollY) :
                (Math.abs(dScrollY) < mScreenHeight / 3 ? startY - dScrollY : startY - mScreenHeight - dScrollY);

        startAnim(startY, endY);
    }

    private void startAnim(int startY, int endY) {
        mValueAnimator = ValueAnimator.ofInt(startY, endY);
        mValueAnimator.setDuration(250); // 动画执行时间可以根据需要进行调整
        mValueAnimator.setInterpolator(new DecelerateInterpolator()); // 设置动画插值器

        mValueAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator animation) {
                scrollTo(0, (Integer) animation.getAnimatedValue());
            }
        });

        mValueAnimator.addListener(new AnimatorListenerAdapter() {
            @Override
            public void onAnimationEnd(Animator animation) {
                // 在动画结束时执行需要的操作,例如校正位置确保对齐
                YYLogUtils.w("动画执行结束");
                scrollTo(0, endY);
            }
        });

        mValueAnimator.start();
    }

}

效果如上图,也可以让内部布局跟随手指滚动。

四、ViewGroup处理滑动 GestureDetector + Animator 简化 Scroller 方式

既然动画可以直接做滚动,我们能不能以 Scroller 的方式做动画?

当然是可以的,其实 Scroller 内部也是类似的原理,我们现在把动画的监听得到的值赋值到我们自己的 CurrY 中,在 computeScroll 中就是通过我们自己的 CurrY 去做 scrollTo 的滚动了,代码如下:

java 复制代码
public class ViewGroup7 extends LinearLayout {

    private int mScreenHeight;
    private GestureDetector mGestureDetector;
    private ValueAnimator mValueAnimator;
    private int mStart;
    private int mEnd;
    private int mCurrY;

    // ...
 
    @SuppressLint("ClickableViewAccessibility")
    public ViewGroup7(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);

        mGestureDetector = new GestureDetector(context, new GestureDetector.SimpleOnGestureListener() {

            @Override
            public boolean onDown(MotionEvent e) {
                mStart = getScrollY();
                return true;
            }

            @Override
            public boolean onSingleTapUp(MotionEvent e) {
                //如果是按下抬起,可以触发 onSingleTapUp ,但是如果是按下滑动,则不会触发
                doEventUp();
                return true;
            }

            public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
                // 处理滑动事件
                if (mValueAnimator != null && mValueAnimator.isRunning()) {
                    mValueAnimator.cancel();
                }

                scrollBy(0, (int) distanceY);
                return true;

            }

        });

        setFocusable(true);
        setClickable(true);
        setEnabled(true);
        setLongClickable(true);

    }


    @Override
    public boolean onTouchEvent(MotionEvent event) {

        boolean result = mGestureDetector.onTouchEvent(event); //全部事件都交给 GestureDetector 处理

        if (!result) {
            if (event.getAction() == MotionEvent.ACTION_UP) {
                // 手指抬起时并且 GestureDetector 没有触发onSingleTapUp, 那么你可以在这里处理
                doEventUp();
            }
            return true;
        }
        return result;

    }

    private void doEventUp() {
        if (mValueAnimator != null && mValueAnimator.isRunning()) {
            mValueAnimator.cancel();
        }

        mEnd = getScrollY();
        int dScrollY = mEnd - mStart;
        YYLogUtils.w("doEventUp执行了 dScrollY:" + dScrollY);

        int startY = getScrollY();
        int endY = dScrollY > 0 ?
                (dScrollY < mScreenHeight / 3 ? startY - dScrollY : startY + mScreenHeight - dScrollY) :
                (Math.abs(dScrollY) < mScreenHeight / 3 ? startY - dScrollY : startY - mScreenHeight - dScrollY);

        startAnim(startY, endY);
    }

    private void startAnim(int startY, int endY) {
        mValueAnimator = ValueAnimator.ofInt(startY, endY);
        mValueAnimator.setDuration(250); // 动画执行时间可以根据需要进行调整
        mValueAnimator.setInterpolator(new DecelerateInterpolator()); // 设置动画插值器

        mValueAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator animation) {
                mCurrY = (Integer) animation.getAnimatedValue();
            }
        });

        mValueAnimator.addListener(new AnimatorListenerAdapter() {

            @Override
            public void onAnimationStart(Animator animation) {
                super.onAnimationStart(animation);
                invalidate();
            }

            @Override
            public void onAnimationEnd(Animator animation) {
                // 在动画结束时执行需要的操作,例如校正位置确保对齐
                scrollTo(0, endY);
            }
        });

        mValueAnimator.start();
    }

    @Override
    public void computeScroll() {
        super.computeScroll();

        if (mValueAnimator != null && mValueAnimator.isRunning()) {
            scrollTo(0, mCurrY);
            postInvalidate();
        }
    }

}

效果也是如上图所示,子布局跟随手指滚动。这么多方式都能实现滚动,那么如果我想做惯性的滚动是不是可以用同样的这么多方式实现呢?又通过哪些方式可以实现惯性的滚动呢?让我们带着问题往下看。

五、 通过 onTouch + VelocityTracker 实现的惯性滚动

VelocityTracker可以帮助我们计算触摸事件的速度和方向,比如滑动的速度和方向。

惯性滚动(Fling操作): 当用户在屏幕上快速滑动(比如滑动列表或图片浏览器)并抬起手指时,可以利用速度来继续滚动内容一段时间,模仿物理世界的惯性。

翻页效果: 比如阅读器或轮播图,当用户轻扫时,根据滑动速度决定是否翻页。

速度敏感的操作: 在绘图软件中,根据用户画线的速度来调整线条粗细。

消除操作: 轻扫使列表项消失(比如在邮件应用中滑动来删除邮件)。

一般来说我们自己实现 VelocityTracker 分为以下三个步骤:

1、在 MotionEvent.ACTION_DOWN 事件中初始化 VelocityTracker 并添加移动之前的事件。

2、将之后的移动(MotionEvent.ACTION_MOVE)事件添加到 VelocityTracker 中。

3、在 MotionEvent.ACTION_UP 事件中获取最后的滚动速度,并根据该速度执行惯性滚动。

具体的代码实现如下:

ini 复制代码
public class ViewGroup7 extends ViewGroup implements View.OnTouchListener {

    private int mScreenHeight;
    private int mLastY;
    private int mStart;
    private int mEnd;

    private Scroller mScroller;
    private VelocityTracker mVelocityTracker;

    // ...

    @Override
    public void computeScroll() {
        super.computeScroll();

        if (mScroller.computeScrollOffset()) {

            // 真正fling滚动中
            scrollTo(0, mScroller.getCurrY());
            postInvalidate();

        } else if (mScroller.isFinished()) {
            // 当fling操作完成后的逻辑
            adjustToEndPosition();
        }
    }

    private void adjustToEndPosition() {
        mEnd = getScrollY();
        int dScrollY = mEnd - mStart;

        if (dScrollY == 0 || Math.abs(dScrollY) == mScreenHeight) return;
        YYLogUtils.w("computeScroll  mScroller.isFinished dScrollY:" + dScrollY);

        // 接下来你可以判断滚动接近哪个屏幕并滚动到那里
        int finalY;
        if (dScrollY > 0) {
            if (dScrollY < mScreenHeight / 3) {
                finalY = mEnd - dScrollY;
            } else {
                finalY = mEnd + mScreenHeight - dScrollY;
            }
        } else {
            if (-dScrollY < mScreenHeight / 3) {
                finalY = mEnd - dScrollY;
            } else {
                finalY = mEnd - mScreenHeight - dScrollY;
            }
        }

        // 这里需要确保finalY不会超出你的视图界限,比如不要小于0或大于最大滚动高度
        finalY = Math.max(0, Math.min(finalY, mScreenHeight * (getChildCount() - 1)));

        mScroller.startScroll(0, mEnd, 0, finalY - mEnd);
        postInvalidate();
    }

    @Override
    public boolean onTouch(View v, MotionEvent event) {
        int y = (int) event.getY();
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                // 初始化 VelocityTracker
                if (mVelocityTracker == null) {
                    mVelocityTracker = VelocityTracker.obtain();
                } else {
                    mVelocityTracker.clear();
                }
                mVelocityTracker.addMovement(event);

                //当停止动画的时候,它会马上滚动到终点,然后向动画设置为结束。
                if (!mScroller.isFinished()) {
                    mScroller.abortAnimation();
                }

                mLastY = y;
                mStart = getScrollY();
                break;
            case MotionEvent.ACTION_MOVE:
                //当停止动画的时候,它会马上滚动到终点,然后向动画设置为结束。
                if (!mScroller.isFinished()) {
                    mScroller.abortAnimation();
                }

                //将移动事件添加到 VelocityTracker 中
                mVelocityTracker.addMovement(event);

                int dy = mLastY - y;
                if (getScrollY() < 0) {
                    dy = 0;
                }
                //开始滚动
                scrollBy(0, dy);
                mLastY = y;
                break;
            case MotionEvent.ACTION_UP:

                mVelocityTracker.addMovement(event);
                mVelocityTracker.computeCurrentVelocity(1000); // 计算速度

                int initialYVelocity = (int) mVelocityTracker.getYVelocity();
                doFling(-initialYVelocity);

                mVelocityTracker.recycle(); // 回收 VelocityTracker
                mVelocityTracker = null;

                break;
            case MotionEvent.ACTION_CANCEL:
                if (mVelocityTracker != null) {
                    mVelocityTracker.recycle();
                    mVelocityTracker = null;
                }
                break;

        }

        return true;
    }

    private void doFling(int initialVelocity) {
        int scrollY = getScrollY();
        int maxY = Math.max(0, getChildCount() * mScreenHeight - mScreenHeight); // 计算最大滚动距离

        mScroller.fling(0, scrollY, 0, initialVelocity, 0, 0, 0, maxY);

        invalidate();
    }
}

这里我做了特殊的处理,在惯性结束之后的位置上我判断了位置并继续运动实现吸顶或吸底的效果,如果不想要这个效果可以去掉这个判断即可。

惯性+滚动效果:

通过自定义实现 VelocityTracker 的配置与计算,通过 Scroller 实现相对的滚动,当然了通过上面的示例我们也都知道了 Scroller 的 Fling 其实也是一个动画,真正的动起来还是在 computeScroll 中自己实现的。就算不用 Scroller 通过自定义动画我们一样能实现同样的效果,下面会讲到。

六、通过 GestureDetector + Scroller 实现的惯性滚动

这里我们就换一种方式,和滚动的逻辑类似,我们一样可以通过 GestureDetector + Scroller 的方式实现实现的惯性滚动,代码如下:

scss 复制代码
public class ViewGroup7 extends LinearLayout {

    private int mScreenHeight;
    private GestureDetector mGestureDetector;
    private Scroller mScroller;
    private int mStart;
    private int mEnd;

   //...

    @SuppressLint("ClickableViewAccessibility")
    public ViewGroup7(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);

        mGestureDetector = new GestureDetector(context, new GestureDetector.SimpleOnGestureListener() {

            @Override
            public boolean onDown(MotionEvent e) {
                mStart = getScrollY();
                return true;
            }

            @Override
            public boolean onSingleTapUp(MotionEvent e) {
                //如果是按下抬起,可以触发 onSingleTapUp ,但是如果是按下滑动,则不会触发
                doEventUp();
                return true;
            }

            public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
                // 处理滑动事件
                if (!mScroller.isFinished()) {
                    mScroller.abortAnimation();
                }
                scrollBy(0, (int) distanceY);
                return true;

            }

            @Override
            public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {

                doFling((int) -velocityY);
                return true;
            }
        });

        setFocusable(true);
        setClickable(true);
        setEnabled(true);
        setLongClickable(true);

    }

    private void doFling(int velocityY) {
        int scrollY = getScrollY();
        int maxY = Math.max(0, getChildCount() * mScreenHeight - mScreenHeight);

        mScroller.fling(0, scrollY, 0, velocityY, 0, 0, 0, maxY);

        invalidate();
    }


    @Override
    public void computeScroll() {
        super.computeScroll();

        if (mScroller.computeScrollOffset()) { // 检查是否滚动操作完成
            scrollTo(0, mScroller.getCurrY());

            if (mScroller.getCurrX() == getScrollX() && mScroller.getCurrY() == getScrollY()) {
                postInvalidate();
            }

        }
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {

        boolean result = mGestureDetector.onTouchEvent(event); //全部事件都交给 GestureDetector 处理

        if (!result) {
            if (event.getAction() == MotionEvent.ACTION_UP) {
                // 手指抬起时并且 GestureDetector 没有触发onSingleTapUp, 那么你可以在这里处理
                doEventUp();
            }
            return true;
        }
        return result;

    }

    private void doEventUp() {
        YYLogUtils.w("doEventUp doEventUp");
        mEnd = getScrollY();

        int dScrollY = mEnd - mStart;
        if (dScrollY > 0) {
            if (dScrollY < mScreenHeight / 3) {
                mScroller.startScroll(0, getScrollY(), 0, -dScrollY);

            } else {
                mScroller.startScroll(0, getScrollY(), 0, mScreenHeight - dScrollY);
            }
        } else {
            if (-dScrollY < mScreenHeight / 3) {
                mScroller.startScroll(0, getScrollY(), 0, -dScrollY);
            } else {
                mScroller.startScroll(0, getScrollY(), 0, -mScreenHeight - dScrollY);
            }
        }
        invalidate(); // 必须调用此方法来触发重绘
    }

}

可以看到 GestureDetector 实现起来相对更简单,省略了惯性的计算逻辑,直接就能使用 Scroller 的方式进行滚动了。

惯性效果:

既然 GestureDetector 与 VelocityTracker 都能实现惯性,我应该怎么选择呢

GestureDetector 的 onFling 方法中,速度以参数形式直接传递给你。你不需要做额外的工作去计算它。实际上 GestureDetector 的 onFling 方法内部实际上是使用 VelocityTracker 来计算滑动速度的。GestureDetector 是一个高级的封装,它内部管理了 VelocityTracker 的实例,并在需要的时候进行了适当的速度计算。

VelocityTracker 是相对更底层一些的方式,使用也更灵活,需要开发者按照触摸事件(按下、移动、抬起)为单位手动调用 addMovement(MotionEvent) 方法,然后使用 computeCurrentVelocity(int) 来计算速度。在没有清晰开始和结束手势的场合,或者当需要对滑动的速度进行更细微的监控和控制的时候用这个比较合适,比如我只想上下滚动有惯性,向下滚动没有惯性。

GestureDetector 更为方便快捷,可以快速实现默认的惯性,如果需要更精细化的控制或自定义惯性,那么VelocityTracker 更加适合。具体使用哪个,取决于应用场景和开发者的具体需求。

七、通过 GestureDetector + Animator 实现的惯性滚动

上述两种惯性的方式我们都是根据 Scroller 来实现的,与滚动逻辑同理,其实我们可以使用自定义属性动画来替代 Scroller 来实现自己的惯性运动。代码如下:

scss 复制代码
public class ViewGroup7 extends LinearLayout {

    private int mScreenHeight;
    private GestureDetector mGestureDetector;
    private ValueAnimator mValueAnimator;
    private FlingAnimation flingY = null;
    private int mStart;
    private int mEnd;
    private int mCurrY;

    @SuppressLint("ClickableViewAccessibility")
    public ViewGroup7(Context context, @Nullable AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);

        mGestureDetector = new GestureDetector(context, new GestureDetector.SimpleOnGestureListener() {

            @Override
            public boolean onDown(MotionEvent e) {
                mStart = getScrollY();
                return true;
            }

            @Override
            public boolean onSingleTapUp(MotionEvent e) {
                //如果是按下抬起,可以触发 onSingleTapUp ,但是如果是按下滑动,则不会触发
                doEventUp();
                return true;
            }

            public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
                // 处理滑动事件
                if (mValueAnimator != null && mValueAnimator.isRunning()) {
                    mValueAnimator.cancel();
                }

                scrollBy(0, (int) distanceY);  //跟随手指滑动
                return true;

            }

            @Override
            public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
                YYLogUtils.w("onFling  == 开始");
                if (flingY != null) {
                    flingY.cancel();
                }

                // 设置起始值为当前滚动的Y值
                int startY = getScrollY();

                // 设置最大和最小值,这里设置为从头到尾滚动的范围
                int minY = 0;
                int maxY = Math.max(0, getChildCount() * mScreenHeight - mScreenHeight);

                flingY = new FlingAnimation(new FloatValueHolder());
                flingY.setStartValue(startY)
                        .setStartVelocity(-velocityY)
                        .setMinValue(minY)
                        .setMaxValue(maxY)
                        .addUpdateListener((animation, value, velocity) -> {
                            YYLogUtils.w("onFling value:" + value);
                            mCurrY = (int) value;
                            scrollTo(0, (int) value);  //注意动画惯性的方式需要处理滚动的边界否则会报错,这里我懒没有处理
                        })
                        .start();

                return true;
            }
        });

        setFocusable(true);
        setClickable(true);
        setEnabled(true);
        setLongClickable(true);

    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {

        boolean result = mGestureDetector.onTouchEvent(event); //全部事件都交给 GestureDetector 处理

        if (!result) {
            if (event.getAction() == MotionEvent.ACTION_UP) {
                // 手指抬起时并且 GestureDetector 没有触发onSingleTapUp, 那么你可以在这里处理
                doEventUp();
            }
            return true;
        }
        return result;

    }

    private void doEventUp() {
        if (mValueAnimator != null && mValueAnimator.isRunning()) {
            mValueAnimator.cancel();
        }

        mEnd = getScrollY();
        int dScrollY = mEnd - mStart;

        int startY = getScrollY();
        int endY = dScrollY > 0 ?
                (dScrollY < mScreenHeight / 3 ? startY - dScrollY : startY + mScreenHeight - dScrollY) :
                (Math.abs(dScrollY) < mScreenHeight / 3 ? startY - dScrollY : startY - mScreenHeight - dScrollY);

        startAnim(startY, endY);
    }

    private void startAnim(int startY, int endY) {
        mValueAnimator = ValueAnimator.ofInt(startY, endY);
        mValueAnimator.setDuration(250); // 动画执行时间可以根据需要进行调整
        mValueAnimator.setInterpolator(new DecelerateInterpolator()); // 设置动画插值器

        mValueAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator animation) {
                mCurrY = (Integer) animation.getAnimatedValue();
            }
        });

        mValueAnimator.addListener(new AnimatorListenerAdapter() {

            @Override
            public void onAnimationStart(Animator animation) {
                super.onAnimationStart(animation);
                invalidate();  //启动重绘,否则无法实现滚动效果
            }

            @Override
            public void onAnimationEnd(Animator animation) {
                // 在动画结束时执行需要的操作,例如校正位置确保对齐

                scrollTo(0, endY);
            }
        });

        mValueAnimator.start();
    }

    @Override
    public void computeScroll() {
        super.computeScroll();
        YYLogUtils.w("直接computeScroll了");

        if (flingY != null && flingY.isRunning()) {
            YYLogUtils.w("computeScroll - flingY - scroll");
            scrollTo(0, mCurrY);   //注意动画惯性的方式需要处理滚动的边界否则会报错,这里我懒没有处理
            postInvalidate();
        }

        if (mValueAnimator != null && mValueAnimator.isRunning()) {
            scrollTo(0, mCurrY);
            postInvalidate();
        }
    }

}

我们同样的可以通过属性动画做手指的跟随移动,再通过特有的 FlingAnimation 可以做惯性相关的动画,关于 FlingAnimation 有兴趣的可以深入了解,它除了可以做 Float 值的变化,还能直接变化对象的属性,例如 TranslationX、TranslationY 等。

我们这里图简单就只是与属性动画类似做了 Float 值的变化监听,并实现滚动效果。

动画的方式实现惯性效果:

总结

使用 GestureDetector 可以帮助我们快速实现检测手势,比如单击、双击、长按、滑动,惯性等。它是谷歌给我们的快速实现类,缺点是不方便自定义。

使用 VelocityTracker 可以帮助我们计算触摸事件的速度和方向,比如滑动的速度和方向。它是用于惯性和滑动方向判断的底层类,可以方便自定义。

使用 Scroller 可以在视图中实现平滑滚动效果。虽然 Scroller 自身不直接使视图滚动,它通过内部跟踪和计算滚动的位置,确保滚动操作看起来平滑连贯。

当然 Scroller 内部并不是基于属性动画实现的,而是基于自定义的插值器或时间函数来进行滚动位置的计算。但是我们使用属性动画+差值器可以实现类似于 Scroller 的效果,本文也做出了各种演示。

在其他人使用这些组合的时候大家能明白是什么意思就行,至于自己想怎么用完全可以自由组合,任意结合都能实现类似的效果。

其实除了以上这些开箱即用的谷歌工具库,谷歌还对一些其他交互场景提供了解决方案,如 ViewGroup 内部的协调运动,谷歌首先给出 ViewDragHelper 的解决方案,ViewDragHelper 是一个独立于 GestureDetector 和 Scroller 的工具,它通过自己的方法来处理事件和实现拖拽以及平滑移动效果。

后面对 ViewGroup 与 ViewGroup 之间的协调运动,谷歌又提出了 CoordinatorLayout 的方案,可以让内部不同的子布局协调运动,谷歌内置了常用得到协调效果,同时我们也可以自定义 behavior 的协调效果。

再往后走,对容器内部的协调运动谷歌直接另起炉灶,提出 ConstraintLayout 与 MotionLayout 方案,我们无需自定义复杂的 behavior,只需要配置对应的 xml 配置文件就可以实现复杂的 ViewGroup 内部子 View 的协调效果。

以上的部分效果我都出过一些示例,有兴趣的可以在我的文章列表中搜索一下。

而对滚动容器之间的嵌套与协调又提供了 NestedScrollingParent 与 NestedScrollingChild 方案解决嵌套滚动问题。所以我说谷歌爸爸真的是为开发者超碎了心,解决方案真心太多了!

回归正题,我们本文只是复习 ViewGroup 基本的滚动操作与惯性操作而已,还有一些其他之前没有讲到的知识点我会单独再出文章和大家一起复习。

关于本文的内容如果想查看源码可以点击这里 【传送门】。你也可以关注我的这个Kotlin项目,我有时间都会持续更新。

惯例,我如有讲解不到位或错漏的地方,希望同学们可以指出交流。

如果感觉本文对你有一点点的启发,还望你能点赞支持一下,你的支持是我最大的动力啦!

Ok,这一期就此完结。

相关推荐
一笑的小酒馆7 小时前
Android CameraX适配Android15
android
hnlgzb7 小时前
安卓app开发,如何快速上手kotlin和compose的开发?
android·开发语言·kotlin
alexhilton8 小时前
Jetpack Compose 2025年12月版本新增功能
android·kotlin·android jetpack
思成不止于此8 小时前
【MySQL 零基础入门】DQL 核心语法(二):表条件查询与分组查询篇
android·数据库·笔记·学习·mysql
安卓理事人12 小时前
安卓图表MpAndroidChart使用
android
奋斗的小鹰13 小时前
在已有Android工程中添加Flutter模块
android·flutter
介一安全14 小时前
【Frida Android】实战篇13:企业常用非对称加密场景 Hook 教程
android·网络安全·逆向·安全性测试·frida
lin625342214 小时前
Android右滑解锁UI,带背景流动渐变动画效果
android·ui
鹏多多17 小时前
Flutter输入框TextField的属性与实战用法全面解析+示例
android·前端·flutter
2501_9160088917 小时前
iOS 开发者工具全景图,构建从编码、调试到性能诊断的多层级工程化工具体系
android·ios·小程序·https·uni-app·iphone·webview