Listener reports raw pointer events and never competes for them; GestureDetector turns pointer events into gestures (tap, drag, scale) through recognizers that compete in the gesture arena; InkWell handles taps and also paints the Material ripple, hover and focus highlight.
**InkWell**: the default for a tappable surface on a Material screen; it needs a Material ancestor to draw the ripple on, and gives keyboard focus and hover feedback for free
**GestureDetector: any gesture beyond a tap (double tap, long press, drag, scale), or a screen without Material ink; it draws nothing itself
Listener: when you need every pointer event at once and must not take gestures away from anyone: a drawing canvas, a press effect, touch analytics
Common mistake**: a tap built on Listener.onPointerUp fires even after the finger slid away or the list scrolled, because nothing checks the slop or the arena
FlutterMiddle
Gestures & Pointer Input
Gesture arena, hit testing, drag and scale, custom recognizers, keyboard, hover, drag-and-drop
Questions closed
0 / 20
- 0Review
Questions
Both stop a subtree from reacting to touches. IgnorePointer removes the subtree from hit testing, so taps pass through to whatever is behind it; AbsorbPointer is hit as a solid block and swallows the taps, so widgets behind it get nothing either.
**IgnorePointer: for layers that must not block the content under them: a fading scrim, a watermark, a confetti overlay
AbsorbPointer: for freezing a form while it saves, when a tap must not fall through to what lies behind it
Ancestors still get the tap**: AbsorbPointer cuts off only its own subtree; a GestureDetector above it still receives the pointer
The look does not change: unlike onPressed: null, buttons inside do not turn grey, so show the disabled state yourself
Toggle the flag: switch ignoring or absorbing instead of adding and removing the wrapper, which would change the tree and reset the subtree's state
Wrap each row in Dismissible, remove the item from your data in onDismissed, and key the row by the item's identity, such as ValueKey(item.id). An index key moves to a different item when the list shrinks, so Flutter matches the old row's state to the wrong item.
**Remove the item in onDismissed: if it is still in the list after the animation, Flutter throws "A dismissed Dismissible widget is still part of the tree"
Why not ValueKey(index): after a removal every index shifts, and the row below inherits the deleted row's element and state, such as an expanded tile or a typed value
confirmDismiss**: returns Future<bool?>; true dismisses, false or null slides the row back, so this is where a confirmation dialog goes
**direction and background**: limit the swipe to one side and show what will happen under the row; secondaryBackground covers the other direction
Undo: keep the removed item and its index in the parent, because the row's State is disposed together with the row
Every recognizer under the finger joins an arena for that pointer; each can accept or reject, and the last one left wins. A tap does not claim the gesture while a drag is still possible, so it usually wins at pointer-up, when the arena is swept and the first member to have joined wins.
Who enters: the hit test produces a path from the deepest widget up, and dispatching the down event along it adds each detector's recognizers in that order
Winning early: a recognizer accepts as soon as it is sure: a vertical drag once the finger moves kTouchSlop (18 logical px by default) vertically, a long press after kLongPressTimeout (500 ms); all others are rejected
Dropping out: a tap rejects itself once the finger moves too far; a pointer with a single recognizer resolves as soon as the arena closes after pointer-down
The sweep: if nobody accepted by pointer-up, the first member wins, which is why the inner of two nested onTaps fires and the outer one does not
Hold: a double-tap recognizer can hold the arena after pointer-up and delay the sweep by up to 300 ms
HitTestBehavior decides whether a detector counts as hit where its child paints nothing, and whether widgets behind it are hit-tested too. With the default deferToChild, the empty gaps in a Row or inside padding are not hit, so taps there are lost.
**deferToChild**: hit only where a child is hit; the default when GestureDetector has a child
**opaque**: the whole box is hit and siblings behind it in a Stack are not tested; the usual fix for dead gaps
**translucent**: the whole box is hit and testing goes on to the siblings behind, so they receive the pointer too; the default when child is null. Two onTaps still meet in one arena, so usually only one of them fires
What counts as hit: Text, Icon, Image and a Container with a color are hit; an empty SizedBox, Padding or the space spaceBetween leaves are not. Container(color: Colors.transparent) is an invisible but hittable ColoredBox
Layout still bounds it: no behavior makes a widget hittable outside its parent's box
Tapping the star prints star, but about 300 ms after the finger lifts; tapping the padding prints nothing. The delay comes from the outer onDoubleTap, the silence from the default HitTestBehavior.deferToChild.
Star: three recognizers join the arena in hit-test order: the inner tap, the outer tap, the outer double tap. Nobody accepts by pointer-up, so the sweep decides
The hold: the double-tap recognizer holds the arena after the first release and waits kDoubleTapTimeout (300 ms) for a second tap; when it gives up, the sweep runs and the first member, the inner tap, wins
Padding: Padding paints nothing and the outer detector defers to its child, so the hit test never reaches the outer detector there and no recognizer enters the arena
Fixes: behavior: HitTestBehavior.opaque on the outer detector makes the padding a target; moving the double tap off the ancestor gives every tap inside the card its instant response back
Only overlay. translucent lets the hit test continue, so both detectors receive the pointer, but both are tap recognizers in one arena and an arena has a single winner: at pointer-up the sweep picks the first member, the overlay, because the Stack hit-tested it first.
Hit testing: a Stack tests its children from the top; the childless overlay adds itself but reports "not hit" (that is what translucent does), so the Stack carries on to the card
The arena: both TapGestureRecognizers join; the overlay's comes first in the path and wins the sweep, and the card's is rejected
**opaque prints the same: the difference is that the card would not even see the pointer
To really run both**: observe instead of competing: a Listener(behavior: HitTestBehavior.translucent, onPointerUp: ...) overlay sees every touch without joining the arena, or simply call both handlers from one onTap
Avoid the hack: a recognizer that overrides rejectGesture to call acceptGesture makes both fire, but breaks the guarantee that one gesture has one owner
Every build creates a new TapGestureRecognizer and nothing ever disposes it, because a TextSpan does not own its recognizer. Each rebuild leaves one undisposed recognizer behind; keep a single one in a State, refresh its onTap in build and dispose it in dispose().
Why nobody disposes it: TextSpan is an immutable description, and its docs say the code that creates the recognizer must call dispose(); RenderParagraph only routes pointer-down events to it
What it costs: a parent that rebuilds every frame, such as one animating the keyboard inset, creates a recognizer per frame; in a widget test 20 rebuilds created 20 and disposed none. The harm is the undisposed objects, which leak_tracker flags, plus one allocation per rebuild; dispose() is what releases any pointer a recognizer still tracks
How the fix works: the State lives as long as the widget is mounted, so one recognizer serves every rebuild; assigning onTap in build picks up the parent's newest callback
Catching it: leak_tracker: add the leak_tracker_flutter_testing dev dependency, call LeakTesting.enable() in test/flutter_test_config.dart, then give a testWidgets experimentalLeakTesting: LeakTesting.settings.withTrackedAll(); the recognizers show up as notDisposed when the test file tears down. A WidgetSpan with a TextButton is an alternative that needs no recognizer at all
Every single tap on that row now fires about 300 ms late: the arena can no longer resolve at pointer-up, because the double-tap recognizer holds it for kDoubleTapTimeout in case a second tap arrives.
Tap alone: resolved by the sweep at pointer-up, with no added delay
Tap plus double tap: the double-tap recognizer holds the arena after the first release, and the single tap waits out the whole 300 ms
Tap plus long press is free: a quick tap rejects the long press at pointer-up; the long press claims only after 500 ms, and then onTap never runs
Hide the wait: onTapDown still fires within 100 ms, so draw the pressed state there and leave only the commit in onTap
The design call: do not make every tap pay 300 ms for a rare gesture; give the secondary action its own button or a long press
Both drags are in the same arena, and each accepts as soon as the finger travels kTouchSlop (18 logical px by default) along its own axis, so on a diagonal the axis crossed first wins: a steep swipe scrolls the list, a flat one moves the row. It is a race, not a hierarchy.
Nesting does not help: the inner-first rule applies only to the sweep at pointer-up, and a drag is decided long before that
**Use onHorizontalDragUpdate, not onPanUpdate**: pan needs kPanSlop (36 px) of movement in any direction, so the list wins more diagonal swipes, the row starts moving late, and you get vertical deltas you never wanted
Same axis inside same axis is different: a horizontal carousel inside a horizontal PageView sees the same moves, the inner one is first in the path and claims first, so the outer one never moves unless you decide it should
Do not hand-roll it: Dismissible already handles the drag, the threshold, confirmDismiss and the resize animation, with the same axis race as above
Two bugs. GestureDetector asserts when pan and scale callbacks are both set, because scale is a superset of pan; and ScaleUpdateDetails.scale is measured from the start of the gesture, not from the previous frame, so multiplying it in on every update compounds it.
The assert: "Having both a pan gesture recognizer and a scale gesture recognizer is redundant; scale is a superset of pan." Both would claim the same pointers. Keep the scale callbacks and take the translation from focalPointDelta
The compounding: while the fingers keep moving at 2x, d.scale stays 2.0 on every update, so _scale *= d.scale doubles on each one. Save the scale in onScaleStart and assign base * d.scale
One finger still works: the scale recognizer tracks a single finger too, with pointerCount == 1 and scale == 1.0, so the same callbacks pan the map
Check the built-in first: InteractiveViewer already does pan, pinch, fling and bounds
A Draggable<T> carries a typed data payload and shows feedback under the finger; a DragTarget<T> accepts it in onAcceptWithDetails. The feedback is built in the Overlay, so it inherits nothing from the screen around the card: no Material and no screen-level providers.
Wrap the feedback: in Material, with an explicit width; without it, text gets the debug yellow underline. Pass what the card needs by value
**Inside a scrolling list use LongPressDraggable**: a plain Draggable claims the pointer after a small move in any direction and beats the list's scroll; the long-press variant waits, and affinity: Axis.horizontal is the other option
**childWhenDragging: what stays in the original's place during the drag
Typed payload**: a Draggable<Task> only lights up a DragTarget<Task>; onWillAcceptWithDetails filters, and the builder's candidate list drives the hover highlight. The older onWillAccept and onAccept are deprecated
Change state in one place: onAcceptWithDetails; the source's onDragEnd fires on every release, accepted or not
Reordering a list: use ReorderableListView rather than building it from these two
d.globalPosition is where the finger is on the screen, but Positioned wants the card's top-left corner in the Stack's own coordinates, so the card jumps by the Stack's offset (app bar, padding) plus the point where it was grabbed. Move it by d.delta instead.
Two mismatches at once: globalPosition is measured from the screen's corner, left and top from the Stack's; and it is the point under the finger, not the card's corner. Under a 56 px AppBar the card jumps down by the AppBar height plus the grab offset (56 + 30 px for a 60 px card grabbed at its centre)
**Why delta works**: it is the step since the previous update in the detector's own coordinates, so adding it keeps the grabbed point under the finger wherever the board sits, even inside a scaled InteractiveViewer
When you need an absolute point: for snapping to a grid, convert with the board's RenderBox.globalToLocal(d.globalPosition) (or put the detector on the board itself and use d.localPosition), then subtract the grab offset saved in onPanStart. With the detector on the card, d.localPosition is relative to the card and stays near the grab point, so it cannot give a board position
Why it slips through: on a full-screen board with no app bar only the grab offset is wrong, so a quick demo looks almost right
Catching it: a widget test: start a gesture on the card, move it by Offset(100, 0) and expect tester.getTopLeft to change by exactly that
Velocity.zero. The drag recognizer reports a velocity only when the release was a fling; a finger that stopped before lifting has no recent movement, so the velocity is zeroed and code that commits on velocity alone never fires.
Where the number comes from: a VelocityTracker fits the last ~100 ms of moves and treats the finger as stopped if no move arrived in the last 40 ms
The thresholds: under kMinFlingVelocity (50 px/s), or under kTouchSlop (18 px by default) of travel inside that window, you get zero; above, the value is capped at kMaxFlingVelocity (8000 px/s)
Commit on velocity or distance: a fling decides by its direction; otherwise how far the row travelled decides. Dismissible uses the same rule
Units: divide pixels per second by the travel distance before handing them to a 0..1 AnimationController; raw pixels make the animation finish in one frame
**primaryVelocity**: set for horizontal and vertical drags; it is null in onPanEnd, which has no primary axis
InteractiveViewer has no double-tap zoom, so you animate its TransformationController yourself; the jump comes from two writers to one Matrix4, your animation and the user's pinch. Stop the animation in onInteractionStart so the finger always wins.
**TransformationController**: a ValueNotifier<Matrix4>: listen to it for a zoom badge, assign to it to reset, and use toScene(offset) to turn a viewport point into content coordinates
Zoom around the tap: capture the point in onDoubleTapDown (onDoubleTap gets no details) and build a matrix that keeps that content point under the finger
Read, do not write, during gestures: the viewer writes the matrix while the user interacts; read the current zoom with value.getMaxScaleOnAxis()
**Inside a PageView**: keep panEnabled: false until the image is zoomed in, or the pan and the page swipe fight for the same finger
The cost: the double-tap recognizer now delays every single tap on the image, such as tap-to-hide-the-toolbar, by 300 ms
Shortcuts maps a key combination to an Intent, Actions maps that intent type to the code to run, and the lookup starts from the focused widget, so with no focus inside the subtree the shortcut never fires. For a few local keys, CallbackShortcuts with a Focus is enough.
Focus first: the usual cause: add Focus(autofocus: true) or a FocusableActionDetector inside, and remember that a focused text field keeps Space, arrows and other editing keys for itself
Why intents: one Ctrl+S can save different things in different panes, because the nearest enabled Action above the focused widget runs; a toolbar button reuses it with Actions.maybeInvoke(context, const SaveIntent()) from a context below Actions
**CallbackShortcuts: binds an activator straight to a callback: no intents, no actions, fine for one screen
Cmd vs Ctrl**: bind SingleActivator(LogicalKeyboardKey.keyS, meta: true) for macOS and control: true for the rest
A modifier during a click: shift-click range selection is not a shortcut; read HardwareKeyboard.instance.isShiftPressed inside the tap handler
Raw key events: Focus(onKeyEvent:) with KeyEvent, returning KeyEventResult.handled; RawKeyboard and RawKeyEvent are deprecated
Follow the pointer step by step and make the framework print each one: did the hit test reach the button, did something above it swallow the event, or did its recognizer lose the arena. If the button never shows up in the hit path, gestures are the wrong place to look.
**Turn on the flags in main()**: debugPaintPointersEnabled highlights every box the hit test reaches, debugPrintHitTestResults prints the path, debugPrintGestureArenaDiagnostics shows who joined and who won
Outside the parent's box: a negative Positioned or an overflowing child paints fine, but hit testing follows layout, so the part outside gets no pointers
Something invisible on top: Container(color: Colors.transparent) is a hittable ColoredBox, and Opacity or AnimatedOpacity at 0 still hit-tests; use IgnorePointer, Visibility or Offstage
Nothing to hit: a detector whose child paints nothing gets no hits under the default deferToChild
Swallowed above: an ancestor AbsorbPointer or IgnorePointer left on by a stale flag
Lost in the arena: an ancestor drag claimed the pointer first; an ancestor onDoubleTap makes the tap 300 ms late rather than dead
Disabled: onPressed: null gives no ripple at all, which looks exactly like a dead button
RawGestureDetector takes recognizer factories, so you can configure stock recognizers beyond GestureDetector (supportedDevices, allowedButtonsFilter, a custom long-press duration) or plug in your own; GestureDetector is a thin wrapper around it.
Writing one: extend OneSequenceGestureRecognizer: addAllowedPointer starts tracking each pointer, handleEvent sees every event, and resolve(GestureDisposition.accepted) wins the arena for all tracked pointers
Losing correctly: stop tracking in rejectGesture, and call resolve(GestureDisposition.rejected) as soon as your gesture can no longer happen; a recognizer that never resolves leaves the decision to the sweep, where it can win taps it never wanted
Factories, not instances: GestureRecognizerFactoryWithHandlers keeps one recognizer per type across rebuilds and re-applies only the handlers, so the map key must be the recognizer type
Usually a smell: check HitTestBehavior, an axis-specific drag or an existing widget first; overriding rejectGesture to call acceptGesture is a hack that breaks one owner per gesture
Not all of it goes through the gesture arena: a wheel tick is a PointerScrollEvent on the pointer-signal channel, a trackpad pinch is PointerPanZoom*, a pen is a stylus pointer with pressure; a touch-only canvas misses wheel zoom, trackpad pinch, hover and pen pressure.
Wheel: recognizers never see it; read it in Listener(onPointerSignal:) and claim it with GestureBinding.instance.pointerSignalResolver.register(event, handler), so the canvas and a scroll view behind it do not both act on one tick
Trackpad: where the OS reports trackpad gestures (macOS, for example), ScaleGestureRecognizer and scrollables consume the pan-zoom events, so onScaleUpdate already gets a pinch; raw Listener code needs onPointerPanZoomUpdate
**InteractiveViewer handles both**: the wheel zooms, a trackpad scroll pans, and trackpadScrollCausesScale: true makes a trackpad scroll zoom instead
Stylus and hover: check event.kind for stylus (or invertedStylus, the eraser end) and normalize pressure with pressureMin and pressureMax; touch screens usually report a fixed or unreliable value. Mouse moves with no button down are PointerHoverEvents; MouseRegion gives enter, exit and the cursor, and a mouse drag starts after about 1 px
**Branch on PointerDeviceKind, not on the platform**: iPads take mice and pens, and many laptops have touchscreens
The screen reader takes over the touch screen: your recognizers no longer see the user's swipes, and the platform sends SemanticsActions to the focused semantics node instead. A control that only understands pointer events cannot be operated at all.
Why taps still work: GestureDetector publishes tap and long-press semantic actions for onTap and onLongPress; drag callbacks become scroll actions at most, and Listener publishes nothing
Describe the control, not the gesture: a value you drag is a slider: Semantics(slider: true, value: ..., onIncrease: ..., onDecrease: ...) lets both readers adjust it with their own gestures
Gesture-only actions: a swipe to archive has no standard action, so expose it in customSemanticsActions; VoiceOver lists those in its Actions rotor, TalkBack in its actions menu
Report the result: when nothing visible takes focus after an action, put the status into text marked Semantics(liveRegion: true) so it is read out
The rule underneath: every gesture-only affordance needs a focusable alternative; check with showSemanticsDebugger: true and then on a real device