Android 开发艺术探索读书笔记 3 -- View 的事件体系(上)

本篇文章主要介绍以下几个知识点:

  • View 的基础知识;
  • View 的滑动;
  • 弹性滑动 。
hello,夏天 (图片来源于网络)

3.1 View的基础知识

3.1.1 什么是View

View 代表一个控件,是 Android 中所有控件的基类,如 ButtonTextViewRelativeLayoutListview 等的共同基类都是 View。

ViewGroup 继承 View,内部包含了许多个控件,即一组 View,子 View 同样还可以是 ViewGroup。例如 Button 是个 View,而 LinearLayout 既是 View 也是一个 ViewGroup。

3.1.2 View的位置参数

View 的位置由它的四个顶点决定,分别对应于 View 的四个属性:top (左上角纵坐标)、left (左上角横坐标)、right (右下角横坐标),bottom (右下角纵坐标)。

值得注意的是,上面坐标都是相对于 View 的父容器来说的,是一种相对坐标,其关系如下:

View 的位置坐标和父容器的关系

从图中的关系可得宽高的关系为:

width = right - left 
height = bottom - top

获取 View 的四个参数方式如下:

 Left = getLeft();
 Right = getRight();
 Top = getTop();
 Bottom = getBottom()

从 Android3.0开始,View 增加了额外几个参数,xytranslationXtranslationY,其换算关系如下:

// x,y 是 View 左上角的图标
// translationX、translationY 是左上角相对父容器的偏移量,默认值是 0
// 这几个参数也是相对于父容器的坐标
x = left + translationX
y = top + translationY

值得注意的是,View 在平移过程中,topleft 是原始左上角的位置信息,不发生改变,发生改变的是xytranslationXtranslationY这四个参数。

3.1.3 MotionEvent 和 TouchSlop

3.1.3.1 MotionEvent

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

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

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

  • 点击屏幕后离开松开,事件序列为 DOWN -> UP
  • 点击屏幕滑动一会再松开,事件序列为 DOWN -> MOVE ->…..> MOVE_UP

通过 MotionEvent 对象可得到点击事件发生的 x 和 y 坐标。系统提供了两组方法:

  • getX/getY 返回相对于当前 View左上角的 x 和 y 坐标
  • getRawX/getRawY 返回相对于手机屏幕左上角的 x 和 y 坐标

3.1.3.2 TouchSlop

TouchSlop 是一个常量,指系统所能识别出的滑动最小距离,若手指在屏慕上滑动的距离小于这个常量,系统就不认为是滑动操作。

TouchSlop 的值和设备有关,不同设备下可能不同,可通过 ViewConfigurtion.get(getContext()).getScaledTouchSlop获取。

在处理滑动时,可利用这个常量来做一些过滤,提升用户体验。

3.1.4 VelocityTracker、GestureDetector 和 Scroller

3.1.4.1 VelocityTracker

速度追踪,用于追踪手指在屏幕上滑动的速度,包括水平和竖直方向上的速度。使用过程如下:

首先,在 View 的 onTouchEvent方法中追踪当前单击事件的速度:

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

接着,采用如下方式获得当前的速度:

// 获取速度的之前必须先计算速度, 在 getXVelocity 和 getYVelocity 前调用此方法
velocityTracker.computeCurrentVelocity(1000);
int xVelocity = (int) velocityTracker.getXVelocity();
int yVelocity = (int) velocityTracker.getYVelocity();

值得注意的是,这里的速度是指一段时间内手指滑动的屏幕像素,如将时间设为 1000ms 时,在 1s 内,手指在水平方向滑动 100 像素,那么水平速度就是 100(注:速度可以为负数,当手指从右向左滑时为负)。其计算公式如下:

速度 = (终点位置 -  起点位置)/ 时间段

根据上面的公式和 Android 系统的坐标系可知,手指逆着坐标系的正方向滑动, 产生的速度为负值。

最后,调用 clear 方法来重置并回收内存:

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

3.1.4.2 GestureDetector

手势检测,用于辅助检测用户的单击、滑动、长按、双击等行为。使用过程如下:

首先,创建一个 GestureDetector 对象:

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

接着,接管目标 View 的 onTouchEvent 方法,在待监听 View 的 onTouchEvent 方法中添加如下实现:

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

之后就可以有选择地实现 OnGestureListenerOnDoubleTapListener 中的方法了,其方法介绍如下:

OnGestureListener 和 OnDoubleTapListener 中的方法介绍(1)
OnGestureListener 和 OnDoubleTapListener 中的方法介绍(2)

上表中较常用的有 onSingleTapUp(单击),onFling(快速滑动),onScroll(推动),onLongPress(长按)和 onDoubleTap(双击)。
  
实际开发中,可不用 GestureDetector,完全可以在 view 中的 onTouchEvent 中实现所需的监听。

建议:若只是监听滑动相关的,在 onTouchEvent 实现即可,若要监听双击行为的,就使用 GestureDetector

3.1.4.3 Scroller

弹性滑动对象,用于实现 View 的弹性滑动。

当使用 View 的 scrollTo/scrollBy 方法进行滑动时,可用 Scroller 来实现滑动的过度效果,提升用户体验。

Scroller 本身是无法让 View 弹性滑动,需配合 View 的 computScrioll 方法才能实现,固定代码如下(后面再介绍它为什么能实现弹性滑动):

    Scroller scroller = new Scroller(getContext());

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

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

3.2 View的滑动

实现 View 的滑动有三种方式:

  1. 通过 View 本身提供的 scrollTo/scrollBy 方法来实现滑动;

  2. 通过动画给 View 施加平移效果来实现滑动;

  3. 通过改变 Viev 的 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) {
            // 可通过 getScrollX 和 getScrollY 方法分别得到 View 内部的两个属性 mScrollX 和 mScrollY
            int oldX = mScrollX;
            int oldY = mScrollY;
            // 在滑动过程中,    
            // mScrollX 的值总是等于 View 左边缘和 View 内容左边缘在水平方向的距离
            //          从左向右滑动,mScrollX 为负值,反之为正值
            // mScrollY 的值总是等于 View 上边缘和 View 内容上边缘在竖直方向的距离
            //          从上往下滑动,mScrollY 为负值,反之为正值
            // 注:View 边缘是指 View 的位置,由四个顶点组成,而 View 内容边缘是指 View 中的内容的边缘
            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) {
        // 调用 scrolrTo 方法,实现了基于当前位置的相对滑动
        scrollTo(mScrollX + x, mScrollY + y);
    }

值得注意的是,scrolTo/scrollBy 只能改变 View 内容的位置而不能变 View 在布局中的位置。

如图,假设水平和竖直方向的滑动都为100像素,使用 scrollTo/scrollBy 来实现滑动,只能将 view 的内容进行移动,不能将 view 本身进行移动:

mScrollX 和 mScrollY 的变换规律示意图

3.2.2 使用动画

使用动画来移动 View,主要是操作 View 的 translationXtranslationY 属性,既可采用传统的 View 动画,也可采用属性动画。

如在100ms内将一个 View 从原始位置向右下角移动100个像素的 View 动画代码如下:

<?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>

若采用属性动画的话,100ms 内将一个 View 从原始位置向右平移100个像素可以这样:

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

值得注意的是,View 动画是对 View 的影像做操作,并不能真正改变 View 的位置参数,若要保存动画后的状态须将 fillAfter 属性设为true,否则动画完成之后就会消失(注:属性动画不会有这样的问题)。

3.2.3 改变布局参数

改变布局参数,即改变 LayoutParams,如把一个Button向右平移100px,只需将这个 Bution 的LayoutParams 里的 marginLeft 的值增加100px即可。

或者,在 button 左边放置一个空 view,默认宽度为0,当向右移动 Button 时,重置空 View 的宽度即可,当空 view 宽度增大时,button 就自动被挤向右边,即实现了向右平移的效果。

重置一个View 的 LayoutParams 的代码如下:

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

3.2.4 各种滑动方式的对比

上面介绍了三种滑动方式,总结如下:

  • scrollTo/scrollBy:操作简单,适合对View内容的滑动;
    优点:可以比较方便地实现滑动效果并且不影响内部元素的单击事件。
    缺点:只能滑动View的内容,并不能滑动View本身。

  • 动画:操作简单,适用于没有交互的 View 和实现复杂的动画效果;
    优点:一些复杂的效果必须要通过动画才能实现。
    缺点:使用View动画或者在Android3.0以下使用属性动画,均不能改变View本身的属性。

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

下面来实现一个手滑动的效果,自定义一个 View,拖动它可以在整个屏幕上随意滑动,核心代码如下:

    /* 
     * 重写 onTouchEvent 方法并处理它的 ACTION_MOVE 事件,
     * 根据两次滑动之间的距离就可以实现它的滑动,
     * 为了实现全屏滑动,采用改变布局的方式来实现
     */
    @Override
    public boolean onTouchEvent(MotionEvent event) {
        // 1. 获取手指当前坐标(注:不能使用getX和getY方法,
        // 因为这个是要全屏滑动的,所以需要获取当前点击事件在屏幕中的坐标而不是相对于View本身的坐标)
        int x = (int) event.getRawX();
        int y = (int) event.getRawY();
        switch (event.getAction()){
            case MotionEvent.ACTION_DOWN:
                break;

            case MotionEvent.ACTION_MOVE:
                // 2. 获取两次滑动之间的位移
                int deltaX = x - mLastX;
                int deltaY = y - mLastY;
                // 3. 移动(这里采用的是动画兼容库 nineoldandroids 中的 ViewHelper类)
                int trabslationX = ViewHelper.getTranslationX(this) + deltaX;
                int trabslationY = ViewHelper.getTranslationY(this) + deltaY;
                ViewHelper.setTranslationX(this,trabslationX);
                ViewHelper.setTranslationY(this,trabslationY);
                break;

            case MotionEvent.ACTION_UP:
                break;
        }
        mLastX = x;
        mLastY = y;
        return true;
    }

3.3 弹性滑动

实现弹性滑动方法很多,其共同的思想是:将一次大的滑动分成若干个小的滑动,并且在一个时间段完成。

下面介绍一些弹性滑动的具体实现方式。

3.3.1 使用 Scroller

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

就这样,View 的每一次重绘都会导致 View 进行小幅度的滑动,而多次的小幅度滑动组成了弹性滑动,这就是Scroller 的工作机制。

3.3.2 通过动画

动画本身就是一种渐进的过程,因此通过它来实现滑动天然就具有弹性效果。

如下代码可让一个view在100ms内左移100像素:

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

可利用动画的特性来实现一些动画不能实现的效果,拿 scorllTo 来说,模仿 scroller 来实现 View 的弹性滑动,那么利用动画的特性可用这样做:

        final int startX = 0;
        final int deltaX = 100;
        // 本质上没有作用于任何对象上,只是在1000ms内完成了整个动画过程
        final ValueAnimator animator = ValueAnimator.ofInt(0,1).setDuration(1000);
        animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator valueAnimator) {
                // 在动画的每一帧到来时获取动画完成的比例,
                // 根据这个比例计算出当前View所要滑动的距离
                // 其思想和Scroller类似,通过改变一个百分比配合scrolITo方法来完成View的滑动
                float fraction = animator.getAnimatedFraction();
                mButton.scrollTo(startX + (int)(deltaX * fraction),0);
            }
        });
        animator.start();

3.3.3 使用延时策略

延时策略,其核心思想是通过发送一系列延时消息从而达到一种渐近式的效果,具体来说可以使用 Handler 或 View 的 postDelayed 方法,也可用线程的sleep方法。

对于 postDelayed 方法来说,可通过它来延时发送一个消息,然后在消息中来进行View的滑动,如果接连不断地发送这种延时消息,那么就可以实现弹性滑动的效果。

对于 sleep 方法来说,通过在while循环中不断的滑动View和sleep,就可以实现弹性滑动的效果。

下面采用Handler来做个示例(其他方法思想类似),在大约1000ms内将View的内容向左移动了100像素,代码如下:

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

    private int count = 1;

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

本篇文章就介绍到这。

推荐阅读更多精彩内容