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
PointerDownEventis 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 aHitTestResultlist.Event Dispatching: After hit testing, the
HitTestResultlist 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, aPointerMoveEventis dispatched.Event Cleanup: When the finger lifts (producing a
PointerUpEvent) or the event is canceled (PointerCancelEvent), the corresponding event is first dispatched, and then theHitTestResultlist is cleared.
Important Notes:
Hit testing occurs when the
PointerDownEventis 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
HitTestResultlist before parent render objects. During event dispatching, the list is traversed from front to back, so child components'handleEventmethods 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:
renderViewis theRenderObjectcorresponding toRenderView. ThehitTestmethod of theRenderObjectprimarily 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 inWidgetorElement.
Step 2: After completing the render tree hit test, the
hitTestmethod ofGestureBindingis 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, andtrueis returned; otherwise,falseis 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
hitTestwill 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 theHitTestResultlist, andhitTestreturnstrue. 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
hitTestSelfis 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 theHitTestResultlist 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'shitTestChildrenwill also returntrue, ultimately leading to the parent node'shitTestreturningtrueand 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 itshitTestSelfmethod. 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
hitTestreturnstrue), 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 usesHitTestBehaviorto 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:
Listenerlistens for a wider range of event types.The
hitTestSelfofListenerdoes 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
behaviorisdeferToChild,hitTestSelfreturns 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
behaviorisopaque,hitTestSelfreturns true, and thehitTargetvalue is always true, meaning the current component passes the hit test.When
behavioristranslucent,hitTestSelfreturns false, and thehitTargetvalue now depends on the return value ofhitTestChildren. However, regardless of thehitTargetvalue, 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
hitTestofHitTestBlockerreturns false, which ensures that all child nodes of theStackcan participate in the hit test.HitTestBlocker'shitTestalso callshitTestChildren, so the descendants ofHitTestBlockerhave the opportunity to participate in the hit test, allowing events on theContainerto be triggered normally.HitTestBlockeris a very flexible class that can intercept different phases of the hit test. WithHitTestBlocker, you can achieve the functionality of bothIgnorePointerandAbsorbPointer. For example, when bothupanddownofHitTestBlockerare 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
hitTestreturns true, the traversal stops, meaning subsequent child nodes will not participate in the hit test. This principle can be understood through theStackwidget.In most cases, when the
HitTestBehaviorof aListeneris set toopaqueortranslucent, the effect is the same. They only differ when their child node'shitTestreturns false.HitTestBlockeris a flexible component that allows us to intervene in various phases of the hit test.