【Android View事件(三)】Scroll类源码分析与应用


【本文出自大圣代的技术专栏 http://blog.csdn.net/qq_23191031
【禁止任何商业活动。转载烦请注明出处】

学前准备

详解Android控件体系与常用坐标系
Android常用触控类分析:MotionEvent 、 ViewConfiguration、VelocityTracker

前言

在前面的几篇文章,我向大家介绍的都是单一View事件,从这篇文章开始,我将向大家介绍连续的事件 —— 滑动。滑动是移动端设备提供的重要功能,正是由于强大的滑动事件让我们小巧的屏幕可以展现无限的数据。而滑动事件冲突却常常困扰着广大开发者。孙子云:知己知彼,百战不殆。想更好的协调滑动事件,不知道其中原理的确困难重重。当你学习本篇文章之后你会发现其实Scroll很简单,你只是被各种文章与图书弄糊涂了。

在真正讲解之前,我们需要掌握Android坐标系与触控事件相关知识,对此不太明确的同学请参见上文的 学前准备

View滑动产生的原理

从原理上讲View滑动的本质就是随着手指的运动不断地改变坐标。当触摸事件传到View时,系统记下触摸点的坐标,手指移动时系统记下移动后的触摸的坐标并算出偏移量,并通过偏移量来修改View的坐标,不断的重复这样的过程,从而实现滑动过程。

1 scrollTo 与 scrollBy

说到Scroll就不得不提到scrollTo()与scrollBy()这两个方法。

1.1 scrollTo

首先我们要知道Android每一个控件都有滚动条,只不过系统对我们隐藏了,所以我们看不见。
对于控件来说它的大小是有限的,(例如我们指定了大小、屏幕尺寸的束缚等),系统在绘制图像的时候只会在这个有限的控件内绘制,但是内容(content)的载体Canvas在本质上是无限的,例如我们的开篇图片,控件仿佛就是一个窗口我们只能通过它看到这块画布。

    /**
     * Set the scrolled position of your view. This will cause a call to
     * {@link #onScrollChanged(int, int, int, int)} and the view will be
     * invalidated.
     * @param x the x position to scroll to
     * @param y the y position to scroll to
     */
    public void scrollTo(int x, int y) {//滚动到目标位置
        if (mScrollX != x || mScrollY != y) {
            int oldX = mScrollX; // 已经滚动到的X
            int oldY = mScrollY; //已经滚动到的Y
            mScrollX = x;
            mScrollY = y;
            invalidateParentCaches();
            onScrollChanged(mScrollX, mScrollY, oldX, oldY);//回调方法,通知状态改变
            if (!awakenScrollBars()) {
                postInvalidateOnAnimation(); //重新绘制
            }
        }
    }

通过注释Set the scrolled position of your view我们可以清楚的得知 scrollTo(x,y)的作用就是将View滚动到(x,y)这个点,注意是滚动(scroll本意滚动,滑动是translate)。

在初始时 mScrollX 与mScrollY均为0,表示着View中展示的是从画布左上角开始的内容(如图 1),当调用scrollTo(100,100)时相当于将View的坐标原点滚动到(100,100)这个位置,展示画布上从(100,100)开始的内容(如图2),但是事实上View是静止不动的,所以最终的效果是View的内容平移了(-100,-100)的偏移量(如图3)

image.png

1.2 scrollBy

/** 
    * Move the scrolled position of your view. This will cause a call to 
    * {@link #onScrollChanged(int, int, int, int)} and the view will be 
    * invalidated. 
    * @param x the amount of pixels to scroll by horizontally 
    * @param y the amount of pixels to scroll by vertically 
    */  
   public void scrollBy(int x, int y) {  
       scrollTo(mScrollX + x, mScrollY + y);  
   }  

学习scrollTo在学习scrollBy就简单了,通过源码可以看到它里面调用了ScrollTo(),传入的参数是mScrollX+x,也就是说这次x是一个增量,所以scrollBy实现的效果就是,在当前位置上,再偏移x距离
这是ScrollTo()和ScrollBy()的重要区别。

1.3 小结:

  1. scrollTo与scrollBy都会另View立即重绘,所以移动是瞬间发生的
  2. scrollTo(x,y):指哪打哪,效果为View的左上角滚动到(x,y)位置,但由于View相对与父View是静止的所以最终转换为相对的View的内容滑动到(-x,-y)的位置。
  3. scrollBy(x,y): 此时的x,y为偏移量,既在原有的基础上再次滚动
  4. scrollTo与scrollBy的最用效果会作用到View的内容,所以要是想滑动当前View,就需要对其父View调用二者。也可以在当前View中使用((View)getParent).scrollXX(x,y)达到同样目的。

2 Scroller

OK,通过上面的学习我们知道scrollTo与scrollBy可以实现滑动的效果,但是滑动的效果都是瞬间完成的,在事件执行的时候平移就已经完成了,这样的效果会让人感觉突兀,Google建议使用自然过渡的动画来实现移动效果。因此,Scroller类这样应运而生了。

2.1 简单实例

举一个简单的实例方便大家的理解与学习 Scroller

主要代码

public class CustomScrollerView extends LinearLayout {
    private Scroller mScroller;

    private View mLeftView;
    private View mRightView;

    private float mInitX, mInitY;
    private float mOffsetX, mOffsetY;

    public CustomScrollerView(Context context) {
        this(context, null);
    }

    public CustomScrollerView(Context context, AttributeSet attrs) {
        this(context, attrs, 0);
    }

    public CustomScrollerView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        init();
    }

    private void init() {
        this.setOrientation(LinearLayout.HORIZONTAL);

        mScroller = new Scroller(getContext(), null, true);
    }

    @Override
    protected void onFinishInflate() {
        super.onFinishInflate();

        if (getChildCount() != 2) {
            throw new RuntimeException("Only need two child view! Please check you xml file!");
        }

        mLeftView = getChildAt(0);
        mRightView = getChildAt(1);
    }

    @Override
    public boolean dispatchTouchEvent(MotionEvent ev) {
        switch (ev.getActionMasked()) {
            case MotionEvent.ACTION_DOWN:
                mInitX = ev.getX();
                mInitY = ev.getY();
                super.dispatchTouchEvent(ev);
                return true;
            case MotionEvent.ACTION_MOVE:
                //>0为手势向右下
                mOffsetX = ev.getX() - mInitX;
                mOffsetY = ev.getY() - mInitY;
                //横向手势跟随移动
                if (Math.abs(mOffsetX) - Math.abs(mOffsetY) > ViewConfiguration.getTouchSlop()) {
                    int offset = (int) -mOffsetX;
                    if (getScrollX() + offset > mRightView.getWidth() || getScrollX() + offset < 0) {
                        return true;
                    }
                    this.scrollBy(offset, 0);
                    mInitX = ev.getX();
                    mInitY = ev.getY();
                    return true;
                }
                break;
            case MotionEvent.ACTION_CANCEL:
            case MotionEvent.ACTION_UP:
                //松手时刻滑动
                int offset = ((getScrollX() / (float) mRightView.getWidth()) > 0.5) ? mRightView.getWidth() : 0;
//                this.scrollTo(offset, 0);
                mScroller.startScroll(this.getScrollX(), this.getScrollY(), offset - this.getScrollX(), 0);
                invalidate();
                mInitX = 0;
                mInitY = 0;
                mOffsetX = 0;
                mOffsetY = 0;
                break;
        }
        return super.dispatchTouchEvent(ev);
    }

    @Override
    public void computeScroll() {
        if (mScroller.computeScrollOffset()) {
            this.scrollTo(mScroller.getCurrX(), mScroller.getCurrY());
            postInvalidate(); //允许在非主线程中出发重绘,它的出现就是简化我们在非UI线程更新view的步骤
        }
    }
}

主要布局

    <com.im_dsd.blogdemo.CustomScrollerView
        android:layout_width="200sp"
        android:layout_height="200sp"
        android:layout_centerInParent="true"
        android:orientation="horizontal"
        >

        <TextView
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:background="@android:color/holo_blue_light"/>

        <TextView
            android:layout_width="100sp"
            android:layout_height="match_parent"
            android:background="@android:color/holo_green_light"/>
    </com.im_dsd.blogdemo.CustomScrollerView>

项目效果

通过上面实例我们可以发现在自定义View的过程中使用Scroller的流程如下图所示:

下面我们就按照这个流程进行源码分析吧

2.2 源码分析

对于Scroller类 Google给出的如下解释:

This class encapsulates scrolling. You can use scrollers ( Scroller or OverScroller) to collect the data you need to produce a scrolling animation
for example, in response to a fling gesture. Scrollers track scroll offsets for you over time, but they don’t automatically apply those positions to your view. It’s your responsibility to get and apply new coordinates at a rate that will make the scrolling animation look smooth.

我们中可以看出:Scroller 是一个工具类,它只是产生一些坐标数据,而真正让View平滑的滚动起来还需要我们自行处理。我们使用的处理工具就是—— scrollTo与scrollBy

2.2.1 构造方法分析

public Scroller(Context context) {
    this(context, null);
}

public Scroller(Context context, Interpolator interpolator) {
    this(context, interpolator,
        context.getApplicationInfo().targetSdkVersion >= Build.VERSION_CODES.HONEYCOMB);
}

public Scroller(Context context, Interpolator interpolator, boolean flywheel) {
    mFinished = true;
    if (interpolator == null) {
        mInterpolator = new ViscousFluidInterpolator();
    } else {
        mInterpolator = interpolator;
    }
    mPpi = context.getResources().getDisplayMetrics().density * 160.0f;
    //摩擦力计算单位时间减速度
    mDeceleration = computeDeceleration(ViewConfiguration.getScrollFriction());
    mFlywheel = flywheel;

    mPhysicalCoeff = computeDeceleration(0.84f); // look and feel tuning
}

Scroller的构造方法没啥特殊的地方只不过第二个参数interpolator是插值器,不同的插值器实现不同的动画算法(这里不是重点不做展开,以后重点讲解),如果我们不传,则默认使用ViscousFluidInterpolator()插值器。

2.2.2 startScroll与fling

/** 
     * 使用默认滑动时间完成滑动 
     */  
    public void startScroll(int startX, int startY, int dx, int dy) {  
        startScroll(startX, startY, dx, dy, DEFAULT_DURATION);  
    }  

    /** 
     * 在我们想要滚动的地方调运,准备开始滚动,手动设置滚动时间
     *  
     * @param startX  滑动起始X坐标 
     * @param startY     滑动起始Y坐标 
     * @param dx    X方向滑动距离 
     * @param dy   Y方向滑动距离 
     * @param duration  完成滑动所需的时间      
     */  
    public void startScroll(int startX, int startY, int dx, int dy, int duration) {  
        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;  
    }  

    /** 
     * 开始基于滑动手势的滑动。根据初始的滑动手势速度,决定滑动的距离(滑动的距离,不能大于设定的最大值,不能小于设定的最小值)  
     */
public void fling(int startX, int startY, int velocityX, int velocityY,
    int minX, int maxX, int minY, int maxY) {
    ......
    mMode = FLING_MODE;
    mFinished = false;
    ......
    mStartX = startX;
    mStartY = startY;
    ......
    mDistance = (int) (totalDistance * Math.signum(velocity));

    mMinX = minX;
    mMaxX = maxX;
    mMinY = minY;
    mMaxY = maxY;
    ......
    mFinalY = Math.min(mFinalY, mMaxY);
    mFinalY = Math.max(mFinalY, mMinY);
}

在这两个方法中,都是一些全局变量的赋值,果真没有实现滚动的方法,也佐证了Scroller是一个工具的解读。而要实现滑动还是要依靠我们手动调用View的invalidated()方法触发computeScroll()方法。

   @Override
    public void computeScroll() {
        if (mScroller.computeScrollOffset()) {
            this.scrollTo(mScroller.getCurrX(), mScroller.getCurrY());
             postInvalidate(); //允许在非主线程中出发重绘,它的出现就是简化我们在非UI线程更新view的步骤
        }
    }

一旦触发成功就会调用Scroller.computeScrollOffset()方法,返回结果如果为true表示当前的滑动尚未结束,如果返回false表示滑动完成。
在Scroller类中,最最重要的就是这个computeScrollOffset方法,看上去只是返回了一个boolean类型,但他却是Scroller的核心,所有的坐标与滑动时间都由它计算完成。他将原本瞬间的滑动拆分成连续平滑的过程。

/** 
     * Call this when you want to know the new location.  If it returns true, 
     * the animation is not yet finished.  loc will be altered to provide the 
     * new location. 
     * 调用这个函数获得新的位置坐标(滑动过程中)。如果它返回true,说明滑动没有结束。 
     * getCurX(),getCurY()方法就可以获得计算后的值。 
     */   
    public boolean computeScrollOffset() {  
        if (mFinished) {//是否结束  
            return false;  
        }  

        int timePassed = (int)(AnimationUtils.currentAnimationTimeMillis() - mStartTime);//滑动开始,经过了多长时间  

        if (timePassed < mDuration) {//如果经过的时间小于动画完成所需时间  
            switch (mMode) {  
            case SCROLL_MODE:  
                float x = timePassed * mDurationReciprocal;  

                if (mInterpolator == null)//如果没有设置插值器,利用默认算法  
                    x = viscousFluid(x);   
                else//否则利用插值器定义的算法  
                    x = mInterpolator.getInterpolation(x);  

                mCurrX = mStartX + Math.round(x * mDeltaX);//计算当前X坐标  
                mCurrY = mStartY + Math.round(x * mDeltaY);//计算当前Y坐标  
                break;  
            case FLING_MODE:  
                final float t = (float) timePassed / mDuration;  
                final int index = (int) (NB_SAMPLES * t);  
                final float t_inf = (float) index / NB_SAMPLES;  
                final float t_sup = (float) (index + 1) / NB_SAMPLES;  
                final float d_inf = SPLINE[index];  
                final float d_sup = SPLINE[index + 1];  
                final float distanceCoef = d_inf + (t - t_inf) / (t_sup - t_inf) * (d_sup - d_inf);  

                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;  
    }  

从代码可以看到,如果我们没有设置插值器,就会调用内部默认算法。

/** 
     * 函数翻译是粘性流体 
     * 估计是一种算法 
     */  
    static float viscousFluid(float x)  
    {  
        x *= sViscousFluidScale;  
        if (x < 1.0f) {  
            x -= (1.0f - (float)Math.exp(-x));  
        } else {  
            float start = 0.36787944117f;   // 1/e == exp(-1)  
            x = 1.0f - (float)Math.exp(1.0f - x);  
            x = start + x * (1.0f - start);  
        }  
        x *= sViscousFluidNormalize;  
        return x;  
    }  

接着是两个重要的get方法

/** 
     * Returns the current X offset in the scroll.  
     *  
     * @return The new X offset as an absolute distance from the origin. 
     * 获得当前X方向偏移 
     */  
    public final int getCurrX() {  
        return mCurrX;  
    }  

    /** 
     * Returns the current Y offset in the scroll.  
     *  
     * @return The new Y offset as an absolute distance from the origin. 
     * 获得当前Y方向偏移 
     */  
    public final int getCurrY() {  
        return mCurrY;  
    }  

2.2.3 其他方法

public class Scroller  {
    ......
    public Scroller(Context context) {}
    public Scroller(Context context, Interpolator interpolator) {}
    public Scroller(Context context, Interpolator interpolator, boolean flywheel) {}
    //设置滚动持续时间
    public final void setFriction(float friction) {}
    //返回滚动是否结束
    public final boolean isFinished() {}
    //强制终止滚动
    public final void forceFinished(boolean finished) {}
        //返回滚动持续时间
    public final int getDuration() {}
    //返回当前滚动的偏移量
    public final int getCurrX() {}
    public final int getCurrY() {}
    //返回当前的速度
    public float getCurrVelocity() {}
    //返回滚动起始点偏移量
    public final int getStartX() {}
    public final int getStartY() {}
        //返回滚动结束偏移量
    public final int getFinalX() {}
    public final int getFinalY() {}
    //实时调用该方法获取坐标及判断滑动是否结束,返回true动画没结束
    public boolean computeScrollOffset() {}
    //滑动到指定位置
    public void startScroll(int startX, int startY, int dx, int dy) {}
    public void startScroll(int startX, int startY, int dx, int dy, int duration) {}
    //快速滑动松开手势惯性滑动
    public void fling(int startX, int startY, int velocityX, int velocityY,
            int minX, int maxX, int minY, int maxY) {}
    //终止动画,滚到最终的x、y位置
    public void abortAnimation() {}
    //延长滚动的时间
    public void extendDuration(int extend) {}
    //返回滚动开始经过的时间
    public int timePassed() {}
    //设置终止时偏移量
    public void setFinalX(int newX) {}
    public void setFinalY(int newY) {}
}

3 总结:

  1. 滑动的本质就是View随着手指的运动不断地改变坐标
  2. scrollTo(x,y)指的就是View滚动到(x,y)这个位置,但是View 要相当于父控件静止不懂,所以相对的View的内容就会滑动到(-x, -y)的位置
  3. scrollTo、scrollBy移动是瞬间的
  4. 滑动效果作用的对象是View内容
  5. Scroller类其实是一个工具类,生产滑动过程的平滑坐标,但最终的滑动动作还是需要我们自行处理
  6. Scroller类的使用流程:

参考

《Android群英传》
http://blog.csdn.net/crazy__chen/article/details/45896961
http://blog.csdn.net/yanbober/article/details/49904715

猜你喜欢

转载自blog.csdn.net/qq_23191031/article/details/78452364