Android Scroller实现View弹性滑动完全解析

说起View的滑动效果,实现的方法有多种,例如使用动画,或者通过改变View的布局参数,其实除了这两种外,在Android中View已经为我们提供了scrollTo()scrollBy()方法来实现滑动效果,这两个方法也是我们接下来要重点讨论的...

但是呢,这两个方法实现的滑动效果都是瞬时完成的,并不能带来良好的体验哦,所以此时就需要我们的重量级嘉宾Scroller登场了,通过Scroller可以实现View在一定时间间隔内的弹性滑动,以带来更好的视觉体验,这么说可能有点抽象,举个例子,当我们使用ViewPager时,手指滑动一段距离松开后ViewPager会自动的滑动一段距离后停止,这个效果就是通过Scroller实现的,这样说应该能更好的理解Scroller的用途了。

本着从理论到实践的原则,我们来一步步的揭秘Scroller...

1、你应该知道的scrollTo()和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);
    }
   /**
     * 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;
            int oldY = mScrollY;
            mScrollX = x;
            mScrollY = y;
            invalidateParentCaches();
            onScrollChanged(mScrollX, mScrollY, oldX, oldY);
            if (!awakenScrollBars()) {
                postInvalidateOnAnimation();
            }
        }
    }

两个方法第一个参数x表示相对于当前位置横向移动的距离,正值向左移动,负值向右移动。第二个参数y表示相对于当前位置纵向移动的距离,正值向上移动,负值向下移动,单位都是像素。
不同的是,scrollTo()方法让View相对于初始的位置滚动某段距离,scrollBy()方法则是让View相对于当前的位置滚动某段距离。同时可以发现scrollBy()是通过scrollTo()方法实现的。

上边我们说过,两个方法第一个参数x表示相对于当前位置横向移动的距离,正值向左移动,负值向右移动。这是为什么呢???看下scrollTo()方法中的mScrollX = x;一行,我们传入的x值被赋值给了mScrollX ,mScroller又是什么东东呢?其实View有一个getScrollX(),继续走进源码的世界:

public final int getScrollX() {
        return mScrollX;
    }

可以看到通过getScrollX()方法可以返回mScrollX,也就是View在 水平方向移动的距离,其实关于mScrollX的值有个变化规律:mScrollX等于View左边缘和View内容左边缘的水平方向x坐标的差值,同样的道理mScrollY 等于View上边缘和View内容上边缘的垂直方向y坐标的差值

通过scrollTo()或scrollBy()实现View的移动,只是View的内容的移动,View本身的位置并不会发生改变。此时的View可以理解为一个ViewGroup,而内容可以理解成ViewGroup中的子View,假设View的坐标原点为(0, 0),如果View内容从左向右移动,则View的内容左边缘x值将大于0,而View的左边缘x值始终为0,所以View左边缘和View内容左边缘的水平方向x坐标的差值小于0,也就是mScrollX的值小于0,这也就解释了为什么当scrollTo()或scrollBy()的x参数为负值,看起来View是向右移动的,这样也就可以理解x参数为正值的情况了,同理纵向上的y参数值也是一个道理。

说了这么多scroll相关的方法,也该聊聊我们的Scroller了...

2、Scroller弹性滑动原理

首先看一下Scroller的典型用法:

Scroller mScroller = new Scroller(mContext);

private void smoothScroll(int destX, int destY) {
        int scrollX = getScrollX();
        int deltaX = destX - scrollX;
        mScroller.startScroll(scrollX, 0, deltaX, 0, 500);
        invalidate();
    }

@Override
    public void computeScroll() {
        super.computeScroll();
        if (mScroller.computeScrollOffset()) {
            scrollTo(mScroller.getCurrX(), mScroller.getCurrY());
            invalidate();
        }
    }

目测startScroll()方法和View的滑动有关,继续看源码:

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

原来startScroll()方法只是进行相关参数的初始化,其中startX、startY代表滑动的起点,dx、dy代表需要滑动的距离,duration代表整个滑动需要的时间。骗子...这里并没有View滑动的相关逻辑。那么View如何通过Scroller实现弹性滑动呢?其实是通过startScroll()下面的invalidate();方法,略抽象,可能我们更多的知道invalidate()方法能够使得View重绘,其实奥秘就在这里,通过使View重绘,会间接的执行computeScroll()方法,继续看源码:

public void computeScroll() {
    }

原来是一个空方法,所以需要我们自行实现,so sad...
而在computeScroll()中,我们首先通过computeScrollOffset()方法判断滑动是否结束,这个方法如何工作呢?继续看源码:

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

看第一个if条件,如果滑动动画已经finish,则返回fasle,继续看第二个if条件,如果执行滑动动画已经花费的时间小于整个滑动动画需要的时间,则计算出mCurrXmCurrY的值,也就是当前View内容左边缘的x、y坐标的值,同时返回true,所以,如果滑动的动画未结束则返回true否则返回false。当返回true时,则调用scrollTo(mScroller.getCurrX(), mScroller.getCurrY())进行View的滑动,其中参数mScroller.getCurrX()、mScroller.getCurrY()得到的就是上边的mCurrX和mCurrY,scrollTo之后继续执行invalidate()方法,此时又会导致View重绘,又一次执行computeScroll()方法...,这样的反复执行,直到computeScrollOffset()方法返回flase,即完成View的滑动。

通过上边的分析可以看出,仅仅通过Scroller,并不能实现View的滑动效果,同时需要配合View的invalidate()、computeScroll()、scrollTo()方法才可以完成。

说了这么多的理论,也该实践一下了...

3、自定义开关按钮

首先看下效果:

ToggleButton

录制的效果不好,有兴趣可自行下载源码体验哦...

大概的实现思路是这样的, 通过自定义ViewGroup,并设置开关背景,其中滑块是ImageView,将滑块作为子View添加到ViewGroup中,然后监听ViewGroup的onTouchEvent处理相关手势,以及给滑块设置点击事件,来实现滑块的移动效果。当然这只是一个简单实现方式,可能存在缺陷,如有问题,欢迎拍砖...

具体的看下onTouchEvent方法:

public boolean onTouchEvent(MotionEvent event) {
        int x = (int) event.getX();
        isValidToggle = false;
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                if (!mScroller.isFinished()) {
                    mScroller.abortAnimation();
                }
                break;
            case MotionEvent.ACTION_MOVE:
                int deltaX = mLastX - x;
                //边界检测判断,防止滑块越界
                if (deltaX + getScrollX() > 0) {
                    scrollTo(0, 0);
                    return true;
                } else if (deltaX + getScrollX() + getMeasuredWidth() / 2 < 0) {
                    scrollTo(-getMeasuredWidth() / 2, 0);
                    return true;
                }
                scrollBy(deltaX, 0);
                break;
            case MotionEvent.ACTION_UP:
                //处理弹性滑动
                smoothScroll();
                break;
        }
        mLastX = x;
        return super.onTouchEvent(event);
    }

当手势为MotionEvent.ACTION_MOVE时,通过scrollBy(deltaX, 0);实现滑块跟随手指的移动,但是滑块的滑动范围只能在ViewGroup中,所以我们进行了边界判断,如果发现越界,scrollTo()方法回到相应的边界。

当手势为MotionEvent.ACTION_UP时,即手指抬起,则开始进行我们的弹性滑动:

private void smoothScroll() {
        int deltaX = 0;
        if (getScrollX() < -getMeasuredWidth() / 4) {
            deltaX = -getScrollX() - getMeasuredWidth() / 2;
            if (!isOpen) {
                isOpen = true;
                isValidToggle = true;
            }
        }

        if (getScrollX() >= -getMeasuredWidth() / 4) {
            deltaX = -getScrollX();
            if (isOpen) {
                isOpen = false;
                isValidToggle = true;
            }
        }
        mScroller.startScroll(getScrollX(), 0, deltaX, 0, 500);
        invalidate();
    }

主要的逻辑就是判断手指抬起时滑块应该滚动到ViewGroup的左边还是右边,已经相应的deltaX值,然后开始滑动操作。
最后看下computeScroll()方法:

public void computeScroll() {
        super.computeScroll();
        if (mScroller.computeScrollOffset()) {
            scrollTo(mScroller.getCurrX(), mScroller.getCurrY());
            invalidate();
        } else {
            if (isValidToggle) {
                if (mToggleListener != null) {
                    mToggleListener.onToggled(isOpen);
                    Log.e("isOpen", isOpen + "");
                }
            }
        }
    }

其中的if结构在前边已经分析过了,在else中,先判断是否是一次有效的打开或关闭操作,如果是则通过接口回调来方便后续具体操作的扩展。

源码下载:
GitHub
CSDN

相信到这里,大家对Scroller以及如何实现View的滑动效果已经有所了解...

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,053评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,527评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,779评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,685评论 1 276
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,699评论 5 366
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,609评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,989评论 3 396
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,654评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,890评论 1 298
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,634评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,716评论 1 330
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,394评论 4 319
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,976评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,950评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,191评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 44,849评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,458评论 2 342

推荐阅读更多精彩内容