Dart Tutorial

Flutter Lesson 32 of 83 3 min read

Gestures in Flutter: GestureDetector, InkWell and Dismissible

Handle taps, long presses, drags and swipes in Flutter with GestureDetector and InkWell, and build swipe-to-delete with Dismissible.

On this page

Buttons handle taps for you. To make any widget respond to touch, wrap it in a gesture widget.

InkWell: a tap with a ripple #

Card(
  clipBehavior: Clip.antiAlias,
  child: InkWell(
    onTap: () => debugPrint('Card tapped'),
    onLongPress: () => debugPrint('Card long-pressed'),
    child: const Padding(
      padding: EdgeInsets.all(24),
      child: Text('Tap me'),
    ),
  ),
)

InkWell shows the Material ripple, so the user sees their touch register. It needs a Material ancestor such as Card, Scaffold or Material to draw on. Use it for anything that should feel like a button.

GestureDetector: every gesture, no visual effect #

import 'package:flutter/material.dart';

void main() => runApp(const MaterialApp(home: Scaffold(body: Center(child: TapBox()))));

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

  @override
  State<TapBox> createState() => _TapBoxState();
}

class _TapBoxState extends State<TapBox> {
  String _last = 'Try a gesture';

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: () => setState(() => _last = 'Tap'),
      onDoubleTap: () => setState(() => _last = 'Double tap'),
      onLongPress: () => setState(() => _last = 'Long press'),
      child: Container(
        width: 220,
        height: 220,
        alignment: Alignment.center,
        decoration: BoxDecoration(
          color: Colors.teal.shade100,
          borderRadius: BorderRadius.circular(24),
        ),
        child: Text(_last, style: const TextStyle(fontSize: 20)),
      ),
    );
  }
}
CallbackFires on
onTap, onDoubleTap, onLongPressTaps
onTapDown, onTapUpFinger down and up, with the position
onPanStart, onPanUpdate, onPanEndDragging in any direction
onHorizontalDragUpdate, onVerticalDragUpdateDragging along one axis
onScaleUpdatePinch to zoom and rotate

Empty areas do not receive taps #

By default a GestureDetector only reacts where its child paints something. A Row with a gap in the middle ignores taps on the gap. Fix it with behavior:

GestureDetector(
  behavior: HitTestBehavior.opaque, // the whole area responds
  onTap: _open,
  child: const Row(children: [Text('Label'), Spacer(), Icon(Icons.chevron_right)]),
)

Dragging #

class _DraggableDotState extends State<DraggableDot> {
  Offset _position = const Offset(100, 100);

  @override
  Widget build(BuildContext context) {
    return Stack(
      children: [
        Positioned(
          left: _position.dx,
          top: _position.dy,
          child: GestureDetector(
            onPanUpdate: (details) => setState(() => _position += details.delta),
            child: const CircleAvatar(radius: 28, child: Icon(Icons.open_with)),
          ),
        ),
      ],
    );
  }
}

details.delta is how far the finger moved since the last update.

Swipe to delete with Dismissible #

ListView.builder(
  itemCount: _items.length,
  itemBuilder: (context, index) {
    final item = _items[index];
    return Dismissible(
      key: ValueKey(item), // must be unique for each item
      direction: DismissDirection.endToStart,
      background: Container(
        color: Colors.red,
        alignment: Alignment.centerRight,
        padding: const EdgeInsets.only(right: 20),
        child: const Icon(Icons.delete, color: Colors.white),
      ),
      onDismissed: (_) {
        setState(() => _items.removeAt(index));
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(
            content: Text('$item removed'),
            action: SnackBarAction(
              label: 'Undo',
              onPressed: () => setState(() => _items.insert(index, item)),
            ),
          ),
        );
      },
      child: ListTile(title: Text(item)),
    );
  },
)

You must remove the item from your data in onDismissed. If it is still in the list on the next build, Flutter throws an error. To ask first, return a Future<bool> from confirmDismiss.

Zoom and pan #

InteractiveViewer adds pinch-to-zoom and panning to any child, with no gesture code.

InteractiveViewer(
  minScale: 1,
  maxScale: 4,
  child: Image.network('https://picsum.photos/800/600'),
)

Reordering a list #

ReorderableListView(
  onReorder: (oldIndex, newIndex) {
    setState(() {
      if (newIndex > oldIndex) newIndex--;
      final item = _items.removeAt(oldIndex);
      _items.insert(newIndex, item);
    });
  },
  children: [
    for (final item in _items) ListTile(key: ValueKey(item), title: Text(item)),
  ],
)

Blocking touches #

AbsorbPointer(absorbing: _loading, child: const MyForm()) // swallows touches
IgnorePointer(child: const DecorativeOverlay())           // lets touches pass through

Guidelines #

  • Make tap targets at least 48 by 48 logical pixels.
  • Give visible feedback for every touch: a ripple, a colour change or movement.
  • Hidden gestures such as swipe and long press are hard to discover. Offer a visible alternative too.
  • For a tappable icon, use IconButton, which already has the right size, ripple and tooltip.

Try it yourself #

Build a list of tasks where swiping left deletes with an Undo snackbar, tapping toggles a strikethrough, and long-pressing opens a bottom sheet with “Edit” and “Share” options.

Practise in the playground Updated by Santosh Adhikari