Android View 滑动实现的几种常用方式
前言
在 Android 开发中,流畅的交互体验至关重要。我们经常会遇到需要自定义可滑动组件的场景,例如实现侧滑菜单、图片浏览、或者复杂的列表交互。虽然系统提供了 ScrollView、NestedScrollView、RecyclerView 等现成控件,但在某些特殊需求下,我们需要深入了解 View 滑动的底层机制来定制行为。
本文旨在系统性地讲解让 View 产生位移的几种核心方式,分析其原理、性能差异及适用场景,并提供完整的代码示例。最后将深入探讨 Scroller 类在惯性滑动中的应用,帮助开发者构建更专业的自定义滚动控件。
基础知识准备
Android 坐标系体系
理解滑动的前提是明确坐标系的定义。Android 中的坐标主要涉及屏幕坐标系和视图坐标系。
-
屏幕坐标系
- 原点位于屏幕左上角 (0, 0)。
- X 轴向右增加,Y 轴向下增加。
- 对应
MotionEvent.getRawX()和getRawY(),表示触摸点距离屏幕边缘的距离。
-
视图坐标系
- 每个 View 都有自己的局部坐标系。
- View 的
left,top,right,bottom属性是相对于父容器的。 MotionEvent.getX()和getY()表示触摸点在当前 View 内部的相对位置。
-
父容器坐标系
- 当 View 嵌套时,子 View 的坐标转换需考虑父容器的偏移量。
在实际滑动计算中,通常使用 getX() 获取当前触摸点相对于 View 的位置,结合上一次触摸点计算位移量 dx 和 dy。
View 滑动方式概览
Android View 实现滑动效果主要通过改变 View 的位置属性或画布偏移来实现。常见的方法包括:
- layout(): 直接修改 View 的布局参数,触发重绘和测量流程。
- offsetLeftAndRight() / offsetTopAndBottom(): 辅助方法,修改 left/top 值并触发重绘。
- translationX / translationY: 修改视觉偏移,不影响实际布局参数,适合动画。
- setX() / setY(): 间接设置 translation 值。
- scrollTo() / scrollBy(): 修改内部画布偏移,用于内容滚动而非 View 整体移动。
大多数情况下,这些操作都需要配合 onTouchEvent 中的 ACTION_MOVE 事件来计算位移量,并调用 invalidate() 刷新界面。
1. layout() 方法
layout(int l, int t, int r, int b) 是 View 定位的核心方法。它通过重新设置 View 的四边边界来改变位置。
源码逻辑简述:
public void layout(int l, int t, int r, int b) {
// ... 省略部分代码
setFrame(l, t, r, b);
// ... 省略部分代码
}
protected boolean setFrame(int left, int top, int right, int bottom) {
// 计算旧尺寸和新尺寸
int oldWidth = mRight - mLeft;
int oldHeight = mBottom - mTop;
int newWidth = right - left;
int newHeight = bottom - top;
// 判断大小是否变化
boolean sizeChanged = (newWidth != oldWidth) || (newHeight != oldHeight);
// 赋值新的边界
mLeft = left;
mTop = top;
mRight = right;
mBottom = bottom;
// 更新 RenderNode 记录
mRenderNode.setLeftTopRightBottom(mLeft, mTop, mRight, mBottom);
if (sizeChanged) {
sizeChange(newWidth, newHeight, oldWidth, oldHeight);
}
return true;
}
实现步骤:
- 在
onTouchEvent的ACTION_DOWN记录初始触摸坐标。 - 在
ACTION_MOVE计算dx和dy。 - 调用
layout(left + dx, top + dy, right + dx, bottom + dy)。
缺点: 每次调用 layout 都会触发一次完整的布局流程(measure -> layout),性能开销较大,不适合高频滚动的场景。
Kotlin 示例:
class ScrollableView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null
) : View(context, attrs) {
private var lastX = 0f
private var lastY = 0f
override fun onTouchEvent(event: MotionEvent): Boolean {
when (event.action) {
MotionEvent.ACTION_DOWN -> {
lastX = event.x
lastY = event.y
return true
}
MotionEvent.ACTION_MOVE -> {
val dx = (event.x - lastX).toInt()
val dy = (event.y - lastY).toInt()
// 注意:这里会触发布局重排,性能较低
layout(left + dx, top + dy, right + dx, bottom + dy)
lastX = event.x
lastY = event.y
invalidate()
}
}
return super.onTouchEvent(event)
}
}
2. offsetLeftAndRight() / offsetTopAndBottom()
这两个方法是 layout 的便捷封装,专门用于调整 View 的位置而不改变其宽高。
源码逻辑:
public void offsetLeftAndRight(int offset) {
// ... 省略
mLeft += offset;
mRight += offset;
mRenderNode.offsetLeftAndRight(offset);
// ... 省略
}
用法:
直接将上述 layout 调用替换为:
offsetLeftAndRight(dx)
offsetTopAndBottom(dy)
特点: 同样会触发布局流程,但语义更清晰,代码更简洁。适用于不需要频繁触发布局测量的场景。
3. translationX / translationY
translationX 和 translationY 属于 View 的属性动画范畴,它们不会改变 View 的实际布局位置(即 left, top 不变),仅影响绘制时的偏移。
优点:
- 不触发布局流程,性能优于
layout。 - 适合做平滑的过渡动画。
- 不会影响父容器的测量结果。
缺点:
- 无法通过
getLocationOnScreen获取真实物理位置。 - 如果与其他布局约束冲突,可能表现异常。
实现:
override fun onTouchEvent(event: MotionEvent): Boolean {
when (event.action) {
MotionEvent.ACTION_MOVE -> {
val dx = (event.x - lastX).toInt()
val dy = (event.y - lastY).toInt()
translationX += dx
translationY += dy
lastX = event.x
lastY = event.y
invalidate()
}
}
return super.onTouchEvent(event)
}
4. setX() / setY()
setX(float x) 和 setY(float y) 本质上是设置 translationX 和 translationY 的快捷方式。
源码:
public void setX(float x) {
setTranslationX(x - mLeft);
}
用法:
x += dx
y += dy
setX(x)
setY(y)
这与直接修改 translationX 效果一致,但语义上更接近绝对坐标设定。
5. scrollTo() / scrollBy()
这是最常用的滚动方式,主要用于 ViewGroup 或支持滚动的 View(如 TextView)。
原理:
scrollTo(x, y):将内容滚动到指定坐标。scrollBy(x, y):基于当前位置增量滚动。- 内部维护
mScrollX和mScrollY变量。 - 实际上是通过平移 Canvas 画布来实现的,View 本身的
left/top不变。
源码:
public void scrollBy(int x, int y) {
scrollTo(mScrollX + x, mScrollY + y);
}
public void scrollTo(int x, int y) {
if (mScrollX != x || mScrollY != y) {
int oldX = mScrollX;
int oldY = mScrollY;
mScrollX = x;
mScrollY = y;
// ... 触发重绘
}
}
注意事项:
- 对于普通
View,scrollTo移动的是 View 内部的内容(如 TextView 的文字)。如果 View 本身没有子元素且背景固定,调用scrollTo可能看不到明显效果,除非重写draw方法。 - 对于
ViewGroup,它会移动所有子 View 的绘制位置。 - 在自定义 View 中,如果想让整个 View 跟随手指移动,通常需要操作父容器或使用
offset系列方法;如果是内部内容滚动,则使用scrollTo。
实现:
// 假设这是一个可以滚动的容器
(parent as View).scrollBy(-dx, -dy)
注:传入负值是因为手指向下滑动时,我们希望内容向上移动,而 scrollBy 的正方向是向右/向下移动画布,导致内容向左/向上移动。
拓展:Scroller 与惯性滑动
上述基础方法仅处理了手指按下和移动的过程,当手指抬起时,View 会立即停止,缺乏惯性效果。为了模拟真实的滚动体验,我们需要引入 Scroller 类。
Scroller 的作用
Scroller 是一个滑动算法工具类,它根据给定的起始位置、目标位置和持续时间,计算出每一帧应该到达的位置。
关键参数:
startScrollX/Y: 起始滚动坐标。deltaX/Y: 增量值,最终位置 = 起始 + 增量。duration: 滑动持续时长(毫秒)。interpolator: 插值器,控制速度曲线(如先快后慢)。
完整惯性滑动实现
要实现流畅的惯性滑动,需要结合 computeScroll() 方法和 Scroller。
- 初始化 Scroller:在构造函数中创建。
- 计算初速度:在
ACTION_UP时计算手指离开时的速度。 - 启动 Scroller:调用
fling或startScroll。 - 循环刷新:在
computeScroll()中检查scroller.computeScrollOffset(),若未结束则更新位置并invalidate()。
完整代码示例:
class InertialScrollableView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null
) : View(context, attrs) {
private var lastX = 0f
private var lastY = 0f
private var velocityTracker: VelocityTracker? = null
private val scroller: Scroller
init {
scroller = Scroller(context)
// 配置过冲效果
overScrollMode = OVER_SCROLL_NEVER
}
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
velocityTracker?.recycle()
}
override fun onTouchEvent(event: MotionEvent): Boolean {
if (velocityTracker == null) {
velocityTracker = VelocityTracker.obtain()
}
velocityTracker?.addMovement(event)
when (event.action) {
MotionEvent.ACTION_DOWN -> {
lastX = event.x
lastY = event.y
scroller.forceFinished(true)
return true
}
MotionEvent.ACTION_MOVE -> {
val dx = (event.x - lastX).toInt()
val dy = (event.y - lastY).toInt()
// 使用 offset 方法移动整个 View
offsetLeftAndRight(-dx)
offsetTopAndBottom(-dy)
lastX = event.x
lastY = event.y
invalidate()
}
MotionEvent.ACTION_UP -> {
velocityTracker?.computeCurrentVelocity(1000)
val vx = velocityTracker?.xVelocity ?: 0f
val vy = velocityTracker?.yVelocity ?: 0f
// 启动惯性滑动
scroller.fling(
scrollX, scrollY,
vx.toInt(), vy.toInt(),
Integer.MIN_VALUE, Integer.MAX_VALUE,
Integer.MIN_VALUE, Integer.MAX_VALUE
)
invalidate()
}
}
return true
}
override fun computeScroll() {
if (scroller.computeScrollOffset()) {
scrollTo(scroller.currX, scroller.currY)
invalidate()
}
}
}
computeScroll() 原理
computeScroll() 是 View 的一个回调方法,通常在 draw() 之前被调用。它的作用是告诉 View 是否需要继续渲染下一帧动画。
- 如果
scroller.computeScrollOffset()返回true,说明滑动尚未结束,需要更新位置并重绘。 - 如果返回
false,说明滑动已完成,停止刷新。
这种机制保证了动画的流畅性,同时避免了不必要的 CPU 消耗。
总结与对比
| 方法 | 触发流程 | 性能 | 适用场景 |
|---|---|---|---|
| layout() | Measure + Layout | 低 | 偶尔的位置调整 |
| offset | Layout | 低 | 简单的位移 |
| translation | Draw | 高 | 动画、非布局干扰 |
| scrollTo | Draw | 高 | 内容滚动、内部偏移 |
| Scroller | Draw + Timer | 高 | 惯性滚动、平滑过渡 |
最佳实践建议:
- 优先使用
translationX/Y进行轻量级位移,避免触发布局。 - 对于列表或内容区域滚动,使用
scrollTo或继承ScrollView。 - 必须实现惯性效果时,务必集成
Scroller或OverScroller。 - 注意
invalidate()的调用频率,避免过度绘制。
通过掌握以上几种方式及其底层原理,开发者可以更灵活地控制 View 的行为,构建出符合预期的高质量交互界面。


