Flutter (59): Gesture principles and gesture conflicts

Time: Column:Mobile & Frontend views:193

59.1 Principles of Gesture Recognition

Gesture recognition and handling occur during the event dispatch phase. GestureDetector is a StatelessWidget that contains RawGestureDetector. Let’s take a look at its build method implementation:

@override
Widget build(BuildContext context) {
  final gestures = <Type, GestureRecognizerFactory>{};
  // Construct TapGestureRecognizer
  if (onTapDown != null ||
      onTapUp != null ||
      onTap != null ||
      // omitted
  ) {
    gestures[TapGestureRecognizer] = GestureRecognizerFactoryWithHandlers<TapGestureRecognizer>(
      () => TapGestureRecognizer(debugOwner: this),
      (TapGestureRecognizer instance) {
        instance
          ..onTapDown = onTapDown
          ..onTapUp = onTapUp
          ..onTap = onTap;
          // omitted
      },
    );
  }

  return RawGestureDetector(
    gestures: gestures, // Pass in gesture recognizers
    behavior: behavior, // Same as HitTestBehavior in Listener
    child: child,
  );
}

Note that we’ve removed much of the code, keeping only the TapGestureRecognizer related parts to explain the tapping gesture recognition process. The RawGestureDetector listens for PointerDownEvent events through the Listener component, as shown in the following source code:

@override
Widget build(BuildContext context) {
  // omitted unrelated code
  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);
}

Now, let’s look at some related methods of TapGestureRecognizer. Since TapGestureRecognizer has multiple layers of inheritance, the author has provided a simplified version:

class CustomTapGestureRecognizer1 extends TapGestureRecognizer {
  void addPointer(PointerDownEvent event) {
    // Adds handleEvent callback to pointerRouter
    GestureBinding.instance!.pointerRouter.addRoute(event.pointer, handleEvent);
  }
  
  @override
  void handleEvent(PointerEvent event) {
    // Performs gesture recognition and decides whether to call acceptGesture or rejectGesture
  }
  
  @override
  void acceptGesture(int pointer) {
    // Called when winning the competition
  }

  @override
  void rejectGesture(int pointer) {
    // Called when losing the competition
  }
}

When a PointerDownEvent is triggered, TapGestureRecognizer’s addPointer method is called, adding the handleEvent method to the pointerRouter. Thus, when the gesture changes, the handleEvent method can be retrieved from pointerRouter for gesture recognition.

Generally, the object that the gesture directly acts upon should handle the gesture, so a simple principle is that only one gesture recognizer should be active for a single gesture. This is where the concept of the gesture arena comes in. Simply put:

Each gesture recognizer (GestureRecognizer) is a "competitor" (GestureArenaMember). When pointer events occur, they compete in the "arena" for handling the event. By default, only one "competitor" will win.

The handleEvent method of GestureRecognizer will identify the gesture. If a gesture occurs, the competitor can declare whether it has won. Once a competitor wins, the arena manager (GestureArenaManager) will notify other competitors of their loss. The winner's acceptGesture will be called, while the others will have their rejectGesture called.

In the previous section, we mentioned that hit testing starts from the RenderBinding's hitTest:

@override
void hitTest(HitTestResult result, Offset position) {
  // Start hit testing from the root node
  renderView.hitTest(result, position: position);
  // This calls the hitTest() method in GestureBinding, which we will discuss in the next section.
  super.hitTest(result, position);
}

After the rendering tree's hit testing is complete, the hitTest() method in GestureBinding is called:

@override // from HitTestable
void hitTest(HitTestResult result, Offset position) {
  result.add(HitTestEntry(this));
}

This is straightforward: GestureBinding also undergoes hit testing, so during the event dispatch phase, GestureBinding’s handleEvent will be called. Since it is added last to the HitTestResult, it will be called last during the event dispatch phase:

@override 
void handleEvent(PointerEvent event, HitTestEntry entry) {
  // Calls the handleEvent of the GestureRecognizer added to pointerRouter
  pointerRouter.route(event);
  if (event is PointerDownEvent) {
    // After dispatching, close the arena
    gestureArena.close(event.pointer);
  } else if (event is PointerUpEvent) {
    gestureArena.sweep(event.pointer);
  } else if (event is PointerSignalEvent) {
    pointerSignalResolver.resolve(event);
  }
}

gestureArena is an instance of the GestureArenaManager class, responsible for managing the arena.

The key line of code here is the first one, which calls the handleEvent of the GestureRecognizer previously added to the pointerRouter. Different handleEvent methods from various GestureRecognizer instances will recognize different gestures, and they will interact with the gestureArena. If the current GestureRecognizer wins, it needs the gestureArena to notify other competitors of their loss. Ultimately, if the current GestureRecognizer wins, its acceptGesture will be called; if it loses, its rejectGesture will be called. The specific implementations differ among GestureRecognizer instances, so readers interested in the details can check the source code.


59.2 Gesture Competition

If a component listens for both horizontal and vertical drag gestures simultaneously, which direction's drag gesture callback will be triggered when dragging diagonally? It actually depends on the displacement components on both axes during the first movement; whichever axis has a larger displacement wins that drag event. As mentioned earlier, each gesture recognizer is a "competitor." When pointer events occur, they compete in the arena for handling that event. By default, only one competitor will win.

For example, suppose there is a ListView whose first child is also a ListView. If we scroll the child ListView, will the parent ListView move? The answer is no; only the child ListView will move because it wins the handling rights for the scroll event.

Let’s look at a simple example:

GestureDetector( // GestureDetector2
  onTapUp: (x) => print("2"), // Listen for parent component's tapUp gesture
  child: Container(
    width: 200,
    height: 200,
    color: Colors.red,
    alignment: Alignment.center,
    child: GestureDetector( // GestureDetector1
      onTapUp: (x) => print("1"), // Listen for child component's tapUp gesture
      child: Container(
        width: 50,
        height: 50,
        color: Colors.grey,
      ),
    ),
  ),
);

When we click the child component (the grey area), the console will only print “1” and not “2.” This is because after the finger lifts, GestureDetector1 and GestureDetector2 compete, and the winning rule is "child components take priority," so GestureDetector1 wins. Since only one competitor can win, GestureDetector2 will be ignored. A simple way to resolve conflicts in this example is to replace GestureDetector with Listener, which we will explain later.

Let’s look at another example. We will recognize both horizontal and vertical drag gestures. When the user presses their finger down, the competition (between horizontal and vertical) is triggered. Once one direction "wins," it will continue moving in that direction until the drag gesture ends. The code is as follows:

class _BothDirectionTest extends StatefulWidget {
  @override
  _BothDirectionTestState createState() => _BothDirectionTestState();
}

class _BothDirectionTestState extends State<_BothDirectionTest> {
  double _top = 0.0;
  double _left = 0.0;

  @override
  Widget build(BuildContext context) {
    return Stack(
      children: <Widget>[
        Positioned(
          top: _top,
          left: _left,
          child: GestureDetector(
            child: CircleAvatar(child: Text("A")),
            // Vertical drag event
            onVerticalDragUpdate: (DragUpdateDetails details) {
              setState(() {
                _top += details.delta.dy;
              });
            },
            onHorizontalDragUpdate: (DragUpdateDetails details) {
              setState(() {
                _left += details.delta.dx;
              });
            },
          ),
        )
      ],
    );
  }
}

In this example, each time you drag, it will only move in one direction (either horizontally or vertically). The competition occurs at the first movement after the finger is pressed down. In this case, the specific "winning" condition is determined by which displacement component is greater during the first move.


59.3 Gesture Conflicts

Since there can only be one winner in gesture competition, conflicts may arise when we use a GestureDetector to listen for multiple gestures. Suppose we have a widget that can be dragged left and right, and we also want to detect touch down and up events on it, as shown in the following code:

class GestureConflictTestRouteState extends State<GestureConflictTestRoute> {
  double _left = 0.0;

  @override
  Widget build(BuildContext context) {
    return Stack(
      children: <Widget>[
        Positioned(
          left: _left,
          child: GestureDetector(
            child: CircleAvatar(child: Text("A")), // Widget to drag and tap
            onHorizontalDragUpdate: (DragUpdateDetails details) {
              setState(() {
                _left += details.delta.dx;
              });
            },
            onHorizontalDragEnd: (details) {
              print("onHorizontalDragEnd");
            },
            onTapDown: (details) {
              print("down");
            },
            onTapUp: (details) {
              print("up");
            },
          ),
        )
      ],
    );
  }
}

Now, when we press and drag the circular "A" and then lift our finger, the console logs are as follows:

I/flutter (17539): down
I/flutter (17539): onHorizontalDragEnd

We notice that "up" is not printed. This is because when dragging begins, the gesture has not yet achieved a complete semantic meaning. At that moment, the TapDown gesture wins, resulting in the "down" printout. When dragging occurs, the drag gesture wins, and when the finger is lifted, the onHorizontalDragEnd and onTapUp events conflict. However, since we are in the context of a drag gesture, onHorizontalDragEnd wins, leading to the printout of "onHorizontalDragEnd."

If our logic strongly depends on the touch down and up events, such as in a carousel component where we want to pause on touch down and resume on touch up, using onTapDown and onTapUp externally won't work, as the carousel might already handle drag gestures (supporting manual swiping) and even zoom gestures. So, what should we do? It's simple: we can listen to raw pointer events using Listener:

Positioned(
  top: 80.0,
  left: _leftB,
  child: Listener(
    onPointerDown: (details) {
      print("down");
    },
    onPointerUp: (details) {
      print("up"); // This will be triggered
    },
    child: GestureDetector(
      child: CircleAvatar(child: Text("B")),
      onHorizontalDragUpdate: (DragUpdateDetails details) {
        setState(() {
          _leftB += details.delta.dx;
        });
      },
      onHorizontalDragEnd: (details) {
        print("onHorizontalDragEnd");
      },
    ),
  ),
);

59.5 Resolving Gesture Conflicts

Gestures are semantic interpretations of raw pointer events, and gesture conflicts occur only at the gesture level, meaning they can happen only between multiple GestureDetectors in the widget tree. If GestureDetector isn't used at all, there won't be any conflicts since each node can receive events. In GestureDetector, to identify semantics, it determines which child nodes should ignore events and which should be effective.

There are two methods to resolve gesture conflicts:

  1. Using Listener: This essentially bypasses the gesture recognition rules.

  2. Custom Gesture Recognizers.

1. Resolving Gesture Conflicts with Listener

Using Listener to resolve gesture conflicts works because competition is limited to gestures, while Listener listens to raw pointer events, which are not semantic gestures. Therefore, the logic for gesture competition does not apply, preventing interference. For example, in the case of two nested Containers, replacing GestureDetector with Listener looks like this:

Listener( // Replace GestureDetector with 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 straightforward; simply swap GestureDetector for Listener. You can replace both or just one. Thus, we should prioritize using Listener when facing gesture conflicts.

2. Resolving Gesture Conflicts with Custom Recognizers

Creating custom gesture recognizers can be more complex. The principle is that when determining the winner of gesture competition, the winner's acceptGesture method is called, indicating "success," while other recognizers' rejectGesture methods are called, indicating "failure." Therefore, we can create a custom gesture recognizer and override its rejectGesture method to call acceptGesture instead, effectively forcing it to also be declared a winner.

Here's an example of a custom tap gesture recognizer:

class CustomTapGestureRecognizer extends TapGestureRecognizer {
  @override
  void rejectGesture(int pointer) {
    // No, I don’t want to fail, I want to succeed.
    super.acceptGesture(pointer); // Declare success
  }
}

Now, we create a new GestureDetector using our custom CustomTapGestureRecognizer:

RawGestureDetector customGestureDetector({
  GestureTapCallback? onTap,
  GestureTapDownCallback? onTapDown,
  Widget? child,
}) {
  return RawGestureDetector(
    child: child,
    gestures: {
      CustomTapGestureRecognizer:
          GestureRecognizerFactoryWithHandlers<CustomTapGestureRecognizer>(
        () => CustomTapGestureRecognizer(),
        (detector) {
          detector.onTap = onTap;
        },
      )
    },
  );
}

We can then modify the calling code:

customGestureDetector( // Replace 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 approach allows for multiple winners in a single gesture processing event.