本篇将带你深刻了解 Flutter 中的手势事件传递、事件分发、事件冲突竞争,滑动流畅等等的原理,帮你构建一个完整的 Flutter 闭环手势知识体系,这也许是目前最全面的手势事件和滑动源码的深刻文章了。git
Flutter 完整实战实战系列文章专栏github
Flutter 中默认状况下,以 Android 为例,全部的事件都是起原生源于 io.flutter.view.FlutterView
这个 SurfaceView
的子类,整个触摸手势事件实质上经历了 JAVA => C++ => Dart 的一个流程,整个流程以下图所示,不管是 Android 仍是 IOS ,原生层都只是将全部事件打包下发,好比在 Android 中,手势信息被打包成 ByteBuffer
进行传递,最后在 Dart 层的 _dispatchPointerDataPacket
方法中,经过 _unpackPointerDataPacket
方法解析成可用的 PointerDataPacket
对象使用。markdown
那么具体在 Flutter 中是如何分发使用手势事件的呢?app
在前面的流程图中咱们知道,在 Dart 层中手势事件都是从 _dispatchPointerDataPacket
开始的,以后会经过 Zone
判断环境回调,会执行 GestureBinding
这个胶水类中的 _handlePointerEvent
方法。(若是对 Zone
或者 GestureBinding
有疑问能够翻阅前面的篇章)ide
以下代码所示, GestureBinding
的 _handlePointerEvent
方法中主要是 hitTest
和 dispatchEvent
: 经过 hitTest
碰撞,获得一个包含控件的待处理成员列表 HitTestResult
,而后经过 dispatchEvent
分发事件并产生竞争,获得胜利者相应。oop
void _handlePointerEvent(PointerEvent event) { assert(!locked); HitTestResult hitTestResult; if (event is PointerDownEvent || event is PointerSignalEvent) { hitTestResult = HitTestResult(); ///开始碰撞测试了,会添加各个控件,获得一个须要处理的控件成员列表 hitTest(hitTestResult, event.position); if (event is PointerDownEvent) { _hitTests[event.pointer] = hitTestResult; } } else if (event is PointerUpEvent || event is PointerCancelEvent) { ///复用机制,抬起和取消,不用hitTest,移除 hitTestResult = _hitTests.remove(event.pointer); } else if (event.down) { ///复用机制,手指处于滑动中,不用hitTest hitTestResult = _hitTests[event.pointer]; } if (hitTestResult != null || event is PointerHoverEvent || event is PointerAddedEvent || event is PointerRemovedEvent) { ///开始分发事件 dispatchEvent(event, hitTestResult); } } 复制代码
了解告终果后,接下来深刻分析这两个关键方法:学习
hitTest
方法主要为了获得一个 HitTestResult
,这个 HitTestResult
内有一个 List<HitTestEntry>
是用于分发和竞争事件的,而每一个 HitTestEntry.target
都会存储每一个控件的 RenderObject
。测试
由于 RenderObject
默认都实现了 HitTestTarget
接口,因此能够理解为: HitTestTarget
大部分时候都是 RenderObject
,而 HitTestResult
就是一个带着碰撞测试后的控件列表。ui
事实上 hitTest
是 HitTestable
抽象类的方法,而 Flutter 中全部实现 HitTestable
的类有 GestureBinding
和 RendererBinding
,它们都是 mixins
在 WidgetsFlutterBinding
这个入口类上,而且由于它们的 mixins
顺序的关系,因此 RendererBinding
的 hitTest
会先被调用,以后才调用 GestureBinding
的 hitTest
。
那么这两个 hitTest 又分别干了什么事呢?
在 RendererBinding.hitTest
中会执行 renderView.hitTest(result, position: position);
,以下代码所示,renderView.hitTest
方法内会执行 child.hitTest
,它将尝试将符合条件的 child 控件添加到 HitTestResult
里,最后把本身添加进去。
///RendererBinding bool hitTest(HitTestResult result, { Offset position }) { if (child != null) child.hitTest(result, position: position); result.add(HitTestEntry(this)); return true; } 复制代码
而查看 child.hitTest
方法源码,以下所示,RenderObjcet
中的hitTest
,会经过 _size.contains
判断本身是否属于响应区域,确认响应后执行 hitTestChildren
和 hitTestSelf
,尝试添加下级的 child 和本身添加进去,这样的递归就让咱们自下而上的获得了一个 HitTestResult
的相应控件列表了,最底下的 Child 在最上面。
///RenderObjcet bool hitTest(HitTestResult result, { @required Offset position }) { if (_size.contains(position)) { if (hitTestChildren(result, position: position) || hitTestSelf(position)) { result.add(BoxHitTestEntry(this, position)); return true; } } return false; } 复制代码
最后 GestureBinding.hitTest
方法不过最后把 GestureBinding
本身也添加到 HitTestResult
里,最后由于后面咱们的流程还会须要回到 GestureBinding
中去处理。
dispatchEvent
中主要是对事件进行分发,而且经过上述添加进去的 target.handleEvent
处理事件,以下代码所示,在存在碰撞结果的时候,是会经过循环对每一个控件内部的handleEvent
进行执行。
@override // from HitTestDispatcher void dispatchEvent(PointerEvent event, HitTestResult hitTestResult) { ///若是没有碰撞结果,那么经过 `pointerRouter.route` 将事件分发到全局处理。 if (hitTestResult == null) { try { pointerRouter.route(event); } catch (exception, stack) { return; } ///上面咱们知道 HitTestEntry 中的 target 是一系自下而上的控件 ///还有 renderView 和 GestureBinding ///循环执行每个的 handleEvent 方法 for (HitTestEntry entry in hitTestResult.path) { try { entry.target.handleEvent(event, entry); } catch (exception, stack) { } } } 复制代码
事实上并非全部的控件的 RenderObject
子类都会处理 handleEvent
,大部分时候,只有带有 RenderPointerListener
(RenderObject) / Listener
(Widget) 的才会处理 handleEvent
事件,而且从上述源码能够看出,handleEvent 的执行是不会被拦截打断的。
那么问题来了,若是同一个区域内有多个控件都实现了 handleEvent
时,那最后事件应该交给谁消耗呢?
更具体为一个场景问题就是:好比一个列表页面内,存在上下滑动和 Item 点击时,Flutter 要怎么分配手势事件? 这就涉及到事件的竞争了。
核心要来了,高能预警!!!
Flutter 在设计事件竞争的时候,定义了一个颇有趣的概念:经过一个竞技场,各个控件参与竞争,直接胜利的或者活到最后的第一位,你就获胜获得了胜利。 那么为了分析接下来的“战争”,咱们须要先看几个概念:
GestureRecognizer
:手势识别器基类,基本上 RenderPointerListener
中须要处理的手势事件,都会分发到它对应的 GestureRecognizer
,并通过它处理和竞技后再分发出去,常见有 :OneSequenceGestureRecognizer
、 MultiTapGestureRecognizer
、VerticalDragGestureRecognizer
、TapGestureRecognizer
等等。
GestureArenaManagerr
:手势竞技管理,它管理了整个“战争”的过程,原则上竞技胜出的条件是 :第一个竞技获胜的成员或最后一个不被拒绝的成员。
GestureArenaEntry
:提供手势事件竞技信息的实体,内封装参与事件竞技的成员。
GestureArenaMember
:参与竞技的成员抽象对象,内部有 acceptGesture
和 rejectGesture
方法,它表明手势竞技的成员,默认 GestureRecognizer
都实现了它,全部竞技的成员能够理解为就是 GestureRecognizer
之间的竞争。
_GestureArena
:GestureArenaManager
内的竞技场,内部持参与竞技的 members
列表,官方对这个竞技场的解释是: 若是一个手势试图在竞技场开放时(isOpen=true)获胜,它将成为一个带有“渴望获胜”的属性的对象。当竞技场关闭(isOpen=false)时,竞技场将寻找一个“渴望获胜”的对象成为新的参与者,若是这时候恰好只有一个,那这一个参与者将成为此次竞技场胜利的青睐存在。
好了,知道这些概念以后咱们开始分析流程,咱们知道 GestureBinding
在 dispatchEvent
时会先判断是否有 HitTestResult
是否有结果,通常状况下是存在的,因此直接执行循环 entry.target.handleEvent
。
循环执行过程当中,咱们知道 entry.target.handleEvent
会触发RenderPointerListener
的 handleEvent
,而事件流程中第一个事件通常都会是 PointerDownEvent
。
PointerDownEvent
的流程在事件竞技流程中至关关键,由于它会触发GestureRecognizer.addPointer
。
GestureRecognizer
只有经过 addPointer
方法将 PointerDownEvent
事件和本身绑定,并添加到 GestureBinding
的 PointerRouter
事件路由和 GestureArenaManager
事件竞技中,后续的事件这个控件的 GestureRecognizer
才能响应和参与竞争。
事实上 Down 事件在 Flutter 中通常都是用来作添加判断的,若是存在竞争时,大部分时候是不会直接出结果的,而 Move 事件在不一样
GestureRecognizer
中会表现不一样,而 UP 事件以后,通常会强制获得一个结果。
因此咱们知道了事件在 GestureBinding
开始分发的时候,在 PointerDownEvent
时须要响应事件的 GestureRecognizer
们,会调用 addPointer
将本身添加到竞争中。以后流程中若是没有特殊状况,通常会执行到参与竞争成员列表的 last,也就是 GestureBinding
本身这个 handleEvent 。
以下代码所示,走到 GestureBinding
的 handleEvent
,在 Down 事件的流程中,通常 pointerRouter.route
不会怎么处理逻辑,而后就是 gestureArena.close
关闭竞技场了,尝试获得胜利者。
@override // from HitTestTarget void handleEvent(PointerEvent event, HitTestEntry entry) { /// 导航事件去触发 `GestureRecognizer` 的 handleEvent /// 通常 PointerDownEvent 在 route 执行中不怎么处理。 pointerRouter.route(event); ///gestureArena 就是 GestureArenaManager if (event is PointerDownEvent) { ///关闭这个 Down 事件的竞技,尝试获得胜利 /// 若是没有的话就留到 MOVE 或者 UP。 gestureArena.close(event.pointer); } else if (event is PointerUpEvent) { ///已经到 UP 了,强行获得结果。 gestureArena.sweep(event.pointer); } else if (event is PointerSignalEvent) { pointerSignalResolver.resolve(event); } } 复制代码
让咱们看 GestureArenaManager
的 close
方法,下面代码咱们能够看到,若是前面 Down 事件中没有经过 addPointer
添加成员到 _arenas
中,那会连参加的机会都没有,而进入 _tryToResolveArena
以后,若是 state.members.length == 1
,说明只有一个成员了,那就不竞争了,直接它就是胜利者,直接响应后续全部事件。 那么若是是多个的话,就须要后续的竞争了。
void close(int pointer) { /// 拿到咱们上面 addPointer 时添加的成员封装 final _GestureArena state = _arenas[pointer]; if (state == null) return; // This arena either never existed or has been resolved. state.isOpen = false; ///开始打起来吧 _tryToResolveArena(pointer, state); } void _tryToResolveArena(int pointer, _GestureArena state) { if (state.members.length == 1) { scheduleMicrotask(() => _resolveByDefault(pointer, state)); } else if (state.members.isEmpty) { _arenas.remove(pointer); } else if (state.eagerWinner != null) { _resolveInFavorOf(pointer, state, state.eagerWinner); } } 复制代码
那竞争呢?接下来咱们以 TapGestureRecognizer
为例子,若是控件区域内存在两个 TapGestureRecognizer
,那么在 PointerDownEvent
流程是不会产生胜利者的,这时候若是没有 MOVE 打断的话,到了 UP 事件时,就会执行 gestureArena.sweep(event.pointer);
强行选取一个。
而选择的方式也是很简单,就是 state.members.first
,从咱们以前 hitTest
的结果上理解的话,就是控件树的最里面 Child 了。 这样胜利的 member 会经过 members.first.acceptGesture(pointer)
回调到 TapGestureRecognizer.acceptGesture
中,设置 _wonArenaForPrimaryPointer
为 ture 标志为胜利区域,而后执行 _checkDown
和 _checkUp
发出事件响应触发给这个控件。
而这里有个有意思的就是 ,Down 流程的 acceptGesture
中的 _checkUp
由于没有 _finalPosition
此时是不会被执行的,_finalPosition
会在 handlePrimaryPointer
方法中,得到_finalPosition
并判断 _wonArenaForPrimaryPointer
标志为,再次执行 _checkUp
才会成功。
handlePrimaryPointer
是在 UP 流程中pointerRouter.route
触发TapGestureRecognizer
的handleEvent
触发的。
那么问题来了,_checkDown
和 _checkUp
时在 UP 事件一次性被执行,那么若是我长按住的话,_checkDown
不是没办法正确回调了?
固然不会,在 TapGestureRecognizer
中有一个 didExceedDeadline
的机制,在前面 Down 流程中,在 addPointer
时 TapGestureRecognizer
会建立一个定时器,这个定时器的时间时 kPressTimeout = 100毫秒
,若是咱们长按住的话,就会等待到触发 didExceedDeadline
去执行 _checkDown
发出 onTabDown
事件了。
_checkDown
执行发送过程当中,会有一个标志为_sentTapDown
判断是否已经发送过,若是发送过了也不会在重发,以后回到本来流程去竞争,手指抬起后获得胜利者相应,同时在_checkUp
以后_sentTapDown
标识为会被重置。
这也能够分析点击下的几种场景:
一、区域内只有一个 TapGestureRecognizer
:Down 事件时直接在竞技场 close
时就获得竞出胜利者,调用 acceptGesture
执行 _checkUp
,到 Up 事件的时候经过 handlePrimaryPointer
执行 _checkUp
,结束。
二、区域内有多个 TapGestureRecognizer
:Down 事件时在竞技场 close
不会竞出胜利者,在 Up 事件的时候,会在 route
过程经过handlePrimaryPointer
设置好 _finalPosition
,以后通过竞技场 sweep
选取排在第一个位置的为胜利者,调用 acceptGesture
,执行 _checkDown
和 _checkUp
。
一、区域内只有一个 TapGestureRecognizer
:除了 Down 事件是在 didExceedDeadline
时发出 _checkDown
外其余和上面基本没区别。
TapGestureRecognizer
:Down 事件时在竞技场 close
时不会竞出胜利者,可是会触发定时器 didExceedDeadline
,先发出 _checkDown
,以后再通过 sweep
选取第一个座位胜利者,调用 acceptGesture
,触发 _checkUp
那么问题又来了,你有没有疑问,若是有区域两个 TapGestureRecognizer
,长按的时候由于都触发了 didExceedDeadline
执行 _checkDown
吗?
答案是:会的!由于定时器都触发了 didExceedDeadline
,因此 _checkDown
都会被执行,从而都发出了 onTapDown
事件。可是后续竞争后,只会执行一个 _checkUp
,全部只会有一个控件响应 onTap
。
在竞技场竞争失败的成员会被移出竞技场,移除后就没办法参加后面事件的竞技了 ,好比 TapGestureRecognizer
在接受到 PointerMoveEvent
事件时就会直接 rejected
, 并触发 rejectGesture
,以后定时器会被关闭,而且触发 onTapCancel
,而后重置标志位.
总结下:
Down 事件时经过 addPointer
加入了 GestureRecognizer
竞技场的区域,在没移除的状况下,事件能够参加后续事件的竞技,在某个事件阶段移除的话,以后的事件序列也会没法接受。事件的竞争若是没有胜利者,在 UP 流程中会强制指定第一个为胜利者。
滑动事件也是须要在 Down 流程中 addPointer
,而后 MOVE 流程中,经过在 PointerRouter.route
以后执行 DragGestureRecognizer.handleEvent
。
在 PointerMoveEvent
事件的 DragGestureRecognizer.handleEvent
里,会经过在 _hasSufficientPendingDragDeltaToAccept
判断是否符合条件,如:
bool get _hasSufficientPendingDragDeltaToAccept => _pendingDragOffset.dy.abs() > kTouchSlop;
复制代码
若是符合条件就直接执行 resolve(GestureDisposition.accepted);
,将流程回到竞技场里,而后执行 acceptGesture
,而后触发onStart
和 onUpdate
。
回到咱们前面的上下滑动可点击列表,是否是很明确了:若是是点击的话,没有产生 MOVE 事件,因此 DragGestureRecognizer
没有被接受,而Item 做为 Child 第一位,因此响应点击。若是有 MOVE 事件, DragGestureRecognizer
会被 acceptGesture
,而点击 GestureRecognizer
会被移除事件竞争,也就没有后续 UP 事件了。
那这个 onUpdate
是怎么让节目动起来的?
咱们以 ListView
为例子,经过源码能够知道, onUpdate
最后会调用到 Scrollable
的 _handleDragUpdate
,这时候会执行 Drag.update
。
经过源码咱们知道 ListView
的 Drag
实现实际上是 ScrollDragController
, 它在 Scrollable
中是和 ScrollPositionWithSingleContext
关联的在一块儿的。那么 ScrollPositionWithSingleContext
又是什么?
ScrollPositionWithSingleContext
其实就是这个滑动的关键,它其实就是 ScrollPosition
的子类,而 ScrollPosition
又是 ViewportOffset
的子类,而 ViewportOffset
又是一个 ChangeNotifier
,出现以下关系:
继承关系:ScrollPositionWithSingleContext : ScrollPosition : ViewportOffset : ChangeNotifier
因此 ViewportOffset 就是滑动的关键点。上面咱们知道响应区域 DragGestureRecognizer
胜利以后执行 Drag.update
,最终会调用到 ScrollPositionWithSingleContext
的 applyUserOffset
,致使内部肯定位置的 pixels
发生改变,并执行父类 ChangeNotifier
的方法notifyListeners
通知更新。
而在 ListView
内部 RenderViewportBase
中,这个 ViewportOffset
是经过 _offset.addListener(markNeedsLayout);
绑定的,so ,触摸滑动致使 Drag.update
,最终会执行到 RenderViewportBase
中的 markNeedsLayout
触发页面更新。
至于 markNeedsLayout
如何更新界面和滚动列表,这里暂不详细描述了,给个图感觉下:
自此,第十三篇终于结束了!(///▽///)