Gestures & Pointer Input
Irbisa · cheatsheetSeptember 13, 2026

Gestures & Pointer Input

Gesture arena, hit testing, drag and scale, custom recognizers, keyboard, hover, drag-and-drop

Middle Developer16 itemscompressed for a skim
  1. 01

    A drawing canvas needs the raw finger position on every pointer move — do you reach for Listener or GestureDetector, and what does each one cost you?

    Easy

    Listener hands you the raw pointer stream and never competes for it, while GestureDetector runs recognizers that compete in the gesture arena and only call back once they have won.

    // Raw pointer stream — every move, no arena, no delay.
    Listener(
      behavior: HitTestBehavior.opaque,
      onPointerDown: (e) => _strokes.add(Stroke(e.localPosition)),
      onPointerMove: (e) => _strokes.last.extend(e.localPosition),
      onPointerUp: (e) => _commit(),
    …
  2. 02

    Why does onTap fire only when the user lifts their finger, and why does a tap inside a scrollable sometimes not fire at all?

    Easy

    Because a tap is decided by the gesture arena: on pointer-down every recognizer under the finger joins an arena for that pointer, and the tap only wins once nothing else has claimed the sequence.

    // Feedback on down, commit on win, undo on loss — how a button really behaves.
    class PressableCard extends StatefulWidget {
      const PressableCard({super.key, required this.onOpen, required this.child});
      final VoidCallback onOpen;
      final Widget child;
    …
  3. 03

    Taps on the empty half of your GestureDetector do nothing, and the widget behind it in the Stack never fires either — what are deferToChild, opaque and translucent doing?

    Medium

    HitTestBehavior decides two separate things: whether the box counts as a hit target where no child was hit, and whether the hit test stops there or continues to whatever is painted behind it.

    // Dead zone: nothing is painted between the icon and the text, so the gap
    // defers to a child that is not there.
    GestureDetector(
      onTap: _open,                              // behavior defaults to deferToChild
      child: const Row(
        mainAxisAlignment: MainAxisAlignment.spaceBetween,
    …
  4. 04

    A close button hangs half outside its card with Positioned(top: -12) and paints perfectly, but taps on the overhanging half do nothing — why?

    Medium

    Hit testing is bounded by layout, not by painting: RenderBox.hitTest starts with size.contains(position) and refuses anything outside its own box, so a pointer landing outside the Stack never reaches the child painted there.

    // Paints fine, but the outer 12 px sit outside the Stack — dead to hit testing.
    Stack(
      clipBehavior: Clip.none,   // stops the clipping, does nothing for taps
      children: [
        const AttachmentCard(),
        Positioned(
    …
  5. 05

    You add onDoubleTap to a list row that already had onTap, and QA reports that the whole screen now feels laggy — what did that one line cost?

    Medium

    A single tap can no longer be resolved when the finger lifts: the arena has to stay open for kDoubleTapTimeout (300 ms) in case a second tap arrives, so every onTap on that detector now fires 300 ms late.

    // Every single tap on this row now waits 300 ms for a possible second tap.
    GestureDetector(
      onTap: _open,
      onDoubleTap: _like,
      child: const PhotoTile(),
    );
    …
  6. 06

    Swipe-to-archive rows inside a vertical ListView: a diagonal swipe sometimes scrolls the list and sometimes moves the row — what decides it, and how do you make it deterministic?

    Medium

    Both recognizers are in the same arena and each claims victory the moment the finger crosses kTouchSlop (18 logical pixels) on its own axis, so on a diagonal the winner is whichever axis the user crossed first — that is a race, not a hierarchy.

    // Pan claims on ANY direction — this row kills the list's vertical scroll.
    GestureDetector(
      onPanUpdate: (d) => setState(() => _offset += d.delta.dx),
      child: const MessageRow(),
    );
    …
  7. 07

    GestureDetector throws at construction the moment onScaleUpdate appears next to onPanUpdate — what is it telling you, and which of the two should survive?

    Medium

    Scale is a superset of pan, so registering both on one GestureDetector is an error the constructor asserts on — keep the scale callbacks and read the pan out of ScaleUpdateDetails.focalPointDelta.

    // Throws: "Having both a pan gesture recognizer and a scale gesture recognizer
    // is redundant; scale is a superset of pan."
    GestureDetector(
      onPanUpdate: (d) => _pan(d.delta),
      onScaleUpdate: (d) => _zoom(d.scale),
      child: const MapSurface(),
    …
  8. 08

    You need a drag that only two fingers can start, and GestureDetector has no knob for it — what does RawGestureDetector give you that GestureDetector does not?

    Medium

    RawGestureDetector takes a map of recognizer factories instead of a fixed list of callbacks, so you can configure recognizers in ways GestureDetector never exposes and register recognizers of your own — GestureDetector is a thin wrapper that builds exactly this.

    // Stock recognizers, configured the way GestureDetector will not let you.
    RawGestureDetector(
      behavior: HitTestBehavior.opaque,
      gestures: <Type, GestureRecognizerFactory>{
        LongPressGestureRecognizer:
            GestureRecognizerFactoryWithHandlers<LongPressGestureRecognizer>(
    …
  9. 09

    Dragging a card out of a list into a bucket works, but the dragged copy renders unstyled and the list stops scrolling — what is Draggable doing?

    Medium

    Draggable builds its feedback into the app's Overlay and claims the pointer the moment the finger goes down, so the feedback inherits nothing from where the card lives and the ListView never gets a chance to scroll.

    // A card the user drags out of a scrolling list.
    LongPressDraggable<Task>(          // long press, so the ListView keeps its vertical drag
      data: task,
      maxSimultaneousDrags: 1,
      dragAnchorStrategy: pointerDragAnchorStrategy,
      feedback: Material(              // the Overlay has no Material above it
    …
  10. 10

    You swipe a row away, the app throws "A dismissed Dismissible widget is still part of the tree", and the row below shows the wrong expanded state — what went wrong?

    Medium

    onDismissed tells you the row has left the screen, not that it has left your data — you have to remove the item in the same frame, and the row's state has to be keyed by the item's identity rather than by its position.

    // Index key, no removal: throws, and the row below inherits the deleted row's state.
    ListView.builder(
      itemCount: tasks.length,
      itemBuilder: (context, i) => Dismissible(
        key: ValueKey(i),                          // shifts when the list shrinks
        onDismissed: (_) => _showUndoBar(),        // tasks[i] is still in the list
    …
  11. 11

    Pinch-zoom in your photo viewer works until someone double-taps mid-pinch and the image jumps to a random scale — how should you drive an InteractiveViewer?

    Medium

    InteractiveViewer and your double-tap animation are two writers to one Matrix4, so the animation has to be cancelled the instant the user touches the widget again.

    class _ZoomableState extends State<Zoomable> with SingleTickerProviderStateMixin {
      final TransformationController _transform = TransformationController();
      late final AnimationController _anim = AnimationController(
        vsync: this,
        duration: const Duration(milliseconds: 250),
      )..addListener(() => _transform.value = _zoom!.value);
    …
  12. 12

    Your swipe-to-delete flies the card away on a flick but does nothing when the user drags far and then pauses before lifting — what is DragEndDetails.velocity reporting?

    Medium

    A release that was not a fling reports exactly Velocity.zero, not a small number — the recognizer decides whether the gesture ended in a flick at all and zeroes the velocity when it did not.

    // Velocity alone: a slow drag that stops before the lift reports 0 and never commits.
    onHorizontalDragEnd: (d) {
      if (d.primaryVelocity!.abs() > 300) _delete();
    },
    
    // Raw pixels/second into a 0..1 controller: the card teleports.
    …
  13. 13

    Pinch-zoom on your canvas is dead on a MacBook trackpad and the mouse wheel only scrolls it — which events is Flutter delivering that a phone never sends?

    Hard

    Mice and trackpads do not send touch pointers: a wheel tick arrives as a PointerScrollEvent on the pointer signal channel, and a two-finger trackpad gesture arrives as PointerPanZoomStart/Update/End — neither is a pointer sequence, so neither reaches the gesture arena on its own.

    // Wheel and trackpad arrive outside the arena: a Listener sees them, gestures do not.
    Listener(
      onPointerSignal: (event) {
        if (event is! PointerScrollEvent) return;
        // Register, or the ListView behind you zooms and scrolls on the same tick.
        GestureBinding.instance.pointerSignalResolver.register(event, (e) {
    …
  14. 14

    Ctrl+S does nothing in your desktop build, and shift-click range select in a list has no way to ask whether Shift is down — how do you wire keyboard input today?

    Hard

    Shortcuts only fire while focus is inside their subtree, and a modifier held during a click is not a shortcut at all — that is HardwareKeyboard's synchronous state, a different question with a different answer.

    import 'package:flutter/services.dart';
    
    class SaveIntent extends Intent {
      const SaveIntent();
    }
    …
  15. 15

    With VoiceOver on, your drag-to-set brightness control cannot be moved at all and its long-press menu never opens — what happens to pointer events when a screen reader is running?

    Hard

    A screen reader takes the touch screen over: your recognizers stop receiving pointers, and the platform sends SemanticsActions to the focused semantics node instead, so a control that only understands pointer events becomes unusable.

    // Pointer-only: with a screen reader on, no PointerEvent ever arrives here.
    Listener(
      onPointerMove: (e) => _setBrightness(e.localPosition.dx / _width),
      child: const BrightnessBar(),
    );
    …
  16. 16

    A button in the corner of a card is simply dead — no ripple, no callback, nothing in the logs — how do you find out where that tap actually goes?

    Hard

    Follow the pointer's path in order and make the framework print each step: did the hit test even reach the widget, did something above swallow it, and did the recognizer lose the arena.

    import 'package:flutter/gestures.dart';
    import 'package:flutter/rendering.dart';
    
    void main() {
      debugPaintPointersEnabled = true;          // flashes every box that hit-tests
      debugPrintHitTestResults = true;           // the full path, leaf first
    …