【View系列】Scroller源码解析

1,172 阅读6分钟

前言

近几工作比较忙,挺长时间没更新掘金了,今天抽点时间更新一篇吧(好习惯不能丢😂 )。之前在介绍View滑动的相关姿势的时候,文末简单聊了下Scroller,也没有细扣源码细节,今天就一起过一下Scroller的源码细节吧。

常规套路及代码

我们经常看到网上说的Scroller是这么使用:

class CustomView constructor(context: Context, attributeSet: AttributeSet?) :
    View(context, attributeSet) {
  		...
      //创建Scroller
      private val scroller = Scroller(context)

      ...
      //定义个丝滑滚动的方法
      fun smoothScroll(startX: Int, startY: Int, deltaX: Int, deltaY: Int, duration: Int = 2000) {
          //1
          scroller.startScroll(startX, startY, deltaX, deltaY, duration)
          //重新绘制 会导致computeScroll()方法回调
          postInvalidateOnAnimation()
      }

      ...
      //view中方法
      override fun computeScroll() {
              //2
              if (scroller.computeScrollOffset()) {
                  //拿到scroller的当前x
                  val currX = scroller.currX
                  //更新left right
                  left = currX
                  right = left + measuredWidth

                  //拿到scroller的当前y
                  val currY = scroller.currY
                  //更新top bottom
                  top = currY
                  bottom = top + measuredHeight
                  //重新绘制 会导致computeScroll()方法回调
                  postInvalidateOnAnimation()
              }
      }
}


从上面的自定义View代码中我们定义了一个比较常见的方法smoothScroll,这个方法里面的参数含义具体如下:

  • startX 水平方向x的初始值
  • startY 竖直方向y的初始值
  • deltaX 水平方向x滑动距离
  • deltaY 竖直方向y滑动距离
  • duration 滑动整体执行下来的时间

如果我们像下面这样在fragment里面调用这块代码出现的现象是啥样的:

...
//定义相关参数
private val startX = 20.dp
private val startY = 20.dp
private val deltaX = 200.dp
private val deltaY = 200.dp
...
//给buttom设置点击事件
binding.btnStartSmoothScroll.setOnClickListener {
      //调用我们自定义view的smoothScroll方法
      binding.svTest.smoothScroll(startX, startY, deltaX, deltaY)
}
...

现象如下图所示:

scroller.gif

乍一看,感觉像是做了个动画,从视觉上确实跟动画一样。那么接下来我们从自定义view的smoothScroll方法开始研究起来。

源码解释

自定义view的smoothScroll方法实际调用了下面两行代码:

fun smoothScroll(startX: Int, startY: Int, deltaX: Int, deltaY: Int, duration: Int = 2000) {
        //1 调用scroller的startScroll方法
        scroller.startScroll(startX, startY, deltaX, deltaY, duration)
  	//2 重新绘制
        postInvalidateOnAnimation()
}

标注1处scroller的startScroll方法做了些啥:

/**
 *  方法描述:
 *  通过提供一个 起始滑动点、水平和竖直滑动距离、滑动时间来开始一段滑动
 *  起始滑动点由 startX startY决定
 *  滑动距离由dx dy决定
 *  滑动时间由duration决定
 * 
 */
public void startScroll(int startX, int startY, int dx, int dy, int duration) {
    //当前模式滚动模式
    mMode = SCROLL_MODE;
    //是否结束置为false
    mFinished = false;
    //复制滑动时间
    mDuration = duration;
    //记录滑动开始时间
    mStartTime = AnimationUtils.currentAnimationTimeMillis();
    mStartX = startX;
    mStartY = startY;
    //最终水平方向位置
    mFinalX = startX + dx;
    //最终竖直方向位置
    mFinalY = startY + dy;
    mDeltaX = dx;
    mDeltaY = dy;
    mDurationReciprocal = 1.0f / (float) mDuration;
}

上面的方法我们可以看到:Scroller的startScroll方法只是对各个成员变量进行了赋值,并没有开始滑动。那问题来了,我们是如何让view滑动起来的呢?接着我们看一下标注2处的postInvalidateOnAnimation(),这个方法会导致view重绘,间接也导致了View的computeScroll方法调用:

override fun computeScroll() {
    if (scroller.computeScrollOffset()) {
        ...
    }
}

在view的computeScroll方法中我们调用了Scroller的computeScrollOffset()方法,这个方法名字翻译过来意思是计算滚动偏移量,接着我们瞅瞅方法里面到底做了什么:

/**
 * Call this when you want to know the new location.  If it returns true,
 * the animation is not yet finished.
 * 调用这个方法当你想知道当前新位置的时候,如果返回true,那代表当前动画还没结束
 */ 
public boolean computeScrollOffset() {
    //如果结束 返回false
    if (mFinished) {
        return false;
    }
    
    //从调用startScroll到现在过去的时间
    int timePassed = (int)(AnimationUtils.currentAnimationTimeMillis() - mStartTime);
    
    //如果过去的时间小于动画总时间 说明滑动动画还没结束
    if (timePassed < mDuration) {
        switch (mMode) {
        //滚动模式
        case SCROLL_MODE:
            //通过interpolator并根据当前过去的时间计算当前的动画进度
            final float x = mInterpolator.getInterpolation(timePassed * mDurationReciprocal);
            //根据初始值 和动画进度获取当前水平方向坐标  竖直方向坐标同理
            mCurrX = mStartX + Math.round(x * mDeltaX);
            mCurrY = mStartY + Math.round(x * mDeltaY);
            break;
        ...
        }
    }
    else {
         //如果过去的时间大于等于动画总时间 说明滑动动画结束 
         //直接将mCurrX、mCurrY置为最终坐标值,同时mFinished置为true
        mCurrX = mFinalX;
        mCurrY = mFinalY;
        mFinished = true;
    }
    return true;
}

上面的代码注释比较详细,大体逻辑我稍微总结一下:调用这个方法的时候会判断当前时间是否动画结束了,如果没结束,则计算出当前mCurrX、mCurrY的值,也就是当前滑动的坐标点,同时放回true代表动画还没结束。所以我们可以知道调用这个computeScrollOffset方法是用来更新当前应该滑动到的坐标点判断滑动过程是否结束.

这个时候我们再看文章开头中的这段代码:

override fun computeScroll() {
    //如果动画没结束 进入代码块执行逻辑
    if (scroller.computeScrollOffset()) {
        //拿到scroller当前水平坐标值
        val currX = scroller.currX
        //更新left right
        left = currX
        right = left + measuredWidth
        
        //拿到scroller当前竖直坐标值
        val currY = scroller.currY
        //更新top bottom
        top = currY
        bottom = top + measuredHeight
        //重绘
        postInvalidateOnAnimation()
    }
}

是不是可以看出来上述代码的逻辑就是一直在轮询获取当前scroller计算出来的坐标值,然后更新view的矩形区域,直到scroller告诉我们动画结束,当然文章开头gif动画效果也就理解了。

总结

我们从上面一个小的demo入手再加上给大家的一些源码解释,可以对Scroller有个整体认知:

  • startScroll 用于赋值初始值(滑动起始点,滑动距离,滑动时间)
  • computeScrollOffset 计算当前滑动到的位置 并返回滑动动画是否结束
  • scroller 只是一个计算的工具,给我们提供了很多滑动相关的方法,我们需要拿到scroller计算出来的值,去动态更新view的位置就行

在做滑动相关的东西的时候,要灵活运用Scroller/OverScroller,再结合着我们之前铺垫的【View系列】让View滑动起来的几种方式, 就可以做个比较炫酷的滑动view了。

上述源码见Github

最后推荐一下我维护的一个开源库《超好用的Android高亮引导库》。Github地址如下:github.com/hyy920109/H…

拓展

Scroller另外一些比较常用的方法如下:

/**
 *  强制停止动画,停留到了当前帧计算出来的坐标点
 */
public final void forceFinished(boolean finished) {
    mFinished = finished;
}

/**
 * 中断动画 直接就滑动到最终位置了
 */
public void abortAnimation() {
    mCurrX = mFinalX;
    mCurrY = mFinalY;
    mFinished = true;
}

View系列推荐阅读:

  1. 【View系列】View事件分发源码探索
  2. 【View系列】View的measure流程源码全解析
  3. 【View系列】MeasureSpec.UNSPECIFIED是这么用的?
  4. 【View系列】手把手教你解决ViewPager2滑动冲突
  5. 【View系列】让View滑动起来的几种方式