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.