深入 Android RecyclerView SnapHelper 全链路:从 Fling 减速算法到自定义吸附策略的滑动定位引擎

做横向卡片滑动,多数人会直奔 ViewPager2。但碰到这种需求就麻烦了:既要有单页吸附的触感,又要在高速滑动时一次跳过两页。ViewPager2 写死了 ±1 的偏移逻辑,解药在 SnapHelper——前提是你愿意把它拆开看。

SnapHelper 是 RecyclerView 的抽象辅助类,职责单一:滚动停止后修正位置,把某个 item 对齐到指定锚点。系统内置了 LinearSnapHelper(居中吸附)和 PagerSnapHelper(翻页吸附)。把内部的速度映射和对齐计算拆清楚后,自定义吸附策略不过就是改两个方法的返回值。

Fling 事件如何被截获

RecyclerView 处理触摸事件时,ACTION_UP 触发 Fling:

// RecyclerView.onTouchEvent 简化
case MotionEvent.ACTION_UP:
    mVelocityTracker.computeCurrentVelocity(1000, mMaxFlingVelocity);
    if (!fling((int) mVelocityTracker.getXVelocity(),
               (int) mVelocityTracker.getYVelocity())) {
        setScrollState(SCROLL_STATE_IDLE);
    }

fling() 会回调当前设的那个 OnFlingListener。需要明确的是,RecyclerView 只支持同时注册一个 OnFlingListener:setOnFlingListener() 会直接替换掉之前设的那个,并不存在“遍历所有 OnFlingListener,先注册的先执行”这样的机制。事实上,SnapHelper.attachToRecyclerView() 内部在发现已经存在一个 OnFlingListener 时会直接抛出 IllegalStateException,而不是叠加注册。SnapHelper 在 attachToRecyclerView() 里调用 recyclerView.setOnFlingListener(this) 完成注册。这意味着 SnapHelper 一接管 fling,RecyclerView 自带的惯性滚动就不管了——后续的整个滚动过程都得你自己负责。

截获 fling 后的核心路径在 snapFromFling():

// SnapHelper.snapFromFling()
private boolean snapFromFling(LayoutManager layoutManager,
        int velocityX, int velocityY) {
    int targetPos = findTargetSnapPosition(layoutManager, velocityX, velocityY);
    if (targetPos == RecyclerView.NO_POSITION) return false;
    mRecyclerView.smoothScrollToPosition(targetPos);
    return true;
}

两个关键方法各管一摊:findTargetSnapPosition 把速度映射成目标 position,calculateDistanceToFinalSnap 在目标 view 布局完后算出对齐所需的像素偏移。搞清这个分工,定制就水到渠成了。

速度到位置的映射逻辑

PagerSnapHelper 的朴素策略

PagerSnapHelper 的速度映射比很多人预想的简单——它没用物理公式计算滑行距离,只看速度方向:

// PagerSnapHelper.findTargetSnapPosition 核心
int currentPos = helper.getPosition();  // 当前最接近锚点的 item
int target = currentPos + (velocityX > 0 ? 1 : -1);
target = Math.max(0, Math.min(target, itemCount - 1));

速度只决定正负号,位置永远是 ±1。跟 ViewPager2 行为一致:不管你手指滑多快,一次只翻一页。大多数场景够用,但高速滑动只跳一页确实太保守了。

基于物理模型的扩展

要做「速度越快,跨越页数越多」,需要在 findTargetSnapPosition 里自己算滑动距离:

override fun findTargetSnapPosition(
    layoutManager: LayoutManager, velocityX: Int, velocityY: Int
): Int {
    val currentView = findSnapView(layoutManager) ?: return NO_POSITION
    val currentPos = layoutManager.getPosition(currentView)

    // 用 SnapHelper 自带的 calculateScrollDistance(内部基于 Scroller.fling 仿真)
    // 估算本次 fling 的滑行距离,比自己推导公式更贴合真实滚动行为
    val distances = calculateScrollDistance(velocityX, velocityY)
    val distance = distances[0]
    val itemWidth = currentView.width  // 假设等宽
    val pageOffset = (distance / itemWidth.toFloat()).roundToInt()

    return (currentPos + pageOffset)
        .coerceIn(0, layoutManager.itemCount - 1)
}

calculateScrollDistance 是 SnapHelper 基类提供的 public 方法,内部用 android.widget.Scroller.fling() 对系统滑行行为做仿真,直接拿到系统样计算得到的最终位移。这比自己写物理公式更可靠——ViewConfiguration.getScrollFriction() 返回的是一个无量纲的摩擦细化绅不是物理实际存在的摩擦系数,它的数值含义由 Scroller 内部的滑行模型定义(经验拟合的减速曲线,而不是丹尼摩擦定律里的动摩擦力系数),不能直接套用 v² / (2 · μ · g) 这类抛体运动公式——里面乘上重力加速度 9.8 在量纲上就不成立,得到的数值也与实际滑行距离无关。如果不想用 calculateScrollDistance,更诚实的做法是直接把它当成一个需要实测调优的工程经验公式(比如 distance = velocity * velocity / (2 * scrollFriction * someTunedConstant),并明确标注 someTunedConstant 是实测拟合的经验值),不要假装它是一个严谨的物理公式。

不过我在实际项目里不用物理公式,而是做速度分级——物理模型的系数在不同设备上需要反复调试,分级阈值反而更可控:

val absVel = abs(velocityX)
val offset = when {
    absVel > 4000 -> 3
    absVel > 1500 -> 2
    else -> 1
}
return (currentPos + if (velocityX > 0) offset else -offset)
    .coerceIn(0, layoutManager.itemCount - 1)

三个阈值按实际 item 宽度和手感需求微调就行,比物理公式省心。

对齐的计算:中心、边缘还是偏移

找到目标 position 后,smoothScrollToPosition 触发 layout,目标 view 被布局出来。这时 SnapHelper 调用 calculateDistanceToFinalSnap 计算残留偏移:

// LinearSnapHelper 居中计算(源码中 distanceToCenter / getHorizontalHelper 均为 private)
public int[] calculateDistanceToFinalSnap(
        LayoutManager layoutManager, View targetView) {
    int[] out = new int[2];
    if (layoutManager.canScrollHorizontally()) {
        out[0] = distanceToCenter(targetView, 
            getHorizontalHelper(layoutManager));
    }
    // ... 垂直同理
    return out;
}

private int distanceToCenter(View targetView, OrientationHelper helper) {
    int childCenter = helper.getDecoratedStart(targetView) 
        + helper.getDecoratedMeasurement(targetView) / 2;
    int containerCenter = helper.getStartAfterPadding() 
        + helper.getTotalSpace() / 2;
    return childCenter - containerCenter;
}

childCenter - containerCenter:child 在容器中心偏左时,返回值是正的,指示还要往右滑的距离。这个值会传给 SmoothScroller,驱动最终的对齐动画。

自定义对齐就是在改这套计算逻辑,但需要注意 distanceToCenter 和 getHorizontalHelper 在 LinearSnapHelper 源码里都是 private 方法,子类继承后无法直接调用,只能自己重新实现一份等价逻辑。比如做图片浏览器,第一页左对齐(露出后面的预览),其余页居中,可以直接继承 SnapHelper(而非 LinearSnapHelper),用 View 的测量信息自己算距离,绕开对 private 方法的依赖:

class MixedSnapHelper : LinearSnapHelper() {
    override fun calculateDistanceToFinalSnap(
        layoutManager: LayoutManager, targetView: View
    ): IntArray {
        val out = IntArray(2)
        val pos = layoutManager.getPosition(targetView)
        out[0] = if (pos == 0) {
            // 第 0 项左对齐
            targetView.left - layoutManager.paddingLeft
        } else {
            // 其余居中:不依赖父类 private 的 distanceToCenter,自己按 View 边界计算
            val recyclerView = targetView.parent as View
            val childCenter = targetView.left + targetView.width / 2
            val containerCenter = recyclerView.paddingLeft +
                (recyclerView.width - recyclerView.paddingLeft - recyclerView.paddingRight) / 2
            childCenter - containerCenter
        }
        return out
    }
}

三个生产级注意点

ItemDecoration 偏移陷阱

calculateDistanceToFinalSnap 计算 child 位置时,如果 ItemDecoration 修改了 item 的绘制偏移,targetView.left 和 getDecoratedStart() 的值就不一致了。对齐偏差就是从这个差值里来的。修正很简单:统一用 getDecoratedStart / getDecoratedEnd,别碰 left / right。

快速连续滑动时目标 view 未布局

用户连续快速滑动时,上一次对齐动画还没结束,下一次 fling 已经来了。这时 findTargetSnapPosition 返回的目标 item 可能还没 layout,导致 calculateDistanceToFinalSnap 拿到 null。处理方式是直接返回 NO_POSITION,让当前动画自然结束,别强行打断。

滚动动画的曲线控制

smoothScrollToPosition 走的是 RecyclerView 默认插值器,减速曲线没法配置。需要自定义减速曲线的话(比如模仿 iOS 的回弹),直接继承 RecyclerView.SmoothScroller,覆盖 calculateTimeForDeceleration 和 calculateSpeedPerPixel,再通过 layoutManager.startSmoothScroll(customScroller) 触发,完全绕开 SnapHelper 的默认动画。

findTargetSnapPosition 控制停在哪一页,calculateDistanceToFinalSnap 控制对齐到哪个锚点——这两个方法已经覆盖了 90% 的自定义需求。剩下的 10% 在 SmoothScroller 的动画曲线里,那是另一个话题了。