開発ブログへ戻る 落とし穴レポート

SwiftUI の LongPressGesture は指が離れる前に終わる

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 になったら停止すればいい。

シグネチャが教えてくれないのはここです。LongPressGestureminimumDuration が 経過した瞬間に成功します——そして成功したジェスチャは終了した ジェスチャです。pressing クロージャの裏側は gesture state で、gesture state はジェスチャが終わるとリセットされる。 つまり pressing(false) は 約 0.35 秒の時点で、指がまだガラスにしっかり乗ったまま届きます。停止処理が開始処理の直後に 走る。実際に指が離れる瞬間は、もう誰も聞いていないイベントです。

私たちのケースでは被害がいっそう静かでした。止められた録音は約 0.35 秒。そして 「誤タップ破棄」のガードが 300ms 未満の音声を捨てるので、ストリップは黙って元に戻る。 エラーなし、クラッシュなし、途中結果もなし。動いているふりをした、一度も動いたことのない 機能です。

なぜテストが捕まえられなかったか

テストできるものはすべて緑でした。録音セッションの状態機械はユニットテスト済み、 音声パイプラインは正常、ビルドはグリーン。欠陥が住んでいるのは実行時のジェスチャ 認識器の意味論——どのコールバックがどの物理的瞬間に発火するか——であり、そこは ユニットテストの届かない、シミュレータで確かめるのも億劫な層です。捕まえたのはコード レビューで、すべてのコールバックにひとつの質問をぶつけました。「このコードは、 物理的にはいつ実行されるのか?」 pressing への正直な答えは 「あなたの思っている瞬間ではない」でした。

修正:長押しの後ろにドラッグを連結する

定石は——そして私たちの知る限り唯一信頼できる形は—— LongPressGesture の後ろに 距離ゼロの DragGesture を 連結(sequence)することです。長押しの成功が複合ジェスチャをドラッグ段階へ 引き継ぎ、ドラッグが指の本当に離れる瞬間までジェスチャを生かし続けます:

.onTapGesture { switchToVoiceBoard() }   // 短いタップ = 別のアクション
.gesture(
    LongPressGesture(minimumDuration: 0.35, maximumDistance: 30)
        .sequenced(before: DragGesture(minimumDistance: 0, coordinateSpace: .local))
        .onChanged { value in
            // ドラッグ段階では繰り返し発火する。開始はちょうど 1 回に。
            if case .second(true, _) = value, !isRecording { startRecording() }
        }
        .onEnded { value in
            // 長押しが成功したときだけ到達する——これが本物の指離れ。
            if case .second = value { stopRecording() }
        }
)

これが成立する性質は 3 つ。ホールド段階は紛れがない: .second(true, _) は 「長押しが成功し、指はまだ乗っている」の意味。リリースは本物:その時点で走っているのは ドラッグなので、複合ジェスチャの onEnded は指が離れたときに発火します。タップも汚れない:短いタップは長押しを失敗させ、 シーケンスは .second に 到達しないので、独立した onTapGesture が引き受けます。

実務的な注意を 2 つ。第一に、ホールド中は指が動くたびに onChanged が発火するので、 開始は自前のフラグで守って、ちょうど 1 回だけ実行すること。第二に、システムは onEnded を発火させずに ジェスチャをキャンセルできます(着信、ホールド中のバックグラウンド移行)。だから scenePhase の監視を添えて、 アプリがフォアグラウンドを離れたら録音を取り消す。ベルトをして、サスペンダーも。

次の人への警告を残す

壊れている版のほうが、正しい版より短くて読みやすい。これが危険なタイプのバグです。 未来のリファクタリングがこの sequenced ジェスチャを見て「これは onLongPressGesture の ワンライナーで書けるのでは」と考え、忠実に欠陥を復元するでしょう。だから私たちのコードの 上のコメントは、コードが何をするかを説明しません。より単純な書き方がなぜ間違いなのかを、 一文で書いています:LongPressGestureminimumDuration の時点で 指が乗ったまま成功して終了するため、その pressing コールバックにはリリースを検知できない。 コードの形がある罠によって決まっているなら、罠はコードのそばに書き残すべきです。

プッシュトゥトーク付きキーボードは、iPhone・iPad 向け SharkTTY の次のアップデートで 出荷されます。自分のジェスチャの罠を見つけた方、この記事の穴を見つけた方は フィードバックボードへどうぞ。