SharkTTY의 플로팅 키보드에 눌러서 말하는 마이크를 넣고 있습니다. 꾹 누르고 말하면 실시간으로 인식 결과가 보이고, 떼면 멈추고, 결과를 탭해야 터미널에 들어갑니다. 상호작용 자체는 무전기만큼 오래됐죠. 그런데 그 SwiftUI 구현에는 함정이 있어서, 우리 테스트 스위트를 전부 통과한 채 「절대 동작할 수 없는 기능」을 멀쩡한 얼굴로 내보낼 뻔했습니다 — 녹음이 시작 후 3분의 1초 만에 매번 스스로 멈추는 겁니다. 이 글은 우리가 처음에 검색해서 찾고 싶었던 바로 그 글입니다.
가장 당연해 보이는 API가 틀린 API
SwiftUI는 눌러서 녹음하는 버튼에 필요한 걸 그대로 주는 것처럼 보입니다:
.onLongPressGesture(minimumDuration: 0.35, maximumDistance: 30) { pressing in
if !pressing { stopRecording() } // ← 0.35초에 호출된다. 손가락을 뗄 때가 아니라
} perform: {
startRecording()
}
대부분이 읽는 방식대로 읽으면 — 「pressing은
손가락이 닿아 있는지 알려주고, perform은
길게 누르기가 시작될 때 호출된다」 — 완벽해 보입니다.
perform에서 녹음을 시작하고,
pressing이 false가 되면 멈추면 되죠.
시그니처가 말해 주지 않는 부분은 이겁니다. LongPressGesture는
minimumDuration이 지나는
순간 성공합니다 — 그리고 성공한 제스처는 끝난 제스처입니다.
pressing 클로저의 뒤에는
gesture state가 있고, gesture state는 제스처가 끝나면 리셋됩니다. 그래서
pressing(false)는 약
0.35초 시점, 손가락이 아직 화면에 딱 붙어 있는 채로 도착합니다. 정지 처리가 시작 처리
바로 뒤에 실행되고, 실제로 손가락이 떨어지는 순간은 이미 아무도 듣지 않는 이벤트가 됩니다.
우리 경우 피해는 한층 조용했습니다. 끊긴 녹음은 약 0.35초짜리였고, 「실수 탭 버리기」 가드가 300ms 미만의 오디오를 전부 삼켰으며, 스트립은 아무 말 없이 원래대로 돌아왔습니다. 에러도, 크래시도, 부분 결과도 없음. 동작하는 척하는 UI를 입은, 한 번도 동작한 적 없는 기능.
테스트가 왜 못 잡았나
테스트할 수 있는 건 전부 통과했습니다. 녹음 세션의 상태 기계는 단위 테스트가 있었고,
오디오 파이프라인은 정상이었고, 빌드는 초록불이었습니다. 결함은 온전히 런타임에서의
제스처 인식기 의미론 — 어느 콜백이 어느 물리적 순간에 호출되는가 — 에 살고 있는데,
그곳이 바로 단위 테스트가 닿지 않고 시뮬레이터로 확인하기도 귀찮은 층입니다. 잡아낸 건
코드 리뷰였고, 모든 콜백에 같은 질문 하나를 던졌습니다. 「이 코드는 물리적으로 정확히
언제 실행되는가?」 pressing에
대한 정직한 답은 「당신이 생각하는 그때가 아니다」였습니다.
해결: 길게 누르기 뒤에 드래그를 연결한다
정석 패턴은 — 우리가 아는 한 유일하게 믿을 만한 패턴은 —
LongPressGesture 뒤에
거리 0의 DragGesture를
연결(sequence)하는 것입니다. 길게 누르기의 성공이 결합 제스처를 드래그 단계로
넘기고, 드래그가 손가락이 진짜로 떨어질 때까지 제스처를 살려 둡니다:
.onTapGesture { switchToVoiceBoard() } // 짧은 탭 = 다른 동작
.gesture(
LongPressGesture(minimumDuration: 0.35, maximumDistance: 30)
.sequenced(before: DragGesture(minimumDistance: 0, coordinateSpace: .local))
.onChanged { value in
// 드래그 단계에선 반복 호출된다. 시작은 정확히 한 번만.
if case .second(true, _) = value, !isRecording { startRecording() }
}
.onEnded { value in
// 길게 누르기가 성공했을 때만 도달한다 — 진짜 손가락 떼기.
if case .second = value { stopRecording() }
}
)
이 조합을 지탱하는 성질은 셋입니다. 누르는 단계는 오해의 여지가 없습니다:
.second(true, _)는
「길게 누르기가 성공했고 손가락은 아직 붙어 있다」는 뜻. 떼는 순간은 진짜입니다:
그 시점에 돌고 있는 건 드래그라서, 결합 제스처의
onEnded는 손가락이 떨어질
때 호출됩니다. 탭도 깨끗합니다: 짧은 탭은 길게 누르기를 실패시키고, 시퀀스는
.second에 도달하지 못하며,
별도의 onTapGesture가
그것을 맡습니다.
실무 메모 두 가지. 첫째, 누르는 동안 손가락이 움직일 때마다
onChanged가 호출되므로,
시작은 자체 플래그로 지켜서 정확히 한 번만 실행되게 하세요. 둘째, 시스템은
onEnded를 한 번도 호출하지
않고 제스처를 취소할 수 있습니다(전화 수신, 누른 채로 백그라운드 전환). 그러니
scenePhase 관찰자를 붙여서
앱이 포그라운드를 벗어나면 녹음을 취소하세요. 벨트를 매고, 멜빵도 하나 더.
다음 사람을 위한 경고를 남겨라
깨진 버전이 올바른 버전보다 더 짧고 더 읽기 좋습니다. 이게 위험한 부류의
버그입니다. 미래의 리팩터링이 sequenced 제스처를 보고 「이거
onLongPressGesture 한 줄이면
되잖아」라고 생각하고는, 결함을 충실하게 복원할 테니까요. 그래서 우리 코드 위의 주석은
코드가 무엇을 하는지 설명하지 않습니다. 더 단순한 쪽이 왜 틀렸는지를 한 문장으로 적습니다:
LongPressGesture는
minimumDuration에 손가락이
붙은 채로 성공하고 끝나므로, 그 pressing 콜백으로는 떼는 순간을 감지할 수 없다.
코드의 형태가 어떤 함정 때문에 정해졌다면, 그 함정을 코드 옆에 적어 두세요.
눌러서 말하는 키보드는 iPhone·iPad용 SharkTTY의 다음 업데이트로 나갑니다. 당신만의 제스처 함정을 찾았거나 이 글의 허점을 발견했다면? 피드백 보드가 열려 있습니다.