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的mScrollX和mScrollY,说白了就是移动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()方法需要传入初始速度,这个速度通常来自GestureDetector或VelocityTracker。如果你自己写手势处理,别忘了在手指抬起时计算速度。否则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。
五、知识体系总览
下面这张图是我自己总结的滑动体系结构。你可以看到,从底层到上层,每一层都解决不同的问题。
你看,从最底层的scrollTo/scrollBy,到Scroller/OverScroller,再到ViewDragHelper,每一层都在解决更复杂的问题。实际项目中,我通常这样选:
- 如果只是简单的内容偏移(比如自定义进度条),用scrollTo/scrollBy就够了。
- 如果需要平滑滑动或fling效果,用OverScroller(别用Scroller了,OverScroller是它的超集)。
- 如果需要拖拽子View、侧滑菜单、可拖拽排序,直接上ViewDragHelper。
最后提醒一句:
滑动冲突是Android开发中最容易出bug的地方之一。如果你同时用了ViewDragHelper和父容器的滑动(比如ViewPager),一定要在onInterceptTouchEvent里做好判断。我见过太多因为滑动冲突导致界面卡死的案例了。
好了,关于View的滑动与弹性滑动,我就讲到这里。这些工具用熟了,自定义View的交互能力会上一个大台阶。嗯,去试试吧。