7、Scroller与VelocityTracker:平滑滚动的实现原理,Fling手势的速度追踪与衰减

各位同学,今天我们来聊聊Android滚动机制里两个核心角色——ScrollerVelocityTracker

说实话,很多做了两三年的Android开发,对这两个类还是「会用但不懂」。知道怎么调scrollTo、怎么用fling,但问起内部怎么算的、为什么能平滑停下来,就支支吾吾了。

嗯,今天我们就把它彻底讲透。

7.1 VelocityTracker:手指离开那一瞬间的速度

先问个问题:为什么需要VelocityTracker?

你想想看,用户手指在屏幕上快速一划然后松开,系统怎么知道这一划有多快?是快如闪电还是慢如蜗牛?

答案就是——VelocityTracker

它本质上是一个速度计算器。它会记录手指移动的历史坐标和时间,然后用这些数据算出瞬时速度。

7.1.1 基本用法

我个人习惯在onTouchEvent里这样用:

// 1. 创建
private VelocityTracker mVelocityTracker = VelocityTracker.obtain();

// 2. 在ACTION_DOWN时重置
case MotionEvent.ACTION_DOWN:
    mVelocityTracker.clear();
    mVelocityTracker.addMovement(event);
    break;

// 3. 在ACTION_MOVE时持续添加
case MotionEvent.ACTION_MOVE:
    mVelocityTracker.addMovement(event);
    // 注意:这里可以每帧都add,但不要每帧都compute
    break;

// 4. 在ACTION_UP时计算速度
case MotionEvent.ACTION_UP:
    mVelocityTracker.computeCurrentVelocity(1000); // 单位:像素/秒
    float velocityX = mVelocityTracker.getXVelocity();
    float velocityY = mVelocityTracker.getYVelocity();
    // 用这个速度去启动Scroller的fling
    break;

// 5. 用完记得回收
@Override
public void onDetachedFromWindow() {
    super.onDetachedFromWindow();
    if (mVelocityTracker != null) {
        mVelocityTracker.recycle();
        mVelocityTracker = null;
    }
}
⚠️ 我曾经踩过的坑: computeCurrentVelocity()的参数是时间单位,传入1000表示「每秒多少像素」。如果你传入1,得到的就是「每毫秒多少像素」,数值会非常小,容易搞混。我建议统一用1000。

7.1.2 速度计算的原理

VelocityTracker内部是怎么算速度的?说白了就是位移除以时间

但它不是简单用最后两个点算,那样太粗糙了。它会取最近一段时间(默认100ms)内的多个采样点,做加权平均。

公式大致是:

V = (Δx₁ * w₁ + Δx₂ * w₂ + ...) / (Δt₁ * w₁ + Δt₂ * w₂ + ...)

权重w越靠近当前时刻越大。这样算出来的速度更平滑,也更符合人的直觉。

💡 小技巧: 如果你发现fling速度太灵敏或太迟钝,可以调整computeCurrentVelocity()的参数。数值越大,速度越「敏感」。我一般用500~1000之间。

7.2 Scroller:平滑滚动的幕后英雄

好,速度拿到了。接下来怎么让View平滑地滚起来?

这就是Scroller的活了。

很多人以为Scroller是负责「滚动」的,其实不对。Scroller只负责计算每一帧应该滚动到哪个位置,真正的滚动动作还是靠View的scrollTo()scrollBy()来执行。

7.2.1 Scroller的核心方法

方法 作用
startScroll() 启动一段匀速滚动(很少用,因为不平滑)
fling() 启动一段带衰减的惯性滚动(最常用)
computeScrollOffset() 计算当前帧的滚动位置,返回true表示还在滚动中
getCurrX() / getCurrY() 获取当前帧应该滚动到的位置
isFinished() 判断滚动是否结束
abortAnimation() 强制停止滚动

7.2.2 标准用法:配合View的computeScroll

这是最经典的搭配:

// 在自定义View中
private Scroller mScroller = new Scroller(getContext());

// 启动fling
public void fling(int velocityX, int velocityY) {
    // 参数:起始X, 起始Y, 速度X, 速度Y, 最小X, 最大X, 最小Y, 最大Y
    mScroller.fling(
        getScrollX(), getScrollY(),
        velocityX, velocityY,
        0, maxScrollX,
        0, maxScrollY
    );
    postInvalidateOnAnimation(); // 触发重绘
}

@Override
public void computeScroll() {
    if (mScroller.computeScrollOffset()) {
        // 还在滚动中,更新位置
        scrollTo(mScroller.getCurrX(), mScroller.getCurrY());
        postInvalidateOnAnimation(); // 继续下一帧
    }
}

注意这个computeScroll()方法。它是View的一个回调,在draw()方法中被调用。你不需要手动调用它,系统会在每一帧绘制时自动调用。

🔑 关键理解: Scroller + computeScroll 构成了一个「帧循环」:
  1. fling() 启动后,Scroller内部记录起始位置、速度、衰减系数
  2. 每一帧,computeScrollOffset() 根据当前时间计算新位置
  3. scrollTo() 执行实际滚动
  4. postInvalidateOnAnimation() 请求下一帧
  5. 直到 computeScrollOffset() 返回false,循环结束

7.3 Fling的衰减算法:从物理到代码

为什么fling能模拟出「惯性滑动然后慢慢停」的效果?

Scroller内部用的是物理衰减模型。简单说就是:

位置 = 起始位置 + 速度 × 时间 - 0.5 × 减速度 × 时间²

但实际实现更复杂。Scroller用了三次样条插值,把速度从初始值逐渐衰减到0。衰减的快慢由mDeceleration控制,这个值跟屏幕密度有关。

我记得在Android 4.x时代,Google改过一次衰减算法。老版本的fling滑起来特别「飘」,新版本更「黏手」。如果你在低版本设备上测试过,应该能感觉到差异。

7.3.1 关键参数:摩擦力

Scroller里有个mFlywheel的概念,其实就是摩擦力。摩擦力越大,衰减越快,滑动距离越短。

你可以通过ViewConfiguration.getScaledMaximumFlingVelocity()getScaledMinimumFlingVelocity()来获取系统建议的速度范围。

⚠️ 避坑指南: 我曾经在做一个相册应用时,发现fling滑不到底。查了半天,原来是fling()的边界参数设错了。注意:最小边界要≤起始位置,最大边界要≥起始位置,否则fling会立即结束。

7.4 实战:手写一个可fling的滚动容器

光说不练假把式。我们写一个简单的可垂直滚动的ViewGroup:

public class FlingScrollLayout extends ViewGroup {
    private Scroller mScroller;
    private VelocityTracker mVelocityTracker;
    private int mTotalHeight; // 所有子View的总高度

    public FlingScrollLayout(Context context) {
        super(context);
        mScroller = new Scroller(context);
    }

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        if (mVelocityTracker == null) {
            mVelocityTracker = VelocityTracker.obtain();
        }
        mVelocityTracker.addMovement(event);

        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                // 手指按下时,停止正在进行的fling
                if (!mScroller.isFinished()) {
                    mScroller.abortAnimation();
                }
                mLastY = event.getY();
                break;

            case MotionEvent.ACTION_MOVE:
                float deltaY = mLastY - event.getY();
                // 边界检查:不能滚过头
                int targetScrollY = (int) (getScrollY() + deltaY);
                targetScrollY = Math.max(0, Math.min(targetScrollY, mTotalHeight - getHeight()));
                scrollTo(getScrollX(), targetScrollY);
                mLastY = event.getY();
                break;

            case MotionEvent.ACTION_UP:
                mVelocityTracker.computeCurrentVelocity(1000);
                int velocityY = (int) mVelocityTracker.getYVelocity();
                // 启动fling
                mScroller.fling(
                    getScrollX(), getScrollY(),
                    0, -velocityY, // 注意:速度方向与滚动方向相反
                    0, 0,
                    0, mTotalHeight - getHeight()
                );
                postInvalidateOnAnimation();
                break;
        }
        return true;
    }

    @Override
    public void computeScroll() {
        if (mScroller.computeScrollOffset()) {
            scrollTo(mScroller.getCurrX(), mScroller.getCurrY());
            postInvalidateOnAnimation();
        }
    }

    // ... onMeasure和onLayout略
}
💡 注意速度方向: 手指向上滑(deltaY为负),内容应该向下滚(scrollY增大)。所以fling时速度要取反。这个细节我当年调了半小时才搞明白。

7.5 知识体系总览

下面这张图总结了本章的核心脉络:

Scroller + VelocityTracker 知识体系 用户手指触摸屏幕 VelocityTracker 记录历史坐标 → 计算瞬时速度 速度值 (px/s) Scroller.fling() 物理衰减模型 → 每帧计算新位置 computeScroll() + scrollTo() 帧循环:更新位置 → 请求重绘 → 直到停止

整个流程就是:触摸 → 速度追踪 → 惯性计算 → 帧循环滚动。每一步都有对应的类和算法在背后支撑。

7.6 总结

好了,这一章的内容就这些。我帮你理一下重点:

  • VelocityTracker:负责在手指抬起时计算瞬时速度。记得用完要recycle。
  • Scroller:负责模拟惯性滚动。它只算位置,不负责滚动。
  • computeScroll():View提供的帧循环回调,是连接Scroller和实际滚动的桥梁。
  • fling衰减:基于物理模型,用三次样条插值实现平滑减速。

说实话,这两个类单独看都不难,但把它们组合起来用对,才是真正理解Android滚动机制的开始。下一章我们会深入OverScroller和边缘回弹效果,那又是另一番天地了。

公众号:蓝海资料掘金营,微信deep3321