58.1 Flutter Event Handling Process
The Flutter event handling process primarily consists of two steps. To focus on the core flow, we will use user touch events as an example:
Hit Testing: When a finger is pressed down, a
PointerDownEvent
is triggered. The current rendering (render object) tree is traversed in a depth-first manner, performing a "hit test" on each render object. If the hit test passes, the render object is added to aHitTestResult
list.Event Dispatching: After hit testing, the
HitTestResult
list is traversed, and the event handling method (handleEvent
) of each render object is called to process thePointerDownEvent
. This process is called "event dispatching." Subsequently, when the finger moves, aPointerMoveEvent
is dispatched.Event Cleanup: When the finger lifts (producing a
PointerUpEvent
) or the event is canceled (PointerCancelEvent
), the corresponding event is first dispatched, and then theHitTestResult
list is cleared.
Important Notes:
Hit testing occurs when the
PointerDownEvent
is triggered. A complete event flow is down > move > up (cancel).If both parent and child components listen for the same event, the child component will respond first. This is because the hit testing process traverses in a depth-first manner, allowing child render objects to be added to the
HitTestResult
list before parent render objects. During event dispatching, the list is traversed from front to back, so child components'handleEvent
methods are called before those of parent components.
Now, let's look at some code regarding the entire event handling process:
// When a new event is triggered, Flutter calls this method void _handlePointerEventImmediately(PointerEvent event) { HitTestResult? hitTestResult; if (event is PointerDownEvent) { hitTestResult = HitTestResult(); // Initiate hit testing hitTest(hitTestResult, event.position); if (event is PointerDownEvent) { _hitTests[event.pointer] = hitTestResult; } } else if (event is PointerUpEvent || event is PointerCancelEvent) { // Get the hit test result and then remove it hitTestResult = _hitTests.remove(event.pointer); } else if (event.down) { // PointerMoveEvent // Directly get the hit test result hitTestResult = _hitTests[event.pointer]; } // Event dispatching if (hitTestResult != null) { dispatchEvent(event, hitTestResult); } }
The code above is just the core part; the complete code is located in the GestureBinding
implementation. Now, let's explain some details about hit testing and event dispatching.
58.2 Detailed Hit Testing
1. Starting Point of Hit Testing
Whether an object can respond to an event depends on whether it is added to the HitTestResult
list during the hit testing process. If it is not added, subsequent event dispatching will not be sent to it. Let's examine the hit testing process: when a user event occurs, Flutter calls hitTest()
starting from the root node (RenderView
).
@override void hitTest(HitTestResult result, Offset position) { // Start hit testing from the root node renderView.hitTest(result, position: position); // Calls the hitTest() method in GestureBinding, which will be introduced in the next section. super.hitTest(result, position); }
The above code is located in RenderBinding
. The core code is only two lines. The overall hit testing consists of two steps:
Step 1:
renderView
is theRenderObject
corresponding toRenderView
. ThehitTest
method of theRenderObject
primarily functions to recursively traverse each node in the child tree (render tree) in a depth-first manner and perform hit testing on them. This process is known as "render tree hit testing."
Note: For convenience, "render tree hit testing" can also be referred to as component tree or node tree hit testing. However, it is important to understand that the logic of hit testing resides in
RenderObject
, not inWidget
orElement
.
Step 2: After completing the render tree hit test, the
hitTest
method ofGestureBinding
is called, which mainly handles gestures, as we will introduce later.
2. Render Tree Hit Testing Process
The hit testing process for the render tree involves a recursive process where the parent node's hitTest
method continuously calls the child nodes' hitTest
methods. Here is the source code for RenderView
's hitTest()
:
// Initiate hit testing; position is the coordinate where the event is triggered (if any). bool hitTest(HitTestResult result, { Offset position }) { if (child != null) child.hitTest(result, position: position); // Recursively hit test the subtree // The root node is always added to the HitTestResult list result.add(HitTestEntry(this)); return true; }
Since RenderView
only has one child, it directly calls child.hitTest
. If a render object has multiple child nodes, the hit testing logic is as follows: if any child node passes the hit test, or if the current node "declares" itself as passing the hit test, the current node will also pass the hit test. For example, let's look at the implementation of RenderBox
:
bool hitTest(HitTestResult result, { @required Offset position }) { ... if (_size.contains(position)) { // Check if the event's trigger position is within the component's range if (hitTestChildren(result, position: position) || hitTestSelf(position)) { result.add(BoxHitTestEntry(this, position)); return true; } } return false; }
In the above code:
hitTestChildren()
determines whether any child nodes have passed the hit test. If so, the child components are added to theHitTestResult
, andtrue
is returned; otherwise,false
is returned. This method recursively calls the hit test method of child components.hitTestSelf()
decides whether the node itself passes the hit test. If the node needs to ensure that it can respond to events, it can override this function and returntrue
, effectively "declaring" itself as passing the hit test.
It is important to note that the indicator for a node passing the hit test is that it is added to the HitTestResult
list, not the return value of hitTest
. Although in most cases, nodes passing the hit test return true
, developers can override hitTest
when customizing components, which may lead to returning false
when passing or returning true
when not passing the hit test. While this is not advisable, it may be necessary in certain scenarios where custom hit testing logic is required, such as in the HitTestBlocker
component we will implement later in this section.
So the overall logic is:
Check whether the event's trigger position is within the component's range. If not, it will not pass the hit test, and
hitTest
will returnfalse
. If it is, proceed to step two.Call
hitTestChildren()
to check if any child nodes have passed the hit test. If so, the current node is added to theHitTestResult
list, andhitTest
returnstrue
. This means that as long as any child node passes the hit test, its parent node (the current node) will also pass the hit test.If no child nodes pass the hit test, the return value of
hitTestSelf
is considered. If it returnstrue
, the current node passes the hit test; otherwise, it does not.If the current node has child nodes that pass the hit test or if the current node itself passes the hit test, the current node is added to the
HitTestResult
. SincehitTestChildren()
recursively calls the hit test method of child components, the hit testing order in the component tree is depth-first. Thus, if a hit test passes, child components are added to theHitTestResult
list before parent components.
Let’s look at the default implementations of these two methods:
@protected bool hitTestChildren(HitTestResult result, { Offset position }) => false; @protected bool hitTestSelf(Offset position) => false;
If a component has multiple child components, the hitTestChildren()
method must be overridden. This method should call the hitTest
method for each child component. For example, let's examine the implementation in RenderBoxContainerDefaultsMixin
:
// The hitTestChildren() of subclasses will directly call this method bool defaultHitTestChildren(BoxHitTestResult result, { required Offset position }) { // Traverse all child components (from last to first) ChildType? child = lastChild; while (child != null) { final ParentDataType childParentData = child.parentData! as ParentDataType; // isHit is the return value of the current child's hitTest() final bool isHit = result.addWithPaintOffset( offset: childParentData.offset, position: position, // Call the hitTest method of the child component hitTest: (BoxHitTestResult result, Offset? transformed) { return child!.hitTest(result, position: transformed!); }, ); // If any child node's hitTest() method returns true, terminate the traversal and return true if (isHit) return true; child = childParentData.previousSibling; } return false; }
We can see that the main logic in the above code is to traverse and call the hitTest()
method for child components while providing a mechanism to interrupt the process: as soon as a child node's hitTest()
returns true
, traversal will stop, meaning that sibling nodes before it will not have a chance to pass the hit test. Note that the traversal of sibling nodes is done in reverse order.
The parent node will also pass the hit test. Since a child node's
hitTest()
returnedtrue
, the parent node'shitTestChildren
will also returntrue
, ultimately leading to the parent node'shitTest
returningtrue
and being added to theHitTestResult
.When a child node's
hitTest()
returnsfalse
, the traversal continues to check the preceding sibling nodes. If all child nodes returnfalse
, the parent node will call itshitTestSelf
method. If this method also returnsfalse
, the parent node will be deemed to have not passed the hit test.
Now, consider these two questions:
Why is this interruption mechanism needed? Generally, sibling nodes do not occupy overlapping layout space. Therefore, when a user clicks, only one node will respond. Once found (through a passing hit test, where
hitTest
returnstrue
), there is no need to check other sibling nodes. However, exceptions exist, such as in a Stack layout where sibling components may overlap. If we want components at the bottom to respond, we need a mechanism that ensures all child components’ hit test methods returnfalse
, even after finding one node. To address this, Flutter usesHitTestBehavior
to customize this process, which we will introduce later in this section.Why is the traversal of sibling nodes done in reverse order? As stated in 1, sibling nodes usually do not overlap. However, when overlaps occur, typically the later components are on top of the earlier ones, meaning that the later component should respond to clicks while the earlier one does not. Therefore, hit testing should prioritize the later nodes. If we were to traverse in normal order, it would allow hidden components to respond while visible components could not, which is clearly counterintuitive.
Returning to hitTestChildren
, if it is not overridden, it defaults to returning false
, which means descendant nodes will not participate in hit testing, effectively intercepting the event. This is precisely the principle behind how IgnorePointer
and AbsorbPointer
can intercept events.
If hitTestSelf
returns true
, the current node will be added to the HitTestResult
, regardless of whether any child nodes passed the hit test. The difference between IgnorePointer
and AbsorbPointer
is that the former's hitTestSelf
returns false
, while the latter's returns true
.
After the hit testing is complete, all nodes that passed the hit test are added to the HitTestResult
.
58.4 Event Dispatching
The event dispatching process is straightforward: traverse the HitTestResult
and call the handleEvent
method for each node:
// Event dispatching void dispatchEvent(PointerEvent event, HitTestResult? hitTestResult) { ... for (final HitTestEntry entry in hitTestResult.path) { entry.target.handleEvent(event.transformed(entry.transform), entry); } }
Therefore, components only need to override the handleEvent
method to handle events.
58.5 HitTestBehavior
1. Introduction to HitTestBehavior
Let’s first implement a component that listens for PointerDownEvent
:
class PointerDownListener extends SingleChildRenderObjectWidget { PointerDownListener({Key? key, this.onPointerDown, Widget? child}) : super(key: key, child: child); final PointerDownEventListener? onPointerDown; @override RenderObject createRenderObject(BuildContext context) => RenderPointerDownListener()..onPointerDown = onPointerDown; @override void updateRenderObject( BuildContext context, RenderPointerDownListener renderObject) { renderObject.onPointerDown = onPointerDown; } } class RenderPointerDownListener extends RenderProxyBox { PointerDownEventListener? onPointerDown; @override bool hitTestSelf(Offset position) => true; // Always pass the hit test @override void handleEvent(PointerEvent event, covariant HitTestEntry entry) { // Handle the event during dispatching if (event is PointerDownEvent) onPointerDown?.call(event); } }
Since we set the return value of hitTestSelf
to always be true, the PointerDownListener
will pass the hit test regardless of whether child nodes do, which means that handleEvent
will be called during subsequent event dispatching. We can trigger a callback when the event type is PointerDownEvent
. Here’s the test code:
class PointerDownListenerRoute extends StatelessWidget { const PointerDownListenerRoute({Key? key}) : super(key: key); @override Widget build(BuildContext context) { return PointerDownListener( child: Text('Click me'), onPointerDown: (e) => print('down'), ); } }
When you click the text, the console will print 'down'.
The implementation of Listener
is similar to that of PointerDownListener
, with two differences:
Listener
listens for a wider range of event types.The
hitTestSelf
ofListener
does not always return true.
Now, let’s focus on the second point. The Listener
component has a behavior
parameter, which we haven’t introduced before. Let’s explore this in detail. By examining the source code of Listener
, we find that its render object, RenderPointerListener
, inherits from the RenderProxyBoxWithHitTestBehavior
class:
abstract class RenderProxyBoxWithHitTestBehavior extends RenderProxyBox { // The default value of [behavior] is [HitTestBehavior.deferToChild]. RenderProxyBoxWithHitTestBehavior({ this.behavior = HitTestBehavior.deferToChild, RenderBox? child, }) : super(child); HitTestBehavior behavior; @override 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)); // Pass the hit test } return hitTarget; } @override bool hitTestSelf(Offset position) => behavior == HitTestBehavior.opaque; //2 }
We see that behavior
is used in hitTest
and hitTestSelf
, and its value will affect the hit test results of Listener
. Let's look at the possible values for behavior
:
// The behavior of the Listener component during hit testing. enum HitTestBehavior { // The component's hit test result depends on whether child components pass the hit test deferToChild, // The component will definitely pass the hit test, and its hitTest return value is always true opaque, // The component will definitely pass the hit test, but its hitTest return value may be true or false translucent, }
There are three possible values. Let’s analyze the effects of these different values in conjunction with the hitTest
implementation:
When
behavior
isdeferToChild
,hitTestSelf
returns false. Whether the current component can pass the hit test entirely depends on the return value ofhitTestChildren
. In other words, as long as one child node passes the hit test, the current component will also pass the hit test.When
behavior
isopaque
,hitTestSelf
returns true, and thehitTarget
value is always true, meaning the current component passes the hit test.When
behavior
istranslucent
,hitTestSelf
returns false, and thehitTarget
value now depends on the return value ofhitTestChildren
. However, regardless of thehitTarget
value, the current node will be added to theHitTestResult
.
Note that for both opaque
and translucent
, the current component will pass the hit test. The difference lies in the return value of hitTest()
(hitTarget
), which may differ, and the impact of that return value is what we’ve discussed in detail above. Let’s understand this better through an example.
2. Example: Implementing App Watermark
The effect is shown in Figure :
The implementation idea is to overlay a watermark mask on the top layer of the page. We can achieve this using a Stack
, placing the watermark component as the last child:
class WaterMaskTest extends StatelessWidget { const WaterMaskTest({Key? key}) : super(key: key); @override Widget build(BuildContext context) { return Stack( children: [ wChild(1, Colors.white, 200), WaterMark( painter: TextWaterMarkPainter(text: 'wendux', rotate: -20), ), ], ); } Widget wChild(int index, Color color, double size) { return Listener( onPointerDown: (e) => print(index), child: Container( width: size, height: size, color: Colors.grey, ), ); } }
WaterMark
is the component that implements the watermark. The specific logic will be introduced in the chapter on custom components later. For now, it suffices to know that WaterMark
uses a DecoratedBox
. The effect is achieved, but when we click on the first child of the Stack
(the gray rectangular area), we find that the console outputs nothing, which is unexpected. The reason is that the watermark component is on the top layer, and the event is "blocked" by it. Let’s analyze this process:
When clicked, the Stack
has two child components. It first performs a hit test on the second child component (the watermark). Since the watermark component uses a DecoratedBox
, and checking the source code reveals that if the user clicks within the DecoratedBox
, its hitTestSelf
will return true, thus passing the hit test.
Once the watermark component passes the hit test, the hitTestChildren()
of the Stack
will return directly (terminating the traversal of other child nodes), so the first child component of the Stack
will not participate in the hit test and therefore will not respond to the event.
Now that we've identified the issue, the solution is to allow the first child component to participate in the hit test. To achieve this, we need to make the hit test of the second child component return false. Thus, we can wrap the WaterMark
with an IgnorePointer
.
IgnorePointer(child: WaterMark(...))
After making this change and rerunning, we find that the first child component can now respond to events.
If we want all child components of the Stack
to respond to events, how should we implement it? This is likely a pseudo-requirement, as it is almost never encountered in real scenarios, but considering this question can deepen our understanding of the Flutter event handling process.
class StackEventTest extends StatelessWidget { const StackEventTest({Key? key}) : super(key: key); @override 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 this code and clicking on the gray box, what do you think the console will print?
The answer is that it will only print '2'. The reason is that the Stack
first traverses the second child node's Listener
. Since the Container
's hitTest
returns true (in fact, Container
is a composite component, and in this example, it eventually generates a ColoredBox
, with the RenderObject
of ColoredBox
participating in the hit test), the Listener
's hitTestChildren
will return true, leading to the Listener
's hitTest
also returning true. As a result, the first child node will not receive the event.
What if we specify the behavior
property of the Listener
as opaque
or translucent
? The result will still be the same because as long as the Container
's hitTest
returns true, the Listener
's hitTestChildren
will return true, preventing the first node from being tested. So, what are the specific scenarios in which opaque
and translucent
exhibit differences? Theoretically, the two only differ when the child node of the Listener
returns false in the hit test, but in Flutter, almost all UI components return true when clicked, making it hard to find specific scenarios. However, to test their differences, we can artificially create a situation, as shown in the following code:
class HitTestBehaviorTest extends StatelessWidget { const HitTestBehaviorTest({Key? key}) : super(key: key); @override Widget build(BuildContext context) { return Stack( children: [ wChild(1), wChild(2), ], ); } Widget wChild(int index) { return Listener( //behavior: HitTestBehavior.opaque, // Uncomment this line, and clicking will only output 2 behavior: HitTestBehavior.translucent, // Uncomment this line, and clicking will output both 2 and 1 onPointerDown: (e) => print(index), child: SizedBox.expand(), ); } }
Since SizedBox
has no child elements, when it is clicked, its hitTest
will return false. At this point, the Listener
's behavior settings for opaque
and translucent
will show a difference (see comments).
Since such cases are rarely encountered in practical scenarios, if we want all child components of the Stack
to respond to events, we must ensure that all children of the Stack
return false in their hit tests. While wrapping all child components with IgnorePointer
achieves this, it also prevents hit testing on the child components, meaning their subtree will not respond to events. For example, the following code will produce no output when the gray area is clicked:
class AllChildrenCanResponseEvent extends StatelessWidget { const AllChildrenCanResponseEvent({Key? key}) : super(key: key); @override 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, ), ); } }
Even though we are listening for events on the Container
in the child nodes, they are inside the IgnorePointer
, so they have no chance to participate in the hit test and therefore do not respond to any events. It seems there’s no ready-made component to meet the requirements, so we will need to implement a custom component to customize its hit test to meet our needs.
3. HitTestBlocker
Next, we define a HitTestBlocker
component that can intercept various phases of the hit test process.
class HitTestBlocker extends SingleChildRenderObjectWidget { HitTestBlocker({ Key? key, this.up = true, this.down = false, this.self = false, Widget? child, }) : super(key: key, child: child); /// When `up` is true, `hitTest()` will always return false. final bool up; /// When `down` is true, `hitTestChildren()` will not be called. final bool down; /// Return value of `hitTestSelf`. final bool self; @override RenderObject createRenderObject(BuildContext context) { return RenderHitTestBlocker(up: up, down: down, self: self); } @override 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; @override 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; } @override bool hitTestSelf(Offset position) => self; }
The code is quite simple, but it requires the reader to carefully understand it based on previous knowledge. We can replace IgnorePointer
with HitTestBlocker
to allow all child components to respond to events. Here's the updated code:
@override 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 both 2
and 1
. The principle is straightforward:
The
hitTest
ofHitTestBlocker
returns false, which ensures that all child nodes of theStack
can participate in the hit test.HitTestBlocker
'shitTest
also callshitTestChildren
, so the descendants ofHitTestBlocker
have the opportunity to participate in the hit test, allowing events on theContainer
to be triggered normally.HitTestBlocker
is a very flexible class that can intercept different phases of the hit test. WithHitTestBlocker
, you can achieve the functionality of bothIgnorePointer
andAbsorbPointer
. For example, when bothup
anddown
ofHitTestBlocker
are set to true, it behaves the same asIgnorePointer
.
4. When Gestures Are Involved
Let’s slightly modify the above code by replacing Listener
with GestureDetector
. The updated code is as follows:
class GestureHitTestBlockerTest extends StatelessWidget { const GestureHitTestBlockerTest({Key? key}) : super(key: key); @override 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( // Replace Listener with GestureDetector onTap: () => print('$index'), child: Container( width: size, height: size, color: Colors.grey, ), ); } }
What do you think will be printed after clicking? The answer is that only 2
will be printed! This is because although both child components of the Stack
participate and pass the hit test, GestureDetector
decides whether to respond to the event during the event dispatch phase (not during the hit test phase). GestureDetector
has its own mechanism for handling gesture conflicts, which we will introduce in the next section.
58.6 Summary
A component can only respond to an event if it passes the hit test.
Whether a component passes the hit test depends on the value of
hitTestChildren(...) || hitTestSelf(...)
.The hit test order of components in the widget tree follows a depth-first traversal.
The hit test order of a component’s child nodes is reversed, and once a child node’s
hitTest
returns true, the traversal stops, meaning subsequent child nodes will not participate in the hit test. This principle can be understood through theStack
widget.In most cases, when the
HitTestBehavior
of aListener
is set toopaque
ortranslucent
, the effect is the same. They only differ when their child node'shitTest
returns false.HitTestBlocker
is a flexible component that allows us to intervene in various phases of the hit test.