37、View的滑动与弹性滑动:scrollTo/scrollBy、Scroller、OverScroller、ViewDragHelper

滑动,是Android交互的灵魂。

你想想看,一个App如果所有页面都死死钉在屏幕上,用户划不动、拖不走,那体验得多糟糕。我做了这么多年架构,几乎每个项目都绕不开滑动处理。今天我们就来彻底聊透它——从最基础的scrollTo/scrollBy,到Scroller、OverScroller,再到ViewDragHelper。嗯,这些都是我手写自定义View时的老伙计了。

一、scrollTo与scrollBy:最朴素的滑动

先说最底层的两个方法:scrollTo(int x, int y)scrollBy(int dx, int dy)。它们直接操作View的mScrollXmScrollY,说白了就是移动View内容(而不是View本身)的偏移量。

  • scrollTo:绝对滑动。直接跳到目标坐标。
  • scrollBy:相对滑动。在当前偏移基础上再移动一段距离。

我个人习惯用scrollBy做增量滑动,比如手指拖拽时。但要注意:这两个方法都是瞬间完成的,没有动画过渡。如果你直接调用,用户会感觉内容“啪”一下跳过去了,很生硬。

核心要点:

  • mScrollX / mScrollY 是View内容的偏移量,正值表示内容向左/向上移动。
  • scrollTo/scrollBy 只移动内容,不移动View本身的位置。
  • 它们不会触发动画,需要配合其他机制实现弹性效果。
// 示例:点击按钮让内容向右滑动100像素
button.setOnClickListener {
    // 注意:scrollBy(100, 0) 会让内容向左移动?不对!
    // 实际上 scrollBy(100, 0) 会让内容向右移动,因为mScrollX负值表示内容右移。
    // 我刚开始也搞反过,后来记住:mScrollX正值 = 内容左移。
    view.scrollBy(100, 0)
}

我曾经踩过的坑:

scrollBy的正负方向很容易搞混。记住:scrollBy(100, 0) 会让内容向左移动(因为mScrollX=100表示内容左移了100像素)。如果你想让内容向右滑动,应该传负值。嗯,这个坑我至少帮三个同事改过bug。

二、Scroller:让滑动“弹”起来

光有scrollTo/scrollBy还不够,因为用户需要的是平滑的、有惯性的滑动。这时候Scroller就登场了。

Scroller本身不绘制任何东西,它只是一个计算器。你告诉它起始位置、结束位置、持续时间,它就能在每一帧给你算出当前应该滑动到哪。然后你需要在View的computeScroll()方法里,把算出来的值通过scrollTo设置进去,并配合invalidate()触发重绘。

我记得第一次用Scroller时,觉得它很绕。后来想通了:它就是个“动画插值器”,专门为滑动而生。

// 典型用法:在手指抬起后启动一个弹性滑动
class MyView(context: Context) : View(context) {
    private val scroller = Scroller(context)

    fun fling(velocityX: Int, velocityY: Int) {
        // 参数:起始x, 起始y, 速度x, 速度y, 最小x, 最大x, 最小y, 最大y
        scroller.fling(
            scrollX, scrollY,
            velocityX, velocityY,
            0, 1000,  // x范围
            0, 1000   // y范围
        )
        invalidate() // 触发computeScroll
    }

    override fun computeScroll() {
        if (scroller.computeScrollOffset()) {
            scrollTo(scroller.currX, scroller.currY)
            invalidate() // 继续下一帧
        }
    }
}

我的建议:

Scroller的fling()方法需要传入初始速度,这个速度通常来自GestureDetectorVelocityTracker。如果你自己写手势处理,别忘了在手指抬起时计算速度。否则fling效果会非常奇怪。

三、OverScroller:更强大的弹性滑动

OverScroller是Scroller的升级版。它支持越界回弹(overscroll)和更精细的物理模型。说白了,就是当用户滑到边界时,还能再拖一点,然后弹回来——就像iOS的橡皮筋效果。

我个人在项目中几乎只用OverScroller,因为它更接近真实物理世界的滑动体验。而且它内部已经处理好了边界检测,你不需要自己写if判断。

特性 Scroller OverScroller
越界回弹 不支持 支持(通过splineOverScroller)
物理模型 简单减速 更真实的摩擦/张力
推荐场景 简单滑动 复杂交互、列表、自定义View
// OverScroller 的典型用法(与Scroller类似,但多了overScroll方法)
val overScroller = OverScroller(context)

fun overFling(velocityX: Int, velocityY: Int) {
    overScroller.fling(
        scrollX, scrollY,
        velocityX, velocityY,
        0, maxScrollX,  // 有效范围
        0, maxScrollY,
        0, 0,           // overscroll距离
        // 注意:最后两个参数是overScroll的半径,越大弹性越强
    )
    invalidate()
}

override fun computeScroll() {
    if (overScroller.computeScrollOffset()) {
        scrollTo(overScroller.currX, overScroller.currY)
        invalidate()
    }
}

避坑指南:

我曾经在RecyclerView里嵌套自定义滑动View,结果OverScroller和父布局的滑动冲突了。后来发现是requestDisallowInterceptTouchEvent没处理好。记住:如果你用OverScroller实现自定义滑动,一定要在onTouchEvent里调用parent.requestDisallowInterceptTouchEvent(true),否则父容器会抢走事件。

四、ViewDragHelper:拖拽的终极武器

如果你需要实现“拖拽某个子View”或者“侧滑菜单”这类交互,直接用scrollTo/scrollBy会累死。这时候ViewDragHelper就是救星。

ViewDragHelper封装了触摸事件的分发、拖拽边界、弹性回弹、甚至边缘拖拽。你只需要告诉它:哪个子View可以拖、拖的方向是什么、拖到边界后怎么处理。剩下的它全包了。

我记得第一次用ViewDragHelper实现一个“可拖拽关闭的卡片”,只用了不到100行代码。如果自己写事件分发,至少300行起步。

// ViewDragHelper 的典型用法(在自定义ViewGroup中)
class DragLayout(context: Context, attrs: AttributeSet?) : FrameLayout(context, attrs) {
    private val dragHelper: ViewDragHelper = ViewDragHelper.create(this, 1.0f, object : ViewDragHelper.Callback() {
        override fun tryCaptureView(child: View, pointerId: Int): Boolean {
            // 返回true表示这个子View可以被拖拽
            return child == dragView
        }

        override fun clampViewPositionHorizontal(child: View, left: Int, dx: Int): Int {
            // 限制水平拖拽范围
            return left.coerceIn(0, width - child.width)
        }

        override fun onViewReleased(releasedChild: View, xvel: Float, yvel: Float) {
            // 手指抬起后,可以配合OverScroller做弹性滑动
            dragHelper.settleCapturedViewAt(0, 0)
            invalidate()
        }
    })

    private lateinit var dragView: View

    override fun onFinishInflate() {
        super.onFinishInflate()
        dragView = getChildAt(0)
    }

    override fun onTouchEvent(event: MotionEvent): Boolean {
        dragHelper.processTouchEvent(event)
        return true
    }

    override fun computeScroll() {
        if (dragHelper.continueSettling(true)) {
            invalidate()
        }
    }
}

我的经验:

ViewDragHelper的settleCapturedViewAt()内部其实也是用OverScroller实现的弹性滑动。所以它和OverScroller是绝配。如果你需要更复杂的拖拽后动画,可以在onViewReleased里自己启动OverScroller。

五、知识体系总览

下面这张图是我自己总结的滑动体系结构。你可以看到,从底层到上层,每一层都解决不同的问题。

Android 滑动体系结构 基础层:scrollTo / scrollBy 直接操作 mScrollX/mScrollY,无动画,瞬间移动 动画层:Scroller / OverScroller 提供插值计算,配合 computeScroll() 实现平滑滑动 交互层:ViewDragHelper 封装触摸事件、拖拽边界、弹性回弹,简化复杂拖拽 应用层:RecyclerView / NestedScrollView / 自定义View 基于上述机制实现列表滑动、拖拽排序、侧滑菜单等

你看,从最底层的scrollTo/scrollBy,到Scroller/OverScroller,再到ViewDragHelper,每一层都在解决更复杂的问题。实际项目中,我通常这样选:

  • 如果只是简单的内容偏移(比如自定义进度条),用scrollTo/scrollBy就够了。
  • 如果需要平滑滑动或fling效果,用OverScroller(别用Scroller了,OverScroller是它的超集)。
  • 如果需要拖拽子View、侧滑菜单、可拖拽排序,直接上ViewDragHelper。

最后提醒一句:

滑动冲突是Android开发中最容易出bug的地方之一。如果你同时用了ViewDragHelper和父容器的滑动(比如ViewPager),一定要在onInterceptTouchEvent里做好判断。我见过太多因为滑动冲突导致界面卡死的案例了。

好了,关于View的滑动与弹性滑动,我就讲到这里。这些工具用熟了,自定义View的交互能力会上一个大台阶。嗯,去试试吧。

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