Back to the dev blog Pitfall report

SwiftUI's LongPressGesture ends before your finger lifts

We're adding a push-to-talk mic to SharkTTY's floating keyboard: press and hold to dictate, watch a live preview of the transcription, release to stop, tap the result to insert it into the terminal. The interaction is as old as walkie-talkies. The SwiftUI implementation has a trap in it that survived our entire test suite and would have shipped a feature that could not work — recording that stops itself a third of a second after it starts, every time. This is the write-up we wish we'd found first.

The obvious API is the wrong API

SwiftUI appears to offer exactly what a hold-to-record button needs:

.onLongPressGesture(minimumDuration: 0.35, maximumDistance: 30) { pressing in
    if !pressing { stopRecording() }   // ← fires at 0.35s, NOT at finger-up
} perform: {
    startRecording()
}

Read it the way most of us do — pressing tells me whether the finger is down, perform fires when the hold begins” — and it looks perfect. Start the recorder in perform, stop it when pressing goes false.

Here's the part the signature doesn't tell you: a LongPressGesture succeeds the moment minimumDuration elapses — and a gesture that has succeeded is a gesture that has ended. The pressing closure is backed by gesture state, and gesture state resets when the gesture ends. So pressing(false) is delivered at ~0.35 seconds, while the finger is still firmly on the glass. Your stop handler runs immediately after your start handler. The finger lifting, whenever it actually happens, is an event nobody is listening to anymore.

In our case the damage was extra quiet: the recording session it stopped was ~0.35s long, our “discard accidental taps” guard threw away anything under 300ms of audio, and the strip silently returned to normal. No error, no crash, no partial result. A feature that simply never worked, wearing the UI of one that did.

Why tests didn't catch it

Everything testable passed. The recording session's state machine was unit-tested; the audio pipeline worked; the build was green. The defect lives entirely in the semantics of a gesture recognizer at runtime — which callback fires at which physical moment — and that is exactly the layer unit tests don't reach and simulators make tedious to exercise. It was caught in code review, by asking one question of every callback: “at what physical instant does this actually run?” For pressing, the honest answer was “not when you think.”

The fix: sequence a drag after the long press

The canonical pattern — and as far as we can tell the only reliable one — is to sequence a zero-distance DragGesture after the LongPressGesture. The long press succeeding hands the combined gesture over to the drag phase, and the drag keeps it alive until the finger genuinely lifts:

.onTapGesture { switchToVoiceBoard() }   // quick tap = a different action
.gesture(
    LongPressGesture(minimumDuration: 0.35, maximumDistance: 30)
        .sequenced(before: DragGesture(minimumDistance: 0, coordinateSpace: .local))
        .onChanged { value in
            // Fires repeatedly during the drag phase; start exactly once.
            if case .second(true, _) = value, !isRecording { startRecording() }
        }
        .onEnded { value in
            // Reached only when the long press succeeded — the REAL finger-up.
            if case .second = value { stopRecording() }
        }
)

Three properties make this work. The hold phase is unmistakable: .second(true, _) means “the long press succeeded and the finger is still down.” The release is real: onEnded for the sequenced gesture fires at finger-up, because the drag is what's running by then. And taps stay clean: a quick tap fails the long press, the sequence never reaches .second, and the separate onTapGesture owns it.

Two practical notes. First, onChanged fires for every finger movement during the hold, so guard the start with your own flag — it must run exactly once. Second, the system can cancel a gesture without onEnded ever firing (an incoming call, app backgrounding mid-hold), so back the gesture with a scenePhase observer that cancels the recording when the app leaves the foreground. Belt, then suspenders.

Leave a warning for the next person

The broken version is shorter and more readable than the correct one. That's the dangerous kind of bug: a future refactor will look at the sequenced gesture, think “this could be an onLongPressGesture one-liner,” and faithfully restore the defect. So the comment above ours doesn't describe what the code does — it describes why the simpler thing is wrong, in one sentence: a LongPressGesture succeeds and ends at minimumDuration while the finger is still down, so its pressing callback cannot detect release. If a piece of code owes its shape to a trap, write the trap down where the code is.

The push-to-talk keyboard ships with the next SharkTTY update for iPhone and iPad. Found a gesture trap of your own, or a hole in this one? The feedback board is open.