Swipe Trap

Status: ready

Symptom

An important action exists only as a swipe, drag, long press or pinch. A touch user may find it. A VoiceOver, Switch Control, Voice Control or keyboard user can't perform it.

Task:        remove an item from favorites
Only path:   custom swipe left on the row

That's not a shortcut. That's the only door, and it's hidden.

Where it shows up

Delete or archive on a row, reordering, revealing hidden actions, map zoom, onboarding carousels, card dismiss, drag to confirm.

Why it blocks the task

VoiceOver changes what swipes mean: a one-finger swipe moves focus, it doesn't reach the app. Switch Control and Voice Control can't perform a custom path gesture like a finger. A user with a tremor may not manage a long press or a precise drag. If the gesture is the only path, the task is impossible for these users.

Root cause

The team designed for one input method.

  1. A custom DragGesture replaces the system swipe actions, and nothing else exposes the action.
  2. Drag-to-reorder is the only way to change the order.
  3. The map zooms only by pinch.
  4. A long press opens a menu that has no visible button.
  5. QA tested with a finger only.

Before

FavoriteRow(tea)
    .gesture(
        DragGesture().onEnded { value in
            if value.translation.width < -80 { removeFavorite(tea) }
        }
    )

VoiceOver focuses the row and has no way to remove it.

Fix

Verdict: the gesture stays as a shortcut. The same action is also a real control or an accessibility action. Which one depends on how often the action is used.

Why: touch users keep the fast path. Everyone else gets a path that doesn't depend on finger movement.

Action Alternative
Primary, frequent (add to cart) Visible button
Secondary, several per row (archive, share, delete) Visible "More" menu, or accessibility actions
Secondary, one per row Accessibility action
Pinch, drag on a map or a chart Buttons (Zoom in, Zoom out) or a list (Map Dead End)

System swipeActions in a List are already exposed to VoiceOver in the Actions rotor. Use them instead of a custom drag, and don't add a duplicate accessibilityAction:

List(favorites) { tea in
    FavoriteRow(tea)
        .swipeActions {
            Button("Remove", role: .destructive) { removeFavorite(tea) }
        }
}

A custom gesture needs the action added explicitly:

FavoriteRow(tea)
    .gesture(swipeToRemove(tea))
    .accessibilityAction(named: "Remove from favorites") { removeFavorite(tea) }

Several secondary actions, visible to everyone:

Menu {
    Button("Share") { share(tea) }
    Button("Remove from favorites", role: .destructive) { removeFavorite(tea) }
} label: {
    Label("More actions for \(tea.name)", systemImage: "ellipsis.circle")
}

A destructive action reached by an accessibility action gets the same confirmation or undo as the swipe.

Check

Profile:   VoiceOver
Task:      remove a tea from favorites
Expected:  Actions rotor on the row offers "Remove", once
           undo or confirmation is available
Profile:   Switch Control
Task:      the same
Expected:  reachable without a drag, pinch or long press
Profile:   Voice Control
Task:      "Tap More actions for jasmine tea", then "Tap Remove from favorites"
Expected:  both commands work

WCAG mapping

  1. 2.5.1 Pointer Gestures: path-based gestures have a single-pointer alternative.
  2. 2.1.1 Keyboard: the action is operable without touch.
  3. 2.5.7 Dragging Movements: a drag has an alternative without dragging.
  1. Gesture Alternative
  2. Accessible Card
  3. Platform Interaction Contract
  4. Evidence Based Release
  1. Chapter 7
  2. Chapter 10
  3. Chapter 13
  4. Chapter 17
  1. VoiceOver gestures guide

Sources

  1. Apple Human Interface Guidelines: Gestures
  2. Apple Developer: accessibilityAction
  3. W3C: Understanding SC 2.5.1 Pointer Gestures
  4. W3C: Understanding SC 2.5.7 Dragging Movements