raw pointer event handling
hit test
On the mobile side, the original pointer event models of each platform or UI system are basically the same, that is, a complete event is divided into three stages: finger press, finger move, and finger lift, while higher-level gestures ( Such as click, double click, drag, etc.) are based on these primitive events.
When the pointer is pressed, Flutter will perform a hit test (Hit Test) on the application to determine which components (widgets) exist where the pointer is in contact with the screen, and the pointer press event (and subsequent events of the pointer) are then distributed to The innermost component discovered by the hit test, and from there, events bubble up the component tree. These events are distributed from the innermost component to all components on the path to the root of the component tree. This is similar to web development. The event bubbling mechanism of the browser in Flutter is similar, but there is no mechanism in Flutter to cancel or stop the "bubbling" process, and the browser's bubbling can be stopped. Note that only components that pass the hit test will fire the event.
Listener component
It can be used in Flutter Listener
to monitor raw touch events and Listener
is also a functional component. Here is Listener
the constructor definition:
Listener({
Key key,
this.onPointerDown, //手指按下回调
this.onPointerMove, //手指移动回调
this.onPointerUp,//手指抬起回调
this.onPointerCancel,//触摸事件取消回调
this.behavior = HitTestBehavior.deferToChild, //先忽略此参数,后面会专门介绍
Widget child
})
Let's look at an example first, the function of the following code is: When the finger moves on a container, check the position of the finger relative to the container.
class _PointerMoveIndicatorState extends State<PointerMoveIndicator> {
PointerEvent? _event;
Widget build(BuildContext context) {
return Listener(
child: Container(
alignment: Alignment.center,
color: Colors.blue,
width: 300.0,
height: 150.0,
child: Text(
'${
_event?.localPosition ?? ''}',
style: TextStyle(color: Colors.white),
),
),
onPointerDown: (PointerDownEvent event) => setState(() => _event = event),
onPointerMove: (PointerMoveEvent event) => setState(() => _event = event),
onPointerUp: (PointerUpEvent event) => setState(() => _event = event),
);
}
}
Effect after running:
Move your finger in the blue rectangular area to see the current pointer offset. When the pointer event is triggered, the parameters PointerDownEvent
, PointerMoveEvent
, and PointerUpEvent
are PointerEvent
all subclasses, PointerEvent
which include some information about the current pointer. Note Pointer
that "pointer" refers to the event The trigger can be mouse, touchpad, finger.
like:
position
: It is the offset of the pointer relative to the global coordinates.localPosition
: It is the offset of the pointer relative to its own layout coordinates.delta
: The distance between two pointer movement events (PointerMoveEvent
).pressure
: Press pressure, if the mobile phone screen supports pressure sensors (such as iPhone's 3D Touch), this attribute will be more meaningful, if the mobile phone does not support it, it will always be 1.orientation
: The moving direction of the pointer, which is an angle value.
The above are just PointerEvent
some commonly used attributes, in addition to these it has many attributes, you can view the API documentation.
There is also a behavior
property, which determines how the child component responds to the hit test, and this property will be described in detail later.
ignore pointer events
If we don't want a subtree to respond PointerEvent
, we can use IgnorePointer
and AbsorbPointer
, both of these components can prevent the subtree from receiving pointer events, the difference is that it AbsorbPointer
will participate in the hit test itself, but IgnorePointer
it will not participate in itself, which means that AbsorbPointer
it is Can receive pointer events (but not its subtree), IgnorePointer
but not.
A simple example is as follows:
Listener(
child: AbsorbPointer(
child: Listener(
child: Container(
color: Colors.red,
width: 200.0,
height: 100.0,
),
onPointerDown: (event)=>print("in"),
),
),
onPointerDown: (event)=>print("up"),
)
When clicked Container
, because it is on AbsorbPointer
the subtree, it will not respond to pointer events, so the log will not output " in
", but AbsorbPointer
it can receive pointer events, so it will output " up
". If AbsorbPointer
replaced by IgnorePointer
, then neither will be output.
Gesture Recognition
GestureDetector
GestureDetector
It is a functional component for gesture recognition, through which we can recognize various gestures. GestureDetector
It is encapsulated internally Listener
to recognize semantic gestures. Next, we will introduce the recognition of various gestures in detail.
1. Click, double click, long press
We GestureDetector
perform Container
gesture recognition on the phone, and after triggering the corresponding event, Container
the event name is displayed on the screen. In order to increase the click area, Container
set it to 200×100
, the code is as follows:
class _GestureTestState extends State<GestureTest> {
String _operation = "No Gesture detected!"; //保存事件名
Widget build(BuildContext context) {
return Center(
child: GestureDetector(
child: Container(
alignment: Alignment.center,
color: Colors.blue,
width: 200.0,
height: 100.0,
child: Text(
_operation,
style: TextStyle(color: Colors.white),
),
),
onTap: () => updateText("Tap"), //点击
onDoubleTap: () => updateText("DoubleTap"), //双击
onLongPress: () => updateText("LongPress"), //长按
),
);
}
void updateText(String text) {
//更新显示的事件名
setState(() {
_operation = text;
});
}
}
running result:
Note: When listening onTap
to and onDoubleTap
events at the same time, when the user triggers tap
the event, there will be 200
a delay of about milliseconds. This is because the user is likely to click again to trigger the double-click event after clicking, so GestureDetector
it will wait for a while to determine whether it is Double click event. If the user only listens onTap
(not listens onDoubleTap
) to the event, there is no delay.
2. Drag, slide
A complete gesture process refers to the entire process from pressing the user's finger to lifting it. During this period, the user may or may not move after pressing the finger. GestureDetector
There is no distinction between drag and swipe events, they are essentially the same. GestureDetector
The origin (upper left corner) of the component to be monitored will be used as the origin of the gesture. When the user presses the finger on the monitored component, gesture recognition will start.
Let's look at an example of dragging a circular letter A:
class _Drag extends StatefulWidget {
_DragState createState() => _DragState();
}
class _DragState extends State<_Drag> with SingleTickerProviderStateMixin {
double _top = 0.0; //距顶部的偏移
double _left = 0.0;//距左边的偏移
Widget build(BuildContext context) {
return Stack(
children: <Widget>[
Positioned(
top: _top,
left: _left,
child: GestureDetector(
child: CircleAvatar(child: Text("A")),
onPanDown: (DragDownDetails e) {
// 手指按下时会触发此回调
// 打印手指按下的位置(相对于屏幕)
print("用户手指按下:${
e.globalPosition}");
},
onPanUpdate: (DragUpdateDetails e) {
// 手指滑动时会触发此回调
// 用户手指滑动时,更新偏移,重新构建
setState(() {
_left += e.delta.dx;
_top += e.delta.dy;
});
},
onPanEnd: (DragEndDetails e){
// 打印滑动结束时在x、y轴上的速度
print(e.velocity);
},
),
)
],
);
}
}
After running, you can drag in any direction, the running effect:
log:
I/flutter ( 8513): 用户手指按下:Offset(26.3, 101.8)
I/flutter ( 8513): Velocity(235.5, 125.8)
Code explanation:
DragDownDetails.globalPosition
: When the user presses, this property is the offset of the user's pressed position relative to the origin (upper left corner) of the screen (not the parent component).DragUpdateDetails.delta
: When the user slides on the screen, multipleUpdate
events will be triggered, which refers to the sliding offset ofdelta
an event.Update
DragEndDetails.velocity
x、y
: This attribute represents the sliding speed (including two axes) when the user lifts the finger. In the example, the speed when the finger is lifted is not processed. The common effect is to make a deceleration animation according to the speed when the user lifts the finger .
single direction drag
In the above example, it is possible to drag in any direction, but in many scenarios, we only need to drag in one direction, such as a vertical list, which can only recognize gesture events in a specific direction. We will use the above GestureDetector
example Change to only dragging vertically:
class _DragVertical extends StatefulWidget {
_DragVerticalState createState() => _DragVerticalState();
}
class _DragVerticalState extends State<_DragVertical> {
double _top = 0.0;
Widget build(BuildContext context) {
return Stack(
children: <Widget>[
Positioned(
top: _top,
child: GestureDetector(
child: CircleAvatar(child: Text("A")),
onVerticalDragUpdate: (DragUpdateDetails details) {
// 垂直方向拖动事件
setState(() {
_top += details.delta.dy;
});
},
),
)
],
);
}
}
In this way, you can only drag in the vertical direction, and it is the same if you only want to slide in the horizontal direction.
3. Zoom
GestureDetector
Zoom events can be monitored. The following example demonstrates a simple image zoom effect:
class _Scale extends StatefulWidget {
const _Scale({
Key? key}) : super(key: key);
_ScaleState createState() => _ScaleState();
}
class _ScaleState extends State<_Scale> {
double _width = 200.0; // 通过修改图片宽度来达到缩放效果
Widget build(BuildContext context) {
return Center(
child: GestureDetector(
child: Image.asset("./images/sea.png", width: _width), // 指定宽度,高度自适应
onScaleUpdate: (ScaleUpdateDetails details) {
setState(() {
// 缩放倍数在0.8到10倍之间
_width = 200 * details.scale.clamp(.8, 10.0);
});
},
),
);
}
}
running result:
Now you can zoom in and out by pinching two fingers apart or pinching on the picture.
GestureRecognizer
GestureDetector
Internally, one or more gestures are used to GestureRecognizer
recognize various gestures, and GestureRecognizer
the function is to Listener
convert raw pointer events into semantic gestures, and GestureDetector
directly receive a child widget
. GestureRecognizer
It is an abstract class, and a gesture recognizer corresponds to a GestureRecognizer
subclass. Flutter implements a wealth of gesture recognizers, which we can use directly.
Example: Suppose we want to RichText
add click event handlers to different parts of a piece of rich text ( ), but TextSpan
not one widget
, we can’t use it at this time GestureDetector
, but TextSpan
there is a recognizer
property, it can receive one GestureRecognizer
.
Suppose we need to change the color of the text on click:
import 'package:flutter/gestures.dart';
class _GestureRecognizer extends StatefulWidget {
const _GestureRecognizer({
Key? key}) : super(key: key);
_GestureRecognizerState createState() => _GestureRecognizerState();
}
class _GestureRecognizerState extends State<_GestureRecognizer> {
TapGestureRecognizer _tapGestureRecognizer = TapGestureRecognizer();
bool _toggle = false; //变色开关
void dispose() {
//用到GestureRecognizer的话一定要调用其dispose方法释放资源
_tapGestureRecognizer.dispose();
super.dispose();
}
Widget build(BuildContext context) {
return Center(
child: Text.rich(
TextSpan(
children: [
TextSpan(text: "你好世界"),
TextSpan(
text: "点我变色",
style: TextStyle(
fontSize: 30.0,
color: _toggle ? Colors.blue : Colors.red,
),
recognizer: _tapGestureRecognizer
..onTap = () {
setState(() {
_toggle = !_toggle;
});
},
),
TextSpan(text: "你好世界"),
],
),
),
);
}
}
running result:
Note: GestureRecognizer
After use, be sure to call its dispose()
method to release resources (mainly cancel the internal timer).
Flutter event mechanism
Flutter event processing flow
The Flutter event processing process is mainly divided into two steps. In order to focus on the core process, we use the user touch event as an example to illustrate:
- Hit test : When the finger is pressed,
PointerDownEvent
the event is triggered, and the current rendering ( ) tree is traversed in depth firstrender object
, and each rendering object is "hit tested" (hit test
). If the hit test passes, the rendering object will be added to aHitTestResult
list among. - Event distribution : After the hit test is completed,
HitTestResult
the list will be traversed, and the event processing method ( ) of each rendering object will be calledhandleEvent
to processPointerDownEvent
the event. This process is called "event distribution" (event dispatch
). Then when the finger moves,PointerMoveEvent
the event is dispatched. - Event cleaning : When the finger is lifted (
PointerUpEvent
) or the event is canceled (PointerCancelEvent
), the corresponding event will be distributed first, andHitTestResult
the list will be cleared after the distribution is completed.
requires attention:
- Hit testing is
PointerDownEvent
done when an event fires, a completed event stream isdown > move > up (cancle)
. - If both parent and child components listen to the same event, the child component will respond to the event before the parent component . This is because the hit test process is traversed according to the depth-first
HitTestResult
rule, so the child rendering object will be added to the list before the parent rendering object, and because the list is traversed from front to back when the event is dispatchedHitTestResult
, the child component will be earlier than the parent component is calledhandleEvent
.
Let's look at some of the entire event processing process from the code level:
// 触发新事件时,flutter 会调用此方法
void _handlePointerEventImmediately(PointerEvent event) {
HitTestResult? hitTestResult;
if (event is PointerDownEvent ) {
hitTestResult = HitTestResult();
// 发起命中测试
hitTest(hitTestResult, event.position);
if (event is PointerDownEvent) {
_hitTests[event.pointer] = hitTestResult;
}
} else if (event is PointerUpEvent || event is PointerCancelEvent) {
//获取命中测试的结果,然后移除它
hitTestResult = _hitTests.remove(event.pointer);
} else if (event.down) {
// PointerMoveEvent
//直接获取命中测试的结果
hitTestResult = _hitTests[event.pointer];
}
// 事件分发
if (hitTestResult != null) {
dispatchEvent(event, hitTestResult);
}
}
The above code is only the core code, and the complete code is located GestureBinding
in the implementation. Let's introduce some hit testing and event distribution processes respectively.
Hit Test Details
1. The starting point of the hit test
Whether an object can respond to events depends on whether it is added to the list during its hit test HitTestResult
. If it is not added, the subsequent event distribution will not be distributed to itself. Let's take a look at the process of hit testing: when a user event occurs, Flutter will RenderView
call it from the root node ( ) hitTest()
.
void hitTest(HitTestResult result, Offset position) {
//从根节点开始进行命中测试
renderView.hitTest(result, position: position);
// 会调用 GestureBinding 中的 hitTest()方法,我们将在下一节中介绍。
super.hitTest(result, position);
}
The above code is located RenderBinding
in , the core code is only two lines, the overall hit test is divided into two steps, let's explain:
-
The first step:
renderView
isRenderView
the correspondingRenderObject
object, the main functionRenderObject
of the objecthitTest
method is: starting from this node, recursively traverse each node on the subtree (rendering tree) in depth-first order and hit test them. This process is called " render tree hit testing ".Note: For the convenience of expression, "rendering tree hit test" can also be expressed as a component tree or node tree hit test, but we need to know that the logic of the hit test is in , not
RenderObject
inWidget
orElement
. -
Step 2: After the rendering tree hit test is completed, the method of will be called
GestureBinding
,hitTest
which is mainly used to handle gestures, which we will introduce later.
2. Rendering tree hit testing process
The hit test process of the rendering tree is the recursive process of hitTest
continuously calling the child node method in the parent node method .hitTest
Here is the RenderView
source hitTest()
code:
// 发起命中测试,position 为事件触发的坐标(如果有的话)。
bool hitTest(HitTestResult result, {
Offset position }) {
if (child != null)
child.hitTest(result, position: position); //递归对子树进行命中测试
//根节点会始终被添加到HitTestResult列表中
result.add(HitTestEntry(this));
return true;
}
Since RenderView
there is only one child, child.hitTest
just call directly. If a rendering object has multiple child nodes, the hit test logic is: if any child node passes the hit test, or if the current node "forces" itself to pass the hit test, the current node will pass the hit test.
Let's take RenderBox
it as an example to see its hitTest()
implementation:
bool hitTest(HitTestResult result, {
Offset position }) {
...
if (_size.contains(position)) {
// 判断事件的触发位置是否位于组件范围内
if (hitTestChildren(result, position: position) || hitTestSelf(position)) {
result.add(BoxHitTestEntry(this, position));
return true;
}
}
return false;
}
In the above code:
hitTestChildren()
The function is to judge whether any child node has passed the hit test, if yes, it will add the child component to andHitTestResult
return at the same timetrue
; if not, it will return directlyfalse
. In this method, the method of the child component will be called recursivelyhitTest
.hitTestSelf()
Determine whether it passes the hit test. If the node needs to ensure that it can respond to events, it can rewrite this function and return true, which is equivalent to " forcibly declaring " that it has passed the hit test.
It should be noted that the sign that a node passes the hit test is that it is added to HitTestResult
the list, not hitTest
its return value, although in most cases the node will return if it passes the hit test true
, but since developers can override when customizing components hitTest
Yes, so it is possible to return when the hit test is passed false
, or when the hit test is not passed true
. Of course, this is not good. We should avoid it as much as possible when customizing components, but in some cases that need to customize the hit test process It may be necessary to break this tacit understanding in scenarios, such as the components we will implement later HitTestBlocker
.
So the overall logic is:
- First judge whether the trigger position of the event is within the scope of the component, if not, the hit test will not pass,
hitTest
return at this timefalse
, if yes, go to the second step. - It will first call
hitTestChildren()
to determine whether any child nodes pass the hit test, if so, add the current node toHitTestResult
the list, andhitTest
return at this timetrue
. That is, as long as a child node passes the hit test, its parent node (the current node) will also pass the hit test. - If no child node passes the hit test,
hitTestSelf
the return value of the method will be used. If the return value istrue
, the current node passes the hit test, otherwise it will not.
If the current node has child nodes that pass the hit test or the current node itself passes the hit test, the current node is added to HitTestResult
. And because the methods hitTestChildren()
of subcomponents will be called recursively hitTest
, the hit test order of the component tree is depth-first, that is, if the hit test is passed, the subcomponent will be added to the component before the parent component HitTestResult
.
Let's take a look at the default implementation of these two methods as follows:
hitTestChildren(HitTestResult result, {
Offset position }) => false;
bool hitTestSelf(Offset position) => false;
bool
You can see that these two methods return by default false
. If the component contains multiple subcomponents, the hitTestChildren()
method must be rewritten, and the method of each subcomponent should be called in this method hitTest
. For example, let's take a look at RenderBoxContainerDefaultsMixin
the implementation in :
// 子类的 hitTestChildren() 中会直接调用此方法
bool defaultHitTestChildren(BoxHitTestResult result, {
required Offset position }) {
// 遍历所有子组件(子节点从后向前遍历)
ChildType? child = lastChild;
while (child != null) {
final ParentDataType childParentData = child.parentData! as ParentDataType;
// isHit 为当前子节点调用hitTest() 的返回值
final bool isHit = result.addWithPaintOffset(
offset: childParentData.offset,
position: position,
//调用子组件的 hitTest方法,
hitTest: (BoxHitTestResult result, Offset? transformed) {
return child!.hitTest(result, position: transformed!);
},
);
// 一旦有一个子节点的 hitTest() 方法返回 true,则终止遍历,直接返回true
if (isHit) return true;
child = childParentData.previousSibling;
}
return false;
}
bool addWithPaintOffset({
required Offset? offset,
required Offset position,
required BoxHitTest hitTest,
}) {
...// 省略无关代码
final bool isHit = hitTest(this, transformedPosition);
return isHit; // 返回 hitTest 的执行结果
}
We can see that the main logic of the above code is to traverse and call the method of subcomponents hitTest()
, and at the same time provide an interruption mechanism : that is, as long as there is a child node that hitTest()
returns during the traversal process true
:
-
will terminate the child node traversal, which means that the sibling nodes before the child node will have no chance to pass the hit test. Note that the traversal of sibling nodes is in reverse order .
-
The parent node also passes the hit test. Because the child node
hitTest()
returnstrue
the parent nodehitTestChildren
will also returntrue
, which will eventually lead tohitTest
the return of the parent nodetrue
, and the parent node is added toHitTestResult
.
When the child node hitTest()
returns false
, continue to traverse the sibling nodes in front of the child node and perform hit tests on them. If all child nodes return , the false
parent node will call its own hitTestSelf
method. If the method also returns false
, the parent node is considered to have failed the hit test.
Consider the following two questions:
-
Why should this interrupt be formulated?
Because the layout space occupied by sibling nodes does not coincide under normal circumstances, there will only be one node at the coordinate position clicked by the user, so once it is found (passed the hit test,
hitTest
returntrue
), there is no need to judge other sibling nodes up. But there are exceptions. For example, inStack
the layout, the layout space of sibling components will overlap. If we want the component at the bottom to respond to events, we must have a mechanism that allows us to ensure that: even if a node is found, it will The traversal should not be terminated, which means that all subcomponenthitTest
methods must returnfalse
! To this end, this process is customized in FlutterHitTestBehavior
, which we will introduce later. -
Why is the traversal of sibling nodes in reverse order?
As
1
mentioned above, sibling nodes generally do not overlap, and once overlapping occurs, the latter component will often be on top of the front component. When clicked, the latter component should respond to the event, while the front covered component cannot respond. , so the hit test should give priority to testing the following nodes, because once the test is passed, it will not continue to traverse. If we follow the forward traversal, it will appear that the covered component can respond to the event, but the above component cannot, which is obviously not in line with expectations.
Let's go back hitTestChildren
to , if it is not rewritten hitTestChildren
, it will return directly by default false
, which means that the descendant nodes will not be able to participate in the hit test, which is equivalent to the event being intercepted, which is exactly the principle that IgnorePointer
and AbsorbPointer
can intercept the event delivery.
If hitTestSelf
is returned true
, the current node itself will be added to the regardless of whether there are nodes that pass the hit test among the child nodes HitTestResult
. Therefore, the difference between IgnorePointer
and AbsorbPointer
is that the former hitTestSelf
returns false
, while the latter returns true
.
After the hit test is complete, all nodes that pass the hit test are added to HitTestResult
.
event distribution
The event distribution process is very simple, that is, traverse HitTestResult
and call the method of each node handleEvent
:
// 事件分发
void dispatchEvent(PointerEvent event, HitTestResult? hitTestResult) {
...
for (final HitTestEntry entry in hitTestResult.path) {
entry.target.handleEvent(event.transformed(entry.transform), entry);
}
}
So components only need to override handleEvent
the method to handle events.
HitTestBehavior
1. Introduction to HitTestBehavior
Let's first implement a PointerDownEvent
component that can listen to :
class PointerDownListener extends SingleChildRenderObjectWidget {
PointerDownListener({
Key? key, this.onPointerDown, Widget? child})
: super(key: key, child: child);
final PointerDownEventListener? onPointerDown;
RenderObject createRenderObject(BuildContext context) =>
RenderPointerDownListener()..onPointerDown = onPointerDown;
void updateRenderObject(
BuildContext context, RenderPointerDownListener renderObject) {
renderObject.onPointerDown = onPointerDown;
}
}
class RenderPointerDownListener extends RenderProxyBox {
PointerDownEventListener? onPointerDown;
bool hitTestSelf(Offset position) => true; //始终通过命中测试
void handleEvent(PointerEvent event, covariant HitTestEntry entry) {
//事件分发时处理事件
if (event is PointerDownEvent) onPointerDown?.call(event);
}
}
Because we make hitTestSelf
the return value of is always true
, so no matter whether the child node passes the hit test or not, it will pass, so it will be called PointerDownListener
when the event is subsequently distributed . We can trigger the callback when the event type is determined in it. The test code is as follows:handleEvent
PointerDownEvent
class PointerDownListenerRoute extends StatelessWidget {
const PointerDownListenerRoute({
Key? key}) : super(key: key);
Widget build(BuildContext context) {
return PointerDownListener(
child: Text('Click me'),
onPointerDown: (e) => print('down'),
);
}
}
After clicking the text, the console will print ' down
'.
Listener
The implementation of PointerDownListener
is similar to that of , with two differences:
Listener
There are more types of events to monitor.Listener
ThehitTestSelf
is not always returnedtrue
.
Here we need to focus on the second point. Listener
The component has a behavior
parameter, which we haven't introduced before, so let's introduce it in detail below. By looking at Listener
the source code, I found that its rendering object RenderPointerListener
inherits RenderProxyBoxWithHitTestBehavior
the class:
abstract class RenderProxyBoxWithHitTestBehavior extends RenderProxyBox {
//[behavior] 的默认值为 [HitTestBehavior.deferToChild].
RenderProxyBoxWithHitTestBehavior({
this.behavior = HitTestBehavior.deferToChild,
RenderBox? child,
}) : super(child);
HitTestBehavior behavior;
bool hitTest(BoxHitTestResult result, {
required Offset position }) {
bool hitTarget = false;
if (size.contains(position)) {
hitTarget = hitTestChildren(result, position: position) || hitTestSelf(position);
if (hitTarget || behavior == HitTestBehavior.translucent) //1
result.add(BoxHitTestEntry(this, position)); // 通过命中测试
}
return hitTarget;
}
bool hitTestSelf(Offset position) => behavior == HitTestBehavior.opaque; //2
}
We see that it will be used behavior
in hitTest
and hitTestSelf
, and its value will affect Listener
the hit test results of . Let's take a look at behavior
what the values are:
//在命中测试过程中 Listener 组件如何表现。
enum HitTestBehavior {
// 组件是否通过命中测试取决于子组件是否通过命中测试
deferToChild,
// 组件必然会通过命中测试,同时其 hitTest 返回值始终为 true
opaque,
// 组件必然会通过命中测试,但其 hitTest 返回值可能为 true 也可能为 false
translucent,
}
It has three values. Let's hitTest
analyze the effects of different values in combination with implementation:
behavior
WhendeferToChild
,hitTestSelf
returnsfalse
, whether the current component can pass the hit test depends entirely onhitTestChildren
the return value of . That is to say, as long as one child node passes the hit test, the current component will pass the hit test.behavior
Whenopaque
is ,hitTestSelf
returnstrue
,hitTarget
the value is alwaystrue
, the current component passes the hit test.behavior
Whentranslucent
,hitTestSelf
returnsfalse
,hitTarget
the value depends onhitTestChildren
the return value of at this time, but no matterhitTarget
what the value of is, the current node will be added toHitTestResult
.
Note that the current component will pass the hit test behavior
for opaque
and translucent
. The difference between them is that hitTest()
the return value ( hitTarget
) of may be different, so the difference depends on hitTest()
what the return value will affect. We have already introduced this in detail above. Next, we pass An example to understand.
2. Example: implement App watermark
The effect is as shown in the figure:
The implementation idea is to overlay a watermark mask on the top layer of the page, which we can Stack
achieve by passing the watermark component as the last child Stack
:
class WaterMaskTest extends StatelessWidget {
const WaterMaskTest({
Key? key}) : super(key: key);
Widget build(BuildContext context) {
return Stack(
children: [
wChild(1, Colors.white, 200),
WaterMark(
painter: TextWaterMarkPainter(text: 'wendux', rotate: -20),
),
],
);
}
Widget wChild(int index, color, double size) {
return Listener(
onPointerDown: (e) => print(index),
child: Container(
width: size,
height: size,
color: Colors.grey,
),
);
}
}
WaterMark
It is the component to implement the watermark. We will introduce the specific logic later, and now we only need to know that it WaterMark
is used in DecoratedBox
. The effect is realized, but Stack
when we click on the first subcomponent (gray rectangle area), we find that there is no output on the console, which is not as expected, because the watermark component is at the top and the event is "blocked" by it. Let's analyze this process:
-
When clicking,
Stack
there are two sub-components. This is the click test for the second sub-component (watermark component). Since it is used in the watermark component, after checking the source code, it is found that if theDecoratedBox
user clicks on theDecoratedBox
upper position, ithitTestSelf
will returntrue
, so The watermark component passes the hit test. -
After the watermark component passes the hit test, it will cause
Stack
the ofhitTestChildren()
to return directly (stop traversing other child nodes), soStack
the first child component of will not participate in the hit test, so it will not respond to the event.
hitTest
The reason is found, and the solution is to find a way to allow the first subcomponent to participate in the hit test. In this case, we have to find a way to return the second subcomponent false
. So we can wrap IgnorePointer
it with WaterMask
.
IgnorePointer(child: WaterMark(...))
After modification, rerun and find that the first subcomponent can respond to events.
If we want Stack
all subcomponents of to respond to events, how should we achieve it? Of course, this is likely to be a pseudo-requirement , which is rarely encountered in real scenarios, but considering this issue can deepen our understanding of the Flutter event processing process.
class StackEventTest extends StatelessWidget {
const StackEventTest({
Key? key}) : super(key: key);
Widget build(BuildContext context) {
return Stack(
children: [
wChild(1),
wChild(2),
],
);
}
Widget wChild(int index) {
return Listener(
onPointerDown: (e) => print(index),
child: Container(
width: 100,
height: 100,
color: Colors.grey,
),
);
}
}
After running, click on the gray box and guess what the console will print?
- The answer is that only one '
2
' will be printed. The reason is thatStack
the second child node is traversed firstListener
, becauseContainer
thehitTest
will returntrue
(actuallyContainer
is a combined component. In this example,Container
one will be generated eventuallyColoredBox
, and the one participating in the hit test isColoredBox
correspondingRenderObject
), SoListener
the ofhitTestChildren
will be returnedtrue
, and eventually theListener
ofhitTest
will be returnedtrue
, so the first child node will not receive the event.
What if we specify the property Listener
of the as or ? In fact, the result is still the same, because as long as the of will be returned , the final of will be returned , and the first node will not be hit tested again.behavior
opaque
translucent
Container
hitTest
true
Listener
hitTestChildren
true
What are the specific scenarios where opaque
and translucent
can reflect the difference? Theoretically, there is only a difference between the two when Listener
the sub-node hitTest
returns , but the components with UI in Flutter will basically return false
when the user clicks on it, so it is difficult to find specific scenarios, but in order to test their difference , we can forcibly create a scene, such as the following code:hitTest
true
class HitTestBehaviorTest extends StatelessWidget {
const HitTestBehaviorTest({
Key? key}) : super(key: key);
Widget build(BuildContext context) {
return Stack(
children: [
wChild(1),
wChild(2),
],
);
}
Widget wChild(int index) {
return Listener(
//behavior: HitTestBehavior.opaque, // 放开此行,点击只会输出 2
behavior: HitTestBehavior.translucent, // 放开此行,点击会同时输出 2 和 1
onPointerDown: (e) => print(index),
child: SizedBox.expand(),
);
}
}
SizedBox
There is no sub-element, when it is clicked, its hitTest
will return false
, at this time Listener
the behavior
is set to opaque
and translucent
there will be a difference (see comments).
Because the above similar cases hardly appear in actual scenarios, if you want Stack
all subcomponents of to respond to events, you must ensure Stack
that all children of hitTest
are returned false
. Although IgnorePointer
you can do this by wrapping all subcomponents with , but IgnorePointer
also At the same time, hit testing will no longer be performed on subcomponents, which means that its subcomponent tree will not be able to respond to events. For example, after the following code is run, clicking the gray area will not produce any output:
class AllChildrenCanResponseEvent extends StatelessWidget {
const AllChildrenCanResponseEvent({
Key? key}) : super(key: key);
Widget build(BuildContext context) {
return Stack(
children: [
IgnorePointer(child: wChild(1, 200)),
IgnorePointer(child: wChild(2, 200)),
],
);
}
Widget wChild(int index, double size) {
return Listener(
onPointerDown: (e) => print(index),
child: Container(
width: size,
height: size,
color: Colors.grey,
),
);
}
}
Container
Although we have listened to the event in the child node , the child node is IgnorePointer
in , so the child node has no chance to participate in the hit test, so it will not respond to any event. It seems that there is no ready-made component that can meet the requirements, so we can implement a component by ourselves and then customize it hitTest
to meet our requirements.
3. HitTestBlocker
Below we define a component that can intercept hitTest
various processes HitTestBlocker
.
class HitTestBlocker extends SingleChildRenderObjectWidget {
HitTestBlocker({
Key? key,
this.up = true,
this.down = false,
this.self = false,
Widget? child,
}) : super(key: key, child: child);
/// up 为 true 时 , `hitTest()` 将会一直返回 false.
final bool up;
/// down 为 true 时, 将不会调用 `hitTestChildren()`.
final bool down;
/// `hitTestSelf` 的返回值
final bool self;
RenderObject createRenderObject(BuildContext context) {
return RenderHitTestBlocker(up: up, down: down, self: self);
}
void updateRenderObject(BuildContext context, RenderHitTestBlocker renderObject) {
renderObject
..up = up
..down = down
..self = self;
}
}
class RenderHitTestBlocker extends RenderProxyBox {
RenderHitTestBlocker({
this.up = true, this.down = true, this.self = true});
bool up;
bool down;
bool self;
bool hitTest(BoxHitTestResult result, {
required Offset position}) {
bool hitTestDownResult = false;
if (!down) {
hitTestDownResult = hitTestChildren(result, position: position);
}
bool pass = hitTestSelf(position) || (hitTestDownResult && size.contains(position));
if (pass) {
result.add(BoxHitTestEntry(this, position));
}
return !up && pass;
}
bool hitTestSelf(Offset position) => self;
}
We can use HitTestBlocker
direct replacement IgnorePointer
to realize that all subcomponents can respond to events. The code is as follows:
Widget build(BuildContext context) {
return Stack(
children: [
// IgnorePointer(child: wChild(1, 200)),
// IgnorePointer(child: wChild(2, 200)),
HitTestBlocker(child: wChild(1, 200)),
HitTestBlocker(child: wChild(2, 200)),
],
);
}
After clicking, the console will output 2
and at the same time 1
, the principle is also very simple:
-
HitTestBlocker
willhitTest
returnfalse
, which can ensureStack
that all child nodes of can participate in the hit test; -
HitTestBlocker
hitTest
will be called in again ,hitTestChildren
soHitTestBlocker
the descendant nodes of will have the opportunity to participate in the hit test, soContainer
the events on will be triggered normally.
HitTestBlocker
is a very flexible class, it can intercept each stage of the hit test, and HitTestBlocker
can fully realize the functions of IgnorePointer
and through, for example, when both of and are , the function is the same as that of .AbsorbPointer
HitTestBlocker
up
down
true
IgnorePointer
4. The presence of gestures
Let's modify the above code a little bit, Listener
replace with GestureDetector
, the code is as follows:
class GestureHitTestBlockerTest extends StatelessWidget {
const GestureHitTestBlockerTest({
Key? key}) : super(key: key);
Widget build(BuildContext context) {
return Stack(
children: [
HitTestBlocker(child: wChild(1, 200)),
HitTestBlocker(child: wChild(2, 200)),
],
);
}
Widget wChild(int index, double size) {
return GestureDetector( // 将 Listener 换为 GestureDetector
onTap: () => print('$index'),
child: Container(
width: size,
height: size,
color: Colors.grey,
),
);
}
}
Can you guess what will be output after clicking? The answer is only output 2
! This is because although Stack
both subcomponents of will participate and pass the hit test, GestureDetector
will decide whether to respond to the event in the event distribution phase (rather than the hit test phase), and GestureDetector
have a separate mechanism for handling gesture conflicts, which we will introduce below .
Summarize:
- Components can only respond to events if they pass the hit test.
- Whether a component passes the hit test depends on
hitTestChildren(...) || hitTestSelf(...)
the value of . - The hit testing order of components in the component tree is depth-first.
- The order of the component child node hit test is reversed, and once a child node is
hitTest
returnedtrue
, the traversal will be terminated, and subsequent child nodes will not have the opportunity to participate in the hit test. This principle canStack
be understood in conjunction with components. - In most cases the effect
Listener
ofHitTestBehavior
isopaque
ortranslucent
is the same, only when its child node'shitTest
is returned asfalse
is the difference. HitTestBlocker
is a very flexible component through which we can intervene in various stages of hit testing.
Gesture principle and gesture conflict
Principle of Gesture Recognition
Gesture recognition and processing are all in the event distribution stage, GestureDetector
which is one StatelessWidget
, including RawGestureDetector
, let's look at its build
method implementation:
Widget build(BuildContext context) {
final gestures = <Type, GestureRecognizerFactory>{
};
// 构建 TapGestureRecognizer
if (onTapDown != null ||
onTapUp != null ||
onTap != null ||
... //省略
) {
gestures[TapGestureRecognizer] = GestureRecognizerFactoryWithHandlers<TapGestureRecognizer>(
() => TapGestureRecognizer(debugOwner: this),
(TapGestureRecognizer instance) {
instance
..onTapDown = onTapDown
..onTapUp = onTapUp
..onTap = onTap
//省略
},
);
}
return RawGestureDetector(
gestures: gestures, // 传入手势识别器
behavior: behavior, // 同 Listener 中的 HitTestBehavior
child: child,
);
}
Note that we deleted a lot of code above, and only kept TapGestureRecognizer
the relevant code (click gesture recognizer). Let's take click gesture recognition as an example to talk about the whole process. The event will be monitored RawGestureDetector
through the component , and the relevant source code is as follows:Listener
PointerDownEvent
Widget build(BuildContext context) {
... // 省略无关代码
Widget result = Listener(
onPointerDown: _handlePointerDown,
behavior: widget.behavior ?? _defaultBehavior,
child: widget.child,
);
}
void _handlePointerDown(PointerDownEvent event) {
for (final GestureRecognizer recognizer in _recognizers!.values)
recognizer.addPointer(event);
}
Let's take a look at TapGestureRecognizer
several related methods of , because of TapGestureRecognizer
the multi-layer inheritance relationship, here is a simplified version:
class CustomTapGestureRecognizer1 extends TapGestureRecognizer {
void addPointer(PointerDownEvent event) {
//会将 handleEvent 回调添加到 pointerRouter 中
GestureBinding.instance!.pointerRouter.addRoute(event.pointer, handleEvent);
}
void handleEvent(PointerEvent event) {
//会进行手势识别,并决定是是调用 acceptGesture 还是 rejectGesture,
}
void acceptGesture(int pointer) {
// 竞争胜出会调用
}
void rejectGesture(int pointer) {
// 竞争失败会调用
}
}
It can be seen that when PointerDownEvent
the event is triggered, it will be called TapGestureRecognizer
, addPointer
and the method addPointer
will be added to and saved in . In this way, when the gesture changes, you only need to take out the method for gesture recognition.handleEvent
pointerRouter
pointerRouter
GestureRecognizer
handleEvent
Under normal circumstances, the object directly affected by the gesture should handle the gesture, so a simple principle is that only one gesture recognizer should be effective for the same gesture. For this reason, gesture recognition reflects the concept of the gesture arena (Arena ) . simply speaking:
-
Each gesture recognizer (
GestureRecognizer
) is a "competitor" (GestureArenaMember
). When a pointer event occurs, they must compete in the "arena" for the right to handle the event. By default, only one "competitor" will eventually win (win). -
GestureRecognizer
Gestures are recognized in thehandleEvent
, and if a gesture occurs, competitors can announce whether they have won, and once a competitor wins, the arena manager ( )GestureArenaManager
will notify the other competitors that they have lost. -
The winner's
acceptGesture
will be called, and the restrejectGesture
will be called.
In the previous section we said that the hit test starts from RenderBinding
the hitTest
:
void hitTest(HitTestResult result, Offset position) {
// 从根节点开始进行命中测试
renderView.hitTest(result, position: position);
// 会调用 GestureBinding 中的 hitTest()方法
super.hitTest(result, position);
}
GestureBinding
The method in is called after the render tree hit test is complete hitTest()
:
// from HitTestable
void hitTest(HitTestResult result, Offset position) {
result.add(HitTestEntry(this));
}
It's very simple, GestureBinding
and it has passed the hit test. In this way, in the event distribution phase, GestureBinding
will handleEvent
also be called. Since it is added to at the end HitTestResult
, in the event distribution phase GestureBinding
, handleEvent
will be called at the end:
void handleEvent(PointerEvent event, HitTestEntry entry) {
// 会调用在 pointerRouter 中添加的 GestureRecognizer 的 handleEvent
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);
}
}
gestureArena
Is GestureArenaManager
the class instance responsible for managing the arena.
The key code above is the first line, the function will call the previously added in , pointerRouter
different will recognize different gestures, and then it will interact with (if the current wins, you need to notify other competitors that they have failed), In the end, if the current wins, its will be called eventually, and if it fails, its will be called, because this part of the code will be different , just know what it does, if you are interested, you can check the source code yourself.GestureRecognizer
handleEvent
GestureRecognizer
handleEvent
gestureArena
GestureRecognizer
gestureArena
GestureRecognizer
acceptGesture
rejectGesture
GestureRecognizer
gesture competition
If a component is monitored for horizontal and vertical drag gestures at the same time, which direction of the drag gesture callback will be triggered when we drag obliquely? In fact, it depends on the displacement components on the two axes during the first movement, whichever axis is larger, which axis will win the competition in this sliding event. As mentioned above, each gesture recognizer ( GestureRecognizer
) is a "competitor" ( GestureArenaMember
). When a pointer event occurs, they all compete in the "arena" for the right to handle this event. By default, there is only one The "competitor" will win (win).
For example, suppose there is one ListView
, and its first child component is also ListView
. If you slide this child now ListView
, ListView
will the parent move? The answer is no, only the child ListView
will move at this time, because the child ListView
will win at this time and get the right to handle the sliding event.
Let's look at a simple example:
GestureDetector( // GestureDetector2
onTapUp: (x)=>print("2"), // 监听父组件 tapUp 手势
child: Container(
width:200,
height: 200,
color: Colors.red,
alignment: Alignment.center,
child: GestureDetector( //GestureDetector1
onTapUp: (x)=>print("1"), // 监听子组件 tapUp 手势
child: Container(
width: 50,
height: 50,
color: Colors.grey,
),
),
),
);
When we click on the subcomponent (gray area), the console will only print " 1
", but not " 2
". This is because after the finger is lifted, there will be a competition GestureDetector1
with GestureDetector
2. The winning rule is " subcomponent first " , so GestureDetector1
wins, since only one "competitor" can win, so GestureDetector
2 is ignored. The way to resolve the conflict in this example is very simple, just GestureDetector
replace with Listener
, the specific reason will be explained later.
Let's look at another example, let's take the drag gesture as an example, and recognize the drag gesture in the horizontal and vertical directions at the same time. When the user presses the finger, it will trigger a competition (horizontal direction and vertical direction), once a certain direction "wins" , it will move in that direction until the current drag gesture ends. code show as below:
class _BothDirectionTest extends StatefulWidget {
_BothDirectionTestState createState() => _BothDirectionTestState();
}
class _BothDirectionTestState extends State<_BothDirectionTest> {
double _top = 0.0;
double _left = 0.0;
Widget build(BuildContext context) {
return Stack(
children: <Widget>[
Positioned(
top: _top,
left: _left,
child: GestureDetector(
child: CircleAvatar(child: Text("A")),
//垂直方向拖动事件
onVerticalDragUpdate: (DragUpdateDetails details) {
setState(() {
_top += details.delta.dy;
});
},
onHorizontalDragUpdate: (DragUpdateDetails details) {
setState(() {
_left += details.delta.dx;
});
},
),
)
],
);
}
}
After this example runs, each drag will only move in one direction (horizontal or vertical), and the competition occurs when the finger is pressed for the first time (move), the specific "winning" condition in this example is: When the first move The one with the larger components of the displacement in the horizontal and vertical directions wins.
Multiple gesture conflicts
Since there is only one winner in the gesture competition, when we GestureDetector
monitor multiple gestures through one, conflicts may also occur. Suppose there is one widget
, it can be dragged left and right, now we also want to detect the event of finger pressing and lifting on it, the code is as follows:
class GestureConflictTestRouteState extends State<GestureConflictTestRoute> {
double _left = 0.0;
Widget build(BuildContext context) {
return Stack(
children: <Widget>[
Positioned(
left: _left,
child: GestureDetector(
child: CircleAvatar(child: Text("A")), // 要拖动和点击的widget
onHorizontalDragUpdate: (DragUpdateDetails details) {
setState(() {
_left += details.delta.dx;
});
},
onHorizontalDragEnd: (details){
print("onHorizontalDragEnd");
},
onTapDown: (details){
print("down");
},
onTapUp: (details){
print("up");
},
),
)
],
);
}
}
Now we press and hold the circle "A" to drag and then lift up the finger, the console log is as follows:
I/flutter (17539): down
I/flutter (17539): onHorizontalDragEnd
We found that " up
" is not printed, this is because when dragging, when the finger is just pressed and there is no movement, the drag gesture does not have complete semantics. At this time, the TapDown
gesture wins (win), and " down
" is printed at this time, while the drag When moving, the dragging gesture will win, and when the finger is lifted, there is a conflict onHorizontalDragEnd
with and onTapUp
, but because it is in the semantics of dragging, it onHorizontalDragEnd
wins, so " onHorizontalDragEnd
" will be printed.
If our code logic is strongly dependent on finger pressing and lifting, for example, in a carousel component, we want to pause the carousel when the finger is pressed, and resume the carousel when it is lifted, but due to the The image broadcast component itself may have handled the drag gesture (such as supporting manual sliding switching), and may even support the zoom gesture. At this time, it will not work if we use it externally to onTapDown
monitor onTapUp
. What should we do at this time? In fact, it is very simple, just by Listener
listening to the original pointer event:
Positioned(
top:80.0,
left: _leftB,
child: Listener(
onPointerDown: (details) {
print("down");
},
onPointerUp: (details) {
//会触发
print("up");
},
child: GestureDetector(
child: CircleAvatar(child: Text("B")),
onHorizontalDragUpdate: (DragUpdateDetails details) {
setState(() {
_leftB += details.delta.dx;
});
},
onHorizontalDragEnd: (details) {
print("onHorizontalDragEnd");
},
),
),
)
Resolve Gesture Conflicts
Gestures are the semantic recognition of raw pointers. Gesture conflicts are only at the gesture level, that is to say, there will only be conflicts between multiple in the component tree . If they are not used at all , there will be no so-called conflicts. Because every node can receive the eventGestureDetector
GestureDetector
, just in GestureDetector
order to identify the semantics, it will decide which child nodes should ignore the event and which nodes should take effect.
There are two ways to resolve gesture conflicts:
-
use
Listener
. This is equivalent to jumping out of the set of rules for gesture recognition. -
Custom gesture gesture recognizer (
Recognizer
).
1. Resolve gesture conflicts through Listener
Listener
The reason for resolving gesture conflicts is that the competition is only for semantic gestures , but Listener
for listening to raw pointer events . Raw pointer events are not semantic gestures, so the logic of gesture competition will not be followed at all, so they will not affect each other. Taking the above two Container
nested examples as an example, the adopted Listener
solution is:
Listener( // 将 GestureDetector 换为 Listener 即可
onPointerUp: (x) => print("2"),
child: Container(
width: 200,
height: 200,
color: Colors.red,
alignment: Alignment.center,
child: GestureDetector(
onTap: () => print("1"),
child: Container(
width: 50,
height: 50,
color: Colors.grey,
),
),
),
);
The code is very simple, just GestureDetector
replace with Listener
, you can change both, or just one. It can be seen that Listener
it is very simple to resolve conflicts by directly identifying raw pointer events. Therefore, when encountering gesture conflicts, we should give priority to them Listener
.
2. Resolve gesture conflicts by customizing the Recognizer
The way to customize the gesture recognizer is more troublesome. The principle is that when the winner of the gesture competition is determined, the acceptGesture
method of the winner will be called to indicate "declare success", and then the method of other gesture recognition will be called rejectGesture
to indicate "declare failure" .
In this case, we can customize the gesture recognizer ( Recognizer
), and then rewrite its method: call the method rejectGesture
insideacceptGesture
, which is equivalent to forcing it to become a successful competitor when it fails, so that its callback will also execute. (It is a relatively rogue approach)
Let's first customize tap
the gesture recognizer ( Recognizer
):
class CustomTapGestureRecognizer extends TapGestureRecognizer {
void rejectGesture(int pointer) {
//super.rejectGesture(pointer); // 不,我不要失败,我要成功
super.acceptGesture(pointer); // 宣布成功
}
}
// 创建一个新的GestureDetector,用我们自定义的 CustomTapGestureRecognizer 替换默认的
RawGestureDetector customGestureDetector({
GestureTapCallback? onTap,
GestureTapDownCallback? onTapDown,
Widget? child,
}) {
return RawGestureDetector(
child: child,
gestures: {
CustomTapGestureRecognizer:
GestureRecognizerFactoryWithHandlers<CustomTapGestureRecognizer>(
() => CustomTapGestureRecognizer(),
(detector) {
detector.onTap = onTap;
},
)
},
);
}
We use RawGestureDetector
to customize customGestureDetector
, GestureDetector
and we also use RawGestureDetector
to package various Recognizer
to achieve, we need to customize whichever one Recognizer
, just add whichever one.
Now let's look at modifying the calling code:
customGestureDetector( // 替换 GestureDetector
onTap: () => print("2"),
child: Container(
width: 200,
height: 200,
color: Colors.red,
alignment: Alignment.center,
child: GestureDetector(
onTap: () => print("1"),
child: Container(
width: 50,
height: 50,
color: Colors.grey,
),
),
),
);
This is OK. It should be noted that this example also shows that there can be multiple winners in a gesture processing process.
Gesture source code analysis
Specifically, there are two problems that Flutter needs to solve: one is how to determine the Widget that handles gestures (accurately speaking RenderObject
); the other is to determine which gestures to respond to, the most typical one is the distinction between single click and double click.
The key classes and their relationships related to Flutter gesture processing are shown in the figure:
In Figure 8-7, GestureDetector
it is the entry point for developers to respond to gesture events, and the Widget
corresponding underlying drawing node ( RenderObject
) is RenderPointerListener
, this class indirectly implements HitTestTarget
the interface, that is, this class is a target that can be tested by clicking. By implementing HitTestTarget
methods handleEvent
, RenderPointerListener
will participate in _GestureArena
gesture competitions within the gesture arena ( ).
Specifically, during the creation RenderPointerListener
process, the RawGestureDetectorState
corresponding GestureRecognizer
instance will be created according to the callback parameters provided by the developer, GestureRecognizer
and inherited from GestureArenaMember
, this class is _GestureArena
held, which is a unified abstract representation of gesture competition.
GestureRecognizer
There are many subcategories, see Figure 8-8 for details. Among them, OneSequenceGestureRecognizer
it is the most frequently contacted by developers GestureRecognizer
.
_GestureArena
Responsible for managing each member in a gesture arena ( GestureArenaMember
), GestureArenaManager
responsible for managing all gesture arenas. Therefore, GestureArenaManager
only one instance of the global is required, GestureBinding
held by . GestureBinding
At the same time, it is also the entry point for processing gesture events sent by Engine. It _hitTests
holds the click test result ( ) of a gesture event through the field HitTestResult
. Each click test result is actually HitTestEntry
a list of objects, corresponding to one-to-one, HitTestEntry
and HitTestTarget
the latter is exactly the previous mentioned RenderPointerListener
. GestureDetector
In this way, the closed loop from the UI element ( ) to the gesture competition model ( GestureArenaMember
etc.) is completed .
GestureRecognizer
It is the base class of all gesture processors. As can be seen from Figure 8-7, it is GestureRecognizer
inherited from GestureArenaMember
and will be used as the basic unit of gesture competition. GestureRecognizer
There are many subclasses, and each subclass is responsible for realizing the identification of corresponding events. OneSequenceGestureRecognizer
Represents one-time gestures, such as click ( TapGestureRecognizer
), long press ( LongPressGestureRecognizer
), drag ( DragGestureRecognizer
), etc.; double-click ( DoubleTapGestureRecognizer
) is a very special gesture event, which will be analyzed in detail later; MultiDragGestureRecognizer
it represents more complex gesture events (such as pinch zoom) , this article will not do an in-depth analysis.
Gesture processing is mainly divided into two stages: the first stage is HitTestTarget
the collection of targets ( ); the second stage is the competition of gestures. The following are analyzed in turn.
target collection
The click event is generated by Embedder, converted by Engine into unified data and handed over to Flutter for processing. The processing entry in Framework is as _handlePointerEventImmediately
shown in Listing 8-45.
// 代码清单8-45 flutter/packages/flutter/lib/src/gestures/binding.dart
void _handlePointerEventImmediately(PointerEvent event) {
// GestureBinding
HitTestResult? hitTestResult;
if (event is PointerDownEvent || event is PointerSignalEvent || event is
PointerHoverEvent) {
hitTestResult = HitTestResult(); // 第1类事件,开始形成一个手势的事件类型
hitTest(hitTestResult, event.position); // 单击测试,即收集那些可以响应本次单击的实例
if (event is PointerDownEvent) {
// PointerDown类型的事件,通常是一个手势的开始
_hitTests[event.pointer] = hitTestResult; // 存储单击测试结果,以备后续使用
}
} else if (event is PointerUpEvent || event is PointerCancelEvent) {
// 第2类事件,根据event pointer取得第1类事件获得的Hit TestResult,并将其移除
hitTestResult = _hitTests.remove(event.pointer);
// 接收到手势结束的事件,移除本次结果
} else if (event.down) {
// 第3类事件,其他处于down类型的事件,如滑动、鼠标拖曳等
hitTestResult = _hitTests[event.pointer]; // 取出形成手势时存储的单击测试结果
}
if (hitTestResult != null || event is PointerAddedEvent || event is Pointer RemovedEvent) {
dispatchEvent(event, hitTestResult); // 向可响应手势的集合分发本次事件,见代码清单8-47
}
}
The above logic adopts different strategies for different events. For the first type of event, it will try to collect a list of click test results ( HitTestResult
fields path
), recording which objects currently respond to this click. For the second type of event, it will be directly event.pointer
obtained by taking out the first type of event HitTestResult
and removing it. For the third type of event, it is considered to be an intermediate state of the first two types, and the click test result can be directly taken out and used.
For the case of hitTestResult
not doing so null
, it will try to distribute the event, which will be described in detail later. Here, first analyze hitTest
the logic of the method. Among them, the implementation of GestureBinding、RendererBinding、RenderView
and RenderBox
is particularly critical, as shown in Listing 8-46.
// 代码清单8-46 flutter/packages/flutter/lib/src/gestures/binding.dart
// GestureBinding
void hitTest(HitTestResult result, Offset position) {
result.add(HitTestEntry(this));
}
// RendererBinding
void hitTest(HitTestResult result, Offset position) {
renderView.hitTest(result, position: position); // 触发Render Tree的根节点
super.hitTest(result, position); // 将导致执行GestureBinding的hitTest方法
}
bool hitTest(HitTestResult result, {
required Offset position }) {
// RenderView
if (child != null) // Render Tree的根节点将触发子节点的hitTest方法
child!.hitTest(BoxHitTestResult.wrap(result), position: position);
result.add(HitTestEntry(this)); // 最后将自身加入单击测试结果
return true;
}
bool hitTest(BoxHitTestResult result, {
required Offset position }) {
// RenderBox
if (_size!.contains(position)) {
// 单击位置是否在当前Layout的范围内,这是必要条件
if (hitTestChildren(result, position: position) || // 子节点通过了单击测试
hitTestSelf(position)) {
// 自身通过了单击测试,这是充分条件
result.add(BoxHitTestEntry(this, position)); // 生成一个单击测试入口,加入结果
return true;
}
}
return false;
}
Considering the inheritance relationship, RendererBinding
it hitTest
will be executed first, and its logic is mainly the method renderView
of execution. As the root node of the Render Tree , it will traverse each node to perform a single-click test. The most typical method is to recursively test each child node and Do a click test by itself, and then join the queue one by one. Note that it will always be added to the queue as the last element, which is very critical for subsequent gesture competition.hitTest
renderView
RenderBox
hitTest
GestureBinding
Note that in the above logic, the click position within RenderBox
the Layout
range is not a sufficient condition for adding the click test result, and usually requires its own hitTestSelf
method to return true
, which RenderBox
provides an entry for subclasses to freely decide whether to participate in subsequent gesture competition.
gesture competition
After obtaining all the objects that can respond to the click (stored in HitTestResult
), the method GestureBinding
will be triggered dispatchEvent
to complete the distribution of this event. The logic is shown in Listing 8-47.
// 代码清单8-47 flutter/packages/flutter/lib/src/gestures/binding.dart
// GestureBinding
void dispatchEvent(PointerEvent event, HitTestResult? hitTestResult) {
if (hitTestResult == null) {
// 说明是PointerHoverEvent、PointerAddedEvent
try {
// 或者PointerRemovedEvent,在此统一路由分发,其他情况通过handleEvent方法处理
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) {
...... }
}
}
For the case of hitTestResult
not doing so , the method of each object null
will be called in turn , and the subclass that needs to handle the gesture can participate in the gesture competition by implementing this method, and process the gesture after winning the competition.HitTestTarget
handleEvent
HitTestTarget
Commonly GestureDetector
used internally in daily development RenderPointerListener
, this class implements handleEvent
the method and undertakes the distribution of gestures. In addition GestureBinding
, as the core dispatching class and the last one of gestures HitTestTarget
, this class is also implemented, as shown in the code listing 8-48.
// 代码清单8-48 flutter/packages/flutter/lib/src/rendering/proxy_box.dart
// from RenderPointerListener
void handleEvent(PointerEvent event, HitTestEntry entry) {
assert(debugHandleEvent(event, entry));
if (event is PointerDownEvent)
return onPointerDown?.call(event); // 最终在代码清单8-49中进行调用
if (event is PointerMoveEvent)
return onPointerMove?.call(event);
// SKIP PointerUpEvent、PointerCancelEvent等事件
}
// from GestureBinding
void handleEvent(PointerEvent event, HitTestEntry entry) {
pointerRouter.route(event); // 无条件路由给已注册成员,注册逻辑见代码清单8-54
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); // 解析
}
}
GestureBinding
Contains an important member - gestureArena
, which is responsible for managing all gesture competitions, called the gesture arena . The generation and registration of gesture arena members ( ) RenderPointerListener
will be completed in its own process, and then the common click event will be used as an example for analysis.handleEvent
GestureArenaMember
In the above logic, it is introduced when the object onPointerDown
is created , and its essence is a method, as shown in Listing 8-49.Listener
RawGestureDetectorState
// 代码清单8-49 flutter/packages/flutter/lib/src/widgets/gesture_detector.dart
void _handlePointerDown(PointerDownEvent event) {
// RawGestureDetectorState
for (final GestureRecognizer recognizer in _recognizers!.values)
recognizer.addPointer(event); // 成功将事件从HitTestTarget传递到GestureRecognizer
}
void addPointer(PointerDownEvent event) {
// GestureRecognizer
_pointerToKind[event.pointer] = event.kind;
if (isPointerAllowed(event)) {
// 通常为true
addAllowedPointer(event);
} else {
handleNonAllowedPointer(event);
}
}
void addAllowedPointer(PointerDownEvent event) {
} // 子类实现
GestureDetector
onTap
Various parameters such as , , etc. are provided onDoubleTap
, which will be converted into GestureRecognizer
various subclasses of and added to _recognizers
the field.
A single click ( onTap
) event can be divided into one PointerDownEvent
and one PointerUpEvent
, PointerDownEvent
which will trigger the above logic, and onTap
the corresponding gesture recognition class is TapGestureRecognizer
, which addAllowedPointer
will eventually startTrackingPointer
call _addPointerToArena
the method, as shown in Listing 8-50.
// 代码清单8-50 flutter/packages/flutter/lib/src/gestures/recognizer.dart
GestureArenaEntry _addPointerToArena(int pointer) {
// OneSequenceGestureRecognizer
if (_team != null) // 当前recognizer隶属于某一GestureArenaTeam对象
return _team!.add(pointer, this); // 暂不考虑这种情况
return GestureBinding.instance!.gestureArena.add(pointer, this); // 加入手势竞技场
}
There is only one member method that is finally called by the above logic, GestureBinding
and its specific logic is shown in Listing 8-51.gestureArena
add
gestureArena
// 代码清单8-51 flutter/packages/flutter/lib/src/gestures/arena.dart
GestureArenaEntry add(int pointer, GestureArenaMember member) {
// GestureArenaManager
final _GestureArena state = _arenas.putIfAbsent(pointer, () {
return _GestureArena(); // 产生一个手势竞技场
});
state.add(member); // 加入当前手势竞技场
return GestureArenaEntry._(this, pointer, member);
}
_GestureArena
The instance represents a specific arena. If it does not exist currently, a new one will be created, and then the current will be GestureArenaMember
added. If multiple nested uses are used GestureDetector
, then multiple will be added in sequence GestureRecognizer
.
Regardless of the previous logic, the last logic is called GestureBinding
because handleEvent
it was the last to be added to the clicktestresults ( HitTestResult
) list, as shown in Listing 8-48. If it is PointerDownEvent
an event, the arena will be closed, because the previous addition work HitTestTarget
has been completed GestureArenaMember
; if it is PointerUpEvent
an event, the arena will be cleared, because the gesture has ended at this time (the special case of double-click event will be analyzed later).
This stage needs to solve a key problem - when there are more than one GestureArenaMember
(usually TapGestureRecognizer
), who will respond.
First analyze the closure of the arena, as shown in Listing 8-52.
// 代码清单8-52 flutter/packages/flutter/lib/src/gestures/arena.dart
void close(int pointer) {
// GestureArenaManager,关闭pointer对应的手势竞技场
final _GestureArena? state = _arenas[pointer];
if (state == null) return;
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) {
// 存在eagerWinner,定为胜者
_resolveInFavorOf(pointer, state, state.eagerWinner!);
}
}
void _resolveByDefault(int pointer, _GestureArena state) {
if (!_arenas.containsKey(pointer)) return; // 已被移除
final List<GestureArenaMember> members = state.members;
state.members.first.acceptGesture(pointer); // 直接取第1个成员作为胜者
}
The above logic is mainly the closing of the gesture arena. During the closing phase, an attempt will be made to determine the winner of the gesture arena. Take Case1 in Figure 8-9 as an example, A responds to the click event as the winner in the closing stage of the arena. As for Case2 and Case3, they will be analyzed in detail later.
When the arena is closed, PointerUp
the gesture arena will be cleaned up when the event arrives, as shown in Listing 8-53.
// 代码清单8-53 flutter/packages/flutter/lib/src/gestures/arena.dart
void sweep(int pointer) {
// GestureArenaManager,清理pointer对应的手势竞技场
final _GestureArena? state = _arenas[pointer];
if (state == null) return; // 已移除,避免重复处理
if (state.isHeld) {
// 被挂起,直接返回
state.hasPendingSweep = true;
return;
}
_arenas.remove(pointer); // 移除pointer对应的手势竞技场
if (state.members.isNotEmpty) {
state.members.first.acceptGesture(pointer); // 取第1个成员作为胜者,与_resolve
// ByDefault一致
for (int i = 1; i < state.members.length; i++)
state.members[i].rejectGesture(pointer); // 触发败者的rejectGesture方法
}
}
The above logic is mainly to clean up the gesture arena, and return directly if it is suspended. For the case of not being suspended, we can see from the analysis of code list 8-46 that the first element, which is the RenderBox
innermost element of , will be taken, which is in line with our development experience.
In the case of only one member, the Gesture Arena will close
decide the winner directly in the stage, while if there are multiple members, the Gesture Arena will sweep
take the first member in the stage (if it is not suspended) as the winner.
double click event
From the analysis so far, it can be found that the above logic can be completely handled for ordinary single-click events, but what about double-click events? According to the above logic, before the second click starts, the first click is treated as a single click event. The mystery of the solution is in isHeld
the field, and the detailed analysis will begin below.
From Code Listing 8-49, we can see that the double-click event will trigger DoubleTapGestureRecognizer
the addAllowedPointer
method, which will call _trackTap
the method, as shown in Code Listing 8-54.
// 代码清单8-54 flutter/packages/flutter/lib/src/gestures/multitap.dart
// DoubleTapGestureRecognizer
void addAllowedPointer(PointerDownEvent event) {
if (_firstTap != null) {
// 已经记录了一次单击,即当前为第2次单击,接下来进入以下逻辑
if (!_firstTap!.isWithinGlobalTolerance(event, kDoubleTapSlop)) {
return; // 超时,不认为是双击
} else if (!_firstTap!.hasElapsedMinTime() || !_firstTap!.hasSameButton(event)) {
_reset(); // 在短时间(kDoubleTapMinTime)内单击相同位置认为是单击,重置
return _trackTap(event); // 重新追踪
} else if (onDoubleTapDown != null) {
// 认为是双击,触发对应回调onDoubleTapDown
final TapDownDetails details = TapDownDetails( ...... ); // 注意区别于onDoubleTap
invokeCallback<void>('onDoubleTapDown', () => onDoubleTapDown!(details));
}
} // if
_trackTap(event); // 对于首次单击,直接开始追踪,主要逻辑是挂起竞技场
}
void _trackTap(PointerDownEvent event) {
// DoubleTapGestureRecognizer
_stopDoubleTapTimer(); // 见代码清单8-57
final _TapTracker tracker = _TapTracker( // 开始追踪单击事件
event: event, // 触发本次单击事件的PointerDown事件
// 加入竞技场,封装一个GestureArenaEntry对象并返回
entry: GestureBinding.instance!.gestureArena.add(event.pointer, this),
doubleTapMinTime: kDoubleTapMinTime, // 双击的最小时间间隔,默认为40ms
);
_trackers[event.pointer] = tracker;
tracker.startTrackingPointer(_handleEvent, event.transform); // 开始追踪,见代码清单8-56
}
The above logic adds the current gesture to the arena and adds a route to the current event _handleEvent
. As can be seen from the code list 8-48, GestureBinding
before the closing and cleaning logic of the gesture arena, it will be pointerRouter
triggered by routing the current event _handleEvent
. The specific logic is shown in the code list 8-55.
The above logic is critical to implementing double clicks: logic that sweep
fires prior to the stage prior to the end of the first click event to suspend the arena and avoid immediately deciding the winner._handleEvent
// 代码清单8-55 flutter/packages/flutter/lib/src/gestures/multitap.dart
void _handleEvent(PointerEvent event) {
// DoubleTapGestureRecognizer
final _TapTracker tracker = _trackers[event.pointer]!;
// 找到代码清单8-54中的_TapTracker对象
if (event is PointerUpEvent) {
if (_firstTap == null) // 首次单击抬起时触发
_registerFirstTap(tracker); // 见代码清单8-56
else // 第2次单击抬起时触发
_registerSecondTap(tracker); // 见代码清单8-56
} else if (event is PointerMoveEvent) {
// 移除PointerMove事件
if (!tracker.isWithinGlobalTolerance(event, kDoubleTapTouchSlop))
_reject(tracker); // 如果移动一段距离则不再认为是单击事件,这符合用户体验
} else if (event is PointerCancelEvent) {
_reject(tracker);
}
}
It can be seen from the above code that because the first click is _firstTap
, the logic of sum will be triggered null
respectively when the first click ends and the second click ends , as shown in Code Listing 8-56._registerFirstTap
_registerSecondTap
// 代码清单8-56 flutter/packages/flutter/lib/src/gestures/multitap.dart
void _registerFirstTap(_TapTracker tracker) {
// 首次点击,触发DoubleTapGestureRecognizer
_startDoubleTapTimer(); // 启动一个定时器,在一定时间后重置,见代码清单8-57
GestureBinding.instance!.gestureArena.hold(tracker.pointer); // 挂起当前竞技场
_freezeTracker(tracker); // 目标任务已触发,注销当前路由
_trackers.remove(tracker.pointer); // 移除tracker
_clearTrackers(); // 触发_trackers内其他tracker的_reject方法
_firstTap = tracker; // 标记首次单击事件产生,作用于代码清单8-54
}
void _registerSecondTap(_TapTracker tracker) {
// 第2次单击
_firstTap!.entry.resolve(GestureDisposition.accepted); // 第1次单击的tracker,
// 见代码清单8-58
tracker.entry.resolve(GestureDisposition.accepted); // 第2次单击的tracker
_freezeTracker(tracker); // 清理掉第2次单击所注册的路由
_trackers.remove(tracker.pointer); // 移除
_checkUp(tracker.initialButtons); // 触发双击事件对应的回调
_reset(); // 重置,将释放之前挂起的竞技场,见代码清单8-57
}
void _freezeTracker(_TapTracker tracker) {
tracker.stopTrackingPointer(_handleEvent);
}
void _clearTrackers() {
_trackers.values.toList().forEach(_reject);
}
void _checkUp(int buttons) {
// 和前面介绍的onDoubleTapDown不同,此时胜者已经决出
if (onDoubleTap != null) invokeCallback<void>('onDoubleTap', onDoubleTap!);
}
void startTrackingPointer(PointerRoute route, Matrix4? transform) {
// _TapTracker
if (!_isTrackingPointer) {
// 避免重复注册
_isTrackingPointer = true;
GestureBinding.instance!.pointerRouter.addRoute(pointer, route, transform); // 注册路由
} // 由代码清单8-48中GestureBinding的handleEvent方法可知 第2次单击将首先触发route参数,即_handleEvent方法
void stopTrackingPointer(PointerRoute route) {
// _TapTracker
if (_isTrackingPointer) {
_isTrackingPointer = false;
GestureBinding.instance!.pointerRouter.removeRoute(pointer, route); // 注销路由
}
}
The above logic starts a timer when the first click event occurs , which is used to trigger the reset ( _reset
method) logic after a certain amount of time . This is because if two consecutive clicks exceed a certain time interval, it is not counted as a double click, as shown in Listing 8-57.
_registerFirstTap
There are some other logic in , mainly to suspend the arena where the current gesture is located, because it has not yet been determined whether it is a double-click event, and also to _freezeTracker
complete the logout of the route, because the responsibility of the current route (suspend the arena) has been completed . Generally speaking, the logic of routing logout is in the logic triggered by routing registration, which ensures that registration and logout occur in pairs.
_registerSecondTap
It will be triggered by routing in the second click and trigger the arena's winning logic. This part will be analyzed in detail later.
First analyze the logic of starting the timer, as shown in Listing 8-57.
// 代码清单8-57 flutter/packages/flutter/lib/src/gestures/multitap.dart
void _startDoubleTapTimer() {
// DoubleTapGestureRecognizer
_doubleTapTimer ??= Timer(kDoubleTapTimeout, _reset); // 双击最大间隔时间,默认为300ms 超过这一时间后将触发_reset
}
void _reset() {
_stopDoubleTapTimer(); // 重置定时器
if (_firstTap != null) {
if (_trackers.isNotEmpty) _checkCancel();
final _TapTracker tracker = _firstTap!;
_firstTap = null;
_reject(tracker);
GestureBinding.instance!.gestureArena.release(tracker.pointer); // 释放首次单击所在的竞技场
}
_clearTrackers();
}
void _stopDoubleTapTimer() {
if (_doubleTapTimer != null) {
_doubleTapTimer!.cancel();
_doubleTapTimer = null;
}
}
The above logic is very clear, mainly to _reset
complete the cleaning and reset of relevant members through methods after timeout. Next, analyze the winning logic of the Gesture Arena, which is _registerSecondTap
triggered by , as shown in Listing 8-58.
// 代码清单8-58 flutter/packages/flutter/lib/src/gestures/arena.dart
void resolve(GestureDisposition disposition) {
// GestureArenaEntry
_arena._resolve(_pointer, _member, disposition);
}
void _resolve(int pointer, GestureArenaMember member, GestureDisposition
disposition) {
final _GestureArena? state = _arenas[pointer];
if (state == null) return; // 目标竞技场已被移除,说明已经完成决胜
if (disposition == GestureDisposition.rejected) {
state.members.remove(member);
member.rejectGesture(pointer);
if (!state.isOpen) _tryToResolveArena(pointer, state);
} else {
if (state.isOpen) {
// 竞技场还处在开放状态,没有关闭,则设置eagerWinner
state.eagerWinner ??= member; // 竞技场关闭时处理,见代码清单8-52
} else {
// 直接决出胜者
_resolveInFavorOf(pointer, state, member); // 见代码清单8-59
}
} // if
}
Generally speaking, the arena has been closed but not cleaned up at this point, so _resolveInFavorOf
the logic will enter, as shown in Listing 8-59.
// 代码清单8-59 flutter/packages/flutter/lib/src/gestures/arena.dart
void _resolveInFavorOf(int pointer, _GestureArena state, GestureArenaMember
member) {
_arenas.remove(pointer); // InFavorOf,即支持传入的参数member成为竞技场的胜者
for (final GestureArenaMember rejectedMember in state.members) {
if (rejectedMember != member) rejectedMember.rejectGesture(pointer);
}
member.acceptGesture(pointer); // 触发胜者处理响应手势的逻辑 acceptGesture方法由具体子类实现
}
The above logic is mainly to trigger acceptGesture
the method of the winner in the arena. For DoubleTapGestureRecognizer
, the acceptGesture
method is empty, because the logic of responding to the double-click event has been _checkUp
triggered by the method of code list 8-56.
Due to the particularity of its own logic, the double-click event is relatively obscure to analyze from the code. The following takes Case2 in Figure 8-9 as an example for analysis. For area B in Figure 8-9, when the first click event occurs, instances of , , and Tap
will DoubleTap
join the arena, but since the arena is suspended in Listing 8-56, when the finger is lifted and the click ends The arena will not be cleared. At this time , the event can be confirmed as failed. When the second click occurs, the arena will be released, and at the same time, it will judge whether it meets the conditions (within the specified time), and if so, the corresponding callback will be triggered; if the first click is not satisfied, it will be executed with form trigger.Darg
GestureRecognizer
Drag
DoubleTap
Tap
As can be seen, the core of the double-click event is the brief hang of the arena . So far, the double-click event analysis is complete.
Drag event and list sliding
Drag
Drag ( ) events are more common than double-click events . The first problem to be solved is how the drag event wins the competition with the click event, which can be solved by analyzing the TapGestureRecognizer
method handleEvent
, which is in its parent class PrimaryPointerGestureRecognizer
, as shown in Listing 8-60.
// 代码清单8-60 flutter/packages/flutter/lib/src/gestures/recognizer.dart
// PrimaryPointerGestureRecognizer
void handleEvent(PointerEvent event) {
if (state == GestureRecognizerState.possible && event.pointer == primaryPointer) {
final bool isPreAcceptSlopPastTolerance =
!_gestureAccepted && // 当前Recognizer尚在竞争手势
preAcceptSlopTolerance != null && // 判断为非单击的阈值,默认为18像素,下同
_getGlobalDistance(event) > preAcceptSlopTolerance!;
final bool isPostAcceptSlopPastTolerance =
_gestureAccepted && // 当前Recognizer已经成为胜者,例如只有一个竞技场成员时
postAcceptSlopTolerance != null && // 此时如果发现单击变滑动,则仍要拒绝
_getGlobalDistance(event) > postAcceptSlopTolerance!;
if (event is PointerMoveEvent &&
(isPreAcceptSlopPastTolerance || isPostAcceptSlopPastTolerance)) {
resolve(GestureDisposition.rejected); // 如果一次单击中滑动距离超过阈值则拒绝
stopTrackingPointer(primaryPointer!);
} else {
handlePrimaryPointer(event);
}
} // if
stopTrackingIfPointerNoLongerDown(event);
}
It can be seen from the above logic that if a sliding ( ) event occurs Move
and moves a certain distance, it Tap
will refuse to process it. So, if you don't lift up immediately after clicking, but slide a certain distance, the click event will not happen (even if you don't set the logic to respond to the drag event).
The above logic has ensured that the click event Recognizer
will not compete with the drag event, so how does the drag event recognize and respond to the real drag gesture? Next, start the analysis, as shown in Listing 8-61.
// 代码清单8-61 flutter/packages/flutter/lib/src/gestures/recognizer.dart
// DragGestureRecognizer
void handleEvent(PointerEvent event) {
// SKIP 与速度相关的计算
if (event is PointerMoveEvent) {
if (event.buttons != _initialButtons) {
_giveUpPointer(event.pointer);
return;
}
if (_state == _DragState.accepted) {
// 分支1:已经胜出
_checkUpdate( // 直接更新拖曳信息
sourceTimeStamp: event.timeStamp,
delta: _getDeltaForDetails(event.localDelta),
primaryDelta: _getPrimaryValueFromOffset(event.localDelta),
globalPosition: event.position,
localPosition: event.localPosition,
);
} else {
// 分支2:未胜出,正常情况下会先触发本分支
_pendingDragOffset += OffsetPair(local: event.localDelta, global: event.
delta);
_lastPendingEventTimestamp = event.timeStamp;
_lastTransform = event.transform;
final Offset movedLocally = _getDeltaForDetails(event.localDelta);
final Matrix4? localToGlobalTransform =
event.transform == null ? null : Matrix4.tryInvert(event.transform!);
_globalDistanceMoved += PointerEvent.transformDeltaViaPositions(
transform: localToGlobalTransform,
untransformedDelta: movedLocally,
untransformedEndPosition: event.localPosition,
).distance * (_getPrimaryValueFromOffset(movedLocally) ?? 1).sign; // 累计移动距离
if (_hasSufficientGlobalDistanceToAccept(event.kind)) // 达到阈值
resolve(GestureDisposition.accepted); // 接受当前GestureRecognizer
}
}
// SKIP PointerUpEvent / PointerCancelEvent处理:调用_giveUpPointer
}
void _checkUpdate({
...... }) {
assert(_initialButtons == kPrimaryButton);
final DragUpdateDetails details = DragUpdateDetails( ...... );
if (onUpdate != null) // 赋值,见代码清单8-62
invokeCallback<void>('onUpdate', () => onUpdate!(details)); // 见代码清单8-63
}
globalDistanceMoved
In the above logic, when the finger is pressed and swipe for the first time, the drag gesture does not win, so it will enter branch 2 , and then judge whether it exceeds a certain threshold through the calculated size, that is, the distance that has been slid, and if it exceeds, then compete in the gesture won.
After the winner is determined, a callback _checkUpdate
is triggered onUpdate
, which PointerMove
encapsulates the details of the event into an DragUpdateDetails
object and calls the function.
The scrolling of the list is based on this mechanism, and its key classes and their relationships are shown in Figure 8-10.
In Figure 8-10, ScrollableState
it is an ancestor node Viewport
in the Widget Tree . As its name suggests, it is also the key to provide the ability to slide the list. Specifically, an instance is held ScrollableState
by the field , and it will generate a corresponding subclass according to the sliding direction , generally speaking ._gestureRecognizers
GestureRecognizerFactory
GestureRecognizer
DragGestureRecognizer
RenderViewportBase
Determine the current scroll distance by offset
the field, and then layout each child node, then the core problem becomes ScrollableState
how to GestureRecognizer
convert the provided dragging information into the distance of the list in the sliding direction ( ViewportOffset
) and trigger the layout update.
To answer this question, you need to understand a transitive relationship in Figure 8-10, that is, ScrollableState
→ ScrollDragController
( Drag
subclass of) → ScrollPositionWithSingleContext
( ScrollActivityDelegate
implementation class of) , ScrollPositionWithSingleContext
which is ViewportOffset
a subclass of . In this way, the scrolling information can be converted into RenderViewportBase
an offset value. Additionally, ViewportOffset
inherited from ChangeNotifier
, it can RenderViewportBase
notify itself that the scroll distance has changed. In this way, the gesture event drives the sliding update of the list.
In Figure 8-10, the subclasses of and are responsible for implementing various sliding boundary effects, such as Clamping on the Android platform and Bouncing on the iOS platform ScrollPhysics
. And by holding references to each other through fields , they are also a path for the interaction between the producer of the drag event and the ultimate consumer, but not as dedicated as the connected path. Represents the sliding context, that is, the class that actually produces the sliding effect.ScrollActivity
ScrollableState
ScrollPosition
_position
context
ScrollDragController
ScrollContext
The list is nested in ScrollableState
, and this object will generate one RawGestureDetector
, and its _gestureRecognizers
members will generate corresponding gesture processors according to the current axis direction, as shown in the code list 8-62.
// 代码清单8-62 flutter/packages/flutter/lib/src/widgets/scrollable.dart
void setCanDrag(bool canDrag) {
// ScrollableState
if (canDrag == _lastCanDrag && (!canDrag || widget.axis == _lastAxisDirection))
return;
if (!canDrag) {
_gestureRecognizers = const <Type, GestureRecognizerFactory>{
};
_handleDragCancel();
} else {
switch (widget.axis) {
case Axis.vertical:
_gestureRecognizers = <Type, GestureRecognizerFactory>{
VerticalDragGestureRecognizer:
GestureRecognizerFactoryWithHandlers<VerticalDragGestureRecognizer>(
() => VerticalDragGestureRecognizer(),
(VerticalDragGestureRecognizer instance) {
instance
..onDown = _handleDragDown
..onStart = _handleDragStart
..onUpdate = _handleDragUpdate
// SKIP 其他回调注册
},
),
};
break;
case Axis.horizontal: // SKIP,HorizontalDragGestureRecognizer的注册
} // switch
} // if
_lastCanDrag = canDrag;
_lastAxisDirection = widget.axis;
if (_gestureDetectorKey.currentState != null)
_gestureDetectorKey.currentState!.replaceGestureRecognizers(_gestureRecognizers);
}
The above logic is applyContentDimensions
triggered by the method. It can be seen from the code list 8-61 that when a new PointerMove
event arrives, _handleDragUpdate
it will respond to the movement event, and this method will call the method Drag
of the object update
. The specific logic is in ScrollDragController
the class, as shown in the code list 8-63.
// 代码清单8-63 flutter/packages/flutter/lib/src/widgets/scroll_activity.dart
// ScrollDragController
void update(DragUpdateDetails details) {
// 见代码清单8-61的_checkUpdate方法
_lastDetails = details;
double offset = details.primaryDelta!; // 本次在主轴方向滑动的距离
if (offset != 0.0) {
_lastNonStationaryTimestamp = details.sourceTimeStamp;
}
_maybeLoseMomentum(offset, details.sourceTimeStamp);
offset = _adjustForScrollStartThreshold(offset, details.sourceTimeStamp);
if (offset == 0.0) {
return; }
if (_reversed) offset = -offset; // 逆向滑动
delegate.applyUserOffset(offset); // 见代码清单8-64
}
delegate
The specific implementation of is ScrollPositionWithSingleContext
, and its applyUserOffset
logic is shown in the code list 8-64.
// 代码清单8-64 flutter/packages/flutter/lib/src/widgets/scroll_position_with_single_context.dart
// ScrollPositionWithSingleContext
void applyUserOffset(double delta) {
// 更新滚动方向
updateUserScrollDirection(delta > 0.0 ? ScrollDirection.forward : ScrollDirection.
reverse);
setPixels(pixels - physics.applyPhysicsToUserOffset(this, delta)); // 父类ScrollPosition实现
} // pixels是ViewportOffset的成员,表示当前拖曳事件在主轴方向导致的总偏移,即滑动距离
double setPixels(double newPixels) {
//(ScrollPosition)更新滑动距离
if (newPixels != pixels) {
final double overscroll = applyBoundaryConditions(newPixels); // 计算滑动边缘距离
final double oldPixels = pixels;
_pixels = newPixels - overscroll;
// 注意,这里的_pixels不是直接根据newPixels进行更新
if (_pixels != oldPixels) {
notifyListeners(); // 通知列表更新,触发markNeedsLayout方法,见代码清单7-11
didUpdateScrollPositionBy(pixels - oldPixels);
}
if (overscroll != 0.0) {
didOverscrollBy(overscroll);
return overscroll;
}
}
return 0.0;
}
// ScrollPhysics的子类通过实现本方法达到不同的边缘滑动效果
double applyBoundaryConditions(double value) {
final double result = physics.applyBoundaryConditions(this, value);
return result;
}
The above logic will update ViewportOffset
the _pixels
field, and RenderViewportBase
when assigning its own offset
field, it has been markNeedsLayout
added as ViewportOffset
a listener. It can be seen from the above logic that the method notifyListeners
will be triggered performLayout
to drive the update of the list layout.
Next, take Case3 in Figure 8-9 as an example for analysis. When the finger is pressed, the click event and Drag
the drag event will join the arena, and if the movement exceeds a certain threshold during this time, the drag event will win (see Listing 8-61), and the click event will be rejected (see Listing 8 -60).
So far, the analysis of drag event and list sliding mechanism has been completed.
event bus
In App, we often need a broadcast mechanism for cross-page event notification. For example, in an App that requires login, the page will pay attention to user login or logout events to perform some status updates. At this time, an event bus will be very useful. The event bus usually implements the subscriber mode. The subscriber mode includes two roles: publisher and subscriber. Events can be triggered and listened to through the event bus.
Below we implement a simple global event bus, we use the singleton mode, the code is as follows:
// 订阅者回调签名
typedef void EventCallback(arg);
class EventBus {
//私有构造函数
EventBus._internal();
//保存单例
static EventBus _singleton = EventBus._internal();
//工厂构造函数
factory EventBus()=> _singleton;
//保存事件订阅者队列,key:事件名(id),value: 对应事件的订阅者队列
final _emap = Map<Object, List<EventCallback>?>();
//添加订阅者
void on(eventName, EventCallback f) {
_emap[eventName] ??= <EventCallback>[];
_emap[eventName]!.add(f);
}
//移除订阅者
void off(eventName, [EventCallback? f]) {
var list = _emap[eventName];
if (eventName == null || list == null) return;
if (f == null) {
_emap[eventName] = null;
} else {
list.remove(f);
}
}
//触发事件,事件触发后该事件所有订阅者会被调用
void emit(eventName, [arg]) {
var list = _emap[eventName];
if (list == null) return;
int len = list.length - 1;
//反向遍历,防止订阅者在回调中移除自身带来的下标错位
for (var i = len; i > -1; --i) {
list[i](arg);
}
}
}
//定义一个top-level(全局)变量,页面引入该文件后可以直接使用bus
var bus = EventBus();
Example usage:
//页面A中
...
//监听登录事件
bus.on("login", (arg) {
// do something
});
//登录页B中
...
//登录成功后触发登录事件,页面A中订阅者会被调用
bus.emit("login", userInfo);
Note: The standard way to implement the singleton pattern in Dart is to use static
variables + factory constructors , so that you can EventBus()
always return the same instance. You should understand and master this method.
The event bus is usually used for state sharing between components, but there are also some special packages for state sharing between components, such as redux, mobx, and those introduced earlier Provider
. For some simple applications, the event bus is sufficient to meet business needs. If you decide to use the state management package, you must think clearly whether it is really necessary for your app to prevent "simplification into complexity" and over-design.
Of course, you can choose to use the popular library event_bus on the pub.dev community as the event bus in actual production projects .
Notification Notification
Notification (Notification) is an important mechanism in Flutter. In the widget tree, each node can distribute notifications. The notification will be passed up the current node , and all parent nodes can NotificationListener
listen to the notification. In Flutter, this mechanism of passing notifications from child to parent is called Notification Bubbling. Notification bubbling and user touch event bubbling are similar, but there is one difference: notification bubbling can be aborted, but user touch event cannot .
Note: The principle of notification bubbling is similar to that of browser event bubbling in web development. Events are transmitted layer by layer from the source. We can listen to notifications/events at any position on the upper node, or terminate the bubbling process. After bubbling is terminated, the notification will no longer be passed upwards.
Listen for notifications
Notifications are used in many places in Flutter, such as Scrollable
the component, which distributes scroll notifications ( ScrollNotification
) when sliding, and determines the position of the scroll bar Scrollbar
by monitoring .ScrollNotification
Here's an example of listening to scroll notifications for a scrollable component:
NotificationListener(
onNotification: (notification){
switch (notification.runtimeType){
case ScrollStartNotification: print("开始滚动"); break;
case ScrollUpdateNotification: print("正在滚动"); break;
case ScrollEndNotification: print("滚动停止"); break;
case OverscrollNotification: print("滚动到边界"); break;
}
},
child: ListView.builder(
itemCount: 100,
itemBuilder: (context, index) {
return ListTile(title: Text("$index"),);
}
),
);
The scrolling notifications in the above example such as ScrollStartNotification
, , ScrollUpdateNotification
etc. are all inherited from the class ScrollNotification
. Different types of notification subclasses will contain different information, such as ScrollUpdateNotification
an scrollDelta
attribute that records the displacement of the movement. For other notification attributes, you can check the SDK documentation yourself.
In the above example, we NotificationListener
listen to the child ListView
's scrolling notification through NotificationListener
the definition as follows:
class NotificationListener<T extends Notification> extends StatelessWidget {
const NotificationListener({
Key key,
required this.child,
this.onNotification,
}) : super(key: key);
...//省略无关代码
}
We can see that:
-
NotificationListener
Inherits from theStatelessWidget
class, so it can be nested directly intoWidget
the tree. -
NotificationListener
A template parameter can be specified, and the template parameter type must be inherited fromNotification
; when a template parameter is explicitly specified,NotificationListener
only notifications of that parameter type will be received. For example, if we change the above example code to:
// 指定监听通知的类型为滚动结束通知(ScrollEndNotification)
NotificationListener<ScrollEndNotification>(
onNotification: (notification){
// 只会在滚动结束时才会触发此回调
print(notification);
},
child: ListView.builder(
itemCount: 100,
itemBuilder: (context, index) {
return ListTile(title: Text("$index"),);
}
),
);
After the above code is run, it will only print out the notification information on the console when the scrolling ends.
onNotification
The callback is a notification processing callback, and its function signature is as follows:
typedef NotificationListenerCallback<T extends Notification> = bool Function(T notification);
Its return value type is a Boolean value. When the return value is true
, stop bubbling, and its parent Widget
will no longer receive the notification; when the return value is , false
continue to bubble up notifications.
In the implementation of Flutter's UI framework, in addition to the scrollable components that will be sent during the scrolling process ScrollNotification
, there are some other notifications, such as SizeChangedLayoutNotification
, KeepAliveNotification
, LayoutChangedNotification
etc. It is through this notification mechanism that Flutter enables the parent element to be displayed at some specific time. to do something.
custom notification
In addition to Flutter's internal notifications, we can also customize notifications. Let's see how to implement custom notifications:
- Define a notification class to inherit from
Notification
the class;
class MyNotification extends Notification {
MyNotification(this.msg);
final String msg;
}
-
Distribution notice.
Notification
There is adispatch(context)
method, which is used to distribute notifications. As we said, itcontext
is actuallyElement
an interface for operations, whichElement
corresponds to nodes on the tree, and notifications will bubble up from thecontext
corresponding nodes.Element
Let's look at a complete example:
class NotificationRoute extends StatefulWidget {
NotificationRouteState createState() {
return NotificationRouteState();
}
}
class NotificationRouteState extends State<NotificationRoute> {
String _msg="";
Widget build(BuildContext context) {
//监听通知
return NotificationListener<MyNotification>(
onNotification: (notification) {
setState(() {
_msg += notification.msg+" ";
});
return true;
},
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: <Widget>[
// ElevatedButton(
// onPressed: () => MyNotification("Hi").dispatch(context),
// child: Text("Send Notification"),
// ),
Builder(
builder: (context) {
return ElevatedButton(
// 按钮点击时分发通知
onPressed: () => MyNotification("Hi").dispatch(context),
child: Text("Send Notification"),
);
},
),
Text(_msg)
],
),
),
);
}
}
class MyNotification extends Notification {
MyNotification(this.msg);
final String msg;
}
In the above code, every time we click the button MyNotification
, a type of notification will be distributed. We Widget
listen to the notification on the root, and after receiving the notification, we will Text
display the notification on the screen.
Note: The commented part of the code does not work properly, because this
context
is the rootContext
,NotificationListener
but the subtree of the listener, so we useBuilder
to buildElevatedButton
to get the button positioncontext
.
running result:
Prevent notifications from bubbling
Let's change the above example to:
class NotificationRouteState extends State<NotificationRoute> {
String _msg="";
Widget build(BuildContext context) {
return NotificationListener<MyNotification>( // 监听通知
onNotification: (notification){
print(notification.msg); // 打印通知
return false;
},
child: NotificationListener<MyNotification>(
onNotification: (notification) {
setState(() {
_msg += notification.msg + " ";
});
return false; // 返回false表示不阻止冒泡,返回true会阻止冒泡
},
child: ...//省略重复代码
),
);
}
}
The two in the above list NotificationListener
are nested, and the child NotificationListener
's onNotification
callback returns false
, indicating that bubbling is not prevented, so the parent NotificationListener
will still be notified, so the console will print out the notification information; if the return value of the child NotificationListener
's callback is changed to , then The parent will no longer print the notification, because the child has terminated the notification bubbling.onNotification
true
NotificationListener
NotificationListener
Bubble principle
We introduced the phenomenon and use of notification bubbling above, now we go deeper and introduce how to realize notification bubbling in the Flutter framework. In order to clarify this problem, we must look at the source code. We start from the source of the notification distribution, and then follow the vine. Since the notification is sent through Notification
the dispatch(context)
method, let's take a look at dispatch(context)
what is done in the method. The following is the relevant source code:
void dispatch(BuildContext target) {
target?.visitAncestorElements(visitAncestor);
}
dispatch(context)
context
The current visitAncestorElements
method is called in , which traverses the parent elements from the current upwardsElement
; there is a traversal callback parameter, which will be executed for the parent elements traversed during the traversal process. The termination condition of the traversal is: the root has been traversed or a certain traversal callback returns .visitAncestorElements
Element
false
visitAncestorElements
The traversal callback passed to the method in the source code is visitAncestor
a method, let's look at visitAncestor
the implementation of the method:
// 遍历回调,会对每一个父级Element执行此回调
bool visitAncestor(Element element) {
// 判断当前element对应的Widget是否是NotificationListener。
// 由于NotificationListener是继承自StatelessWidget, 故先判断是否是StatelessElement
if (element is StatelessElement) {
// 是StatelessElement,则获取element对应的Widget,判断 是否是NotificationListener 。
final StatelessWidget widget = element.widget;
if (widget is NotificationListener<Notification>) {
// 是NotificationListener,则调用该NotificationListener的_dispatch方法
if (widget._dispatch(this, element))
return false;
}
}
return true;
}
visitAncestor
Widget
It will judge whether each traversed parent is NotificationListener
, if not, return to true
continue traversing upwards, if yes, call NotificationListener
the _dispatch
method, let's look at _dispatch
the source code of the method:
bool _dispatch(Notification notification, Element element) {
// 如果通知监听器不为空,并且当前通知类型是该NotificationListener
// 监听的通知类型,则调用当前NotificationListener的onNotification
if (onNotification != null && notification is T) {
final bool result = onNotification(notification);
// 返回值决定是否继续向上遍历
return result == true;
}
return false;
}
NotificationListener
The callback we can see onNotification
is finally _dispatch
executed in the method, and then it will determine whether to continue bubbling up according to the return value . The implementation of the above source code is actually not complicated. By reading these source codes, some additional points can be noticed:
Context
There are alsoElement
methods for traversing the tree.- We can
Element.widget
getelement
the corresponding relationship of nodeswidget
; we have repeatedly talked about the corresponding relationship ofWidget
and , and we can use these source codes to deepen our understanding.Element
reference: