Flutter (58): Flutter event mechanism

Time: Column:Mobile & Frontend views:197

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:

  1. 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 a HitTestResult list.

  2. Event Dispatching: After hit testing, the HitTestResult list is traversed, and the event handling method (handleEvent) of each render object is called to process the PointerDownEvent. This process is called "event dispatching." Subsequently, when the finger moves, a PointerMoveEvent is dispatched.

  3. Event Cleanup: When the finger lifts (producing a PointerUpEvent) or the event is canceled (PointerCancelEvent), the corresponding event is first dispatched, and then the HitTestResult 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 the RenderObject corresponding to RenderView. The hitTest method of the RenderObject 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 in Widget or Element.

  • Step 2: After completing the render tree hit test, the hitTest method of GestureBinding 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 the HitTestResult, and true 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 return true, 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:

  1. 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 return false. If it is, proceed to step two.

  2. Call hitTestChildren() to check if any child nodes have passed the hit test. If so, the current node is added to the HitTestResult list, and hitTest returns true. 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.

  3. If no child nodes pass the hit test, the return value of hitTestSelf is considered. If it returns true, the current node passes the hit test; otherwise, it does not.

  4. 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. Since hitTestChildren() 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 the HitTestResult 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() returned true, the parent node's hitTestChildren will also return true, ultimately leading to the parent node's hitTest returning true and being added to the HitTestResult.

  • When a child node's hitTest() returns false, the traversal continues to check the preceding sibling nodes. If all child nodes return false, the parent node will call its hitTestSelf method. If this method also returns false, the parent node will be deemed to have not passed the hit test.

Now, consider these two questions:

  1. 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 returns true), 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 return false, even after finding one node. To address this, Flutter uses HitTestBehavior to customize this process, which we will introduce later in this section.

  2. 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:

  1. Listener listens for a wider range of event types.

  2. The hitTestSelf of Listener 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 is deferToChild, hitTestSelf returns false. Whether the current component can pass the hit test entirely depends on the return value of hitTestChildren. In other words, as long as one child node passes the hit test, the current component will also pass the hit test.

  • When behavior is opaque, hitTestSelf returns true, and the hitTarget value is always true, meaning the current component passes the hit test.

  • When behavior is translucent, hitTestSelf returns false, and the hitTarget value now depends on the return value of hitTestChildren. However, regardless of the hitTarget value, the current node will be added to the HitTestResult.

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 :

Flutter (58): Flutter event mechanism

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 of HitTestBlocker returns false, which ensures that all child nodes of the Stack can participate in the hit test.

  • HitTestBlocker's hitTest also calls hitTestChildren, so the descendants of HitTestBlocker have the opportunity to participate in the hit test, allowing events on the Container to be triggered normally.

  • HitTestBlocker is a very flexible class that can intercept different phases of the hit test. With HitTestBlocker, you can achieve the functionality of both IgnorePointer and AbsorbPointer. For example, when both up and down of HitTestBlocker are set to true, it behaves the same as IgnorePointer.

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 the Stack widget.

  • In most cases, when the HitTestBehavior of a Listener is set to opaque or translucent, the effect is the same. They only differ when their child node's hitTest returns false.

  • HitTestBlocker is a flexible component that allows us to intervene in various phases of the hit test.