In a Flutter email app, a swipe-to-archive tile inside a horizontal PageView blocks paging over it; how do you settle this with RawGestureDetector and a custom recognizer?
answer
- two horizontal drag recognizers
- innermost sees the move first
- hasSufficientGlobalDistanceToAccept
- signed globalDistanceMoved
- constructor once, initializer every build
basics
~20 sBoth widgets add horizontal drag recognizers, and the tile's, being innermost, sees the move first and wins either direction. Subclass HorizontalDragGestureRecognizer to accept only leftward drags, and install it with RawGestureDetector and GestureRecognizerFactoryWithHandlers so rightward swipes reach the PageView.
solid answer
~40 sThe tile and the `PageView` each put a `HorizontalDragGestureRecognizer` into the pointer's arena; events reach recognizers innermost first, so the tile crosses the slop first and wins in both directions. I subclass `HorizontalDragGestureRecognizer` and override `hasSufficientGlobalDistanceToAccept` to return true only when the signed `globalDistanceMoved` is below minus `computeHitSlop(...)`, a leftward drag. A rightward swipe then leaves the page view's recognizer to win. Because `GestureDetector` only builds stock recognizers, I install mine with `RawGestureDetector`, keyed by its type, using `GestureRecognizerFactoryWithHandlers`: the constructor closure creates it once, the initializer assigns `onUpdate` and `onEnd` on every build. I flip the direction for RTL and add a non-swipe archive action.
code
dart · 69 linesimport 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
/// Claims only leftward drags; rightward ones stay available to the PageView.
class LeftSwipeRecognizer extends HorizontalDragGestureRecognizer {
LeftSwipeRecognizer({super.debugOwner});
@override
bool hasSufficientGlobalDistanceToAccept(
PointerDeviceKind pointerDeviceKind,
double? deviceTouchSlop,
) {
// globalDistanceMoved is signed along the horizontal axis.
return globalDistanceMoved < -computeHitSlop(pointerDeviceKind, gestureSettings);
}
}
class ArchivableTile extends StatefulWidget {
const ArchivableTile({
super.key,
required this.subject,
required this.onOpen,
required this.onArchive,
});
final String subject;
final VoidCallback onOpen;
final VoidCallback onArchive;
@override
State<ArchivableTile> createState() => _ArchivableTileState();
}
class _ArchivableTileState extends State<ArchivableTile> {
double _dx = 0;
@override
Widget build(BuildContext context) {
return RawGestureDetector(
behavior: HitTestBehavior.opaque,
gestures: <Type, GestureRecognizerFactory>{
LeftSwipeRecognizer: GestureRecognizerFactoryWithHandlers<LeftSwipeRecognizer>(
() => LeftSwipeRecognizer(debugOwner: this), // once per State
(LeftSwipeRecognizer instance) {
// Runs on every build, so callbacks see the latest widget.
instance
..onUpdate = (details) => setState(() {
_dx = (_dx + details.delta.dx).clamp(-160.0, 0.0);
})
..onEnd = (details) {
if (_dx < -120) {
widget.onArchive();
}
setState(() => _dx = 0);
};
},
),
TapGestureRecognizer: GestureRecognizerFactoryWithHandlers<TapGestureRecognizer>(
() => TapGestureRecognizer(debugOwner: this),
(TapGestureRecognizer instance) => instance.onTap = widget.onOpen,
),
},
child: Transform.translate(
offset: Offset(_dx, 0),
child: ListTile(title: Text(widget.subject)),
),
);
}
}go deeper
Recognise the symptom: two widgets that both want horizontal drags cannot both win, and the inner one usually does.
Explain why the innermost drag recognizer wins and how RawGestureDetector and GestureRecognizerFactoryWithHandlers install a recognizer GestureDetector cannot build.
Write the direction-aware recognizer, handle RTL and semantics, and verify with arena diagnostics that taps and vertical scrolls still resolve as expected.
Weigh custom recognizers against changing the interaction, since every custom recognizer is gesture code the team must test, document and keep accessible.
## Why the tile swallows every swipe An email app shows folders in a horizontal `PageView`; each message tile can be swiped left to archive. As soon as the tile gets a horizontal drag handler (`onHorizontalDragUpdate` on a `GestureDetector`, or a `Dismissible`), users can no longer page between folders by swiping over a message. The arena explains it. Both the tile and the `PageView`'s `Scrollable` put a `HorizontalDragGestureRecognizer` into the pointer's arena. Recognizers receive pointer events in the order they joined, which is hit-test order — **innermost first**. When the finger crosses the touch slop, the tile's recognizer sees that move event first, declares victory, and the page view's recognizer is rejected. Direction does not matter: a stock horizontal drag recognizer accepts on `globalDistanceMoved.abs()`, left or right. ## Settling it with a custom recognizer The fix is to make the tile's recognizer **only claim the drags it can use**. `DragGestureRecognizer` exposes the decision as an overridable method: - `hasSufficientGlobalDistanceToAccept(kind, deviceTouchSlop)` — return `true` to declare victory. - `globalDistanceMoved` — distance moved since the pointer went down, **signed** along the recognizer's axis (negative is leftward for a horizontal recognizer). - `computeHitSlop(kind, gestureSettings)` — the slop the stock implementation uses. Override the method to return `true` only for leftward movement beyond the slop. A rightward swipe then never triggers the tile's recognizer; the page view's recognizer passes its own slop test on the same move and wins, and the tile's recognizer is rejected. A leftward swipe goes to the tile, as before. ## Wiring it in with `RawGestureDetector` `GestureDetector` only builds the framework's own recognizer types, so a custom class needs **`RawGestureDetector`**: | Piece | Role | |---|---| | `gestures` | a `Map<Type, GestureRecognizerFactory>` keyed by the recognizer's exact runtime type | | `GestureRecognizerFactoryWithHandlers<T>(constructor, initializer)` | the usual factory: two closures | | constructor closure | creates the recognizer **once**; reused across rebuilds while the same type key stays in the map | | initializer closure | runs on **every build** to (re)assign callbacks such as `onStart`, `onUpdate`, `onEnd`, so they capture the latest `widget` | | `behavior` | the hit-test behaviour, as on `GestureDetector` | Recognizers whose type disappears from the map are disposed automatically. A debug assertion fires if a factory's constructor returns a different type from its key — the reason subclasses need their own key. ## Checks before shipping 1. **Right-to-left layouts.** "Leftward to archive" should follow the reading direction; read `Directionality.of(context)` and flip the sign for RTL. 2. **Semantics.** `RawGestureDetector`'s default semantics look up the stock types by exact key (`TapGestureRecognizer`, `HorizontalDragGestureRecognizer` and a few others), so a custom subclass gets no automatic scroll action. Offer archive another way — a menu item or a custom semantics action — so screen-reader users are not left out. 3. **Tap still works.** A stationary tap has no drag winner, so the sweep awards the tile's `TapGestureRecognizer` and the message opens. 4. **Vertical scrolling.** The folder's `ListView` adds a vertical drag recognizer; a mostly vertical move exceeds its slop first and scrolls the list, independent of this change. ## Alternatives and when to use them - **Change the product.** Paging by tabs instead of swipes, or archiving via a long-press menu, removes the conflict outright. - **`GestureArenaTeam`.** Puts several recognizers in one team so they compete as a unit, optionally with a `captain` that wins on the team's behalf; useful when two recognizers of one widget should not fight each other, not for splitting directions between parent and child. - **`EagerGestureRecognizer`.** Accepts immediately and wins every pointer it sees; it exists mainly for embedding native views and is almost never what a list tile needs. - **Nested scroll views** fighting over the same axis are a scrolling topic of their own, handled with scroll physics and controllers rather than custom recognizers.
- Why is the recognizer created in one closure and configured in another?`RawGestureDetector` keeps recognizers alive across rebuilds, keyed by type. The constructor closure runs only when no recognizer of that type exists yet, so in-progress gestures survive a rebuild. The initializer runs on every build to refresh callbacks, so they never capture a stale `widget` or `State` value.
- What does GestureArenaTeam do, and would it fix this conflict?A `GestureArenaTeam` makes several recognizers join the arena as one member; when the team wins, its `captain`, or the first member to accept, gets the pointer. It is for recognizers that should cooperate, not for a parent and child that should split directions, so it is not the fix here.
saying these in an interview costs you the question
- Wrap the PageView in an opaque GestureDetector so it gets the drag first.
- The outer PageView's recognizer always sees move events before the tile's.
- GestureDetector accepts custom recognizer subclasses through its callbacks.
- The factory's constructor closure runs on every rebuild.
- A stock HorizontalDragGestureRecognizer only accepts drags in one direction.