17、View的滚动与绘制:scrollTo与scrollBy的原理、滚动后的绘制区域变化、Scroller与OverScroller的使用
各位同学,今天我们来聊聊View滚动这个看似简单、实则暗藏玄机的话题。说实话,我早期做Android开发时,对scrollTo和scrollBy的理解也就停留在「能让View动起来」的层面。直到有一次做一个复杂的滑动嵌套页面,各种滚动冲突把我折磨得够呛,我才真正沉下心来把这块啃透。
嗯,今天我就把这几年的实战心得,掰开了揉碎了讲给你听。
17.1 scrollTo与scrollBy:滚动的本质是什么?
先问一个问题:当我们调用View的scrollTo或scrollBy时,到底发生了什么?
说白了,滚动并没有移动View本身的位置,而是移动了View的内容。更准确地说,它改变了View的mScrollX和mScrollY这两个成员变量,然后在下次绘制时,Canvas会根据这两个值做一个平移变换。
来看源码,这是View.java中的核心逻辑:
// View.java
public void scrollTo(int x, int y) {
if (mScrollX != x || mScrollY != y) {
int oldX = mScrollX;
int oldY = mScrollY;
mScrollX = x;
mScrollY = y;
invalidateParentCaches();
onScrollChanged(mScrollX, mScrollY, oldX, oldY);
if (!awakenScrollBars()) {
postInvalidateOnAnimation();
}
}
}
public void scrollBy(int x, int y) {
scrollTo(mScrollX + x, mScrollY + y);
}
看到没?scrollBy其实就是对scrollTo的封装,它基于当前滚动位置做增量移动。而scrollTo则是直接设置目标位置。
关键点:mScrollX和mScrollY的单位是像素,正值表示内容向左/向上移动,负值表示内容向右/向下移动。这个方向很多人会搞反,我刚开始也踩过这个坑。
17.2 滚动后的绘制区域变化
滚动之后,绘制流程是怎么走的?我画了一张图,帮你理清这个逻辑:
你看这个流程,关键就在Canvas平移变换这一步。View在draw()方法中会调用canvas.translate(-mScrollX, -mScrollY),把画布原点移动到滚动后的位置。然后你画的所有内容,都是基于这个新原点。
我的经验:如果你在自定义View中重写了onDraw(),记得所有坐标都是相对于滚动后的画布原点。我曾经在做一个长列表的绘制优化时,忘了考虑滚动偏移,结果绘制的内容全跑偏了,排查了半天才发现是这个问题。
17.3 Scroller与OverScroller:让滚动更丝滑
直接调用scrollTo和scrollBy有个问题——它们是瞬间完成的,没有动画过渡。用户看起来就是「啪」一下跳过去了,体验很差。
这时候就需要Scroller或OverScroller出场了。它们本质上是一个滚动数学引擎,帮你计算出每一帧应该滚动到哪个位置。
| 特性 | Scroller | OverScroller |
|---|---|---|
| 核心能力 | 计算滚动插值 | 计算滚动插值 + 支持越界回弹 |
| 越界处理 | 不支持 | 支持(可配置回弹效果) |
| 使用场景 | 简单滚动动画 | 复杂交互(如ViewPager、RecyclerView) |
| API级别 | API 1 引入 | API 12 引入 |
两者的使用模式非常相似,我直接给你看一个典型用法:
// 初始化
private OverScroller mScroller = new OverScroller(context);
// 触发滚动
public void fling(int velocityX, int velocityY) {
// 参数:起始X, 起始Y, 速度X, 速度Y,
// 最小X, 最大X, 最小Y, 最大Y,
// 越界X偏移, 越界Y偏移
mScroller.fling(
getScrollX(), getScrollY(),
velocityX, velocityY,
0, maxScrollX,
0, maxScrollY,
0, 0
);
postInvalidateOnAnimation();
}
// 在computeScroll中驱动
@Override
public void computeScroll() {
if (mScroller.computeScrollOffset()) {
scrollTo(mScroller.getCurrX(), mScroller.getCurrY());
postInvalidateOnAnimation();
}
}
我曾经踩过的坑:computeScroll()这个方法不会自动调用,你必须手动触发invalidate()或postInvalidateOnAnimation()来让系统重新绘制,从而再次进入computeScroll()。我第一次用Scroller时,忘了在computeScroll()里调用postInvalidateOnAnimation(),结果滚动只动了一帧就停了,我还以为是手机卡了……
17.4 OverScroller的越界回弹机制
OverScroller相比Scroller,最大的亮点就是越界回弹。你想想看,当用户快速滑动列表到底部时,如果直接卡住,体验很生硬。而OverScroller允许你「冲出去」一点,再弹回来,手感就好多了。
它的实现原理其实不复杂:
- 第一阶段:根据惯性计算滚动位置,如果超出边界,记录超出量
- 第二阶段:启动回弹动画,把位置拉回边界内
- 第三阶段:停止滚动
OverScroller内部通过mOverflingDistance和mOverscrollDistance来控制越界的最大距离。你可以通过setOverflingDistance()来调整这个值。
核心要点:OverScroller的computeScrollOffset()返回true时,表示动画还在进行中。你需要持续调用scrollTo()来更新View的滚动位置,并再次触发重绘。当返回false时,动画结束。
17.5 实战建议:如何选择?
说了这么多,到底该用哪个?我个人习惯这样选:
- 简单场景:比如点击按钮让内容平滑滚动到某个位置,用
Scroller就够了 - 复杂交互:比如列表的fling操作、ViewPager的滑动,必须用
OverScroller - 自定义View:如果你在做自定义的滑动控件,建议直接上
OverScroller,因为用户对越界回弹的期待已经成了默认行为
嗯,关于View滚动与绘制的内容,今天就先聊到这里。滚动这块看似简单,但真正用好、用对,还是需要一些实战积累的。希望今天的分享能帮你少走一些弯路。
一个小技巧:调试滚动相关的问题时,可以在onDraw()里把mScrollX和mScrollY打印出来,或者直接在View上画出来。这样你能直观地看到滚动位置的变化,比单纯看log要高效得多。
公众号:蓝海资料掘金营,微信deep3321