Flutter GestureDetector: Taps, Drags, Swipes, and Hit Tests

Coding Liquids tutorial cover featuring Sagnik Bhattacharya for GestureDetector taps, drags, and swipes.
Coding Liquids tutorial cover featuring Sagnik Bhattacharya for GestureDetector taps, drags, and swipes.

A card that responds to a tap is straightforward; a surface that distinguishes double taps, follows a finger, recognises a deliberate swipe, and remains predictable inside nested widgets needs more care. This tutorial builds those interactions with Flutter’s GestureDetector and shows where seemingly simple callbacks can conflict.

Follow me on Instagram@sagnikteaches

You will connect tap and press callbacks, move a widget with Offset values, classify horizontal swipes from release velocity, and control empty-space hit testing. You will also see why the gesture arena matters when parent and child detectors both want the same pointer sequence.

Connect on LinkedInSagnik Bhattacharya

The examples use only Flutter SDK classes, so a normal flutter create project is sufficient. Run them on a physical device or emulator as well as with a mouse: pointer speed, touch slop, and the size of the interactive region all affect how an interaction feels.

Subscribe on YouTube@codingliquids
The Complete Flutter Guide course thumbnail

The Complete Flutter Guide: Build Android, iOS and Web apps

Go from scratch to building industry-standard apps with Riverpod, Firebase, animations, REST APIs, and more.

Enrol now

Connect taps, double taps, and a deliberate long press

GestureDetector exposes recognisable user intentions rather than raw pointer events. onTap reports a completed primary-button tap, onDoubleTap waits for a second eligible tap, and onLongPress fires after the pointer remains down long enough without moving beyond the accepted tolerance.

The following application is paste-able as lib/main.dart. Registering both single- and double-tap callbacks introduces a short delay before onTap can be confirmed, because Flutter must first determine whether another tap is coming.

import 'package:flutter/material.dart';

void main() => runApp(const GestureApp());

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

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      home: Scaffold(
        body: Center(child: TapSurface()),
      ),
    );
  }
}

class TapSurface extends StatefulWidget {
  const TapSurface({super.key});

  @override
  State<TapSurface> createState() => _TapSurfaceState();
}

class _TapSurfaceState extends State<TapSurface> {
  String _message = 'Try a gesture';

  void _show(String message) {
    setState(() {
      _message = message;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Semantics(
      button: true,
      label: 'Gesture demonstration surface',
      child: GestureDetector(
        behavior: HitTestBehavior.opaque,
        onTap: () => _show('Single tap'),
        onDoubleTap: () => _show('Double tap'),
        onLongPress: () => _show('Long press'),
        child: SizedBox(
          width: 280,
          height: 160,
          child: DecoratedBox(
            decoration: BoxDecoration(
              color: Colors.indigo.shade100,
              borderRadius: BorderRadius.circular(20),
            ),
            child: Center(
              child: Text(
                _message,
                style: const TextStyle(fontSize: 22),
              ),
            ),
          ),
        ),
      ),
    );
  }
}

A long press is normally appropriate for a secondary action, such as opening contextual options, rather than the only route to an essential command. Keep an obvious alternative available for keyboard, switch-control, and first-time users. For more detailed feedback, related callbacks such as onTapDown, onTapUp, and onLongPressStart provide positions and timing stages.

Move a widget by accumulating pan deltas

A pan can travel in any direction. Each onPanUpdate call receives DragUpdateDetails; its delta is the movement since the previous update, while localPosition and globalPosition describe the current pointer position in different coordinate systems.

This example adds each delta to an Offset and clamps the result so the draggable square remains inside its pad. It is complete and can replace the previous main.dart.

import 'dart:math' as math;

import 'package:flutter/material.dart';

void main() => runApp(const PanApp());

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

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      home: Scaffold(
        body: Center(child: PanPad()),
      ),
    );
  }
}

class PanPad extends StatefulWidget {
  const PanPad({super.key});

  @override
  State<PanPad> createState() => _PanPadState();
}

class _PanPadState extends State<PanPad> {
  static const double _handleSize = 64;
  Offset _position = const Offset(20, 20);

  @override
  Widget build(BuildContext context) {
    return SizedBox(
      width: 320,
      height: 260,
      child: LayoutBuilder(
        builder: (context, constraints) {
          return GestureDetector(
            behavior: HitTestBehavior.opaque,
            onPanUpdate: (details) {
              final next = _position + details.delta;
              final maxX =
                  math.max(0.0, constraints.maxWidth - _handleSize);
              final maxY =
                  math.max(0.0, constraints.maxHeight - _handleSize);

              setState(() {
                _position = Offset(
                  next.dx.clamp(0.0, maxX).toDouble(),
                  next.dy.clamp(0.0, maxY).toDouble(),
                );
              });
            },
            child: DecoratedBox(
              decoration: BoxDecoration(
                color: Colors.blueGrey.shade50,
                border: Border.all(color: Colors.blueGrey),
              ),
              child: Stack(
                children: [
                  Positioned(
                    left: _position.dx,
                    top: _position.dy,
                    child: const DecoratedBox(
                      decoration: BoxDecoration(
                        color: Colors.deepOrange,
                        shape: BoxShape.circle,
                      ),
                      child: SizedBox(
                        width: _handleSize,
                        height: _handleSize,
                        child: Icon(
                          Icons.open_with,
                          color: Colors.white,
                        ),
                      ),
                    ),
                  ),
                ],
              ),
            ),
          );
        },
      ),
    );
  }
}

Using delta avoids a common jump on drag start because the handle continues from its existing position. If the surface may resize, clamp the stored offset during layout as well; an orientation change can otherwise leave an earlier position outside the new bounds.

Classify a swipe from release velocity

A swipe is usually an application-level decision, not a separate callback. onHorizontalDragEnd supplies a DragEndDetails object whose nullable primaryVelocity is the horizontal release speed in logical pixels per second. Positive values travel right and negative values travel left.

import 'package:flutter/material.dart';

void main() => runApp(const SwipeApp());

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

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      home: Scaffold(
        body: Center(child: SwipeSurface()),
      ),
    );
  }
}

class SwipeSurface extends StatefulWidget {
  const SwipeSurface({super.key});

  @override
  State<SwipeSurface> createState() => _SwipeSurfaceState();
}

class _SwipeSurfaceState extends State<SwipeSurface> {
  static const double _minimumSwipeVelocity = 700;
  String _result = 'Fling horizontally';

  void _finishDrag(DragEndDetails details) {
    final velocity = details.primaryVelocity ?? 0;

    setState(() {
      if (velocity.abs() < _minimumSwipeVelocity) {
        _result = 'Drag ended without a swipe';
      } else if (velocity > 0) {
        _result = 'Right swipe';
      } else {
        _result = 'Left swipe';
      }
    });
  }

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      key: const Key('swipe-surface'),
      behavior: HitTestBehavior.opaque,
      onHorizontalDragEnd: _finishDrag,
      child: SizedBox(
        width: 300,
        height: 180,
        child: DecoratedBox(
          decoration: BoxDecoration(
            color: Colors.teal.shade100,
            borderRadius: BorderRadius.circular(16),
          ),
          child: Center(
            child: Text(
              _result,
              style: const TextStyle(fontSize: 20),
            ),
          ),
        ),
      ),
    );
  }
}

Tune the threshold with real devices rather than treating 700 as universal. A destructive swipe should normally require both sufficient distance and velocity, display the item moving during onHorizontalDragUpdate, and offer undo. The null fallback also keeps the decision defined when no primary velocity is available.

The Complete Flutter Guide course thumbnail

The Complete Flutter Guide: Build Android, iOS and Web apps

Go from scratch to building industry-standard apps with Riverpod, Firebase, animations, REST APIs, and more.

Enrol now

Make the intended region participate in hit testing

A detector can only receive a pointer routed through its rendered bounds. An unbounded detector does not acquire a useful touch area merely because it has a callback, and transparent-looking space may not be hit-tested as expected without an explicit behaviour.

GestureDetector(
  behavior: HitTestBehavior.opaque,
  onTap: () {
    debugPrint('The entire 48 by 48 region was tapped');
  },
  child: const SizedBox(
    width: 48,
    height: 48,
    child: Icon(Icons.more_vert),
  ),
)

HitTestBehavior.opaque makes the whole bounded region a hit target and prevents targets visually behind it from also being hit at that position. translucent lets the detector receive events while allowing targets behind it to enter the hit-test result. deferToChild participates only where a child is hit. Choose the behaviour to express layering, not as a general fix for every missed gesture.

When behavior is left null, GestureDetector normally defers to its child when it has one and uses translucent behaviour when it has no child. Regardless of behaviour, establish accessible dimensions deliberately; a visible icon smaller than the recommended touch area can sit inside a larger SizedBox.

Understand why recognisers enter a gesture arena

Several widgets can observe the initial pointer hit, but Flutter should not interpret one movement simultaneously as a tap, horizontal drag, vertical scroll, and pan. Their recognisers enter a gesture arena and accept or reject the sequence as evidence accumulates. The winning recogniser receives the completed gesture callbacks; rejected recognisers may receive cancellation callbacks.

A child detector and an enclosing parent detector can therefore both see that a pointer landed within their bounds, yet changing either detector to opaque does not force it to win. Hit testing determines who may compete; the arena determines which recognised gesture succeeds.

GestureDetector(
  onTap: () => debugPrint('Outer tap'),
  child: Padding(
    padding: const EdgeInsets.all(32),
    child: GestureDetector(
      onTap: () => debugPrint('Inner tap'),
      child: const SizedBox(
        width: 160,
        height: 80,
        child: ColoredBox(color: Colors.amber),
      ),
    ),
  ),
)

In this nested tap case, the inner recogniser normally wins because it enters the arena first; the outer callback does not also represent the same tap. If both layers genuinely need the raw pointer stream, investigate Listener, but remember that raw pointer handling leaves intent recognition and accessibility decisions to your code. Arena diagnostics can be enabled during debugging with debugPrintGestureArenaDiagnostics = true from package:flutter/gestures.dart.

Choose InkWell when the surface promises a Material response

GestureDetector recognises gestures but draws no pressed state or ripple. InkWell recognises taps and paints a Material ink reaction, making it the usual choice for a tappable tile, card action, or custom Material control. It requires a Material ancestor because the splash is painted on that material.

Material(
  color: Colors.indigo.shade50,
  borderRadius: BorderRadius.circular(16),
  child: InkWell(
    borderRadius: BorderRadius.circular(16),
    onTap: () => debugPrint('Material action'),
    child: const Padding(
      padding: EdgeInsets.symmetric(
        horizontal: 24,
        vertical: 16,
      ),
      child: Text('Open details'),
    ),
  ),
)

Use GestureDetector for pan tracking, custom drag combinations, or an interaction that should not imply a Material surface. Use InkWell when visible touch feedback is part of the design. For clipped rounded ink, clip the Material itself—for example with ClipRRect around it—because clipping only the InkWell does not necessarily clip ink painted by an ancestor material.

Avoid gesture combinations that fight one another

The most frequent problems come from assigning overlapping meanings to the same movement:

  • Do not attach both pan logic and directional drag logic to one surface without a clear reason. A pan recogniser and a horizontal recogniser may compete for the same sequence; choose the narrowest gesture matching the interaction.
  • Do not expect an immediate onTap while also registering onDoubleTap. Flutter must leave time for the second tap before confirming the single tap.
  • Do not classify every completed horizontal drag as a swipe. Check velocity, distance, or both, and handle primaryVelocity being null.
  • Do not mix globalPosition with offsets measured in a local layout. Convert coordinates with a RenderBox when necessary, or use localPosition and delta consistently.
  • Do not use HitTestBehavior.translucent expecting it to make a parent win the arena. It affects hit-test participation, not recogniser priority.
  • Do not hide essential actions behind long presses or swipes. Supply labelled, focusable alternatives and verify the control with screen-reader and keyboard navigation.

Also consider cancellation. A recogniser can lose the arena or have its pointer sequence interrupted, so temporary visual state started in onPanStart should be cleared from onPanEnd and onPanCancel. Treat cancellation as a normal outcome rather than an exceptional one.

Verify swipe direction with a widget test

Widget tests can generate gestures at controlled speeds. This self-contained test builds a horizontal detector, performs a rightward fling, and confirms that the release velocity selects the expected branch.

import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';

void main() {
  testWidgets('a fast rightward fling is a right swipe',
      (tester) async {
    String result = 'waiting';

    await tester.pumpWidget(
      MaterialApp(
        home: Scaffold(
          body: StatefulBuilder(
            builder: (context, setState) {
              return GestureDetector(
                key: const Key('surface'),
                behavior: HitTestBehavior.opaque,
                onHorizontalDragEnd: (details) {
                  final velocity = details.primaryVelocity ?? 0;
                  setState(() {
                    result = velocity > 700
                        ? 'right swipe'
                        : 'not a swipe';
                  });
                },
                child: SizedBox(
                  width: 300,
                  height: 160,
                  child: Center(child: Text(result)),
                ),
              );
            },
          ),
        ),
      ),
    );

    await tester.fling(
      find.byKey(const Key('surface')),
      const Offset(250, 0),
      1200,
    );
    await tester.pumpAndSettle();

    expect(find.text('right swipe'), findsOneWidget);
  });
}

Add companion tests for a slow drag, a leftward fling, and a cancelled interaction if cancellation changes visible state. Automated checks confirm callback decisions, but device testing remains necessary for comfort: a threshold that passes a synthetic fling may still feel too sensitive with a thumb inside a scrolling screen.

Further reads

Keep going with these related tutorials from this site.