Every Touch Is a Tournament
How ~250 lines of Dart decide what your finger meant — the gesture arena, from the inside.
Your finger touches the screen. Before the framework can draw a single frame in response, it has to answer a question that sounds simple but isn't: what was that?
A tap? A drag beginning? A long press that hadn't yet fired? A scroll gesture that will fling across the screen in the next 200 milliseconds? All of those interpretations are valid at the moment of contact. The framework has no way to know which one you intended — and it won't know until you either move your finger or lift it.
This is the fundamental ambiguity of touch input, and every mobile framework has to solve it somehow. Some frameworks punt the problem to the developer. Some use heuristics baked into individual widgets. Flutter's answer is different, and it's one of the most elegant pieces of design in the entire framework: a cooperative resolution protocol called the gesture arena, implemented in a single ~250-line Dart file.
The arena is not a simple priority queue. It's not a first-come-first-served dispatcher. It's a state machine with six reachable states, four distinct resolution paths, and a protocol that treats every gesture recognizer as a voluntary participant in a negotiation — not a combatant in a fight. Recognizers join, make their case, and withdraw when they know they're wrong. The arena only forces a decision when nobody will make one voluntarily.
This article is a deep tour of that design: how it works, why it works, and the decade of bug fixes, edge cases, and aha-moments that shaped it into what it is today.
The Gentlemen's Agreement
To understand why the arena is designed the way it is, you need to understand what happens in the first few milliseconds of a touch. It's not what you might expect.
When your finger touches the screen, the engine sends a PointerDownEvent to the framework. That event doesn't go to a single widget — it goes through a hit test, which walks the render tree from root to leaf, collecting every widget whose painted bounds contain the touch point. If you touch a button that sits inside a ListView that sits inside a Scaffold, all three of those widgets are in the hit test path.
Each widget in that path can register one or more gesture recognizers. The ListView registers a VerticalDragGestureRecognizer. The button might register a TapGestureRecognizer. A LongPressDraggable ancestor might register a LongPressGestureRecognizer. All of these recognizers get added to the arena for the same pointer. They all think this touch might be theirs.
And here's the first surprising thing about Flutter's design: nobody knows how many recognizers will join. The arena stays open for new entrants until the GestureBinding — which always inserts itself as the last entry in the hit test path — calls gestureArena.close(pointer). Only then is the list of competitors final. This ordering guarantee (the binding is always last) is load-bearing; if it broke, recognizers could join after the arena was already trying to resolve, and the entire protocol would unravel.
Once the arena is closed, the negotiation begins. But it's not a negotiation where every recognizer fights to the death. It's more like a room full of experts, each trying to say "wait, this might be mine" — and each equally ready to say "actually, no, this isn't my gesture after all" the moment the evidence points away from them.
The State Machine (Explained Through a Real Touch)
Let's follow a concrete scenario: you tap a GestureDetector(onTap: ...) that sits inside a vertically scrolling ListView. Your finger touches down. You don't move it. After 100ms, you lift it.
Here's what the arena sees, step by step:
Phase 1: The Arena Opens
Gesture arena 3 ★ Opening new gesture arena.
Gesture arena 3 ❙ Adding: TapGestureRecognizer#a1b2
Gesture arena 3 ❙ Adding: VerticalDragGestureRecognizer#c3d4
Gesture arena 3 ❙ Closing with 2 members.
Two recognizers have joined. The tap recognizer (from the GestureDetector) and the vertical drag recognizer (from the ListView). The arena is now closed to new members. But two members are still in it. We are in the fighting state.
Phase 2: Nothing Happens (and that's important)
No PointerMoveEvent arrives. Your finger is stationary. Both recognizers wait. The tap recognizer is waiting for PointerUpEvent. The drag recognizer is waiting for movement past the touch slop threshold (18 logical pixels). Neither has enough information to make a decision.
This waiting is the cooperative heart of the design. Neither recognizer has to do anything. The arena will not force a decision while there are multiple members and nobody has volunteered a resolution. It just sits there, holding the pointer, waiting.
Phase 3: The Sweep
Gesture arena 3 ❙ Winner: TapGestureRecognizer#a1b2
Gesture arena 3 ❙ Rejecting: VerticalDragGestureRecognizer#c3d4
Your finger lifts. PointerUpEvent arrives. GestureBinding calls gestureArena.sweep(pointer). The arena looks at its list of members — two recognizers, neither has resolved — and applies the tiebreaker: the first member that was added wins.
The TapGestureRecognizer was added first. It gets acceptGesture() called, which fires your onTap callback. The VerticalDragGestureRecognizer gets rejectGesture(), cleans up, and goes back to waiting for the next pointer.
This is Path C — the sweep win. It's the simplest resolution and the most common. When the user taps without moving, the tap wins because it was added first. The ordering of add() calls is the implicit priority system.
The Default Win, or: The Single Greatest Microtask in Flutter
Now let's change the scenario slightly. Same setup — GestureDetector inside ListView — but this time, you start scrolling. Your finger moves 30 pixels downward before you lift it.
The arena opens the same way:
Gesture arena 3 ★ Opening new gesture arena.
Gesture arena 3 ❙ Adding: TapGestureRecognizer#a1b2
Gesture arena 3 ❙ Adding: VerticalDragGestureRecognizer#c3d4
Gesture arena 3 ❙ Closing with 2 members.
But now the PointerMoveEvent stream begins. On each move, the VerticalDragGestureRecognizer checks the cumulative distance:
- 5 pixels: less than 18. Still waiting.
- 12 pixels: less than 18. Still waiting.
- 22 pixels: past the slop. "This is a drag. I'm in."
At 22 pixels, the drag recognizer calls resolve(GestureDisposition.rejected) — on itself. Wait, what?
Yes. When the drag recognizer exceeds the pre-accept slop tolerance on the primary axis, it does not immediately accept the gesture. Instead, it calls resolve(rejected) to remove itself from the tap-in-a-scrollable deadlock. It effectively says: "This is a drag gesture, but the arena still has a tap recognizer in it, and tap recognizers don't handle drags. Let me step aside and let the arena sort out the rest."
Actually, no — let me correct that. What really happens is that the drag recognizer, having exceeded preAcceptSlopTolerance, determines "this isn't a tap" and rejects itself from the gesture for that pointer. But at the same time it starts tracking the pointer for drag purposes. The rejection is about removing itself from the competition for this interpretation — not about giving up the pointer entirely.
Either way, after the drag recognizer calls resolve(rejected), it is removed from the arena's member list. Now there is only one member left: the TapGestureRecognizer.
And here's where the load-bearing single line enters the picture. In April 2016, Adam Barth changed _tryToResolveArena to schedule the single-remaining-member win asynchronously via scheduleMicrotask, rather than resolving it synchronously. The PR description is characteristically understated:
"Wait until the end of the microtask to tell gesture recognizers that they've won in the gesture arena. This lets recognizers dispose/reject themselves before the default winner is resolved."
Why does this matter? Because the PointerMoveEvent that caused the drag recognizer to reject itself is still being processed. Other recognizers might still be evaluating their state. If the arena resolved synchronously the moment the drag recognizer dropped out, the tap recognizer would win before any other recognizer could decide "wait, actually I should reject too."
The scheduleMicrotask defers the win to the end of the current microtask queue — after all the current event's handlers have run, but before the next frame. This tiny gap is enough for every recognizer to finish its evaluation. It's the difference between "the last recognizer standing wins" and "the correct recognizer wins."
This is Path B — the default win. It's the resolution that makes "tap inside scrollable" work. And it's enabled by a one-line scheduling decision that has survived ten years of framework evolution without modification.
The Eager Winner: When You Know Before Everyone Else
Some recognizers can make their decision before the arena closes. They don't need to wait for PointerMoveEvent or PointerUpEvent — they have all the information they need at PointerDownEvent time, or shortly after.
The classic example is LongPressGestureRecognizer. It starts a timer on pointer down. If the deadline fires (default: 500ms) and the pointer hasn't moved past slop, the long press recognizer knows: "This is a long press. I am the correct interpretation."
But there's a timing problem. The deadline might fire before gestureArena.close() has been called — remember, close() is deferred to the end of the hit test dispatch, which happens after the PointerDownEvent has been routed. If the long press timer fires during that routing, the recognizer tries to call resolve(accepted) while the arena is still open.
Before October 2016, this caused a crash. The resolve() call on an open arena had nowhere to go — the arena wasn't ready to decide yet. Adam Barth's fix was the eagerWinner field:
// In _resolve(), when the arena is still open:
if (disposition == GestureDisposition.accepted) {
state.eagerWinner ??= member;
return;
}
The eager winner is a promise: "I want to win, but I'll wait until the arena is closed to actually claim victory." When close() is eventually called, _tryToResolveArena checks:
if (state.eagerWinner != null) {
_resolveInFavorOf(pointer, state, state.eagerWinner!);
}
The arena immediately gives the win to the eager winner, bypassing the scheduleMicrotask entirely. There's no need to wait — someone already made a claim.
This is Path A — the eager win. It's used by long press, by any recognizer with a deadline that fires before close, and by any custom recognizer that can identify itself as the correct interpretation during the open phase.
One important consequence: when the long press wins eagerly, the tap recognizer — which is also in the arena — gets rejectGesture(). This is why a long press and a tap can coexist in the same widget without conflict: if the user holds long enough, the long press wins. If they lift before the deadline, the tap wins by sweep. The arena mediates the conflict automatically.
Hold/Release: The Escape Hatch
The sweep, the default win, and the eager win cover the vast majority of use cases. But there's one scenario they don't handle: what if a recognizer needs to defer the arena's resolution past PointerUpEvent?
Imagine a platform view — say, an embedded Android map. Flutter has touch events for the map area, but the map is actually rendered by the native Android view hierarchy. Flutter needs to know: should it handle this touch, or forward it to the native view?
The problem is that Flutter can't know immediately. It might need to wait for a response from the platform channel, or from the native gesture recognizers. If the arena sweeps on PointerUpEvent before that response arrives, the wrong thing wins.
The solution, added by Kris Giesing way back in October 2015, is hold() and release():
// Recognizer calls:
gestureArena.hold(pointer);
// Later, when ready:
gestureArena.release(pointer);
When hold() is called, sweep() on pointer-up sees isHeld == true and defers:
void sweep(int pointer) {
if (state.isHeld) {
state.hasPendingSweep = true;
return; // Not yet. Wait for release.
}
// ... normal sweep logic
}
When release() is eventually called, it checks hasPendingSweep and fires the sweep that was deferred. The arena enters the deferred state — the sixth and rarest state in the machine.
No built-in Flutter recognizer uses hold/release. It's a public API escape hatch for custom use cases: platform views, async gesture confirmation, anything that needs to say "don't decide yet, I'm still thinking." But its existence tells you something about the design philosophy: the arena doesn't assume it knows all the answers. It provides hooks for recognizers to opt out of the normal timing.
When 8 Became 18: The Touch Slop Story
There's a magic number in Flutter: 18.0 logical pixels. It's the default touch slop — the distance a pointer can move before the framework considers it "intentional movement" rather than "natural finger jitter."
It wasn't always 18. For the first two years of Flutter's life, it was 8.0. And changing it turned out to be one of the most impactful one-line changes in the framework's history.
In July 2017, Ian Hickson submitted PR #11419. The description is pure Hixie:
It was 8.0. It's now arbitrarily 18.0.
The commit message tells a better story:
"Changing this required adjusting some tests. Adjusting the tests required debugging the tests. Debugging the tests required fixing bugs that the tests revealed."
The problem was this: at 8.0 logical pixels, many real-world touches would accidentally exceed the slop, causing drag recognizers to reject taps in scrollable lists. Your finger touches down, your thumb rolls a microscopic 9 pixels, and suddenly the scroll view thinks you're scrolling when you were trying to tap. This was especially common on larger phones where thumb reach causes natural roll.
Bumping the slop to 18.0 made taps more tolerant and scrolling more deliberate. But it also broke dozens of unit tests that had been written assuming the 8.0 threshold. Fixing those tests revealed that several gesture-related bugs had been masked by the tight slop — the bugs were always there, but the 8.0 threshold caused the arena to resolve before the buggy code path was ever reached. Raising the slop changed the timing of resolution, and suddenly bugs were visible.
The slop is not hardcoded. It's defined as kTouchSlop in constants.dart and is overridable per-device via DeviceGestureSettings.touchSlop, which the engine populates from the platform's native gesture configuration. On Android, it typically matches the system's ViewConfiguration.getScaledTouchSlop(). On iOS, it's 18.0 by default but respects system accessibility settings.
And if you're writing a custom recognizer, you can override it:
class SensitiveDragRecognizer extends VerticalDragGestureRecognizer {
@override
double get preAcceptSlopTolerance => 12.0; // More sensitive than default 18.0
}
There's also postAcceptSlopTolerance — the threshold for canceling a gesture after it's been accepted. If the user starts a drag and then moves perpendicular to the drag axis by more than this value, the gesture is canceled. Both default to the same value (18.0), but they serve different purposes and can be set independently.
The Team: When Recognizers Form Alliances
By early 2017, the arena had settled. Tap works. Drag works. Long press works. The default win and eager win handle the common cases. But there was one scenario that broke everything: the Slider.
A Slider widget needs to handle two gestures simultaneously: a drag (to move the thumb) and a tap (to jump the thumb to a position). In the normal arena, these two recognizers would fight each other. The tap recognizer and the drag recognizer would both join the arena for the same pointer, and one of them would lose.
But they shouldn't fight at all. They're on the same side. The Slider doesn't care which recognizer wins — it just cares that one of them wins, and that it wins fast.
Adam Barth's solution, PR #7481 in January 2017, was the GestureArenaTeam. A team wraps multiple recognizers and presents a single face to the global arena:
final team = GestureArenaTeam();
final drag = HorizontalDragGestureRecognizer()..team = team;
final tap = TapGestureRecognizer()..team = team;
Internally, the team creates a single _CombiningGestureArenaMember that joins the global arena on behalf of all its members. From the arena's perspective, there's one competitor: the team. Inside the team, when acceptGesture() is called, the implementation picks a winner:
void acceptGesture(int pointer) {
_winner ??= _owner.captain ?? _members[0];
for (final member in _members) {
if (member != _winner) member.rejectGesture(pointer);
}
_winner!.acceptGesture(pointer);
}
Without a captain, the first team member becomes the winner. This is the Slider pattern: as soon as external competitors are out of the arena, the team wins (via default win or sweep), and the first member in the team — typically the horizontal drag recognizer — fires immediately. No waiting for slop, no microtask delay. The team has already been filtered down to one candidate.
With a captain, introduced by amirh in PR #20883 (August 2018), the captain always wins. This serves a different purpose. The captain is typically a synthetic recognizer that never claims gestures for itself — it acts as a sentinel. When the captain wins, the code that created the team knows: "some gesture in my team has been recognized." The AndroidView uses this to forward the touch to the native Android view hierarchy.
Think of it this way: without a captain, the team is a filter — it hides internal competition from the global arena. With a captain, the team is a forwarder — it relays the win to a centralized handler.
The Scars: Bugs That Shaped the Design
Every edge case in the arena exists because someone hit it in production. Here are the ones that left the deepest marks on the code:
The Tap Inside Scrollable (2016)
The bug: tapping on a button inside a ListView would sometimes fail to register. The tap recognizer and the drag recognizer were both in the arena, and the drag recognizer would reject itself — but the tap recognizer would win synchronously, before the drag recognizer's rejection had fully propagated. The result: the drag recognizer would sometimes still receive pointer events after the tap had "won," causing both gestures to fire.
The fix: scheduleMicrotask in _resolveByDefault. Defer the default win so the drag recognizer's rejection can fully process first. This is the single most important change in the arena's history.
The LongPressDraggable Crash (2016)
The bug: tapping above a LongPressDraggable would crash. The LongPressGestureRecognizer would claim the arena as an eager winner, but its MultiDragPointerState had already nulled out its reference to the arena entry. When the arena tried to resolve, it called rejectGesture() on an already-disposed state object.
The fix: the eagerWinner mechanism. Instead of resolving immediately when resolve(accepted) is called on an open arena, record the intent and defer execution until close().
The 8-to-18 Touch Slop (2017)
See above. The change itself was one line. The test fixes it required revealed three separate bugs in gesture handling that had been latent for years.
Platform View Gestures (2018)
The bug: touches on embedded platform views (AndroidView, UiKitView) were not forwarded to the native view hierarchy when they should have been. The Flutter gesture arena would claim all touches, even ones that the native view should handle.
The fix: GestureArenaTeam with captain. The captain acts as a proxy: when it wins, Flutter knows to forward the gesture to the native side.
Long Press + Scroll Deadlock (2020)
The bug: a LongPressGestureRecognizer inside a scrollable would prevent scrolling. The long press timer would fire, the recognizer would become the eager winner, and the scroll gesture would be blocked — even if the user was clearly trying to scroll.
The issue, #48447, was debated at length. The fundamental problem: the long press recognizer fires its deadline based on time, while the scroll recognizer fires based on distance. If the user is a slow scroller, the deadline might fire before the slop is exceeded.
The fix is architectural rather than code-level: don't nest long press recognizers inside scrollables. If you must, use a GestureArenaTeam to let the long press and the scroll negotiate priority.
The Hold/Release Assertion (2025)
The bug: a recognizer called hold() but never called release() — because of an error path that bypassed the release call. The arena stayed in the deferred state permanently. Every subsequent touch on that pointer ID was silently ignored.
The fix: assert-based detection. Scrollable now asserts _hold == null before entering a new scroll gesture, catching the orphaned hold. The lesson for custom recognizer authors: always pair hold with release in a try/finally or dispose().
iOS Edge Gesture Delay (2023)
The bug: gestures near the edges of an iOS device felt delayed. Taps and drags near the screen edges had a noticeable ~150ms latency compared to center-screen touches.
The root cause was iOS's edge gesture system: the OS delays delivering edge touches to the app while it waits to determine if the user is performing a system edge gesture (swipe to go back, control center, etc.). The Flutter arena handled the touch just fine once it arrived — but the arrival itself was delayed.
The issue, #120178, is fundamentally unfixable at the Flutter level. It's a platform behavior that Flutter can only work around by treating edge touches differently in the gesture detector. The practical takeaway: don't put time-sensitive gesture recognizers near screen edges on iOS.
Looking Inside: The Debugging Flag
The arena's internal logging is disabled by default but is one of the most useful debugging tools in the Flutter arsenal. Enable it with:
import 'package:flutter/gestures.dart';
debugPrintGestureArenaDiagnostics = true;
You'll get a live trace of every arena operation. Here's a real trace of a long press that fires its deadline before the pointer moves:
Gesture arena 5 ★ Opening new gesture arena.
Gesture arena 5 ❙ Adding: TapGestureRecognizer#333
Gesture arena 5 ❙ Adding: LongPressGestureRecognizer#444
Gesture arena 5 ❙ Closing with 2 members.
Gesture arena 5 ❙ Accepting: LongPressGestureRecognizer#444
Gesture arena 5 ❙ Self-declared winner: LongPressGestureRecognizer#444
Gesture arena 5 ❙ Rejecting: TapGestureRecognizer#333
Note the "Self-declared winner" message — this is the eager win path. The long press claimed the arena before close() was called, and when close() arrived, the arena immediately resolved in its favor. No sweep, no microtask, no waiting.
Compare with a tap inside a scrollable (drag rejected, default win):
Gesture arena 3 ★ Opening new gesture arena.
Gesture arena 3 ❙ Adding: TapGestureRecognizer#a1b2
Gesture arena 3 ❙ Adding: VerticalDragGestureRecognizer#c3d4
Gesture arena 3 ❙ Closing with 2 members.
Gesture arena 3 ❙ Rejecting: VerticalDragGestureRecognizer#c3d4
Gesture arena 3 ❙ Default winner: TapGestureRecognizer#a1b2
The "Default winner" message means _resolveByDefault fired via scheduleMicrotask. The drag recognizer rejected itself, the tap was the last one standing, and the arena waited until the end of the microtask before declaring it the winner.
These traces are invaluable when debugging gesture conflicts. If you see "Closing with 2 members" followed by a sweep, you know both recognizers stayed in the arena until pointer up — neither rejected itself. That's your signal that the two recognizers are fighting over the same gesture space.
Writing a Recognizer That Plays Nice
Most Flutter developers never write a gesture recognizer. The built-in ones — tap, drag, long press, scale — cover the vast majority of use cases. But if you need custom gesture detection (a "hold to confirm" button, a custom swipe direction, a three-finger gesture), you need to understand the contract your recognizer must fulfill.
Here's a minimal recognizer that confirms a gesture after the user holds still for a deadline:
class HoldToConfirmGestureRecognizer extends PrimaryPointerGestureRecognizer {
HoldToConfirmGestureRecognizer({
required this.onConfirmed,
super.deadline = const Duration(milliseconds: 800),
});
final VoidCallback? onConfirmed;
@override
void handlePrimaryPointer(PointerEvent event) {
if (event is PointerMoveEvent && isPreAcceptSlopPastTolerance) {
resolve(GestureDisposition.rejected);
}
}
@override
void didExceedDeadlineWithEvent(PointerDownEvent event) {
resolve(GestureDisposition.accepted);
}
@override
void acceptGesture(int pointer) {
super.acceptGesture(pointer);
onConfirmed?.call();
}
@override
void rejectGesture(int pointer) {
// Clean up. Another recognizer won.
}
@override
String get debugDescription => 'hold to confirm';
}
Here's what each override does and why:
handlePrimaryPointer: Called on every pointer event while this recognizer is tracking. If the pointer moves past preAcceptSlopTolerance (default 18 pixels), the recognizer calls resolve(rejected) — "this isn't a hold, the user moved." This is the self-rejection that makes the cooperative protocol work. If the recognizer stayed in the arena despite movement, it would block other recognizers (like a parent scroll view).
didExceedDeadlineWithEvent: Called when the timer fires and the pointer hasn't moved past slop. The recognizer calls resolve(accepted) — "I'm claiming this." If the arena is still open (which it typically is, since close() hasn't been called yet at this point), this sets eagerWinner. If the arena is already closed, this is an explicit claim that triggers immediate resolution.
acceptGesture: Called when the recognizer wins the arena, by any path. Always call super.acceptGesture(pointer) first — it handles internal state cleanup. Then fire your callback. This is where your custom behavior lives.
rejectGesture: Called when the recognizer loses. Another recognizer won the arena. Clean up any pending state, timers, or callbacks. Don't fire your onConfirmed callback here — the gesture was rejected.
debugDescription: Used in arena diagnostic printing. "Hold to confirm" will appear in the debugPrintGestureArenaDiagnostics trace instead of the default class name.
The key insight: your recognizer's job in the arena is not to fight for the gesture. It's to make a decision as quickly as possible and communicate that decision to the arena. The faster you call resolve(rejected) when you know you're wrong, the faster the correct recognizer can win. The cooperative protocol depends on every participant being a good citizen.
Why This Design Matters
The gesture arena is not the only way to solve touch disambiguation. UIKit uses a responder chain — a hierarchical delegation where each view can choose to handle or forward a touch. Android uses a similar system with onInterceptTouchEvent. Both are authoritative: a parent can reach into a child's gesture handling and say "no, this is mine."
Flutter's arena is different. It's cooperative, not authoritative. No recognizer can force another recognizer to lose. Every recognizer makes its own decision about whether the touch is its gesture, and the arena only steps in when nobody will decide.
This has a subtle but profound consequence: gesture recognizers are composable in a way that responder-chain systems struggle with. You can nest a GestureDetector inside a ListView inside a Dismissible inside a PageView, and all four gesture systems will negotiate among themselves without any of them needing to know about the others. The arena doesn't care what kind of recognizers are competing — it only cares about the protocol.
The trade-off is that the system relies on every recognizer behaving well. A recognizer that never calls resolve() will block the arena indefinitely. A recognizer that calls hold() and never release() creates a permanent leak. The system is robust against correct participants and fragile against bad ones — which is why the built-in recognizers are heavily tested and custom recognizer authors need to understand the contract.
But that fragility is bounded. The arena is ~250 lines of Dart in a single file. The state machine has 4 booleans and 6 states. The entire protocol is visible in a single screen of code. When something goes wrong, you can read the whole thing and understand exactly what happened. That's not just good engineering — it's good design.
Acknowledgments and Sources
The gesture arena was built primarily by Adam Barth, Kris Giesing, Ian Hickson (Hixie), amirh, and the Flutter gestures team. The design has been remarkably stable since 2017, with the core architecture unchanged since the GestureArenaTeam and captain additions.
Primary source files:
arena.dart— The arena itself (~250 lines)team.dart—GestureArenaTeamand_CombiningGestureArenaMemberrecognizer.dart— BaseGestureRecognizerandOneSequenceGestureRecognizerbinding.dart—GestureBinding, the dispatch entry pointtap.dart—TapGestureRecognizerlong_press.dart—LongPressGestureRecognizerconstants.dart—kTouchSlopand other constants
Key PRs and issues:
- PR #3552 (2016) —
scheduleMicrotaskfor default win / "tap inside scrollable" - PR #6348 (2016) —
eagerWinner/ LongPressDraggable crash fix - PR #7481 (2017) —
GestureArenaTeam - PR #11419 (2017) — Touch slop 8.0 → 18.0
- PR #20883 (2018) — Team captain support
- Issue #20953 (2018) — Platform view gesture forwarding
- Issue #48447 (2020) — Long press vs scroll deadlock
- Issue #120178 (2023) — iOS edge gesture delay
- Issue #172174 (2025) — Hold/release assertion
Further reading: