17、View的滚动与绘制:scrollTo与scrollBy的原理、滚动后的绘制区域变化、Scroller与OverScroller的使用

各位同学,今天我们来聊聊View滚动这个看似简单、实则暗藏玄机的话题。说实话,我早期做Android开发时,对scrollTo和scrollBy的理解也就停留在「能让View动起来」的层面。直到有一次做一个复杂的滑动嵌套页面,各种滚动冲突把我折磨得够呛,我才真正沉下心来把这块啃透。

嗯,今天我就把这几年的实战心得,掰开了揉碎了讲给你听。

17.1 scrollTo与scrollBy:滚动的本质是什么?

先问一个问题:当我们调用View的scrollTo或scrollBy时,到底发生了什么?

说白了,滚动并没有移动View本身的位置,而是移动了View的内容。更准确地说,它改变了View的mScrollXmScrollY这两个成员变量,然后在下次绘制时,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则是直接设置目标位置。

关键点mScrollXmScrollY的单位是像素,正值表示内容向左/向上移动,负值表示内容向右/向下移动。这个方向很多人会搞反,我刚开始也踩过这个坑。

17.2 滚动后的绘制区域变化

滚动之后,绘制流程是怎么走的?我画了一张图,帮你理清这个逻辑:

View滚动绘制流程 调用 scrollTo/scrollBy 更新 mScrollX/mScrollY 触发 invalidate() ViewRootImpl.performTraversals() View.draw(Canvas) Canvas平移变换 onDraw() 中 Canvas 已偏移 dispatchDraw() 子View同样受影响 💡 核心:滚动改变的是 Canvas 的绘制起点,而非 View 的布局位置。getLeft()/getTop() 不受影响。

你看这个流程,关键就在Canvas平移变换这一步。View在draw()方法中会调用canvas.translate(-mScrollX, -mScrollY),把画布原点移动到滚动后的位置。然后你画的所有内容,都是基于这个新原点。

我的经验:如果你在自定义View中重写了onDraw(),记得所有坐标都是相对于滚动后的画布原点。我曾经在做一个长列表的绘制优化时,忘了考虑滚动偏移,结果绘制的内容全跑偏了,排查了半天才发现是这个问题。

17.3 Scroller与OverScroller:让滚动更丝滑

直接调用scrollToscrollBy有个问题——它们是瞬间完成的,没有动画过渡。用户看起来就是「啪」一下跳过去了,体验很差。

这时候就需要ScrollerOverScroller出场了。它们本质上是一个滚动数学引擎,帮你计算出每一帧应该滚动到哪个位置。

特性 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内部通过mOverflingDistancemOverscrollDistance来控制越界的最大距离。你可以通过setOverflingDistance()来调整这个值。

核心要点:OverScroller的computeScrollOffset()返回true时,表示动画还在进行中。你需要持续调用scrollTo()来更新View的滚动位置,并再次触发重绘。当返回false时,动画结束。

17.5 实战建议:如何选择?

说了这么多,到底该用哪个?我个人习惯这样选:

  • 简单场景:比如点击按钮让内容平滑滚动到某个位置,用Scroller就够了
  • 复杂交互:比如列表的fling操作、ViewPager的滑动,必须用OverScroller
  • 自定义View:如果你在做自定义的滑动控件,建议直接上OverScroller,因为用户对越界回弹的期待已经成了默认行为

嗯,关于View滚动与绘制的内容,今天就先聊到这里。滚动这块看似简单,但真正用好、用对,还是需要一些实战积累的。希望今天的分享能帮你少走一些弯路。

一个小技巧:调试滚动相关的问题时,可以在onDraw()里把mScrollXmScrollY打印出来,或者直接在View上画出来。这样你能直观地看到滚动位置的变化,比单纯看log要高效得多。


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