skip to content

In Flutter's gesture arena, how is a tap on an email tile resolved against the enclosing PageView's horizontal drag for the same pointer?

level: middleimportance: must knowfreq 46%

answer

  1. one arena per pointer
  2. join on down, close after dispatch
  3. drag declares victory past the slop
  4. sweep on pointer up
  5. first member, the innermost, wins

basics

~20 s

Both recognizers join the pointer's arena on pointer down. If the finger moves past the touch slop, the drag declares victory and the tap is rejected; if not, the arena is swept on pointer up and its first member, the innermost tap, wins.

solid answer

~40 s

On pointer down every recognizer on the hit-test path joins that pointer's arena, innermost first, and the arena closes once the down event is dispatched. From then on a member can accept, which rejects everyone else at once, or reject itself. A drag recognizer accepts as soon as movement along its axis passes the touch slop, so a horizontal swipe goes to the `PageView` and the tile's tap gets `onTapCancel`. A tap recognizer never declares early; it leaves if the finger moves too far. If nobody has won when the finger lifts, the arena is swept and the first member, the tile's tap, wins. `debugPrintGestureArenaDiagnostics` logs every step.

code

dart · 32 lines
dart
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';

void main() {
  // Logs every arena: members joining, eager winners, sweeps, default wins.
  debugPrintGestureArenaDiagnostics = true;
  runApp(const MaterialApp(home: InboxPager()));
}

class InboxPager extends StatelessWidget {
  const InboxPager({super.key});

  @override
  Widget build(BuildContext context) {
    // PageView: horizontal drag recognizer. ListView: vertical drag recognizer.
    // ListTile's InkWell: tap recognizer. All three join one arena per pointer.
    return Scaffold(
      body: PageView(
        children: [
          for (final folder in const ['Inbox', 'Archive'])
            ListView.builder(
              itemCount: 30,
              itemBuilder: (context, i) => ListTile(
                title: Text('$folder message $i'),
                onTap: () => debugPrint('open $folder/$i'),
              ),
            ),
        ],
      ),
    );
  }
}

go deeper

for a junior

Remember that competing gestures on one finger are settled by the gesture arena, and that a still tap goes to the innermost widget.

for a middle

Walk through join, close, accept or reject, and sweep, and explain why drags win on movement while taps win at the sweep.

for a senior

Use arena diagnostics to explain a lost tap or a hijacked swipe, and reason about onTapDown followed by onTapCancel in scrolling lists.

for a principal

Decide how gesture-heavy surfaces are composed so arena conflicts are designed out, rather than patched with custom recognizers screen by screen.

## One arena per pointer Every finger that touches the screen gets its own **gesture arena**, managed by `GestureArenaManager` (reachable as `GestureBinding.instance.gestureArena`). The lifecycle, as the `GestureBinding` source documents it: 1. **Pointer down.** Flutter hit-tests the position. Every widget on the hit-test path that has gesture recognizers hands the event to them with `addPointer`, and each interested recognizer **joins the arena** as a `GestureArenaMember`. The path runs from the deepest render object outwards, so the innermost recognizers join first. 2. **Close.** When dispatch of the down event finishes, the arena is **closed**: no new members. If only one member joined, it wins by default right away (in a microtask). 3. **Move events.** Members watch the pointer and each may **resolve** itself: `accepted` (declare victory; everyone else is rejected at once) or `rejected` (leave; if one member remains, it wins). 4. **Pointer up.** The arena is **swept**. If nobody has won yet, the **first member — the innermost — wins**, and all others get `rejectGesture`. Every member receives exactly one of `acceptGesture(pointer)` or `rejectGesture(pointer)` per arena it joined. ## How the common recognizers behave | Recognizer | Declares victory when | Leaves the arena when | |---|---|---| | Tap (`TapGestureRecognizer`) | never early; it waits for the sweep or to be last | the pointer moves beyond the touch slop (`kTouchSlop`, 18 logical pixels) | | Horizontal / vertical drag | movement along its axis exceeds the slop | it loses to someone else | | Pan | movement in any direction exceeds the pan slop | it loses to someone else | | Long press | the press lasts `kLongPressTimeout` (500 ms) | the pointer moves too far or lifts early | | Double tap | a second tap lands in time | the timeout passes; it **holds** the arena meanwhile | ## The email app, event by event In an inbox built as a `PageView` of folders, each a vertical `ListView` of `ListTile`s, one touch on a tile creates an arena with three members: the tile's tap (innermost), the list's vertical drag, the page view's horizontal drag. - **Stationary tap.** Nothing moves beyond the slop, no one declares. On pointer up the sweep awards the first member — the tile's tap — and `onTap` opens the message. - **Vertical flick.** The vertical drag passes its slop first and declares victory; the tap and the horizontal drag are rejected (the tile's ink highlight is cancelled). The list scrolls. - **Horizontal swipe.** The horizontal drag wins the same way, and the page view moves to the next folder. That is the whole answer to "why does a child's tap beat a parent's drag": taps win **by default at the sweep**, drags win **by declaring early once the finger moves**. Neither is about widget priority. ## What this explains - **`onTapDown` can fire for a gesture that later becomes a scroll.** The tap recognizer reports a down after a short deadline (`kPressTimeout`, 100 ms) even before it has won; if a drag then wins, you get `onTapCancel` instead of `onTap`. - **Nested taps.** A tap on a child `GestureDetector` inside a tappable parent goes to the child, because it joined first and wins the sweep. `HitTestBehavior` cannot change this; it decides who is on the path, not who wins. - **A lone recognizer is instant.** With only a horizontal drag in the arena, it wins at close, so dragging starts on the first pixel of movement instead of after the slop. ## Debugging Set `debugPrintGestureArenaDiagnostics = true` (in `package:flutter/gestures.dart`) to log every arena: who joined, who resolved, who won and why ("Eager winner", "Default winner", sweep). `debugPrintRecognizerCallbacksTrace` additionally logs each callback a recognizer fires. Both belong in debug sessions only.

  • Why does a single horizontal drag recognizer start dragging immediately, without waiting for the slop?
    When the arena closes with only one member, the arena manager resolves it in that member's favour straight away. The recognizer has already won, so it does not need to prove intent by crossing the slop, and the first pixel of movement can drive the drag.
  • What does a tap recognizer do when the finger moves 30 logical pixels before lifting?
    It rejects itself once movement exceeds its pre-accept slop tolerance, which defaults to the touch slop of 18 logical pixels. Any `onTapDown` already reported is followed by `onTapCancel`, and the remaining members, typically a drag, settle the arena.

An auction where the drag bidders shout as soon as they are sure, and the tap never bids; when the gavel falls on pointer up with no bid made, the item goes to whoever registered first at the door, the innermost tap.

saying these in an interview costs you the question

  • The parent widget always gets the gesture first because it is higher in the tree.
  • A tap recognizer declares victory as soon as the finger touches down.
  • The arena waits for pointer up before any recognizer can win.
  • Setting HitTestBehavior.opaque on the parent makes its drag beat the child's tap.
  • All recognizers share one arena for the whole screen.