Scroller实现滚动的原理

以前买了一本《Android 开发艺术探索》,当时看完也是感觉受益匪浅,书上面也是留下了努力学习的笔记,哈哈,结果不知道怎么搞丢了,也是艰难,最近又新买了一本,看起来还是感觉受益匪浅,哈哈。

先看一个简单的使用Scroller的例子

1562401862002342.gif

从上面的图片中也可以看出来,这里的滚动是指View内容的滚动而非View本身位置的改变。

先来一张流程图。

Scroller实现滚动的原理.jpg

上面例子中使用到的自定义的TestSmoothScrollView,代码如下

class TestSmoothScrollView @JvmOverloads constructor(
        context: Context,
        attrs: AttributeSet? = null,
        defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {

    private var scroller: Scroller = Scroller(context)
    private val paint = Paint()
    private var color: Int = 0

    init {
        color = context.resources.getColor(R.color.colorAccent)
        paint.color = color
        paint.style = Paint.Style.FILL
    }

    override fun onDraw(canvas: Canvas) {
        canvas.drawColor(color)
        paint.color = context.resources.getColor(R.color.colorPrimary)
        canvas.drawRect(0f, 0f, width.toFloat(), height.toFloat(), paint)
    }

    /**
     * 使用 scroller滚动
     *
     * @param destX 在水平方滚动到的目的地
     * @param destY 竖直方向上滚动的目的地
     */
    fun smoothScrollTo(destX: Int, destY: Int) {
        //要滚动的距离
        val deltaX = destX - scrollX
        val deltaY = destY - scrollY

        scroller.startScroll(scrollX, scrollY, deltaX, deltaY, 1000)
        invalidate()
    }

    override fun computeScroll() {
        if (scroller.computeScrollOffset()) {
            scrollTo(scroller.currX, scroller.currY)
            invalidate()
        }
    }
}

调用TestSmoothScrollView的smoothScrollTo方法即可实现滚动。

btnStartScroll.setOnClickListener {
   //向右下方向滚动100像素
   smoothScrollView.smoothScrollTo(-100, -100)
}

我们先来看一下TestSmoothScrollView的smoothScrollTo方法

/**
  * 使用 scroller滚动
  *
  * @param destX 在水平方滚动到的目的地
  * @param destY 竖直方向上滚动的目的地
  */
fun smoothScrollTo(destX: Int, destY: Int) {

     //计算出要滚动的距离
     val deltaX = destX - scrollX
     val deltaY = destY - scrollY
     //注释1处
     scroller.startScroll(scrollX, scrollY, deltaX, deltaY, 1000)
     //注释2处
     invalidate()
}

首先我们根据View当前的scrollX,scrollY 和传入的参数计算出水平和竖直方向上要滚动的距离。然后在注释1处调用了Scroller的startScroll方法。

Scroller的startScroll方法

/**
 * 通过提供一个起点,滚动距离和滚动时间开始滚动。
 * 
 * @param startX 水平方向上的滚动起点,单位是像素。
 * @param startY 竖直方向上的滚动起点,单位是像素。
 * @param dx 水平滚动距离,单位是像素。正值会使View的内容向左滚动。
 * @param dy 竖直方向上的滚动距离,单位是像素。正值会使View的内容向上滚动。
 * @param 滚动时间,单位是毫秒
 */
public void startScroll(int startX, int startY, int dx, int dy, int duration) {
   //为mMode赋值为SCROLL_MODE
    mMode = SCROLL_MODE;
    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方法中,代码很简单,只是保存了某些值。有几个比较重要的点。

  • 将mMode赋值为SCROLL_MODE
  • 为开始滚动时间mStartTime赋值
  • 计算滚动时间的倒数mDurationReciprocal

但是我们发现这里并没有让View滚动起来。那么View是怎么滚动起来的呢,答案就是TestSmoothScrollView的smoothScrollTo方法的注释2处,调用了invalidate方法。

调用invalidate以后,会导致View重绘,View在重绘过程中又会调用computeScroll方法,而computeScroll又会从Scroller中获取当前的scrollX和scrollY然后通过scrollTo方法实现滚动。接着又调用invalidate方法进行第二次重绘,如此反复直到滑动到最终的位置。

调用invalidate以后,会导致TestSmoothScrollView重绘,TestSmoothScrollView的父View的drawChild()方法会调用下面的方法来让TestSmoothScrollView绘制自己。

/**
 * ViewGroup的drawChild()方法会调用该方法来让每个子View来绘制自己。
 *
 * 在这里View会根据 layer type 来指定 渲染行为和硬件加速。
 */
boolean draw(Canvas canvas, ViewGroup parent, long drawingTime) {
    //...
    if (drawingWithRenderNode) {
        //注释1处
        renderNode = updateDisplayListIfDirty();
        if (!renderNode.isValid()) {
            renderNode = null;
            drawingWithRenderNode = false;
        }
    }
    //...

}

注释1处,调用updateDisplayListIfDirty方法。

public RenderNode updateDisplayListIfDirty() {

    try {
        if (layerType == LAYER_TYPE_SOFTWARE) {
            //...
        } else {
           //注释1处
           computeScroll();
           //注释2处
           canvas.translate(-mScrollX, -mScrollY);
           //注释3处
           draw(canvas);    
        }
    } 
    //...
}

注释1处,调用computeScroll方法,我们重写了该方法,方法内部会计算出当前的mScrollXmScrollY

注释2处,画布偏移mScrollXmScrollY这才是实现内容滚动的根本原因!!!

注释3处,在偏移了的画布上绘制内容,表现出来的结果就是我们的内容偏移了。画布每一帧都偏移一点,从而产生了滚动的效果。

接下来,我们看一看我们重写的computeScroll方法。

override fun computeScroll() {
    //注释1处,
    if (scroller.computeScrollOffset()) {
        //注释2处,调用scrollTo方法
        scrollTo(scroller.currX, scroller.currY)
       //继续调用invalidate方法请求重绘。
        invalidate()
    }
}

我们看下注释1处Scroller的computeScrollOffset方法

/**
 * 如果你想知道新的位置,请调用这个方法。如果该方法返回true,说明动画还没结束。
 */ 
public boolean computeScrollOffset() {
    if (mFinished) {
        return false;
    }
    //计算流逝的时间
    int timePassed = (int)(AnimationUtils.currentAnimationTimeMillis() - mStartTime);
    //如果没有结束
    if (timePassed < mDuration) {
        switch (mMode) {//注意,我们在上面为mMode赋值为SCROLL_MODE
        case SCROLL_MODE:
            //插值器根据流逝的时间计算应改变的百分比x
            final float x = mInterpolator.getInterpolation(timePassed * mDurationReciprocal);
            mCurrX = mStartX + Math.round(x * mDeltaX);
            mCurrY = mStartY + Math.round(x * mDeltaY);
            break;
        //...
        }
    }
    else {
        //结束
        mCurrX = mFinalX;
        mCurrY = mFinalY;
        mFinished = true;
    }
    return true;
}

在上面的方法中,如果已经到了滚动的结束时间,那么滚动结束,该方法返回false。

如果时间还没有到滚动的结束时间,步骤如下:

  1. 计算流逝的时间
  2. 插值器根据流逝的时间计算应改变的百分比x
  3. 计算当前应该滚动到的位置赋值给mCurrX,mCurrY。
  4. 返回true。

如果返回了true,则View会调用scrollTo方法保存当前应该到达位置,然后继续调用invalidate方法请求重绘。

if (scroller.computeScrollOffset()) {
    //调用scrollTo方法滚动到当前应该到达位置currX,currY
    scrollTo(scroller.currX, scroller.currY)
   //继续调用invalidate方法请求重绘。
    invalidate()
}

View的scrollTo方法。

public void scrollTo(int x, int y) {
    if (mScrollX != x || mScrollY != y) {
        int oldX = mScrollX;
        int oldY = mScrollY;
        //为mScrollX和mScrollY赋值
        mScrollX = x;
        mScrollY = y;
        invalidateParentCaches();
        onScrollChanged(mScrollX, mScrollY, oldX, oldY);
        if (!awakenScrollBars()) {
            postInvalidateOnAnimation();
        }
    }
}

Scroller的Fling

/**
 * 依据一个fling手势开始滚动。滚动的距离依赖fling的初始速度。
 * 
 * @param startX 滚动起点的X坐标
 *
 * @param startY 滚动起点的Y坐标
 *
 * @param velocityX 在X坐标轴上的初始速度,测量单位是像素/秒
 *
 * @param velocityY 在Y坐标轴上的初始速度,测量单位是像素/秒
 *
 * @param minX 最小的X轴上的值。scroller的滚动不会超过这个点
 *       
 * @param maxX 最大的X轴上的值。scroller的滚动不会超过这个点。
 *       
 * @param minY 最小的Y轴上的值。scroller的滚动不会超过这个点。
 *      
 * @param maxY 最小的Y轴上的值。scroller的滚动不会超过这个点。
 *        
 */
public void fling(int startX, int startY, int velocityX, int velocityY,
        int minX, int maxX, int minY, int maxY) {
    //继续一个未结束的滚动或者fling
    if (mFlywheel && !mFinished) {
        //一个未结束的滚动或者fling当前的速度
        float oldVel = getCurrVelocity();

        float dx = (float) (mFinalX - mStartX);
        float dy = (float) (mFinalY - mStartY);
        //求x和y平方和的二次方根
        float hyp = (float) Math.hypot(dx, dy);

        float ndx = dx / hyp;
        float ndy = dy / hyp;
        //一个未结束的滚动或者fling在X轴和Y轴上的当前的速度
        float oldVelocityX = ndx * oldVel;
        float oldVelocityY = ndy * oldVel;
        //如果一个未结束的滚动或者fling和这次fling在X轴和Y轴上的速度方向都相同,则累加在X轴和Y轴上的速度
        if (Math.signum(velocityX) == Math.signum(oldVelocityX) &&
                Math.signum(velocityY) == Math.signum(oldVelocityY)) {
            velocityX += oldVelocityX;
            velocityY += oldVelocityY;
        }
    }
   //当前模式是fling
    mMode = FLING_MODE;
    mFinished = false;
   //求velocityX和velocityX平方和的二次方根,也就是当前速度
    float velocity = (float) Math.hypot(velocityX, velocityY);
    //保存当前速度 
    mVelocity = velocity;
    mDuration = getSplineFlingDuration(velocity);
    mStartTime = AnimationUtils.currentAnimationTimeMillis();
    mStartX = startX;
    mStartY = startY;

    float coeffX = velocity == 0 ? 1.0f : velocityX / velocity;
    float coeffY = velocity == 0 ? 1.0f : velocityY / velocity;
    
    double totalDistance = getSplineFlingDistance(velocity);
    //计算总共要滚动的距离(带方向)
    mDistance = (int) (totalDistance * Math.signum(velocity));
        
    mMinX = minX;
    mMaxX = maxX;
    mMinY = minY;
    mMaxY = maxY;
    //最终要到达的X坐标
    mFinalX = startX + (int) Math.round(totalDistance * coeffX);
    // Pin to mMinX <= mFinalX <= mMaxX
    mFinalX = Math.min(mFinalX, mMaxX);
    mFinalX = Math.max(mFinalX, mMinX);
    //最终要到达的Y坐标
    mFinalY = startY + (int) Math.round(totalDistance * coeffY);
    // Pin to mMinY <= mFinalY <= mMaxY
    mFinalY = Math.min(mFinalY, mMaxY);
    mFinalY = Math.max(mFinalY, mMinY);
}

fling方法中主要做了2件事:

  1. 如果有一个未结束的滚动或者fling和这次fling在X轴和Y轴上的速度方向都相同,则累加在X轴和Y轴上的速度。

  2. 计算出最新的速度mVelocity、滚动时间mDuration、滚动距离mDistance、起始坐标mStartX,mStartY、终点坐标mFinalXmFinalY

下面还是要看computeScroll方法。

public boolean computeScrollOffset() {
    if (mFinished) {
        return false;
    }

    int timePassed = (int)(AnimationUtils.currentAnimationTimeMillis() - mStartTime);
    //还没结束
    if (timePassed < mDuration) {
        switch (mMode) {
        case SCROLL_MODE:
            final float x = mInterpolator.getInterpolation(timePassed * mDurationReciprocal);
            mCurrX = mStartX + Math.round(x * mDeltaX);
            mCurrY = mStartY + Math.round(x * mDeltaY);
            break;
        case FLING_MODE:
            final float t = (float) timePassed / mDuration;
            final int index = (int) (NB_SAMPLES * t);
            float distanceCoef = 1.f;
            float velocityCoef = 0.f;
            if (index < NB_SAMPLES) {//这几行代码看不懂,哈哈
                final float t_inf = (float) index / NB_SAMPLES;
                final float t_sup = (float) (index + 1) / NB_SAMPLES;
                final float d_inf = SPLINE_POSITION[index];
                final float d_sup = SPLINE_POSITION[index + 1];
                velocityCoef = (d_sup - d_inf) / (t_sup - t_inf);
                distanceCoef = d_inf + (t - t_inf) * velocityCoef;
            }
            //根据时间的流逝计算出当前速度
            mCurrVelocity = velocityCoef * mDistance / mDuration * 1000.0f;
            //计算出当前X坐标   
            mCurrX = mStartX + Math.round(distanceCoef * (mFinalX - mStartX));
            // Pin to mMinX <= mCurrX <= mMaxX
            mCurrX = Math.min(mCurrX, mMaxX);
            mCurrX = Math.max(mCurrX, mMinX);
            //计算出当前Y坐标       
            mCurrY = mStartY + Math.round(distanceCoef * (mFinalY - mStartY));
            // Pin to mMinY <= mCurrY <= mMaxY
            mCurrY = Math.min(mCurrY, mMaxY);
            mCurrY = Math.max(mCurrY, mMinY);
            //已经结束
            if (mCurrX == mFinalX && mCurrY == mFinalY) {
                mFinished = true;
            }

            break;
        }
    }
    else {
        mCurrX = mFinalX;
        mCurrY = mFinalY;
        mFinished = true;
    }
    return true;
}

我们发现fling和滚动的区别就在于当前的坐标mCurrXmCurrY的计算方式而已,其他并没有什么区别。真正的滚动还是依赖于我们重写方法来滚动到计算出来的mCurrXmCurrY

override fun computeScroll() {
    if (scroller.computeScrollOffset()) {
        //滚动到当前坐标`mCurrX`和`mCurrY`
        scrollTo(scroller.currX, scroller.currY)
        invalidate()
    }
}

参考链接:

*《Android 开发艺术探索》

  • Android—ScrollView源码分析及简单实现

你可能感兴趣的:(Scroller实现滚动的原理)