Android开发艺术探索知识回顾——第3章 View的事件体系:2、View的滑动、弹性滑动

 

3.2 View的滑动

3.1节介绍了 View 的一些基础知识和概念,本节开始介绍很重要的一个内容:View的滑动。在Android设备上,滑动几乎是应用的标配,不管是下拉刷新还是 SlidingMenu,它们的基础都是滑动。从另外一方面来说,Android手机由于屏幕比较小,为了给用户呈现更多的内容,就需要使用滑动来隐藏和显示一些内容。

基于上述两点,可以知道,滑动在 Android开发中具有很重要的作用,不管一些滑动效果多么绚丽,归根结底,它们都是由不同的滑动外加一些特效所组成的。因此,掌握滑动的方法是实现绚丽的自定义控件的基础

通过三种方式可以实现View的滑动:

第一种是通过 View 本身提供的 scrollTo/scrollBy 方法来实现滑动;

第二种是通过动画给 View 施加平移效果来实现滑动;

第三种是通过改变 View 的 LayoutParams 使得 View 重新布局从而实现滑动。

从目前来看,常见的滑动方式就这么三种,下面一一进行分析。

 

3.2.1 使用 scrollTo/scrollBy

为了实现 View 的滑动,View 提供了专门的方法来实现这个功能,那就是 scrollTo 和 scrollBy,我们先来看看这两个方法的实现,如下所示。


    /**
     * 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();
            }
        }
    }

    /**
     * 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);
    }

从上面的源码可以看出,scrollBy 实际上也是调用了 scrollTo 方法,它实现了基于当前位置的相对滑动,而 scrollTo 则实现了基于所传递参数的绝对滑动,这个不难理解。利用 scrollTo 和 scrollBy 来实现 View的 滑动,这不是一件困难的事,但是我们要明白滑动过程中 View内部的两个属性 mScrollX 和 mScrollY 的改变规则,这两个属性可以通过 getScrollX getScrollY 方法分别得到。

这里先简要概况一下:在滑动过程中,mScrollX 的值总是等于 View 左边缘和 View 内容左边缘在水平方向的距离,而 mScrollY 的值总是等于 View 上边缘和 View 内容上边缘在竖直方向的距离。View 边缘是指 View 的位置,由四个顶点组成,而 View 内容边缘是指 View 中的内容的边缘,scrollTo 和 scrollBy 只能改变 View 内容的位置而不能改变 View 在布局中的位置。

mScrollX 和 mScrollY 的单位为像素,并且当 View 左边缘在 View 内容左边缘的右边时,mScrollX为正值,反之为负值;当 View 上边缘在 View 内容上边缘的下边时,mScrollY为正值,反之为负值。换句话说,如果从左向右滑动,那么 mScrollX 为负值,反之为正值;如果从上往下滑动,那么 mScrollY 为负值,反之为正值。

为了更好地理解这个问题,下面举个例子,如图3-3所示。在图中假设水平和竖直方向的滑动距离都为 100 像素,针对图中各种滑动情况,都给出了对应的 mScrollX 和 mScrollY 的值。根据上面的分析,可以知道,使用 scrollTo 和 scrollBy 来实现 View 的滑动,只能将 View 的内容进行移动,并不能将 View 本身进行移动,也就是说,不管怎么滑动,也不可能将当前 View 滑动到附近 View 所在的区域,这个需要仔细体会一下。

3-3 mScrollX 和 mScrollY 的变换规律示意

 

3.2.2 使用动画

上一节介绍了釆用 scrollTo/scrollBy 来实现View的滑动,本节介绍另外一种滑动方式, 即使用动画,通过动画我们能够让一个 View 进行平移,而平移就是一种滑动。

使用动画来移动 View,主要是操作 View 的 translationX 和 translationY 属性,既可以釆用传统的 View 动画,也可以釆用属性动画,如果采用属性动画的话,为了能够兼容 3.0 以下的版本,需要釆用开源动画库 nineoldandroids (http://nineoldandroids.com/)

采用 View 动画的代码,如下所示。此动画可以在 100ms 内将一个 View 从原始位置向右下角移动100个像素。

<?xml version="1.0" encoding="utf-8"?>
<set xmlns:android="http://schemas.android.com/apk/res/android"
     android:fillAfter="true"
     android:zAdjustment="normal">

    <translate
        android:duration="100"
        android:fromXDelta="0"
        android:fromYDelta="0"
        android:interpolator="@android:anim/linear_interpolator"
        android:toXDelta="100"
        android:toYDelta="100"
        />

</set>

如果采用属性动画的话,就更简单了,以下代码可以将一个 View 在 100ms 内从原始位置向右平移100像素。

 ObjectAnimator.ofFloat(targetview,"translationX",0,100).setDuration(100).start();

 

View 动画并不能真正改变 View 的位置

上面简单介绍了通过动画来移动 View 的方法,关于动画会在第5章中进行详细说明。 使用动画来做 View 的滑动需要注意一点,View 动画是对 View 的影像做操作,它并不能真正改变 View 的位置参数,包括宽/高。

fillAfter属性保持动画最后状态

如果希望动画后的状态得以保留还必须将 fillAfter 属性设置为true,否则动画完成后其动画结果会消失。比如:我们要把 View 向右移动100像素,如果 fillAfter 为 flase,那么在动画完成的一刹那,View 会瞬间恢复到动画前的状态; 如果 fillAfter为 true,在动画完成后,View会停留在距原始位置100像素的右边。

属性动画保持动画最后状态

使用属性动画并不会存在上述问题,但是在 Android 3.以下无法使用属性动画,这个时候我们可以使用动画兼容库 nineoldandroids 来实现属性动画,尽管如此,在 Android 3.0以下的手机上通过 nineoldandroids 来实现的属性动画本质上仍然是 View 动画。

 

View 动画的点击事件,带来的严重问题

上面提到 View 动画并不能真正改变 View 的位置,这会带来一个很严重的问题。试想 —下,比如我们通过 View 动画将一个 Button 向右移动 100px,并且这个 View 设置的有单击事件,然后你会惊奇地发现,单击新位置无法触发 onClick 事件,而单击原始位置仍然可以触发onClick 事件,尽管 Button 已经不在原始位置了。

这个问题带来的影响是致命的, 但是它却又是可以理解的,因为不管 Button 怎么做变换,但是它的位置信息 ( 四个顶点和宽/高 ) 并不会随着动画而改变,因此在系统眼里,这个 Button 并没有发生任何改变,它的真身仍然在原始位置。

在这种情况下,单击新位置当然不会触发 onClick 事件了,因为 Button 的真身并没有发生改变,在新位置上只是 View 的影像而已。基于这一点,我们不能简单地给一个 View 做平移动画并且还希望它在新位置继续触发一些单击事件。

点击事件解决方法

从 Android 3.0 开始,使用属性动画可以解决上面的问题,但是大多数应用都需要兼容到 Android 2.2,在 Android 2.2 上无法使用属性动画,因此这里还是会有问题。那么这种问题难道就无法解决了吗?也不是的,虽然不能直接解决这个问题,但是还可以间接解决这个问题,这里给出一个简单的解决方法。

针对上面 View 动画的问题,我们可以在新位置预先创建一个和目标 Button —模一样的Button,它们不但外观一样连 onClick 事件也一样。当目标 Button 完成平移动画后,就把目标Button隐藏,同时把预先创建的Button显示出来,通过这种间接的方式我们解决了上面的问题。这仅仅是个参考,面对这种问题时读者可以灵活应对。

 

3.2.3 改变布局参数

本节将介绍第三种实现 View 滑动的方法,那就是改变布局参数,即改变LayoutParams这个比较好理解了,比如我们想把一个Button向右平移100px,我们只需要将这个 Button 的 LayoutParams 里的 marginLeft 参数的值增加100px即可,是不是很简单呢?

还有一种情形,为了达到移动 Button 的目的,我们可以在 Button 的左边放置一个空的 View这个空 View 的默认宽度为0,当我们需要向右移动 Button 时,只需要重新设置空 View 的宽度即可,当空 View 的宽度增大时 (假设 Button 的父容器是水平方向的 LinearLayout ),Button 就自动被挤向右边,即实现了向右平移的效果。如何重新设置一个 View 的 LayoutParams 呢?很简单,如下所示。

MarginLayoutParams params = (MarginLayoutParams)mButton1.getLayoutParams();
params.width +=100;
params.leftMargin +=100;
mButton1.requestLayout();
//或者mButton1.setLayoutParams(params);

通过改变 LayoutParams 的方式去实现 View 的滑动同样是一种很灵活的方法,需要根据不同情况去做不同的处理。

 

3.2.4 各种滑动方式的对比

上面分别介绍了三种不同的滑动方式,它们都能实现 View 的滑动,那么它们之间的差别是什么呢?

先看 scrollTo/scrollBy 这种方式,它是 View 提供的原生方法,其作用是专门用于 View 的滑动,它可以比较方便地实现滑动效果并且不影响内部元素的单击事件。但是它的缺点也是很显然的:它只能滑动 View 的内容,并不能滑动 View 本身。

再看动画,通过动画来实现 View 的滑动,这要分情况。如果是 Android 3.0 以上并釆用属性动画,那么釆用这种方式没有明显的缺点;如果是使用 View 动画或者在 Android 3.0 以下使用属性动画,均不能改变View本身的属性。在实际使用中,如果动画元素不需要响应用户的交互,那么使用动画来做滑动是比较合适的,否则就不太适合。但是动画有一个很明显的优点,那就是一些复杂的效果必须要通过动画才能实现。

最后再看一下改变布局这种方式,它除了使用起来麻烦点以外,也没有明显的缺点, 它的主要适用对象是一些具有交互性的 View,因为这些 View 需要和用户交互,直接通过动画去实现会有问题,这在 3.2.2节 中己经有所介绍,所以这个时候我们可以使用直接改变布局参数的方式去实现。

针对上面的分析做一下总结,如下所示。

scrollTo/scrollBy操作简单,适合对 View 内容的滑动;

动画:操作简单,主要适用于没有交互的 View 和实现复杂的动画效果;

改变布局参数:操作稍微复杂,适用于有交互的 View

下面我们实现一个跟手滑动的效果,这是一个自定义View,拖动它可以让它在整个屏幕上随意滑动。这个View实现起来很简单,我们只要重写它的 onTouchEvent 方法并处理 ACTION_MOVE 事件,根据两次滑动之间的距离就可以实现它的滑动了。

为了实现全屏滑动,我采用用动画的方式来实现。原因很简单,这个效果无法釆用 scrollTo 来实现。另外,它还可以釆用改变布局的方式来实现,这里仅仅是为了演示,所以就选择了动画的方式, 核心代码如下所示。

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        int x = (int) event.getRawX();
        int y = (int) event.getRawY();
        switch (event.getAction()){
            case MotionEvent.ACTION_DOWN:

                break;
            case MotionEvent.ACTION_MOVE:
                int deltaX = x - mLastX;
                int deltaY = y - mLastY;
                Log.d(TAG, "move, deltaX:" + deltaX + " deltaY:" + deltaY);
                int translationX = ViewHelper.getTranslationX(this) + deltaX;
                int translationY = ViewHelper.getTranslationY(this) + deltaY;
                ViewHelper.setTranslationX(this,translationX);
                ViewHelper.setTranslationY(this,translationY);
                break;
            case MotionEvent.ACTION_UP:

                break;
        }
        mLastX = x;
        mLastY = y;
        return true;
    }

通过上述代码可以看出,这一全屏滑动的效果实现起来相当简单。首先我们通过 getRawX 和 getRawY 方法来获取手指当前的坐标,注意不能使用 getX 和 getY 方法,因为这个是要全屏滑动的,所以需要获取当前点击事件在屏幕中的坐标而不是相对于 View 本身的坐标;

其次,我们要得到两次滑动之间的位移,有了这个位移就可以移动当前的 View,移动方法釆用的是动画兼容库 nineoldandroids 中的ViewHelper 类所提供的 setTranslationX setTranslationY 方法。实际上,ViewHelper类提供了一系列 get/set 方法,因为ViewsetTranslationX setTranslationY 只能在 Android 3.0 及其以上版本才能使用,但是 ViewHelper 所提供的方法是没有版本要求的,与此类似的还有 setXsetScaleXsetAlpha 等方法。

这一系列方法实际上是为属性动画服务的,更详细的内容会在第5章进行进一步的介绍。这个自定义 View 可以在 2.x 及其以上版本工作,但是由于动画的性质,如果给它加上 onClick 事件,那么在 3.0 以下版本它将无法在新位置响应用户的点击,这个问题在前面已经提到过。

3.3 弹性滑动

知道了 View 的滑动,我们还要知道如何实现 View 的弹性滑动,比较生硬地滑动过去,这种方式的用户体验实在太差了,因此我们要实现渐近式滑动。那么如何实现弹性滑动呢?

其实实现方法有很多,但是它们都有一个共同思想:将一次大的滑动分成若干次小的滑动,并在一个时间段内完成,弹性滑动的具体实现方式有很多,比如通过 Scroller、Handler#postDelayed 以及 Thread#sleep 等,下面一一进行介绍。

3.3.1 使用 Scroller

Scroller 的使用方法在 3.1.4 节中已经进行了介绍,下面我们来分析一下它的源码,从而探究为什么它能实现 View 的弹性滑动。


    Scroller scroller = new Scroller(mContext);

    //缓慢滚动到指定位置
    private void smootthScrollTo(int destX,int destY){
        int scrollX = getScrollX();
        int deltaX = destX - scrollX;
        //1000ms内滑向destX,效果就是慢慢滑动
        scroller.startScroll(scrollX,0,deltaX,0,1000);
        invalidate();
    }

    @Override
    public void computeScroll() {
        if(scroller.computeScrollOffset()){
            scrollTo(scroller.getCurrX(),scroller.getCurrY());
            postInvalidate();
        }
    }

上面是 Scroller 的典型的使用方法,这里先描述它的工作原理:当我们构造一个 Scroller 对象并且调用它的 startScroll 方法时,Scroller内部其实什么也没做,它只是保存了我们传递的几个参数,这几个参数从 startScroll 的原型上就可以看岀来,如下所示。

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

这个方法的参数含义很清楚,startX 和 startY 表示的是滑动的起点,dx 和 dy 表示的是要滑动的距离,而 duration 表示的是滑动时间,即整个滑动过程完成所需要的时间,注意这里的滑动是指 View 内容的滑动而非 View 本身位置的改变。

可以看到,仅仅调用 startScroll 方法是无法让 View 滑动的,因为它内部并没有做滑动相关的事,那么 Scroller 到底是如何让 View 弹性滑动的呢?

答案就是:startScroll 方法下面的 invalidate 方法,虽然有点不可思议,但是的确是这样的。invalidate 方法会导致 View 重绘,在 View的 draw 方法中又会调用 computeScroll 方法,computeScroll 方法在 View 中是一个空实现,因此需要我们自己去实现,上面的代码已经实现了 computeScroll 方法。正是因为这个 computeScroll 方法,View 才能实现弹性滑动。

这看起来还是很抽象,其实是这样的:当 View 重绘后会在 draw 方法中调用 computescroll,而 computeScroll 又会去向 Scroller 获取当前的 scrollX 和 ScrollY。然后通过 scrolrTo 方法实现滑动;接着又调用 postlnvalidate 方法来进行第二次重绘,这一次重绘的过程和第一次重绘一样,还是会导致 computeScroll 方法被调用;然后继续向 Scroller 获取当前的 scrollX 和 scrollY,并通过 scrolTTo 方法滑动到新的位置,如此反复。直到整个滑动过程结束。我们再来看下 Scroller 的 computeScrollOffset 方法的实现:
 

    /**
     * Call this when you want to know the new location.  If it returns true,
     * the animation is not yet finished.
     */ 
    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;
    }

是不是突然就明白了?这个方法会根据时间的流逝来计算岀当前的 scrollX 和 scrollY 的值。计算方法也很简单,大意就是根据时间流逝的百分比来算出 scrollX 和 scrollY 改变的百分比并计算出当前的值,这个过程类似于动画中的插值器的概念,这里我们先不去深究这个具体过程。这个方法的返回值也很重要,它返回 true 表示滑动还未结束,false 则表示滑动已经结束,因此当这个方法返回 true 时,我们要继续进行 View 的滑动。

通过上面的分析,我们应该明白 Scroller 的工作原理了,这里做一下概括:Scroller 本身并不能实现 View 的滑动,它需要配合 View computeScroll 方法才能完成弹性滑动的效果,它不断地让 View 重绘,而每一次重绘距滑动起始时间会有一个时间间隔,通过这个时间间隔 Scroller 就可以得出 View 当前的滑动位置,知道了滑动位置就可以通过 scrollTo 方法来完成 View 的滑动。

就这样,View 的每一次重绘都会导致 View 进行小幅度的滑动,而多次的小幅度滑动就组成了弹性滑动,这就是Scroller的工作机制。由此可见,Scroller的设计思想是多么值得称赞,整个过程中它对 View 没有丝亳的引用,甚至在它内部连计时器都没有。

3.3.2 通过动画

动画本身就是一种渐近的过程,因此通过它来实现的滑动天然就具有弹性效果,比如以下代码可以让一个 View 的内容在 100ms 内向左移动 100 像素。

ObjectAnimator.ofFloat(targetview, "translationX", 0, 100).setDuration (100).start();

不过这里想说的并不是这个问题,我们可以利用动画的特性来实现一些动画不能实现的效果。还拿 scrollTo 来说,我们也想模仿 Scroller来实现 View 的弹性滑动,那么利用动画的特性,我们可以采用如下方式来实现:

       final int startX = 0;
       final int deltaX = 100;
       ValueAnimator animator = ValueAnimator.ofInt(0,1).setDuration(1000);
       animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator valueAnimator) {
                float fraction = animator.getAnimatedFraction();
                mButton1.scrollTo(startX + (int)(deltaX * fraction),0);
            }
        });
       animator.start();

在上述代码中,我们的动画本质上没有作用于任何对象上,它只是在 1000ms 内完成了整个动画过程。利用这个特性,我们就可以在动画的每一帧到来时获取动画完成的比例,然后再根据这个比例计算出当前 View 所要滑动的距离。

注意,这里的滑动针对的是 View 的内容而非 View 本身。可以发现,这个方法的思想其实和 Scroller 比较类似,都是通过改变一个百分比配合 scrollTo 方法来完成 View 的滑动。需要说明一点,采用这种方法除了能够完成弹性滑动以外,还可以实现其他动画效果,我们完全可以在 onAnimationUpdate 方法中加上我们想要的其他操作。

3.3.3 使用延时策略

本节介绍另外一种实现弹性滑动的方法,那就是延时策略。它的核心思想是通过发送一系列延时消息从而达到一种渐近式的效果,具体来说可以使用 Handler 或 View postDelayed 方法,也可以使用线程的 sleep方法。对于postDelayed方法来说,我们可以通过它来延时发送一个消息,然后在消息中来进行View的滑动,如果接连不断地发送这种延时消息,那么就可以实现弹性滑动的效果。对于sleep方法来说,通过在 while 循环中不断地滑动 View和 sleep ,就可以实现弹性滑动的效果。

下面釆用 Handler 来做个示例,其他方法请读者自行去尝试,思想都是类似的。下面的代码在大约 1000ms 内将 View 的内容向左移动了 100 像素,代码比较简单,就不再详细介绍了。之所以说大约 1000ms,是因为采用这种方式无法精确地定时,原因是系统的消息调度也是需要时间的,并且所需时间不定。

    private static final  int MESSAGE_SCROLL_TO = 1;
    private static final  int FRAME_COUNT = 30;
    private static final  int DELAYED_TIME = 33;

    private int mCount = 0;

    private Handler mHandler = new Handler(){
        @Override
        public void handleMessage(Message msg) {
            switch (msg.what){
                case MESSAGE_SCROLL_TO:
                    mCount++;
                    if(mCount <= FRAME_COUNT){
                        float fraction = count / (float)FRAME_COUNT;
                        int scrollX = (int)(fraction * 100);
                        mButton1.scrollTo(scrollX,0);
                        mHandler.sendEmptyMessageDelayed(MESSAGE_SCROLL_TO,DELAYED_TIME);
                    }
                    break;
            }
        }
    };

上面几种弹性滑动的实现方法,在介绍中侧重更多的是实现思想,在实际使用中可以对其灵活地进行扩展从而实现更多复杂的效果。

 

 

 

 

 

 

 

 

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

被开发耽误的大厨

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值