【Android】View学习之View的事件体系

View是什么

首先我们需要理解什么是View。View是安卓中所有控件的基类,无论是简单的TextView、Button,还是复杂的LinearLayout、ListView,它们的共同基类都是View,ViewGroup其实也是继承了View。Button是一个View,而LinearLayout是View的同时也是ViewGroup,ViewGroup的内部可以有子View,而子View同样可以是ViewGroup,以此类推。

View的位置参数

View的位置由它的四个顶点来决定,分别对应了View的四个属性——top、left、right、bottom。需要注意的是,这些坐标都是相对于View的父容器而说的,是一种相对坐标。View的坐标和父容器的关系如图。在Android中,x、y轴的正方向分包为右、下。

img

很容易得出,View宽高和坐标关系如下:

width = right – left

height = bottom – top

通过View的getLeft()、getRight()…方法可以得到这四个参数。

从Android 3.0开始,View增加了几个额外的参数:x.、y、translateX、translateY。x、y是View左上角的坐标,而translateX,translateY则是View左上角相对于父容器的偏移量。这几个参数也是相对父容器的坐标,并且translateX、translateY的默认值为0。与前面的参数一样,View也为它们提供了get/set方法。

需要注意:View平移过程中,top、left表示的是原始左上角位置,并不会改变。此时发生改变的是x、y、translatX、translateY这四个参数。

MotionEvent和TouchSlop

MotionEvent

手指接触屏幕后产生的一系列事件中,典型的事件类型有一下几种:

  • ACTION_DOWN:手指刚刚接触屏幕
  • ACTION_MOVE:手指在屏幕上移动
  • ACTION_UP:手指从屏幕松开的一瞬间

正常情况,一次手指触碰屏幕的行为会发生一系列点击事件,比如如下情况:

  • 点击屏幕后松开,事件顺序:DOWN->UP
  • 点击屏幕滑动一会松开,事件顺序:DOWN->MOVE->…->MOVE->UP

上述的情况是典型的事件序列,同时通过MotionEvent对象我们可以得到发生点击事件的x,y坐标。为此,系统提供了两组方法 getX/getYgetRawX/getRawY。它们的区别:getX/getY返回的是相对于当前View左上角的x,y坐标。getRawX/getRawY返回的是相对于屏幕左上角的x,y。

TouchSlop

TouchSlop是系统能识别的被认为滑动的最小距离。如果滑动时距离小于这个常量,则系统不认为这是滑动。它的值在不同设备上可能不同,通过 ViewConfiguration.get(getContext()).getScaledTouchSlop() 即可获取这个常量。这个常量的可以帮助我们在处理滑动时做一些过滤。

在源码中可以找到这个常量的定义,它处于frameworks/base/core/res/res/values/config.xml文件中。

VelocityTracker、GestureDetector 和 Scroller

VelocityTracker

顾名思义,速度追踪,用于追踪手指在滑动时的速度,包括水平和竖直方向的速度。使用过程很简单,首先在View的onTouchEvent中追踪当前单击事件的速度:

VelocityTracker velocityTracker = VelocityTracker.obtain();
velocityTracker.addMovement(event);

接着,当我们想知道当前滑动速度,可以采用如下方式:

VelocityTracker.computeCurrentVelocity(1000);
int xVelocity = (int) velocityTracker.getXVelocity();
int yVelocity = (int) velocityTracker.getYVelocity();

有两点需要注意:

一、获取速度前必须先计算速度,即先调用computeCurrentVelocity()方法。

二、此处的速度是指一段时间内手指划过的像素数,当手指从右往左划时,速度为负值。

最后,不需要使用它时,调用clear及recycle方法即可重置并回收内存。

velocityTracker.clear();
velocityTracker.recycle();

GestureDetector

手势检测,用于辅助用户单击、滑动、长按、双击等行为。

使用GestureDetector,首先需要创建一个GestureDetector对象并实现OnGestureListener接口,根据需要还可以实现OnDoubleTapListenr从而监听双击事件。

GestureDetector mGestureDetector = new GestureDetector(this);
//解决长按屏幕无法拖动的情况
mGestureDetector.setIsLongpressEnabled(false);

接着接管View的onTouchEvent方法,在其中添加如下实现:

boolean consume = mGestureDetector.onTouchEvent(event);
return consume;

之后,我们可以有选择地实现 OnGestureListener和OnDoubleTapListener中的方法了,这两个接口的方法介绍如表:

方法名 描述 所属接口
onDown 手指触摸屏幕一瞬间,由一个ACTION_DOWN触发 OnGestureListener
onShowPress 手指轻轻触摸屏幕,未松开或拖动,由一个ACTION_DOWN触发(注意与onDown的区别,它强调的是没有松开或拖动的状态) OnGestureListener
onSingleTapUp 手指触摸后松开,伴随一个MotionEvent ACTION_UP而触发,是单击行为 OnGestureListener
onScroll 手指按下屏幕并拖动,由一个ACTION_DOWN,多个ACTION_MOVE触发,是拖动行为 OnGestureListener
onLongPress 用户长按 OnGestureListener
onFling 用户按下触摸屏,快速滑动后松开,由一个ACTION_DOWN,多个ACTION_MOVE和一个ACTION_UP触发,是快速滑动行为 OnGestureListener
onDoubleTap 双击,由两次连续的单击组成,不可能与onSingleTapConfirmed共存 OnDoubleTapListener
onSingleTapConfirmed 严格的单击行为(注意它与onSingleTapUp的区别,如果触发了onSingTapConfirmed,那么后面不可能再跟着一个单击行为,即只可能是双击,不可能是双击中的一次单击) OnDoubleTapListener
onDoubleTapEvent 表示发生了双击行为,在双击期间ACTION_DOWN,ACTION_MOVE和ACTION_UP都会触发此回调 OnDoubleTapListener

日常开发中可以不使用GestureDetector,自己在View的onTouchEvent中实现所需的监听。如果是监听滑动相关的,最好在onTouchEvent中实现。如果监听是双击这种行为,就使用GestureDetector。

Scroller

弹性滑动对象,用于实现View的弹性滑动。我们知道(我们不知道!),当使用View的 scrollTo/ScrollBy 方法来滑动时,这个过程是瞬间完成的,这个没有过渡效果的滑动用户体验非常不好,这时就可用Scroller来山西爱你有过渡的滑动,这个过程不是瞬间完成的,而是在一定时间间隔内完成的。

Scroller本身无法让View弹性滑动,需要与View的computeScroll方法配合使用才能完成。典型代码如下:

Scroller mScroller = new Scroller(mContext);

//缓慢滑动到指定位置
private void smoothScrollTo(int desX, int desY){
    int scrollX = getScrollX();
    int delta = destX - scrollX;
    //1000ms内滑向destX,效果就是慢慢滑动
    mScroller.startScroll(scrollX, 0, delta, 0, 1000);
    invalidate();
}

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

View的滑动

Android设备上,滑动几乎是应用的标配,不论下拉刷新还是SlidingMenu,它们的基础均是滑动。通过三种方法可以实现View的滑动:

​ 一、通过View本身的 scrollTo/scrollBy 方法

​ 二、通过动画给View施加平移效果来实现滑动

​ 三、通过改变View的LayoutParams使得View重新布局而实现滑动

使用 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的改变规则。

在滑动过程中,mScrollX的值等于View左边和View内容左边缘在水平方向的距离,mScrollY则等于View上边缘与View内容上边缘在竖直方向的距离。

因此,scrollTo和scrollBy只能改变View内容的位置而不能改变View在布局中的位置。当View左边缘在View内容左边缘右边时,mScrollX为正值,当View上边缘在View内容上边缘下边时,mScrollY为正值。

换句话说,如果从左往右滑动,mScrollX为负值,反之为正值。如果从上往下滑动,mScrollY为负值,反之为正值

使用scrollTo和scrollBy来实现View的滑动,只能将View内容进行移动,不能将View本身进行移动。也就是说不论怎样滑动,也不可能将当前View移动到附近View的区域

使用动画

通过动画我们可以让一个View进行平移。而平移就是一种滑动。用动画来移动View,主要是操作View的translateX和translateY属性。既可以采用传统View动画,也可以采用属性动画。

采用View动画,将View从原始位置在100ms内向右下角移动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>

如果使用属性动画:

ObjectAnimator.offFloat(targetView, "translateX", 0, 100).setDuration(100).start();

虽然两者可以达到一样的效果,但是需要注意的是,View动画是对View的影像做操作,不能真正改变View的位置参数,包括宽高。并且如果希望动画后状态改变需要将fillAfter属性设置为true。即使将fillAfter置为了true,实际上也仅仅是改变了View影像的位置,它的诸如onClick等事件仍然需要在原来的位置才可触发,也就是系统看来View的位置没有发生变化。

使用属性动画就解决了这一问题,它是通过改变View的属性值来达成的,因此系统看来View实际也是改变了的。关于属性动画,可以看我另外一篇博客:【Android】属性动画学习与总结

改变布局参数

这个很好理解,比如我们想把Button右移100px,我们可以将这个Button的LayoutParams里的marginLeft值增加100px。

还有一种方式就是在Button的左边放置一个空的View,要移动Button,只需要改变View的宽度即可。

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

几种方式的对比

首先看scrollTo / scrollBy方法,它们是View提供的原生方法,可以比较方便地实现View的滑动。但它们的缺点很明显,只能滑动View的内容,不能滑动View本身。

再看动画,分为两种。如果是使用属性动画,采用这种方式没有明显的缺点。如果采用View动画,则不能改变View本身的属性。如果动画元素不需要相应用户的交互,使用动画来做滑动比较合适的,否则不太合适。不过动画实现滑动有个明显的优点:一些复杂效果必须通过动画实现。

再看改变布局的方式,除了使用麻烦,也没有明显的缺点。主要适用对象是一些有交互性的View。因为这些View需要与用户交互,用动画来实现会有问题,此时可以通过改变布局参数而实现。

做个总结

  • scrollTo/scrollBy:操作简单,适合对View内容的滑动
  • 动画:操作简单,适用于没有交互的View和实现复杂的动画效果。
  • 改变布局参数:操作稍微复杂,适用有交互的View

弹性滑动

众所周知,比较生硬的滑动的用户体验实在太差了,因此我们需要实现渐进式滑动。渐进式滑动的实现方法非常多,但他们都有一个共同的思想:将大的滑动分成若干小的滑动,并在某一时间段完成。实现方式非常多,如用Handler.postDelayed方法及Thread.sleep方法等等。

使用Scroller

之前已经介绍了Scroller的使用方法。这里我们来分析它为什么能实现View的弹性滑动。

Scroller mScroller = new Scroller(mContext);

//缓慢滑动到指定位置
private void smoothScrollTo(int desX, int desY){
    int scrollX = getScrollX();
    int delta = destX - scrollX;
    //1000ms内滑向destX,效果就是慢慢滑动
    mScroller.startScroll(scrollX, 0, delta, 0, 1000);
    invalidate();
}

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

这里是Scroller的典型使用方法,下面先描述它的工作原理

当我们创建Scroller对象并调用它的startScroll方法,Scroller内部其实什么也没做,只是保存几个传递的参数,如下:

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表示滑动的时间。可以看到,仅仅调用startScroll是无法让View滑动的,因为它内部没有做滑动相关的事情。

Scroller真正的滑动方法是下面的invalidate方法。它会导致View重绘,在View的draw方法中又会调用computeScroll方法(需要我们自己实现,上面已实现)。而computeScroll方法又会向Scroller获取当前scrollX和scrollY,之后通过scrollTo方法实现滑动。接着,调用postInvalidate方法进行二次重绘。这次重绘和第一次重绘一样,如此反复,直到滑动结束。

我们看看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;
                ...
        }
        return true;
    }

这个方法会根据时间流逝计算当前scrollX的值(类似属性动画插值器)。它返回true代表滑动未结束,我们要继续进行View的滑动。

通过动画

动画本身就是一种渐进过程,比如如下代码可以让View100ms内向右移动100px:

ObejctAnimator.ofFloat(targetView, "translateX", 0F, 100F).setDuration(100).start();

通过ValueAnimator也可以实现:

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

具体使用动画如何解决滑动,可以参考我的另一个文章【Android】属性动画学习与总结

使用延时策略

另外一种实现弹性滑动的方法,也就是使用延时策略。它的核心思想是发送一系列延时消息达到渐进式效果。如使用Handler或View的postDelayed方法,或者使用线程的sleep方法。

下面用Handler做一个示范:

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(){
    public void handleMessage(Message msg){
        switch(msg.what){
            case MESSAGE_SCROLL_TO:
                mCount++;
                if(mCount <= FRAME_COUNT){
                    float fraction = mCount / (float) FRAME_COUNT;
                    int scrollX = (int) (fraction * 100);
                    targetView.scrollTo(scrollX, 0);
                    mHandler.sendEmptyMessageDelayed(MESSAGE_SCROLL_TO, DELAYED_TIME);
                }
                break;
            default:
                break;  
        };
    };
}
N0tExpectErr0r

N0tExpectErr0r

一名热爱代码的 Android 开发者

留下你的评论

*评论支持代码高亮<pre class="prettyprint linenums">代码</pre>