欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页  >  移动技术

Android自定义控件从零开始-第三篇 深入解析View的事件和常用方法

程序员文章站 2022-06-22 23:41:15
目录1、View的触摸事件1.1 触摸事件的类型1.2 触摸事件的传递1.2.1 Activity的事件1.2.2 没有处理的事件1.2.3 点击没有子View的位置1.2.4 点击多个子View的位置1.2.5 拦截子View的事件1.2.6 事件坐标系1.2.7 onTouch和onTouchEvent的关系2、自定义控件常用方法2.1 绘制相关方法2.2 事件处理相关方法2.3 其他Android自定义控件从零开始-第一篇 View的绘制流程 中我们简单了解了View绘制最基础的一些流程和API。...

Android自定义控件从零开始-第一篇 View的绘制流程 中我们简单了解了View绘制最基础的一些流程和API。
Android自定义控件从零开始-第二篇 自定义TooBar、跑马灯 中我们根据最基础的绘制流程完成了两个简单的控件。
今天我们将深入解析View的事件和常用方法,学会了这些后,至少一些日常的自定义控件对你而言将不再是难点。

1、View的触摸事件

用户交互是除了页面显示之外的主要功能,比如用户点击一个按钮,滑动一个列表,左右切换页面,双指放大页面等操作都是对用户触摸事件的处理,实际中由于页面的复杂性,还可能造成事件冲突问题。所以在一段时期基本上每个面试Android岗位的都会问到事件分发,自定义控件有时候也会涉及到非常复杂的事件处理,所以需要对触摸事件的传递机制有深入的了解。

1.1 触摸事件的类型

触摸事件的类型主要在MotionEvent类中,从数值来说是从0到6。从7到12不是触摸事件,而是手机外设操作的事件,如鼠标、游戏操作杆等,我们只看触摸事件。下面是提取出来的类型和对应中文注释。

  //手指按下屏幕动作
  public static final int ACTION_DOWN             = 0;
  //手指离开屏幕动作
  public static final int ACTION_UP               = 1;
  //手指在屏幕上移动动作
  public static final int ACTION_MOVE             = 2;
  //取消当前手势动作,父容器拦截事件时会触发,非用户操作
  public static final int ACTION_CANCEL           = 3;
  //手指离开View边界动作
  public static final int ACTION_OUTSIDE          = 4;
  //多点触摸按下屏幕动作
  public static final int ACTION_POINTER_DOWN     = 5;
  //多点触摸离开屏幕动作
  public static final int ACTION_POINTER_UP       = 6;
  //非触摸事件,鼠标Hover移动动作
  public static final int ACTION_HOVER_MOVE       = 7;
  //非触摸事件,比如鼠标滚轮移动操作
  public static final int ACTION_SCROLL           = 8;
  //非触摸事件,鼠标Hover进入动作
  public static final int ACTION_HOVER_ENTER      = 9;
  //非触摸事件,鼠标Hover离开动作
  public static final int ACTION_HOVER_EXIT       = 10;
  //非触摸事件,外设按钮按下操作
  public static final int ACTION_BUTTON_PRESS   = 11;
  //非触摸事件,外设按钮松开动作
  public static final int ACTION_BUTTON_RELEASE  = 12;

有兴趣可以自定义一个MyView,重写onTouchEvent方法,打印出所有的触摸事件log,这样对于事件类型和手指操作会更加直观的对应上,这里特别介绍几个不容易触发的事件。

  • ACTION_CANCEL:取消当前手势动作。在View处理触摸事件过程中,父容器突然将事件劫持自己处理,就会给子View一个ACTION_CANCEL事件通知。比如说,TextView放入到ScrollView中,手指滑动TextView一段距离后,就能够收到一个ACTION_CANCEL事件,因为ScrollView要自己处理滚动事件了,后续事件就不会再传递给TextView了。
  • ACTION_OUTSIDE:手指离开View边界动作。WindowManager类中定义了窗口标记FLAG_WATCH_OUTSIZE_TOUCH只有当View所在窗口Window开启了这个标记后,View才能收到用户点击窗口外面的事件。比如使用PopupWindow时,调用了setOutsideTouchable(true) 方法后,会设置 curFlags |= WindowManager.LayoutParams.FLAG_WATCH_OUTSIDE_TOUCH;开启PopupWindow外部点击事件,这个时候我们才能在代码内部处理窗口外部的事件来使PopupWindow消失,以下为重点代码:
 @Override
        public boolean onTouchEvent(MotionEvent event) {
          ....
            if ((event.getAction() == MotionEvent.ACTION_DOWN)
                    && ((x < 0) || (x >= getWidth()) || (y < 0) || (y >= getHeight()))) {
                dismiss();
                return true;
            } else if (event.getAction() == MotionEvent.ACTION_OUTSIDE) {
                //处理ACTION_OUTSIDE事件使PopupWindow消失
                dismiss();
                return true;
            } else {
                return super.onTouchEvent(event);
            }
        }
  • ACTION_POINTER_DOWN:多点触摸动作。如果你重写onTouchEvent方法并想打印出对应log,你会发现没有对应的log,但事件确实发生了,而event.getAction()返回261。这里我们需要了解下event.getAction()返回值的含义,event.getAction()中的源码是 return nativeGetAction(mNativePtr),mNativePtr本质上一个32位的整型,低8位表示事件类型,即0到12,高8位表示触控点的索引,多点触摸时会给每个触摸点分配一个索引值,二进制显示261就是100000101,拆开后事件类型就是5,对应ACTION_POINTER_DOWN类型,所以应该使用event.getActionMasked()方法过滤掉触控点索引值。
  • ACTION_POINTER_UP:多点触控时,一个触控点离开屏幕就会触发此事件,具体获取类型同上。

1.2 触摸事件的传递

事件传递过程涉及到几个API如下:

  1. dispatchTouchEvent:分发事件。如果返回true,表示事件分发下去后被处理了;如果返回fasle,则表示分发下去后没有被任何View处理。
  2. onInterceptTouchEvent:拦截事件。如果返回true,表示拦截事件;如果返回false,表示不拦截。这拦截的是本来要转给子View的事件,所以这个方法是ViewGroup独有的。
  3. onTouchEvent:处理事件。如果返回true,表示处理事件;如果返回false,表示不处理事件。

想要研究事件的传递,实质上就是研究上述三个API的调用顺序,传递过程如下图:
Android自定义控件从零开始-第三篇 深入解析View的事件和常用方法
事件传递首先从ViewGroup开始,父容器调用dispatchTouchEvent分发事件,在该方法内会调用onInterceptTouchEvent判断是否为拦截事件,如果onInterceptTouchEvent返回true,表示拦截此事件,那么事件会直接传递给ViewGroup的onTouchEvent方法处理。如果onInterceptTouchEvent返回false,事件会传递给View的dispatchTouchEvent方法,View中没有onInterceptTouchEvent方法去判断是否拦截,所以会直接调用onTouchEvent方法处理事件。如果View的onTouchEvent方法返回true,表示事件被处理,事件传递结束,如果返回false,那么事件会传递给ViewGroup的onTouchEvent方法处理。
下面的小章节会很复杂很罗嗦,但我不是为了讲解功能而讲解,我们看源码除了为了看懂代码是干什么的,还要看懂别人为什么这么设计,我未来想创建一个好用的*该怎么做,能不能借鉴这些好的设计思路,这才是我们读源码的关键。

1.2.1 Activity的事件

当手指在屏幕上触发事件后,系统服务会将事件传递到当前显示的Activity,由Activity来继续分发事件。Activity中dispatchTouchEvent方法实现如下:

    public boolean dispatchTouchEvent(MotionEvent ev) {
        if (ev.getAction() == MotionEvent.ACTION_DOWN) {
            onUserInteraction();
        }
        //获取到Activity的PhoneWindow,将事件传递给其中的DecorView,
        //DecorView再将事件分发给它的子View
        if (getWindow().superDispatchTouchEvent(ev)) {
            //如果事件分发下去有子View消费,就直接返回
            return true;
        }
        //如果DecorView将事件分发下去后没有任何View处理,就会交给Activity的onTouchEvent处理
        return onTouchEvent(ev);
    }
1.2.2 没有处理的事件

如果一个布局中的ViewGroup和View都不消费事件,即它们的onTouchEvent方法都返回一个false时,这个时候会发现只有ACTION_DOWN事件发生了传递。这是因为ACTION_DOWN相当于一个试探事件,如果这个事件传递下去,没有任何View想要处理,那么后续事件就没有继续往下传递的必要了。
用一个Activity为例子,当触摸一个Activity页面时,Activity还是能收到事件,并将事件传递给DecorView,具体的事件拦截是在DecorView中。先找到事件传递的源码,getWindow().superDispatchTouchEvent(ev)实际调用的PhoneWindow的superDispatchTouchEvent方法。

   @Override
    public boolean superDispatchTouchEvent(MotionEvent event) {
        return mDecor.superDispatchTouchEvent(event);
    }

PhoneWindow实际上调用的DecorView的superDispatchTouchEvent方法

  public boolean superDispatcdishTouchEvent(MotionEvent event) {
        return super.dispatchTouchEvent(event);
    }

DecorView实际上调用的ViewGroup的dispatchTouchEvent方法,找到相关逻辑代码

      // Check for interception.
            final boolean intercepted;
            if (actionMasked == MotionEvent.ACTION_DOWN
                    || mFirstTouchTarget != null) {
                final boolean disallowIntercept = (mGroupFlags & FLAG_DISALLOW_INTERCEPT) != 0;
                if (!disallowIntercept) {
                    intercepted = onInterceptTouchEvent(ev);
                    ev.setAction(action); // restore action in case it was changed
                } else {
                    intercepinted = false;
                }
            } else {
                // There are no touch targets and this action is not an initial down
                // so this view group continues to intercept touches.
                intercepted = true;
            }

这段代码是用来检测是否拦截子View的事件。当actionMasked == MotionEvent.ACTION_DOWN或者mFirstTouchTarget != null时,intercepinted等于true拦截事件。第一个条件很简单,主要看mFirstTouchTarget什么时候为null。mFirstTouchTarget时TouchTarget对象,用来保存处理触摸事件的View及触摸点id值,可以看到下方赋值方法,如果有子View处理事件,那么ViewGroup的dispatchTouchEvent方法给mFirstTouchTarget赋值,相反默认就为null,也就满足了拦截条件。

    /**
     * Adds a touch target for specified child to the beginning of the list.
     * Assumes the target child is not already present.
     */
    private TouchTarget addTouchTarget(@NonNull View child, int pointerIdBits) {
        final TouchTarget target = TouchTarget.obtain(child, pointerIdBits);
        target.next = mFirstTouchTarget;
        mFirstTouchTarget = target;
        return target;
    }

综上所述,如果ACTION_DOWN分发下去没有被处理,会造成mFirstTouchTarget 为null,满足了拦截条件。其他后续事件又满足了actionMasked == MotionEvent.ACTION_DOWN事件,intercepinted等于true,这就是Activity视图根节点DecorView不会将ACTION_MOVE和ACTION_UP传递给子View的原理。

1.2.3 点击没有子View的位置

当点击ViewGroup但不在View范围内时,可以想得到View肯定不会触发事件,我们来研究具体的逻辑代码。
Android自定义控件从零开始-第三篇 深入解析View的事件和常用方法
之前说过,父容器调用dispatchTouchEvent分发事件,我们直接在此方法中找判断触摸点不在子View的逻辑。

for (int i = childrenCount - 1; i >= 0; i--) {
            ...
            final int childIndex = getAndVerifyPreorderedIndex(
               childrenCount, i, customOrder);
            final View child = getAndVerifyPreorderedView(
                      preorderedList, children, childIndex);
            if (!child.canReceivePointerEvents()
                       || !isTransformedTouchPointInView(x, y, child, null)) {
             continue;
   }
          ....
 }

ViewGroup在分发事件时,会遍历所有的子View,这一步是为了检查子View的可见性和子View的位置,
如果子View不可见或者子View不在点击事件的位置,则会调用continue跳过这次循环,如果循环结束都没有找到子View,则把事件交给自己的onTouchEvent处理。
canReceivePointerEvents检查子View的可见:

 protected boolean canReceivePointerEvents() {
        //如果子View可见或者子View存在洞话,则返回true,表示子View可以接收触摸事件
        return (mViewFlags & VISIBILITY_MASK) == VISIBLE || getAnimation() != null;
    }

重点方法,很多自定义控件处理触摸都是套用此方法原理 isTransformedTouchPointInView方法将触摸事件的位置转为以子View为坐标系的位置,检查触摸点是否在子View范围内:

protected boolean isTransformedTouchPointInView(float x, float y, View child,
            PointF outLocalPoint) {  
         //获取长度为2的临时数组,用来保存x,y
        final float[] point = getTempLocationF();
        point[0] = x;
        point[1] = y;
        //将触摸事件位置转换为以子View为坐标系的位置
        transformPointToViewLocal(point, child);
        //判断转换后的位置是否在子View内部
        final boolean isInView = child.pointInView(point[0], point[1]);
        if (isInView && outLocalPoint != null) {
            outLocalPoint.set(point[0], point[1]);
        }
        return isInView;//true表示事件触摸点在子View的内容
    }

坐标转换的代码很简单,但是需要理解坐标转换

   public void transformPointToViewLocal(float[] point, View child) {
        point[0] += mScrollX - child.mLeft;
        point[1] += mScrollY - child.mTop;
        ...
    }

mScrollX 和mScrollY 是当前ViewGroup的滚动偏移量,child.mLeft和 child.mTop是子View相对ViewGroup左边和顶部的位置。转换前point保存的坐标是以ViewGroup为坐标系,转换后point保存的坐标是以子View为坐标系。

Android自定义控件从零开始-第三篇 深入解析View的事件和常用方法

1.2.4 点击多个子View的位置

多个子View重叠在一起,事件只会传递给最上层的View,这说明ViewGroup对自身子View的管理是有顺序的,看相关处理源码,也是在dispatchTouchEvent方法中

                        //对子View进行排序,Z值越大,绘制顺序靠前的排在数组后面,当子View个数小于等于1或者Z值都为0时,buildTouchDispatchChildList返回null
                        final ArrayList<View> preorderedList = buildTouchDispatchChildList();
                        //当preorderedList 等于空并且开启了可调整子View绘制顺序的标记时,customOrder 为true
                        final boolean customOrder = preorderedList == null
                                && isChildrenDrawingOrderEnabled();
                        final View[] children = mChildren;
                        //从后往前遍历子View
                        for (int i = childrenCount - 1; i >= 0; i--) {
                            //如果customOrder为true,则返回调整了的下表;如果为false,返回i
                            final int childIndex = getAndVerifyPreorderedIndex(
                                    childrenCount, i, customOrder);
                            //当customOrder 为true时,preorderedList 必为空,这时获取到的子View是绘制顺序最靠前的孩子
                            //当customOrder为false时,preorderedList 不为空,就获取到preorderedList最后面的子View
                            //如果preorderedList为空,则获取到的是默认子View数组中最后的子View
                            final View child = getAndVerifyPreorderedView(
                                    preorderedList, children, childIndex);
                       ......
              }

通过上述源码可知,如果不指定Z值和绘制顺序的情况下,最后加入ViewGroup的子View最先获取到事件。具体的Z值和绘制顺序可以自行查看源码或者搜索资料,这里不在概述。

1.2.5 拦截子View的事件

这里是对 1.2.2 的拦截代码补充说明,也是对事件分发机制更深入的详解。

            //是否拦截标志
            final boolean intercepted;
            //检查是否是ACTION_DOWN事件或者mFirstTouchTarget不为空
            if (actionMasked == MotionEvent.ACTION_DOWN
                    || mFirstTouchTarget != null) {
                //检查是否不允许拦截子View的事件
                final boolean disallowIntercept = (mGroupFlags & FLAG_DISALLOW_INTERCEPT) != 0;
                if (!disallowIntercept) {//如果允许拦截
                    //调用onInterceptTouchEvent,决定是否拦截
                    intercepted = onInterceptTouchEvent(ev);
                    ev.setAction(action); 
                } else {
                    //如果不允许拦截,则标记为false
                    intercepinted = false;
                }
            } else {
                // 如果事件不是ACTION_DOWN并且mFirstTouchTarget为空(没有子View消费事件),则拦截事件
                intercepted = true;
            }
            ......
       }

由上述源码可知,如果在处理ACTION_DOWN事件时,ViewGroup选中拦截,也就是onInterceptTouchEvent返回true,表示拦截事件,最后将事件交给ViewGroup自己的onTouchEvent处理。

1.2.6 事件坐标系

在一个View和ViewGoup中,可通过MotionEvent的getX()和getY()获取触摸事件在当前控件中的位置,这就表明同一个触摸事件的坐标在ViewGroup和View中获到的值是不一样的。这里存在两个坐标系。
Android自定义控件从零开始-第三篇 深入解析View的事件和常用方法
具体的转换方法在1.2.3已经说过,这里不在重复列出。

1.2.7 onTouch和onTouchEvent的关系

onTouch是控件设置触摸事件监听器中的方法,和onTouchEvent方法都是处理事件的,那两者有什么区别呢?我们从View类中dispatchTouchEvent方法中找到区别

           if (li != null && li.mOnTouchListener != null
                    && (mViewFlags & ENABLED_MASK) == ENABLED
                    && li.mOnTouchListener.onTouch(this, event)) {//先回调onTouch方法
                result = true;
            }
            //如果View是enable状态并且onTouch返回的是true,那么就不会调用onTouchEvent的方法
            if (!result && onTouchEvent(event)) {
                result = true;
            }
        }

View在分发事件时,在监听器不为null的情况下,会先调用监听器的onTouch方法,如果onTouch返回true,则表示事件在监听器消费了,resule标记为true,后面就不会调用onTouchEvent方法,反之则会调用。

2、自定义控件常用方法

之前面试过一个三年的安卓,问过自定义View有哪些方法是常用的,他就回答了测量布局绘制三个,所以还是需要多了解些。每个方法没有给出示例,只是简单描述了方法功能,后面会考虑单独拿出来做一篇文章,先了解下这些方法。

2.1 绘制相关方法

方法名 功能
inflate 用于解析一个XML布局文件,经常用于自定义组合式控件
onFinishInflate 当所有的子View都解析完后调用,用于自定义ViewGroup中findViewByID,因为在构造方法中找子View的id会返回null,此时子View还没有解析好。
requestLayout 触发measure过程和layout过程,不会调用draw重绘,用于控件的大小发生变化或是想要重置控件位置时。
onSizeChange 控件的大小发生变化时调用,调用轨迹为layout——setFrame——sizeChange——onSizeChange。控件第一次布局时肯定会被调用,可用通过覆写此方法获取到控件的大小。常用语初始化与控件大小相关的成员变量
invalidate 触发View的重新绘制,也就是调用onDraw方法,不会调用测量和布局过程。常用于动画,需要在主线程中使用。
postInvalidate 和Invalidate一样,区别在于可以在子线程中更新UI。常用于触发事件后界面的修改。
setWillNotDraw 用于实现ViewGroup本身的绘制,ViewGroup没有背景的情况下通常不会绘制自己,通过设置setWillNotDraw(false)可以绘制本身。
onAttachedToWindow 当一个View绑定到Window上时的调用,肯定会在onDraw前调用。通常注册一些广播接收器、观察者或者开启一些任务。
onDetachedFromWindow 从Window上移除一个View时调用,对应onAttachedToWindow,通常配合使用一个注册一个反注册。
ViewTreeObserver 视图树的观察者,监听一些视图树的全局变化,包括整个视图树的布局、开始绘制、触摸模式的变化等,需要通过getViewTreeObserver()方法获取监听,常用于监听视图树将要发生绘制,可在View绘制之前做一些事件。

2.2 事件处理相关方法

方法名 功能
ScrollTo和ScrollBy ScrollBy表示滚动的最后位置,内部实现是通过ScrollTo的参数上加上已有的滚动偏移量实现的。ScrollTo表示滚动的偏移量,需要注意的是滚动并不是View的滚动,而是画布的滚动,所以如果想要滚到右边应该加上-号,此方法会触发invazlidate的调用,将View重新绘制一遍,但不会走测量布局过程,也就是View的大小和位置不会变化,变化的事View绘制的内容。
Scroller 实现一个View平滑滚动的工具类,原理为设置一段滚动时间,每消耗一点时间,就重新计算一次新的滚动偏移量,触发View的重绘让View的内容滚动一次,经过多次绘制就会看到平滑滚动的效果。
setTranslationX和setTranslationY 设置View在水平竖直方法相对left和top位置的偏移量,和scrollTo的区别在于不会触发View的重新绘制,只是将原来的绘制内容搬移了位置。
requestDisallowInterceptTouchEvent 子View用于请求父容器是否拦截自己的时间,true时表示不允许父容器拦截自己的时间,通常在子View的dispatchTouchEvent方法或onTouchEvent中调用,常用于解决一些触摸事件冲突的问题。
GestureDectector 用于自定义控件中的手势识别,能够是被短按、长按、滚动、单击、双击、快速滑动,极大简化了对用户触摸事件的处理,通常使用其内部类SimpleOnGestureListener接受识别的事件。
VelocityTracker 用于对用户触摸事件速度的跟踪,GestureDector中就是用它来识别快速滑动的。常用于识别快速滑动或者其他手势
ViewDragHelper 是V4包中的工具类,用于自定义ViewGroup中处理子View的拖动,基本要做一些拖动操作都需要用到它,可以极大简化拖动的处理

2.3

其他方法名 功能
TypedValue 用于完成单纯的数值到对应数据类型数值的转换,常用于自定义控件中做一些变量的初始化,如转px、dp或者sp单位。

本文地址:https://blog.csdn.net/Web311/article/details/109592154