Skip to content

Gestures

A gesture has no label. Nobody sees a swipe or a double tap until they try it, so a gesture that is the only way to do something is a feature most people never find. Gestures also cost something you can feel: a double tap makes every single tap on the same view wait.

Tap each photo once.

Double tap to like

Tap the photo

Like is a button

Tap the photo

Tap each photo once to select it. Double tap the left one to like it.

The left photo also listens for a double tap to like. After your first tap it can't know whether a second one is coming, so it waits out the double-tap window before it selects. The right photo has no double tap; the heart button likes it, and the tap answers in a frame.

Treat every gesture as a shortcut for something people can see and tap.

The vocabulary

People already know what these mean. Use them for the same things, and don't give a familiar gesture a meaning that only your app has.

| Gesture | What it does | Notes | |---|---|---| | Tap | Activate, select | Fires on lift | | Double tap | Zoom in and out | Delays single taps on the same view | | Touch and hold | More options: a context menu on iOS, selection on Android | See Long Press | | Swipe | Reveal row actions, dismiss, scroll, switch pages | See Swipe Actions | | Drag | Move an item, pull a sheet | Long press, then drag, reorders | | Pinch | Zoom, magnify | | | Rotate | Rotate a selected item | | | Edge swipe | Back | System owned, see below |

The double-tap tax

A single tap can't fire until the double tap has failed, and that takes a fixed window after the finger lifts.

  • Android: DOUBLE_TAP_TIMEOUT is 300 ms. Compose's detectTapGestures holds onTap back whenever onDoubleTap is set, and combinedClickable(onDoubleClick = …) does the same.
  • React Native Gesture Handler: Exclusive(doubleTap, singleTap) waits up to maxDelay, 200 ms by default.
  • iOS: a single tap that require(toFail:) a double tap waits for it. Apple doesn't publish the window.
  • Flutter: a GestureDetector with both onTap and onDoubleTap holds the tap until the double tap loses; kDoubleTapTimeout is 300 ms.

So add a double tap only where the single tap does nothing else, or where the wait doesn't matter, like double tap to zoom a photo that's already open. If like is the main thing people do in a feed, give it a button.

System gestures

Some swipes belong to the system. Don't build anything that competes with them.

  • iOS: swipe in from the leading edge to go back. Since iOS 26, a pushed screen also goes back on a left-to-right pan anywhere in its content, so a horizontal carousel or chart scrubber there now competes with Back. Leave the bottom edge to the home indicator and the top edge to Notification Center and Control Center.
  • Android: back is an inward swipe from either edge. You can exclude small strips, like a slider thumb, but the system honors at most 200dp of exclusion per edge. Apps can't opt out of the home and quick-switch swipes at the bottom.

See Back Navigation for building back itself.

Pan against scroll

A horizontal drag inside a vertical list has to decide which way it's going before it takes the touch. Give it a small dead zone. I start the pan after about 10pt of horizontal movement and let it fail if the finger moves 10pt vertically first. That's activeOffsetX and failOffsetY in Gesture Handler. Compose's draggable and anchoredDraggable wait for the 8dp touch slop in their own direction, so a vertical LazyColumn still scrolls. In a pushed iOS 26 screen, a left-to-right pan also competes with the content back swipe; in UIKit, make interactiveContentPopGestureRecognizer wait for yours with require(toFail:).

Give every gesture a twin

Each gesture in this inbox has a visible equivalent. Use the gestures, then turn them off and do the same things with the ··· button.

Inbox
  • Maya Chen9:12
    Draft for Thursday
  • Studio Ops8:40
    Your invoice is ready
  • Leo ParkTue
    Photos from the trip
  • Nadia RahmanMon
    Re: the logo files
  • CalendarSun
    Design review moved
Swipe a row leftArchive in the ··· menu
Touch and hold a rowThe ··· button

Swipe a row left or hold it. Then turn gestures off and do the same with ···.

With gestures off, nothing is out of reach. Apple's own rule for context menus is the same: "Always make context menu items available in the main interface, too." A gesture makes something faster for people who know it. The visible button is what makes it possible for everyone else, and for VoiceOver, TalkBack, Switch Control and keyboards.

Usage

ContentView.swift
// The gesture is a shortcut; the menu is always there.
MailRow(mail)
    .swipeActions(edge: .trailing) {
        Button("Archive", systemImage: "archivebox") { archive(mail) } // iOS 17+ initializer
    }
    .contextMenu { MailActions(mail) }
    .overlay(alignment: .trailing) {
        Menu { MailActions(mail) } label: {
            Image(systemName: "ellipsis").frame(width: 44, height: 44)
        }
        .accessibilityLabel("Actions")
    }
// No onDoubleClick on a primary tap target: it delays every onClick.
Box(
    Modifier.combinedClickable(
        onClick = { open(photo) },
        onLongClick = { select(photo) },
        onLongClickLabel = "Select",
    )
) {
    PhotoImage(photo)
    IconButton(onClick = { like(photo) }, Modifier.align(Alignment.BottomEnd)) {
        Icon(Icons.Outlined.FavoriteBorder, contentDescription = "Like")
    }
}
// Gesture Handler 2.x (Expo SDK 57)
// Double tap to zoom an open photo, where the single-tap wait is fine.
const doubleTap = Gesture.Tap().numberOfTaps(2).onEnd(() => {
  zoom.value = withSpring(zoom.value > 1 ? 1 : 2);
});
const singleTap = Gesture.Tap().onEnd(() => scheduleOnRN(toggleChrome));
const taps = Gesture.Exclusive(doubleTap, singleTap); // single waits up to 200 ms

// A horizontal pan inside a vertical list: decide the axis first.
const pan = Gesture.Pan().activeOffsetX([-10, 10]).failOffsetY([-10, 10]);
// onDoubleTap makes onTap wait up to kDoubleTapTimeout (300 ms).
// Fine on an open photo, wrong on a feed card people tap all day.
GestureDetector(
  onTap: toggleChrome,
  onDoubleTap: toggleZoom,
  child: PhotoView(photo),
)

// In the feed: tap opens, like is a visible button.
GestureDetector(
  onTap: () => open(photo),
  child: Stack(children: [
    PhotoTile(photo),
    Align(
      alignment: Alignment.bottomRight,
      child: IconButton(
        icon: const Icon(Icons.favorite_border),
        tooltip: 'Like',
        onPressed: () => like(photo),
      ),
    ),
  ]),
)

Custom gestures are invisible to assistive tech. VoiceOver and TalkBack replace touch with their own gestures, so a swipe or a double tap you built does nothing for them. Expose each one as an accessibility action, and keep the button on screen.

Resources

Search concepts

Search for a command to run...