天天看点

Scroller源码解析与实践1. startScroll() 源码解析2. computeScrollOffset()源码解析3.逃不开的scrollTo、scrollBy4.fling源码解析5.实现开头的ScrollerTextView

先来瞅一眼看完本篇后最终要实现的效果:ScrollerTextView。普通的TextView是不具备滑动功能的,当文字的长度超出了TextView的宽高范围后就会被截断,所以我通过Scroller与View的scrollTo、scrollBy方法结合实现了一个可以跟随手指移动,并且带有fling效果的TextView。

Scroller源码解析与实践1. startScroll() 源码解析2. computeScrollOffset()源码解析3.逃不开的scrollTo、scrollBy4.fling源码解析5.实现开头的ScrollerTextView

1. startScroll() 源码解析

public void startScroll(int startX, int startY, int dx, int dy, int duration) {
        //设置当前的模式为 SCROLL
        mMode = SCROLL_MODE;
        //整个滑动的过程重置
        mFinished = false;
        //滑动的间隔
        mDuration = duration;
        //从调用这个方法的那一时刻起,记录下当前的时间,这个参数很重要,是后续动态计算滑动距离的关键
        mStartTime = AnimationUtils.currentAnimationTimeMillis();
        mStartX = startX;
        mStartY = startY;
        mFinalX = startX + dx;
        mFinalY = startY + dy;
        mDeltaX = dx;
        mDeltaY = dy;
        //这个参数的含义是:整个滑动时间的倒数。例如,整个滑动时间是500ms,那么这个值就是 1/500
        //整个滑动过程分为500份,每一份的时间是 1/500 ms
        mDurationReciprocal = 1.0f / (float) mDuration;
    }
           

startX: 开始滑动时 X 坐标轴的初始坐标

startY: 开始滑动时 Y 坐标轴的初始坐标

dx: 整个滑动过程中,X 方向滑动的距离

dy: 整个滑动过程中,Y 方向滑动的距离

duration:整个滑动过程所消耗的时间

2. computeScrollOffset()源码解析

在 startScroll 方法中,做的都是最简单的赋值操作,所以想要实现复杂的效果还需要搭配 computeScrollOffset 方法。当我们一旦调用了 startScroll 方法后,会记录下当时的时间,后续随着时间的流逝,我们需要计算出某一时刻的 X、Y 坐标的值,就需要再次调用这个方法:

public boolean computeScrollOffset() {
		// startScroll 方法中已经将这个值置为false了
        if (mFinished) {
            return false;
        }
        // 从调用 startScroll 方法到现在,时间已经流逝了多少
        int timePassed = (int)(AnimationUtils.currentAnimationTimeMillis() - mStartTime);
        // 如果已经流逝的时间还在整个滑动时间的范围内,则计算此时此刻的 X、Y 的值
        if (timePassed < mDuration) {
            switch (mMode) {
            case SCROLL_MODE:
            		// timePassed * mDurationReciprocal 就是流逝的时间占整个滑动时间的百分比
            		// mInterpolator 是一个 估值器,类似于属性动画中的估值器。用来控制时间流逝不同阶段的快慢
            		//最终得到的 x 就是一个小于 1 的百分比
                final float x = mInterpolator.getInterpolation(timePassed * mDurationReciprocal);
                // 某一时刻的坐标值 = 开始点的值 + 百分比 乘以 整个滑动的距离
                mCurrX = mStartX + Math.round(x * mDeltaX);
                mCurrY = mStartY + Math.round(x * mDeltaY);
                break;
            // 先暂时省略fling相关的代码
        }
        //如果流逝的时间已经超过了整个滑动所需的时间了,那么就认为此次滑动结束,并将X、Y的坐标设置为最终值
        else {
            mCurrX = mFinalX;
            mCurrY = mFinalY;
            mFinished = true;
        }
        return true;
    }
           

3.逃不开的scrollTo、scrollBy

刚一接触这两个方法的时候觉得很简单,如果想在 Y 轴上将内容随手指向下滚动100像素,那就直接调用 scrollTo(0, 100) , 但是真的是这样吗?结果可能会与你想要的效果恰恰相反。

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;
            invalidateParentCaches();
            onScrollChanged(mScrollX, mScrollY, oldX, oldY);
            if (!awakenScrollBars()) {
                invalidate();
            }
        }
    }
           

View 中有两个属性: mScrollX 和 mScrollY , 可见当调用 scrollTo 方法后,实际就是将新的 x、y 的值赋值给 mScrollX 和 mScrollY ,然后调用 invalidate 引起view重新绘制,这样就产生了滑动的效果。所以实际上引起界面滑动的真正原因就在于 mScrollX 和 mScrollY,原因其实就在于 View 的 draw 方法中,在绘制内容的时候,考虑了 mScrollX 和 mScrollY :

public void invalidate(int l, int t, int r, int b) {
        final int scrollX = mScrollX;
        final int scrollY = mScrollY;
        //这句代码至关重要
        invalidateInternal(l - scrollX, t - scrollY, r - scrollX, b - scrollY, true, false);
    }
           

在引起View的重新绘制后,View在draw方法中会一步步最终再次调用到上边这个方法,最后一行代码: l - scrollX, t - scrollY, r - scrollX, b - scrollY 就是根据 mScrollX 和 mScrollY 重新确定 View 的边界,由于这里上、下、左、右都是减法,所以如果我们调用 scrollTo(0, 100),view在重新绘制的时候, left 和 right 的值不变, 而 top 和 bottom 的值都减小了 100像素,根据View坐标系这个时候View的位置是在 Y 轴方向整体向上移动了!!!!而并不是我们所想的向下移动!!!!

4.fling源码解析

直接看 fling() 的源码吧:

public void fling(int startX, int startY, int velocityX, int velocityY,
            int minX, int maxX, int minY, int maxY) {
        
        // 省略无关紧要的代码
        
        mMode = FLING_MODE;
        mFinished = false;
        //勾股定理,根据X Y方向计算出斜边的速度
        float velocity = (float) Math.hypot(velocityX, velocityY);
     
        mVelocity = velocity;
        //根据速度计算出此次完整的fling需要的时间
        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);
    }
           

参数解释:

startX:开始滑动点的X坐标

startY:开始滑动点的Y坐标

velocityX:X方向上的初始速度

velocityY:Y方向上的初始速度

minX:当fling结束后,view滑动到X方向上的最小坐标

maxX:当fling结束后,view滑动到X方向上的最大坐标

minY:当fling结束后,view滑动到Y方向上的最小坐标

maxY:当fling结束后,view滑动到Y方向上的最小坐标

fling方法和startScroll方法原理是一样的,内部只是一些赋值操作,记录下初始状态的数据。后续随着时间的流逝,我们需要计算出某一时刻的 X、Y 坐标的值,就需要再次调用这个方法:

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

        int timePassed = (int)(AnimationUtils.currentAnimationTimeMillis() - mStartTime);
    
        if (timePassed < mDuration) {
            switch (mMode) {
            //现在是 FLING_MODE 模式了!!!
            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;
                
                mCurrX = mStartX + Math.round(distanceCoef * (mFinalX - mStartX));
                // Pin to mMinX <= mCurrX <= mMaxX
                mCurrX = Math.min(mCurrX, mMaxX);
                mCurrX = Math.max(mCurrX, mMinX);
                
                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;
    }
           

当 mMode 变为 FLING_MODE 的时候,这时再调用 computeScrollOffset 方法就是计算fling过程中某一个时刻的 X Y 坐标了。里边用了一堆数学上的算法,随着时间的流逝,根据初始速度,计算出某一时刻的X Y 坐标。这里就不分析了(因为我也没仔细看2333)。总之不管是 startScroll 还是 fling , computeScrollOffset 的究极奥义都是根据当前流逝的时间以及一些初始值,计算出某一时刻下的 X Y 的坐标值。

5.实现开头的ScrollerTextView

5.1 实现文字跟随手指上下移动

这个比较简单直接重写 onTouchEvent 方法:

@Override
    public boolean onTouchEvent(MotionEvent event) {
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                mLastY = event.getY();
                break;
            case MotionEvent.ACTION_MOVE:
                float dy = mLastY - event.getY();
                scrollBy(0, (int) dy);
                mLastY = event.getY();
                break;
        }
        return true;
    }
           

首先,在 ACTION_DOWN 手机按下的时候,记录下此时的 Y 坐标为 mLastY,当手指移动的时候,用上一次记录的 mLastY - 当前的Y坐标,调用scrollBy滚动内容并且更新 mLastY 的坐标。这块比较难理解的是为什么是: mLastY - 当前的Y坐标 ,而不是 当前的Y坐标 - mLastY ?。试想下边一种情况:在初始状态没有滚动的情况下,ScrollerTextView 的属性值 mScrollY = 0 ,手指从上向下移动,文字也应该向下滚动。这时mLastY - 当前的Y坐标 得到的值 dy 一定是负值,然后把这个dy传入scrollBy方法中从而使: mScrollY = 0 - dy,mScrollY 也从0 变为了一个负值,根据本文第三章所讲当ScrollerTextView重新绘制的时候,由于 减法的存在 此时ScrollerTextView 的 top 和 bottom 的值都变大了!!!!而上下边框都变大了也就导致视图在视觉上向下移动了,从而也就达到了我们的目的。

5.2 实现fling效果

fling效果只是在 手指抬起的一瞬间触发,所以我们只处理 Touch 事件中的 ACTION_UP 就ok了

@Override
    public boolean onTouchEvent(MotionEvent event) {
        if (mVelocityTracker == null) {
            mVelocityTracker = VelocityTracker.obtain();
        }
        mVelocityTracker.addMovement(event);
        
        switch (event.getAction()) {
           case MotionEvent.ACTION_UP:
                mVelocityTracker.computeCurrentVelocity(1000, mMaxV);
                //手指抬起的时候,计算此时的Y方向的速度
                int vy = (int) mVelocityTracker.getYVelocity();
                if (Math.abs(vy) > mMinV) {
                    mInitFlingY = getScrollY();
                    mScroller.fling(0, getScrollY(), 0, vy, 0, 0, -Integer.MAX_VALUE, Integer.MAX_VALUE);
                    invalidate();
                }

                mVelocityTracker.recycle();
                mVelocityTracker = null;
                break;
        }

        return true;
    }
           

首先在 onTouchEvent 事件的顶部将所有事件都加入到速度跟踪器中,在手指抬起的时候计算当前的速度。这里定义了触发 fling 操作的最大和最小速度,只有在这个范围内滑动效果才最好。并且使用 getScrollY() 作为此次fling的初始Y坐标。并且记录下开始fling的Y坐标mInitFlingY。然后调用invalidate方法立刻触发View的重新绘制。

在速度这块:

当手指从上向下滑动时,得到的Y方向的速度为 正值,随着时间的流逝当执行scroller.computeScrollOffset方法计算最新的坐标时,新的坐标会在 startY 的基础上逐渐增大,但是不会超过 maxY(startY的值可能是正值、负值、0)。

当手指从下向上滑动时, 得到的Y方向的速度为负值。随着时间的流逝当执行scroller.computeScrollOffset方法计算最新的坐标时,新的坐标会在 startY 的基础上逐渐减小,但是不会小于 minY(startY的值可能是正值、负值、0)。

当调用了invalidate方法后,会触发View的重新绘制,View在重绘的过程中会回调 computeScroll 方法,默认这个方法是一个空实现,所以我们就可以利用这个方法,在这个方法中调用scroller.computeScrollOffset计算新的坐标:

@Override
    public void computeScroll() {
        //计算当前时刻最新的坐标值,如果整个过程还没有结束,会返回true
        if (mScroller.computeScrollOffset()) {
            //获取当前最新的Y坐标
            int currY = mScroller.getCurrY();
            //用上一个时刻的Y坐标减去当前时刻的Y坐标得到一个差值
            int diff = mInitFlingY - currY;
            //滚动内容
            scrollBy(0, diff);
            //再次触发invalidate使View不断重绘,也就实现了在fling的整个过程中,随着时间的流逝
            //View 不断的回调computeScroll方法 -> 获取新的坐标 -> 滚动内容 -> 再次出发重绘
            //这样在视觉上就出现了fling的效果
            postInvalidate();
            mInitFlingY = mScroller.getCurrY();
        }
    }
           

源码在这里:https://github.com/haoxinlei1994/studyPro

ok!大功告成!!