美文网首页消零派086号
iOS和Flutter里的事件处理

iOS和Flutter里的事件处理

作者: 意一ineyee | 来源:发表于2021-12-28 20:44 被阅读0次

    目录

    先说一下事件处理里的被处理者:事件
    一、iOS里的事件
    二、Flutter里的事件

    然后说一下事件处理里的处理者:响应者
    三、iOS里的响应者和响应者链
    四、Flutter里的响应者和响应者数组

    然后再说一下响应者具体是怎么处理事件的
    五、iOS里的寻找第一响应者、事件传递和事件响应
    六、Flutter里的寻找第一响应者、事件分发和事件响应

    最后补充一下原始指针事件和手势同时存在时会怎样
    七、iOS里的手势
    八、Flutter里的手势

    iOS和Flutter里的事件处理
    iOS和Flutter里的事件处理实例

    一、iOS里的事件


    iOS里的事件分三类:

    • 触摸事件(本篇我们主要研究一下触摸事件)
    • 加速计事件
    • 远程控制事件

    UITouch

    我们的一根手指触摸屏幕,系统就会为其创建一个对应的UITouch对象,多根手指触摸屏幕,系统就会为其创建多个对应的UITouch对象。每个UITouch对象内部都存储着对应手指触摸屏幕的时间、位置、力度、所在的window、所在的view等信息。当手指离开屏幕一小段时间后,系统判定对应的UITouch对象不会再更新,就会销毁它。

    @interface UITouch : NSObject
    
    // 时间
    @property (nonatomic,readonly) NSTimeInterval timestamp;
    
    // 当前触摸的点在[view]坐标系统下的位置
    - (CGPoint)locationInView:(nullable UIView *)view;
    // 上一个触摸的点在[view]坐标系统下的位置
    - (CGPoint)previousLocationInView:(nullable UIView *)view;
    
    // 力度
    @property (nonatomic,readonly) CGFloat force API_AVAILABLE(ios(9.0));
    
    // 所在的window
    @property (nullable,nonatomic,readonly,strong) UIWindow *window;
    // 所在的view
    @property (nullable,nonatomic,readonly,strong) UIView *view;
    
    // 寻找第一响应者的过程中,该数组会搜集第一响应者hitTested view上添加的手势、hitTested view父视图上添加的手势、...、直到window上添加的手势,这一串view的手势都会被依次添加到这个数组里
    @property (nullable,nonatomic,readonly,copy) NSArray<UIGestureRecognizer *> *gestureRecognizers API_AVAILABLE(ios(3.2));
    
    @end
    

    UIEvent

    创建UITouch对象的时候,系统也会创建一个UIEvent对象。UIEvent对象内部存储着当前触摸事件的类型以及触发当前触摸事件的UITouch对象集合(因为一个触摸事件可能是由多根手指共同触发的)。销毁UITouch对象的时候,也会销毁UIEvent对象。

    @interface UIEvent : NSObject
    
    // 当前触摸事件的类型
    @property (nonatomic,readonly) UIEventType type API_AVAILABLE(ios(3.0));
    
    // 触发当前触摸事件的UITouch对象集合
    @property (nonatomic, readonly, nullable) NSSet<UITouch *> *allTouches;
    
    @end
    

    二、Flutter里的事件


    Flutter里也有相应的触摸事件,叫PointerEvent及其子类PointerDownEventPointerMoveEventPointerCancelEventPointerUpEvent,它们内部存储的东西和iOS差不多,如触摸屏幕的时间、位置(相对于全局坐标系统,如有需要我们得自己转换成局部坐标)、力度、当前触摸事件的类型等。

    abstract class PointerEvent with Diagnosticable {
      const PointerEvent({
        // 当前原始指针事件的唯一标识
        this.pointer = 0,
    
        // 触摸屏幕的时间
        this.timeStamp = Duration.zero,
    
        // 触摸屏幕的位置
        this.position = Offset.zero,
    
        // 触摸屏幕的力度
        this.pressure = 1.0,
        this.pressureMin = 1.0,
        this.pressureMax = 1.0,
    
        // 当前触摸事件的类型
        this.kind = PointerDeviceKind.touch,
    
        // 两次原始指针移动事件(PointerMoveEvent)的距离
        this.delta = Offset.zero,
    
        // 是否正摸着屏幕
        final bool down;
    
        ...
      });
    }
    
    class PointerDownEvent extends PointerEvent {
      ...
    }
    
    class PointerMoveEvent extends PointerEvent {
      ...
    }
    
    class PointerUpEvent extends PointerEvent {
      ...
    }
    
    class PointerCancelEvent extends PointerEvent {
      ...
    }
    

    三、iOS里的响应者和响应者链


    响应者

    iOS里并非所有的对象都能传递和响应事件,只有继承自UIResponder的才行,这类对象被称之为响应者。我们常见的UIApplicationUIViewControllerUIView都继承自UIResponder,所以它们都能传递和响应事件。注意这里的传递是指寻找第一响应者阶段父视图通过hitTest方法把事件传递给子视图以及事件传递阶段UIApplicationwindow通过sendEvent方法把事件精准地传递给第一响应者,响应是指事件响应阶段UIResponder通过touchesBegan、touchesMoved、touchesEnded、touchesCancelled四个方法来响应事件。

    响应者链

    实际开发中,我们的屏幕上肯定不止一个view,也就是说不止一个响应者,而这众多的响应者之间会通过nextResponder属性串起来形成一个叫响应者链的东西,这个链的形成时机是我们把view层级的代码写好后就形成了(可以在addSubview:之后打印验证),不需要等到hitTest的时候。也就是说当我们把一个view添加到它的父视图上后,该viewnextResponder就已经指向了它的父视图;父视图的nextResponder又会指向rootViewControllerviewrootViewControllerviewnextResponder又会指向rootViewControllerrootViewControllernextResponder又会指向windowwindownextResponder又会指向UIApplication

    四、Flutter里的响应者和响应者数组


    响应者

    Flutter里也并非所有的对象都能传递和响应事件,只有真正渲染在屏幕上的东西——即RenderObject(相当于iOS里的UIView)才行,我们也把它们称之为响应者,同时也只有一个特殊的RenderObject——RenderPointerListener(对应的渲染对象Widget为Listener)才能响应事件(iOS里是所有的UIView都能响应事件)。注意这里的传递是指寻找第一响应者阶段父视图通过hitTest方法把事件(准确地说是点击的位置)传递给子视图,响应是指事件响应阶段RenderPointerListener/Listener通过onPointerDown、onPointerMove、onPointerUp、onPointerCancel四个方法来响应事件。

    这里我们回顾一个知识点:

    ——Widget
    ------------ComponentWidget(组件Widget)
    ————————StatelessWidget
    ————————StatefulWidget
    ————RenderObjectWidget(渲染对象Widget)
    ————————SingleChildRenderObjectWidget
    ————————MultiChildRenderObjectWidget

    Widget可以分为两类:组件Widget和渲染对象Widget。

    • 组件Widget是指那些仅仅起到包装其它Widget的作用、Flutter Framework并不会为它们创建对应的RenderObject的Widget,例如我们常用的Container、Text、Image、ListView、GridView、PageView、自定义的Widget等,总之但凡是继承自StatelessWidget或StatefulWidget的Widget都是组件Widget。
    • 渲染对象Widget是指那些Flutter Framework会为它们创建对应的RenderObject的Widget,例如我们常用的SizedBox、Row、Column等,总之但凡是继承自RenderObjectWidget的Widget都是渲染对象Widget。

    也就是说,组件Widget肯定都不是响应者,因为它们压根儿都没真正渲染在屏幕上,只有渲染对象Widget才是响应者(准确地说是它们对应的RenderObject才是响应者),因为它们才会被转换成RenderObject真正渲染在屏幕上。

    因此,如果我们想重写某些Widget的hitTest方法,就不能继承自组件Widget,因为组件Widget根本就没有hitTest方法,而必须继承自渲染对象Widget,它对应的RenderObject才有hitTest方法。同时RenderObject是个抽象类,真正渲染在屏幕上的东西其实是它的子类RenderBox,而RenderBox又是个抽象类,真正渲染在屏幕上的东西其实又是它的子类:单个子对象的时候,就用RenderProxyBoxRenderShiftedBox,它俩的主要区别是前者没有跟布局相关的属性,后者有跟布局相关的属性;多个子对象的时候,就用ContainerRenderObjectMixin,我们可以根据实际情况给渲染对象Widgetcreate不同的RenderObject

    响应者数组

    这里和iOS稍有不同,iOS里是响应者链,Flutter里是响应者数组,不过两者的用途差不多。

    iOS里的响应者链是指众多的响应者之间会通过nextResponder属性串起来形成一个链,当前响应者的nextResponder就是下一个响应者,这个链的形成时机是我们把view层级的代码写好后就形成了,不需要等到hitTest的时候。Flutter里的响应者数组是指众多的响应者会按顺序放在一个数组里,当前响应者在数组里的下一个元素就是下一个响应者,这个数组的形成时机是hitTest的时候(这里的形成时机是指数组把响应者全都add进去,不是指数组本身的创建)。

    五、iOS里的寻找第一响应者、事件传递和事件响应


    有了前两节的理论知识,我们就来回答一个问题“手指触摸屏幕后,发生了什么”,一共分三步:

    • 第一步:寻找第一响应者
    • 第二步:事件传递
    • 第三步:事件响应

    寻找第一响应者

    手指触摸屏幕后,就发生了一个触摸事件,但是这个时候屏幕上可能会有很多个响应者,也就是说可能会有很多个view,那到底该由谁来响应这个触摸事件呢?因此第一步就是要寻找一个最适合响应该触摸事件的响应者——第一响应者firstResponder

    寻找第一响应者的过程涉及到一个关键方法hitTest,寻找第一响应者的过程可以说就是一个递归调用hitTest的过程:

    /// @return 当前view所在层级的第一响应者
    - (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
        // 如果当前view不能响应事件:当前view不允许用户交互 || 当前view隐藏了 || 当前view的透明度小于等于0.01
        // 那么当前view不能作为第一响应者
        if (self.userInteractionEnabled == NO || self.hidden == YES || self.alpha <= 0.01) {
            return nil;
        }
        
        // 如果触摸的点不在当前view身上
        // 那么当前view不能作为第一响应者
        if ([self pointInside:point withEvent:event] == NO) {
            return nil;
        }
        
        // 如果过了前两关,则代表当前view有可能作为第一响应者,但不一定就是它
        // 还得倒序遍历它的子视图,优先它的子视图做第一响应者
        for (int i = self.subviews.count - 1; i >= 0; i--) {
            // 获取子视图
            UIView *childView = self.subviews[I];
            // 把触摸点的坐标转换到子视图的坐标系统下
            CGPoint convertedPoint = [self convertPoint:point toView:childView];
            // 调用子视图的hitTest方法,把触摸事件传递给子视图,寻找子视图这一层级的第一响应者
            UIView *fitView = [childView hitTest:convertedPoint withEvent:event];
            if (fitView) { // 如果最终找到了就返回
                return fitView;
            }
        }
        
        // 只有当前view没有子视图 || 它的子视图都不能响应事件 || 触摸的点都不在它的子视图身上时
        // 当前view才直接作为第一响应者
        return self;
    }
    
    • 手指触摸屏幕后,就发生了一个触摸事件,系统会先把这个触摸事件放进UIApplication管理的一个事件队列里,等轮到处理该触摸事件时,UIApplication就会把该触摸事件出列,并把该触摸事件倒序传递给应用程序的window(注意:因为这个时候才刚开始寻找第一响应者,所以触摸事件还不知道它所在的window是哪个,因此UIApplication还不能精准地给某个特定的window传递事件,而是按显示顺序倒序地给很多个window都传递)
    • window接收到触摸事件后,就会调用自己的hitTest方法,如果发现自己不能响应事件或者触摸的点不在自己身上,UIApplication就会把触摸事件传递给其它的window,通常情况下触摸事件最终会被传递给keyWindowkeyWindow也会调用自己的hitTest方法,通常情况下keyWindow能响应事件并且触摸的点也在keyWindow身上,于是keyWindow又会把该触摸事件倒序传递给它的子视图;(注意:这里执行完,就找到了触摸事件所在的windowUIEvent.UITouch.window属性就有值了)
    • 子视图接收到触摸事件后,就会调用自己的hitTest方法,如果发现自己不能响应事件或者触摸的点不在自己身上,keyWindow就会把触摸事件传递给其它的子视图,如果某个子视图能响应事件并且触摸的点也在它身上,那么它就会继续把触摸事件倒序传递给它的子视图......如此循环,直到找到第一响应者——即触摸事件所在的view(注意:这里执行完,就找到了触摸事件所在的viewUIEvent.UITouch.view属性就有值了)

    举个例子,view层级如下:

    WhiteView(rootViewController的view)
    ————RedView
    ————————YellowView
    ————OrangeView
    ————————GreenView
    ————————CyanView
    
    • 假设我们触摸了GreenView
    • keyWindow调用hitTest方法,发现自己能响应事件并且触摸的点也在自己身上,于是就把触摸事件传递给它的子视图WhiteView
    • WhiteView调用hitTest方法,发现自己能响应事件并且触摸的点也在自己身上,于是就把触摸事件传递给它的子视图OrangeView
    • OrangeView调用hitTest方法,发现自己能响应事件并且触摸的点也在自己身上,于是就把触摸事件传递给它的子视图CyanView
    • CyanView调用hitTest方法,发现自己能响应事件但是触摸的点不在自己身上,于是OrangeView又把触摸事件传递给它的子视图GreenView
    • GreenView调用hitTest方法,发现自己能响应事件并且触摸的点在自己身上,但是自己已经没有子视图了,于是不再做事件传递,所以GreenView就成为第一响应者。

    一些经验:

    • hitTest第一关:如果一个父视图不能响应事件,那么它的子视图肯定就不能响应事件,因为父视图压根就没机会把触摸事件传递给子视图,子视图都不知道这个触摸事件的存在,当然就不能响应事件;
    • hitTest第二关:如果一个父视图能响应事件,它的子视图不能响应事件,那么点击子视图时父视图就会响应事件,但是如果子视图有超出父视图的部分,那么点击子视图超出父视图的部分时,父视图就不会响应事件,因为父视图在判断到触摸的点不在自己身上时就会直接return nil,而不会作为第一响应者;
    • 寻找第一响应者的过程中存在事件传递,父视图是通过hitTest方法把事件传递给子视图的,因此在实际开发中我们可以重写视图的hitTest方法来自定义到底由谁来做第一响应者——即到底由谁来响应触摸事件。至于该重写谁的hitTest方法,我们只需要分析一遍hitTest的过程,看看到底是谁的hitTest方法导致不满足实际需求就可以了。

    事件传递

    经过第一步寻找第一响应者,UIApplication就知道触摸事件该由谁来响应了,因为UIEvent.UITouch.window属性和UIEvent.UITouch.view属性都有值了,接下来要做的就是第二步:将触摸事件传递给第一响应者。UIApplication会通过sendEvent方法把触摸事件精准地传递给触摸事件所在的windowwindow又会通过sendEvent方法把触摸事件精准地传递给触摸事件所在的view——即第一响应者。

    还是上面的例子,我们在GreenViewtouchesBegan方法里打个断点,触摸一下GreenView,查看方法调用栈就能看到事件传递的过程:

    事件响应

    我们知道UIResponder内部提供了四个方法来响应触摸事件,实际上系统为所有的响应者都默认实现了这四个方法,只不过大家默认的实现都是什么都不做,只是调用父类的touches...方法把触摸事件沿着响应者链传递给nextResponder

    - (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
        // 什么都不做
    
        // 只是调用父类的touches...方法把触摸事件沿着响应者链传递给nextResponder
        [super touchesBegan:touches withEvent:event];
    }
    
    - (void)touchesMoved:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
        // 什么都不做
    
        // 只是调用父类的touches...方法把触摸事件沿着响应者链传递给nextResponder
        [super touchesMoved:touches withEvent:event];
    }
    
    - (void)touchesEnded:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
        // 什么都不做
    
        // 只是调用父类的touches...方法把触摸事件沿着响应者链传递给nextResponder
        [super touchesEnded:touches withEvent:event];
    }
    
    - (void)touchesCancelled:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
        // 什么都不做
    
        // 只是调用父类的touches...方法把触摸事件沿着响应者链传递给nextResponder
        [super touchesCancelled:touches withEvent:event];
    }
    

    经过第二步事件传递,第一响应者就接收到了需要响应的触摸事件,接下来要做的就是第三步:事件响应。如果第一响应者重写了触摸事件的四个方法,那么它就会响应该触摸事件;如果第一响应者没有重写触摸事件的四个方法,那么它就不会响应该触摸事件,该触摸事件就会默认地沿着响应者链传递给第一响应者的nextResponder;这样一直传一直传,如果触摸事件传递到
    window乃至UIApplicationUIApplication都没有重写触摸事件的四个方法,那么该触摸事件就会被丢弃。

    一些经验:

    • 实际开发中我们可以重写这四个方法来完成一些自定义的操作,并且可以主动决定要不要调用父类的touches...方法来把触摸事件继续沿着响应者链传递。

    六、Flutter里的寻找第一响应者、事件分发和事件响应


    那在Flutter里“手指触摸屏幕后,发生了什么”,答案和iOS基本一样,还是分三步:

    • 第一步:寻找第一响应者
    • 第二步:事件分发(iOS里是事件传递)
    • 第三步:事件响应

    寻找第一响应者

    Flutter里寻找第一响应者的过程和iOS里几乎一模一样,都是一个递归调用hitTest的过程——PointerDown事件发生后,就从根视图RenderViewhitTest方法开始,倒序递归调用子视图的hitTest方法,如果判断到触摸的点在某个视图内部,就把它放进响应者数组里,位于视图层级上方的视图会被优先放进响应者数组,最终响应者数组的第一个元素就会成为第一响应者。hitTest的默认实现:

    bool hitTest(HitTestResult result, { @required Offset position }) {
      ...
      if (_size.contains(position)) { // 如果触摸的点在Widget范围内
        // 就去检测在不在子视图的范围内 || 执行hitTestSelf
        if (hitTestChildren(result, position: position) || hitTestSelf(position)) {
          // 如果在子视图的范围内,就把子视图和自己都添加进响应者数组
          result.add(BoxHitTestEntry(this, position));
        
          // 同时return true,告诉父视图已经命中了,不用去hitTest自己的兄弟视图了
          return true;
        }
      }
    
      // 如果触摸的点不在Widget范围内,直接return false,告诉父视图去hitTest自己的兄弟视图
      return false;
    }
    

    还是iOS里举过的例子,view层级如下:

    WhiteView
    ————RedView
    ————————YellowView
    ————OrangeView
    ————————GreenView
    ————————CyanView
    
    • 假设我们触摸了GreenView
    • RenderView调用hitTest方法,发现触摸的点也在自己身上,于是就把触摸事件传递给它的子视图WhiteView
    • WhiteView调用hitTest方法,发现触摸的点也在自己身上,于是就把触摸事件传递给它的子视图OrangeView
    • OrangeView调用hitTest方法,发现触摸的点也在自己身上,于是就把触摸事件传递给它的子视图CyanView
    • CyanView调用hitTest方法,发现触摸的点不在自己身上,于是OrangeView又把触摸事件传递给它的子视图GreenView
    • GreenView调用hitTest方法,发现触摸的点在自己身上,但是自己已经没有子视图了,于是不再做事件传递,所以GreenView就成为第一响应者——即响应者数组里的第一个元素;
    • 经过这么一轮查找,响应者数组里依次存放的就是[GreenView、OrangeView、WhiteView、RenderView]

    事件分发

    这一步和iOS有区别,iOS里是事件传递——即window会精准地把触摸事件传递给第一响应者,而Flutter里是事件分发——即GestureBinding会遍历响应者数组里所有的响应者,按顺序把触摸事件分发给所有的响应者。

    ------GestureBinding------
    
    @override // from HitTestDispatcher
    void dispatchEvent(PointerEvent event, HitTestResult? hitTestResult) {
      // hitTestResult.path就是响应者数组
      // entry就是对响应者包装后的一个对象,entry.target就是响应者
      for (final HitTestEntry entry in hitTestResult.path) {
        // entry.target.handleEvent就是调用响应者的handleEvent方法,而handleEvent方法里会真正调用Listener的四个方法
        entry.target.handleEvent(event.transformed(entry.transform), entry);
      }
    }
    

    事件响应

    这一步和iOS也有区别,iOS里是如果第一响应者重写了触摸事件的四个方法,那么它就会响应触摸事件,其它的响应者默认是不响应事件的(当然我们也可以自己搞得其它响应者也响应事件),只有第一响应者没有重写触摸事件的四个方法时,它才不会响应该触摸事件,该触摸事件就会默认地沿着响应者链传递给第一响应者的nextResponder来响应。而Flutter里因为是一次性给所有的响应者都分发了事件,所以只要是实现了四个方法的Listener都会响应事件,没实现的就不响应,不存在往下一个响应者传递这么一说,只不过是第一响应者会第一个响应、第二个响应者会第二个响应等等。

    七、iOS里的手势


    这里我们不专门说手势,主要说一下原始指针事件和手势同时存在时会怎样。

    UIGestureRecognizer的优先级

    UIGestureRecognizer不在响应者链里,更不是UIResponder的子类

    实际上,iOS里并非只有UIResponder才能传递和响应事件,UIGestureRecognizer也行,而且UIGestureRecognizer本质上就是对UIResponder四个方法的封装。常见的手势有点按手势UITapGestureRecognizer、轻扫手势UISwipeGestureRecognizer,平移手势UIPanGestureRecognizer、旋转手势UIRotationGestureRecognizer、缩放手势UIPinchGestureRecognizer、长按手势UILongPressGestureRecognizer

    现在看个例子:

    ViewController.view上添加了一个redViewredView上添加了一个平移手势,并且redView实现了UIResponder的四个方法。

    ------ViewController.h------
    
    #import <UIKit/UIKit.h>
    
    @interface ViewController : UIViewController
    
    @end
    
    
    ------ViewController.m------
    
    #import "ViewController.h"
    #import "RedView.h"
    
    @interface ViewController ()
    
    @property (weak, nonatomic) IBOutlet RedView *redView;
    
    @end
    
    @implementation ViewController
    
    - (void)viewDidLoad {
        [super viewDidLoad];
    
        // redView添加平移手势
        UIPanGestureRecognizer *panGestureRecognizer = [[UIPanGestureRecognizer alloc] initWithTarget:self action:@selector(pan:)];
        [self.redView addGestureRecognizer:panGestureRecognizer];
    }
    
    - (void)pan:(UIPanGestureRecognizer *)panGestureRecognizer {
        NSLog(@"redView panned");
    }
    
    @end
    
    ------RedView.h------
    
    #import <UIKit/UIKit.h>
    
    @interface RedView : UIView
    
    @end
    
    
    ------RedView.m------
    
    #import "RedView.h"
    
    @implementation RedView
    
    - (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
        NSLog(@"redView touchesBegan");
    }
    
    - (void)touchesMoved:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
        NSLog(@"redView touchesMoved");
    }
    
    - (void)touchesEnded:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
        NSLog(@"redView touchesEnded");
    }
    
    - (void)touchesCancelled:(NSSet<UITouch *> *)touches withEvent:(UIEvent *)event {
        NSLog(@"redView touchesCancelled");
    }
    
    @end
    

    redView上执行一次滑动,控制台的打印如下:

    redView touchesBegan // 此时,手势识别器正在识别触摸事件,还没有识别成功...
    redView touchesMoved // 此时,手势识别器正在识别触摸事件,还没有识别成功...
    redView touchesMoved // 此时,手势识别器正在识别触摸事件,还没有识别成功...
    redView touchesMoved // 此时,手势识别器正在识别触摸事件,还没有识别成功...
    redView panned // 此时,手势识别器成功识别触摸事件
    redView touchesCancelled // 此时,系统取消了redView对触摸事件的响应
    redView panned
    redView panned
    ...
    

    从打印可以看出这次滑动触发了redViewtouchesBegantouchesMoved方法,然后触发了一次平移手势的方法,紧接着触发了一次redViewtouchesCancelled方法,接下来就一直触发平移手势的方法,直至滑动结束我们也没见到触发redViewtouchesEnded方法。为什么redViewtouchescancel掉了,而不能正常end官方文档对此有如下解释:

    A window delivers touch events to a gesture recognizer before it delivers them to the hit-tested view attached to the gesture recognizer. Generally, if a gesture recognizer analyzes the stream of touches in a multi-touch sequence and doesn’t recognize its gesture, the view receives the full complement of touches. If a gesture recognizer recognizes its gesture, the remaining touches for the view are cancelled.The usual sequence of actions in gesture recognition follows a path determined by default values of the cancelsTouchesInView, delaysTouchesBegan, delaysTouchesEnded properties.

    意思就是说:

    window在将触摸事件传递给第一响应者hitTested view之前,会优先把触摸事件传递给手势识别器。如果手势识别器成功识别了该触摸事件,那么手势识别器就拥有了该触摸事件的响应权,系统就会取消第一响应者hitTested view对触摸事件的响应;如果手势识别器没能识别该触摸事件,那么第一响应者hitTested view才拥有该触摸事件的响应权。

    一言以蔽之,手势识别器拥有比UIResponder更高的事件响应优先级。(注意如果自己身上没有添加手势,那父视图、爷视图......身上的手势也会比自己的原始指针事件响应优先级高,因为UIEvent.UITouch.gestureRecognizers里存储的是响应者链上所有响应者的手势,不仅仅是自己身上的手势)

    UIGestureRecognizer的两个属性和一个代理方法

    @property (nonatomic) BOOL cancelsTouchesInView;
    @property (nonatomic) BOOL delaysTouchesBegan;
    
    - (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldReceiveTouch:(UITouch *)touch;
    
    • cancelsTouchesInView

    默认值为YES,代表如果手势识别器成功识别了触摸事件,那么手势识别器就拥有该触摸事件的响应权,系统就取消第一响应者hitTested view对触摸事件的响应。

    如果设置为NO,代表就算手势识别器成功识别了触摸事件,系统也不取消第一响应者hitTested view对触摸事件的响应,即手势识别器和第一响应者hitTested view同时响应触摸事件。

    上面例子中如果设置panGestureRecognizer.cancelsTouchesInView = NO;,那么控制台将会打印:

    redView touchesBegan
    redView touchesMoved
    redView touchesMoved
    redView touchesMoved
    redView panned
    redView touchesMoved
    redView panned
    redView touchesMoved
    redView panned
    redView touchesMoved
    ...
    redView touchesEnded
    
    • delaysTouchesBegan

    默认值为NO,代表window不仅会把触摸事件传递给手势识别器,而且在手势识别器识别事件期间还会把触摸事件传递给第一响应者hitTested view

    如果设置为YES,代表window只会把触摸事件传递给手势识别器,不会传递给第一响应者hitTested view,即只有手势识别器响应触摸事件。

    上面例子中如果设置panGestureRecognizer.delaysTouchesBegan = YES;,那么控制台将会打印:

    redView panned 
    redView panned
    redView panned
    ...
    
    • gestureRecognizer: shouldReceiveTouch:enabled属性也行)

    默认返回YES,代表手势识别器响应触摸事件。

    如果返回NO,代表手势识别器不响应触摸事件,即只有第一响应者hitTested view响应触摸事件。

    上面例子中如果设置

    - (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldReceiveTouch:(UITouch *)touch {
        return NO;
    }
    

    那么控制台将会打印:

    redView touchesBegan
    redView touchesMoved
    redView touchesMoved
    redView touchesMoved
    ...
    redView touchesEnded
    

    八、Flutter里的手势


    这里我们也不专门说手势手势竞技,也主要说一下原始指针事件和手势同时存在时会怎样。

    GestureDetector的优先级

    GestureDetector在响应者数组里,因为它就是个`Listener`

    实际上,Flutter里也不止Listener才能传递传递和响应事件,GestureDetector也行,不过话说回来GestureDetector本质上就是个Listener常见的手势也有点按手势、轻扫手势,平移手势、旋转手势、缩放手势、长按手势。

    还是iOS里举过的例子:

    界面上添加了一个redViewredView上添加了一个平移手势,并且redView实现了Listener的四个方法。

    ------main.dart------
    
    import 'package:flutter/material.dart';
    
    void main() {
      runApp(MyApp());
    }
    
    class MyApp extends StatelessWidget {
      @override
      Widget build(BuildContext context) {
        return MaterialApp(
          home: MyHomePage(),
        );
      }
    }
    
    class MyHomePage extends StatefulWidget {
      MyHomePage({
        Key? key,
      }) : super(key: key);
    
      @override
      _MyHomePageState createState() => _MyHomePageState();
    }
    
    class _MyHomePageState extends State<MyHomePage> {
      @override
      void initState() {
        super.initState();
      }
    
      @override
      Widget build(BuildContext context) {
        return Container(
          color: Colors.white,
          padding: EdgeInsets.only(top: 20, left: 20),
          alignment: Alignment.topLeft,
          child: Listener(
            onPointerDown: (_) {
              print("redView onPointerDown");
            },
            onPointerMove: (_) {
              print("redView onPointerMove");
            },
            onPointerUp: (_) {
              print("redView onPointerUp");
            },
            onPointerCancel: (_) {
              print("redView onPointerCancel");
            },
            child: GestureDetector(
              onPanUpdate: (_) {
                print("redView onPanUpdate");
              },
              child: Container(
                color: Colors.red,
                width: 300,
                height: 300,
              ),
            ),
          ),
        );
      }
    }
    

    redView上执行一次滑动,控制台的打印如下:

    flutter: redView onPointerDown
    flutter: redView onPointerMove
    flutter: redView onPanUpdate
    flutter: redView onPointerMove
    flutter: redView onPanUpdate
    flutter: redView onPointerMove
    flutter: redView onPanUpdate
    flutter: redView onPointerUp
    ...
    

    从打印可以看出原始指针事件和平移手势会同时触发,并不像iOS里那样手势会具备更高的优先级。但是手势的响应总是在原始指针事件的后面,这是为什么?

    当我们PointDownredView上时,会先执行第一步寻找第一响应者,最先触发的是RenderBindinghitTest,里面就是先做UI的hitTest——即从renderView开始递归调用hitTest,把命中的子视图都添加到响应者数组里,这一步GestureDetector对应的Listener会被先放进响应者数组里,然后Listener也会被先放进响应者数组里,此时响应者数组里存放的就是[GestureDetector对应的Listener、Listener];然后才会做手势的hitTest——手势的hitTest比较简单,就是把GestureBinding这个类本身添加到响应者数组里,手势相关的回调其实都放在GestureBinding类里由这个类处理,此时响应者数组里存放的就是[GestureDetector对应的Listener、Listener、GestureBinding];到此响应者数组就确定了,第一响应者也就确定了——它就是GestureDetector对应的Listener,第二响应者就是Listener,第三响应者才是GestureBinding所以手势的响应总是在原始指针事件的后面。

    ------RenderBinding------
    
    @override
    void hitTest(HitTestResult result, Offset position) {
      // UI的hitTest:从根节点开始进行命中测试
      renderView.hitTest(result, position: position); 
      // 手势的hitTest:会调用GestureBinding中的hitTest方法
      super.hitTest(result, position); 
    }
    

    两个拦截事件的Widget

    如果我们只想让原始指针事件和手势中的一个响应事件,那就换换它们的父子关系,给子视图外面套一个IgnorePointerAbsorbPointer就行了,它俩分别有一个bool值属性叫ignoringabsorbing用来决定是否拦截事件,我们可以根据实际情况来改变这俩属性的值,其实这俩Widget拦截事件的本质就是拦截响应者不被添加进响应者数组里。

    参考
    1、史上最详细的iOS之事件的传递和响应机制-原理篇
    2、史上最详细的iOS之事件的传递和响应机制-实践篇
    3、iOS 事件(UITouch、UIControl、UIGestureRecognizer)传递机制
    4、iOS触摸事件全家桶
    5、Flutter实战电子书第八章:事件处理与通知
    6、Flutter完整开发实战详解(十三、全面深入触摸和滑动原理)
    7、Flutter中的事件流和手势简析
    8、flutter的RenderBox使用&原理浅析

    相关文章

      网友评论

        本文标题:iOS和Flutter里的事件处理

        本文链接:https://www.haomeiwen.com/subject/ywxaxrtx.html