从GestureDetector组件看Flutter 触摸事件处理机制

GestureDetector

Flutter里有个神奇的组件——GestureDetector,学习过flutter的朋友应该知道,flutter的组件机制还是跟Android原生有很多不同的地方,flutter讲究一切皆组件,这方面很解耦,套起来也方便,比如Text组件,在android里只要是控件就可以设置setOnclickListener,或者重写View的onTouch事件去处理点击,但flutter不是这样,它大部分组件并没有点击的属性,需要套一个GestureDetector组件来实现,关于源码中对GestureDetector是这么描述的:

///  * [GestureDetector], a less flexible but much simpler widget that does the same thing.

意思就是一个不太灵活但更简单更方便使用的一个组件,也就是封装过的,看源码也确实是RawGestureDetector的封装,暂且不谈,下面我们来看看这个组件有哪些属性:

   GestureDetector({

   Key key, //唯一标识

   this.child,//起作用的子组件

   this.onTapDown,//触摸屏幕按下(子组件区域)

   this.onTapUp,//触摸抬起(子组件区域)

   this.onTap,//发生了点击事件,触发顺序是onTapDown->onTapUp->onTap,中断则不触发,事件传递树的最末端

   this.onTapCancel,//按下在onTapUp事件发生前,手指离开组件区域触发

   this.onDoubleTap,//双击,当设有onTap单击事件时,不响应

   this.onLongPress,//长按,当响应长按事件,onTap回调cancel事件

   this.onLongPressStart,//长按开始

   this.onLongPressEnd,//长按结束

   this.onLongPressUp,//长按手抬起

   this.onVerticalDragDown,//设置有长按事件或者无拖动抬起,则会回调cancel事件,

   this.onVerticalDragStart,//手指触摸屏幕并开始垂直拖动,同时onTap回调cancel事件,onVerticalDrag和onHorizontalDrag只可响应一个,谁先谁占有事件

   this.onVerticalDragUpdate,//手指触摸屏幕并垂直拖动中

   this.onVerticalDragEnd,//垂直拖动结束

   this.onVerticalDragCancel,//垂直拖动事件取消,参考onVerticalDragDown说明

   this.onHorizontalDragDown,//设置有长按事件或者无拖动抬起,则会回调cancel事件,
   
   this.onHorizontalDragStart,//手指触摸屏幕并开始水平拖动,同时onTap回调cancel事件,onVerticalDrag和onHorizontalDrag只可响应一个,谁先谁占有事件

   this.onHorizontalDragUpdate,//水平拖动中

   this.onHorizontalDragEnd,//水平拖动结束

   this.onHorizontalDragCancel,//垂直拖动事件取消,参考onHorizontalDragDown说明

   // onPan可以取代onVerticalDrag或者onHorizontalDrag,三者不能并存
   this.onPanDown,//手指接触屏幕

   this.onPanStart,//手指在屏幕上开始移动

   this.onPanUpdate,//手指在屏幕上一直移动

   this.onPanEnd,//手指离开屏幕

   this.onPanCancel,//当设有LongPress事件时,会直接走此事件回调
   
   //手指屏幕按压,需要压力传感器,模拟器无法触发
   this.onForcePressStart, //手指按压屏幕,当力量达到ForcePressGestureRecognizer.startPressure触发

   this.onForcePressPeak,//手指按压屏幕,当力量达到ForcePressGestureRecognizer.peakPressure触发

   this.onForcePressUpdate,//手指按压屏幕,按压力量变化时触发

   this.onForcePressEnd,//手指离开屏幕

   //两指触摸屏幕
   this.onSecondaryTapDown,//两指触摸屏幕

   this.onSecondaryTapUp,//两指离开屏幕

   this.onSecondaryTapCancel, //当设有LongPress事件时,会直接走此事件回调
   
   //    onScale可以取代onVerticalDrag或者onHorizontalDrag,三者不能并存,不能与onPan并存,会报错
   this.onScaleStart,//缩放拖动

   this.onScaleUpdate,//缩放比例变化

   this.onScaleEnd,//缩放手指抬起

   this.behavior, //点击测试行为规则,有三个值分别是:HitTestBehavior.deferToChild,HitTestBehavior.opaque,HitTestBehavior.translucent,文章后面会介绍

   this.excludeFromSemantics = false,//是否排除手势

   })

GestureDetector是检测手势的widget,同样能响应touch事件的还有Ink,InkWell和InkResponse组件,其中Ink没有点击的回调事件。

Flutter控件分类(交互的维度)

Flutter里控件按交互维度区分,分为自带交互的控件和不带交互的控件

  • 自带交互的控件
    自带交互的控件,如RaisedButton,OutlineButton等,内部会有点击事件的回调,如
Center(
       child: OutlineButton(onPressed: () {
       print("点击了button");
}, child: Text("点击")),

上面代码就可以搞定OutlineButton的点击了

  • 不带交互的控件
    不带交互的控件,如最基本的Text,这类widget本身并没有手势点击之类的监听属性
Text(
    this.data, {
    Key key,
    this.style,
    this.strutStyle,
    this.textAlign,
    this.textDirection,
    this.locale,
    this.softWrap,
    this.overflow,
    this.textScaleFactor,
    this.maxLines,
    this.semanticsLabel,
    this.textWidthBasis,
    this.textHeightBehavior,
  })

如果你想实现单击、双击、滑动监听之类的交互,可以通过在外层套用GestureDetector来实现

 GestureDetector(
            child: Text(
              "我是一个小小小小鸟",
              style: TextStyle(color: _color, fontSize: 18),
            ),
        onTap:(){
            print("点击一下");
        }
         ...
)

InkWell和InkResponse组件也能实现,区别在于GestureDetector提供了最全的手势监听,如单击、双击、长按、垂直横向滑动、缩放这些手势,还有命中测试行为,而InkWell和InkResponse提供部分手势和高亮触摸效果,自带水波纹动画

InkWell({
    Key key,
    Widget child,
    GestureTapCallback onTap,
    GestureTapCallback onDoubleTap,
    GestureLongPressCallback onLongPress,
    GestureTapDownCallback onTapDown,
    GestureTapCancelCallback onTapCancel,
    ValueChanged<bool> onHighlightChanged,
    ValueChanged<bool> onHover,
    Color focusColor,
    Color hoverColor,
    Color highlightColor,
    Color splashColor,
    InteractiveInkFeatureFactory splashFactory,
    double radius,
    BorderRadius borderRadius,
    ShapeBorder customBorder,
    bool enableFeedback = true,
    bool excludeFromSemantics = false,
    FocusNode focusNode,
    bool canRequestFocus = true,
    ValueChanged<bool> onFocusChange,
    bool autofocus = false,
  }

InkResponse({
    Key key,
    this.child,
    this.onTap,
    this.onTapDown,
    this.onTapCancel,
    this.onDoubleTap,
    this.onLongPress,
    this.onHighlightChanged,
    this.onHover,
    this.containedInkWell = false,
    this.highlightShape = BoxShape.circle,
    this.radius,
    this.borderRadius,
    this.customBorder,
    this.focusColor,
    this.hoverColor,
    this.highlightColor,
    this.splashColor,
    this.splashFactory,
    this.enableFeedback = true,
    this.excludeFromSemantics = false,
    this.focusNode,
    this.canRequestFocus = true,
    this.onFocusChange,
    this.autofocus = false,
  })

手势的事件传递起源

无论是原生android还是flutter,UI事件传递的起始点都跟window脱不了关系

//   android 中 Activity.dispatchTouchEvent
public boolean dispatchTouchEvent(MotionEvent ev) {
    if (ev.getAction() == MotionEvent.ACTION_DOWN) {
           onUserInteraction();
     }
    if (getWindow().superDispatchTouchEvent(ev)) {//getWindow()就是window实例
       return true;
     }
   return onTouchEvent(ev);
}

//flutter GestureBinding 中的initInstances方法
 @override
  void initInstances() {
    super.initInstances();
    _instance = this;
    //事件由ui.window.onPointerDataPacket产生,把事件传给GestureBinding._handlePointerDataPacket方法
    window.onPointerDataPacket = _handlePointerDataPacket;
  }

从上面代码中很容易就可以看出,android和flutter的起点都来自于window,当然,虽然起点一致,但传递思路却并不相同。

首先,我们来看一下android是怎样设计的,话不多说,直接上图


android 事件传递图.jpg

从图可以看出,android传递的线路上Activity、PhoneWindow、DecorView、ContentView,自上而下,逐层传递,很像我们日常工作的时候的场景:一天,大领导(Activity)分派一个任务下来,技术总监(PhoneWindow)把任务整理一下又给了技术经理(DecorView),技术经理很忙,又把任务派给了各个技术组长(ContentView),然后组长派任务给底下的组员来做,组员能完成最好,如果不能,又会逐级向上传递,直到事件结束,当然具体做事情决不会这么简单,中间的细节就不说了,可以去找一些专门的技术文章去了解,我们重点讲讲flutter是怎么设计的

Flutter 事件传递源码流程

先从GestureBinding的initInstances方法开始吧

@override
void initInstances() {
  super.initInstances();
  _instance = this;
  window.onPointerDataPacket = _handlePointerDataPacket;
}

ui.Window get window => ui.window;

ui.window.onPointerDataPacket相当于系统的一个接口,专门去接收硬件传递过来的事件方法,具体事件处理交给GestureBinding类的_handlePointerDataPacket方法,接下来看一下方法

final Queue<PointerEvent> _pendingPointerEvents = Queue<PointerEvent>();
...
void _handlePointerDataPacket(ui.PointerDataPacket packet) {
    _pendingPointerEvents.addAll(PointerEventConverter.expand(packet.data, window.devicePixelRatio));
    if (!locked)
      _flushPointerEventQueue();
 }

void _flushPointerEventQueue() {
    assert(!locked);
    while (_pendingPointerEvents.isNotEmpty)
      _handlePointerEvent(_pendingPointerEvents.removeFirst());
 }

从上面代码可以看到,_pendingPointerEvents是个队列,队列里装着一系列的指针事件,通过指针事件转换器把从底层传出的物理坐标逻辑像素根据设备像素比例参数转换成指针事件屏幕坐标,隔离设备相关性,然后通过while循环取出队列的第一个指针事件元素进行处理,继续看_handlePointerEvent这个方法

  void _handlePointerEvent(PointerEvent event) {
    assert(!locked);
    HitTestResult hitTestResult;
    if (event is PointerDownEvent || event is PointerSignalEvent) {
      assert(!_hitTests.containsKey(event.pointer));
      hitTestResult = HitTestResult();
      //确定命中测试的结果,通过hitTest方法来计算出HitTestResult
      hitTest(hitTestResult, event.position);
      if (event is PointerDownEvent) {
        //event.pointer是事件的唯一标识符,相当于事件的id,以id为key将hitTestResult存到_hitTests中
        _hitTests[event.pointer] = hitTestResult;
      }
      assert(() {
        if (debugPrintHitTestResults)
          debugPrint('$event: $hitTestResult');
        return true;
      }());
    } else if (event is PointerUpEvent || event is PointerCancelEvent) {
      //事件结束标记,将hitTestResult从_hitTests取出并移除
      hitTestResult = _hitTests.remove(event.pointer);
    } else if (event.down) {
      //复用PointerDownEvent事件中的hitTestResult
      hitTestResult = _hitTests[event.pointer];
    }
    assert(() {
      if (debugPrintMouseHoverEvents && event is PointerHoverEvent)
        debugPrint('$event');
      return true;
    }());
    if (hitTestResult != null ||
        event is PointerHoverEvent ||
        event is PointerAddedEvent ||
        event is PointerRemovedEvent) {
      dispatchEvent(event, hitTestResult);
    }
  }

_handlePointerEvent方法会对每个取出的PointerEvent进行处理,然后交给dispatchEvent进行分发

  @override // from HitTestDispatcher
  void dispatchEvent(PointerEvent event, HitTestResult hitTestResult) {
    assert(!locked);
    // No hit test information implies that this is a hover or pointer
    // add/remove event.
    if (hitTestResult == null) {
      assert(event is PointerHoverEvent || event is PointerAddedEvent || event is PointerRemovedEvent);
      try {
        pointerRouter.route(event);
      } catch (exception, stack) {
        ...
      }
      return;
    }
    for (final HitTestEntry entry in hitTestResult.path) {
      try {
        entry.target.handleEvent(event.transformed(entry.transform), entry);
      } catch (exception, stack) {
       ...
      }
    }
  }

  @override // from HitTestTarget
  void handleEvent(PointerEvent event, HitTestEntry entry) {
    pointerRouter.route(event);
    if (event is PointerDownEvent) {
      gestureArena.close(event.pointer);
    } else if (event is PointerUpEvent) {
      gestureArena.sweep(event.pointer);
    } else if (event is PointerSignalEvent) {
      pointerSignalResolver.resolve(event);
    }
  }

在事件分发时,如果hitTestResult为空,就从路由表里遍历寻找相应的事件,如果包含这个事件就会继续进行分发,如果hitTestResult不为空,就遍历hitTestResult的path上的所有HitTestEntry,取出事件交给事件路由表继续遍历寻找,找到之后继续分发事件。

RenderBinding

以上的部分都基于GestureBinding进行分析的,其实还有个RenderBinding类

class WidgetsFlutterBinding extends BindingBase with GestureBinding, ServicesBinding, SchedulerBinding, PaintingBinding, SemanticsBinding, RendererBinding, WidgetsBinding {
  static WidgetsBinding ensureInitialized() {
    if (WidgetsBinding.instance == null)
      WidgetsFlutterBinding();
    return WidgetsBinding.instance;
  }
}

这里进行了一系列的绑定操作,有熟悉的gestureBinding,还有RenderBinding(渲染绑定类)

//渲染树与flutter引擎之间的胶水类
mixin RendererBinding on BindingBase, ServicesBinding, SchedulerBinding, GestureBinding, SemanticsBinding, HitTestable{
...
}

这里mixin与on关键字结合使用,使用on关键字,则表示该mixin只能在那个类的子类使用了,mixin中可以调用那个类定义的方法、属性,在on后面发现了GestureBinding这个熟悉的类,那么RenderBinding也会重写GestureBinding类的一些方法和属性,比如hitTest

  @override
  void hitTest(HitTestResult result, Offset position) {
    assert(renderView != null);
    renderView.hitTest(result, position: position);
    super.hitTest(result, position);
  }

继续追踪renderView中的hitTest方法

 bool hitTest(HitTestResult result, { Offset position }) {
    if (child != null)
      child.hitTest(BoxHitTestResult.wrap(result), position: position);
    result.add(HitTestEntry(this));
    return true;
  }

child是RenderBox对象,追踪进RenderBox的hitTest方法

 bool hitTest(BoxHitTestResult result, { @required Offset position }) {
    ...
    //如果区域内包含这个坐标,同时对child或者自身命中测试通过就添加到hitTestResult中
    if (_size.contains(position)) {
      if (hitTestChildren(result, position: position) || hitTestSelf(position)) {
        result.add(BoxHitTestEntry(this, position));
        return true;
      }
    }
    return false;
  }

现在回到hitTest方法中,可以发现,方法是先对child进行命中测试,把符合条件的测试结果通过递归添加完毕,再把自身添加到hitTestResult中,事件通过冒泡的形式由下向上传递,而且不能中途中断冒泡,这个时候HitTestResult的路径顺序就是:

目标节点-->父节点-->根节点-->GestureBinding

接着PointerDown,PointerMove,PointerUp,PointerCancel等事件分发,事件都是从目标Widget往RenderView(根节点)都根据这个顺序来遍历调用它们的handleEvent方法,就像浏览器事件的冒泡过程一样。到此,框架的事件流分析完了。该回到GestureDetector的怀抱了。

GestureDetector源码分析

GestureDetector里封装了大量指针手势事件,比如单击、双击、横向纵向滑动、缩放等,具体属性在文章的开头已经有了一个详细的介绍,下面我们来看这个组件内部是怎么处理这些事件的

class GestureDetector extends StatelessWidget {
...
}

一开头,都一个套路,没什么看头,我们直接看build方法

  @override
  Widget build(BuildContext context) {
    final Map<Type, GestureRecognizerFactory> gestures = <Type, GestureRecognizerFactory>{};
    ...
    return RawGestureDetector(
      gestures: gestures,
      behavior: behavior,
      excludeFromSemantics: excludeFromSemantics,
      child: child,
    );
  }

中间我省去了很大一部分代码,篇幅有点长,其实就是根据注册回调,添加对应的GestureRecognizer,并传递到RawGestureDetector的gesture属性,可以理解为GestureDetector其实就是对RawGestureDetector的一次封装,让我们用起来更方便更直观,继续看RawGestureDetector是个什么东东

class RawGestureDetector extends StatefulWidget {
 ...
  class RawGestureDetectorState extends State<RawGestureDetector> {

   ...
  //默认的命中测试行为,没有子元素,则组件背后也能接收到事件,如果有,就让子元素先响应事件
  HitTestBehavior get _defaultBehavior {
    return widget.child == null ? HitTestBehavior.translucent : HitTestBehavior.deferToChild;
  }

  @override
  Widget build(BuildContext context) {
    Widget result = Listener(
      onPointerDown: _handlePointerDown,
      behavior: widget.behavior ?? _defaultBehavior,
      child: widget.child,
    );
    if (!widget.excludeFromSemantics)
      result = _GestureSemantics(
        child: result,
        assignSemantics: _updateSemanticsForRenderObject,
      );
    return result;
    }
  }
}

直接看state的build方法,发现了一个Listener,看看源码里是怎么说的

///  * [Listener], a widget that reports raw pointer events.

翻译过来就是一个响应原始指针事件的组件,这个就不往下研究了,继续看_handlePointerDown方法

 void _handlePointerDown(PointerDownEvent event) {
    assert(_recognizers != null);
    for (final GestureRecognizer recognizer in _recognizers.values)
      recognizer.addPointer(event);
  }

 void addPointer(PointerDownEvent event) {
    _pointerToKind[event.pointer] = event.kind;
    if (isPointerAllowed(event)) {
      addAllowedPointer(event);
    } else {
      handleNonAllowedPointer(event);
    }
  }

如果设备被认可,那么输入事件就会被手势识别器接受,给每个手势识别器都添加PointDown事件,这个addPointer方法是手势识别器的父类里的,接下来我们去其中的一个扩展子类识别器看看具体怎么操作的,比如HorizontalDragGestureRecognizer

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 160,444评论 4 365
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,867评论 1 298
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 110,157评论 0 248
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,312评论 0 214
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,673评论 3 289
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,802评论 1 223
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 32,010评论 2 315
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,743评论 0 204
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,470评论 1 246
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,696评论 2 250
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,187评论 1 262
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,538评论 3 258
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,188评论 3 240
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,127评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,902评论 0 198
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,889评论 2 283
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,741评论 2 274