playpark で開発している Ima. は、一緒にいる時間だけ、その場の全員でスマホを手放すための iOS アプリです。家族の食卓などで全員が「いま置こう」と合意したときだけセッションが始まり、相手から離れると自動で解ける。我慢で依存を断つ禁欲ツールというより、目の前の相手との時間を守るための、合意ベースの仕掛けに近いものを目指しています。
そのセッション中、選んだアプリは全部ロックされます。ロック中の画面には「緊急」と書かれた赤いボタンがひとつだけ残っていて、これを3秒長押しすると、すべてのロックがその場で解けます。子どもの急な発熱で連絡を取りたい、といった「いますぐ全部開けたい」場面のための命綱ボタンです。だからこそ、ここは絶対に確実に動かないといけない。
ところが、このボタンに「3秒しっかり押したはずなのに、たまに解除されない」という報告が出ました。しかも毎回ではなく、ときどき。再現条件がはっきりしない、いちばん厄介なやつです。この記事は、その間欠バグの原因が SwiftUI のジェスチャ設定にあったこと、そしてどう直したかの記録です。読者として iOS / SwiftUI 開発者を想定しています。
Ima. は App Store にリリース済みです。
症状: リングは満タンなのに解除されない
ボタンの UX はこうです。中央に「緊急」の文字、その周りを囲む赤いプログレスリング。押し続けるとリングが時計回りに伸びていき、3秒で満タン(一周)になると、その瞬間に解除される。途中で指を離せばリングは即座にゼロに戻ります。押している実感をリングで返す、よくある長押し UI です。
報告された症状はこうでした。「3秒押した。リングもちゃんと満タンになった。なのに、ロックが解けないことがある」。
ここが気持ち悪いポイントで、リングが満タンになっているということは、3秒は確かに押せている。なのに解除だけが起きない。しかも毎回ではない。手元で何十回も試すと、たいていは解ける。でも、たまに空振りする。再現しようとすると再現しない(直す側からすると、いちばん心が折れるパターンです)。典型的な間欠バグでした。
なぜ「たまに」なのか — 発火源と表示源が別だった
間欠的に振る舞う、つまり同じ操作なのに結果が割れる、という時点で、どこかに非決定的な入力が混ざっています。解除されたあとの処理 — ロック解除ボタンが押されてから、セッションの状態遷移を経て、実際にシールド(ロック)を無効化するまで — は一本道で、呼ばれれば毎回同じ結果になる決定的な経路でした。間欠性を生んでいるのは、もっと手前のジェスチャ層しかありえない。
調べると、このボタンのジェスチャは2系統に完全に分離していました。
| 系統 | 役割 | 駆動 |
|---|---|---|
LongPressGesture(minimumDuration: 3.0) | 解除を発火する唯一の経路 | minimumDuration への到達 |
DragGesture(minimumDistance: 0) + タイマー | プログレスリングの見た目だけ | 経過時間 |
つまり、画面に見えている進捗(リング)と、実際に解除を起動するトリガーが、別々のジェスチャで駆動されていた。リングを満タンにするのは DragGesture + タイマー、解除を撃つのは LongPressGesture。普段は両方が3秒で揃って完了するので、リングが満タンになる瞬間に解除も走り、何の問題もなく見えます。問題は、この2つが「揃わない」ことがある点でした。
before 側のコードは、ざっくりこういう構造になっていました。
.gesture(
LongPressGesture(minimumDuration: 3.0)
.onEnded { _ in
UINotificationFeedbackGenerator().notificationOccurred(.success)
onReleased() // ← 解除を撃つのはここだけ
}
)
.simultaneousGesture(
DragGesture(minimumDistance: 0)
.onChanged { _ in
if holdTimer.progress() == 0 {
holdTimer.beginHold()
startTicker() // ← リングを進めるのはこっち
}
}
.onEnded { _ in
holdTimer.endHold()
stopTicker()
displayProgress = 0
}
)
LongPressGesture の onEnded でしか onReleased() を呼んでいない。一方リングは DragGesture 由来のタイマーが回している。発火源と表示源が、文字どおり別物だったわけです。
根本原因: maximumDistance のデフォルトは 10pt
ジェスチャのイニシャライザに並ぶデフォルト引数、普段どれだけ意識していますか。minimumDuration は自分で書くけれど、その隣に何が効いているかは実際どうなの? 犯人は、まさにそこにいました。正しくは LongPressGesture(minimumDuration:maximumDistance:) で、maximumDistance のデフォルト値は 10ptです。これは「長押しと判定するために、指がどれだけ動いてもよいか」の許容範囲。3秒の保持中に指がこの 10pt を超えて動くと、LongPressGesture は「長押しではなくドラッグだった」と見なして失敗し、onEnded が呼ばれません。
before のコードは maximumDistance を省略していたので、暗黙的に 10pt 制約が効いていました。ここで2系統分離が牙を剥きます。
- 指が 10pt 以内で収まった押下 →
LongPressGesture成功 →onEnded発火 → 解除される - 指が 10pt を超えてずれた押下 →
LongPressGesture失敗 →onEnded呼ばれない → 解除されない
そして決定的なのは、リング側です。リングを駆動する DragGesture(minimumDistance: 0) は指がどれだけ動こうが継続するので、指がずれていてもタイマーは止まらず、リングは満タンまで進みます。結果、ユーザーには「3秒しっかり押した、リングも満タン、なのに解けない」とだけ映る。
10pt という許容量は、ボタンが小さければ問題になりにくい値です。でもこの緊急ボタンは直径 88pt あります。88pt のボタンを指で3秒押さえている間に、指が 10pt ずれるのは実使用で十分に起こります。少し力が入った、無意識に位置を直した、それだけで超えてしまう(10pt は、思っているより本当にすぐ届きます)。「指のブレ」という、ユーザーごと・試行ごとに変わる不確定要因に解除可否が依存していた。これが「たまに反応しない」の正体でした。
修正方針: 見た目と判定を同じソースに統一する
直し方の選択肢は2つありました。
ひとつは、いちばん手軽な対処。LongPressGesture(minimumDuration: 3.0, maximumDistance: .greatestFiniteMagnitude) と書いて、距離の許容量を実質無限にしてしまう(力技ではありますが、症状そのものには効きます)。指がどれだけ動いても長押し失敗にならなくなるので、この症状自体は消えます。
ただこれは採りませんでした。距離制約は外れても、「リングを進めるジェスチャ」と「解除を撃つジェスチャ」が別物のままという構造的なリスクが残るからです。2つの独立した系が「だいたい同時に完了する」ことに依存している設計は、また別のズレ方をしたときに同じクラスのバグを生みます。見えている進捗と実際の判定が乖離しうる、という根っこを残したくなかった。
そこで採ったのが、プログレス表示と解除判定を単一ソースに統一する方針です。LongPressGesture を丸ごと廃止して、保持の検出は DragGesture(minimumDistance: 0) だけに一本化する。リングを進めているのも、3秒到達を判定するのも、同じ holdTimer という1つの状態にする。「リングが満タン」と「解除が走る」が、定義上ぜったいに一致するようにしたわけです。
判定ロジックは時間を持つ小さな struct EmergencyHoldTimer に閉じ込めました。要は経過時間から進捗を計算するだけのものですが、「3秒に到達した瞬間に1回だけ解除を撃つ」ためのガードを持たせています。
struct EmergencyHoldTimer {
static let requiredSeconds: TimeInterval = 3.0
static let midwayThreshold: TimeInterval = 1.5
private var heldSince: Date?
private var midwayHapticEmitted = false
private var releaseFired = false
mutating func beginHold(at time: Date = Date()) {
heldSince = time
midwayHapticEmitted = false
releaseFired = false
}
mutating func endHold() {
heldSince = nil
midwayHapticEmitted = false
releaseFired = false
}
/// 0.0 ... 1.0 — 3秒で 1.0 にクランプ
func progress(at time: Date = Date()) -> Double {
guard let heldSince else { return 0 }
let elapsed = time.timeIntervalSince(heldSince)
return min(elapsed / Self.requiredSeconds, 1.0)
}
func isCompleted(at time: Date = Date()) -> Bool {
progress(at: time) >= 1.0
}
/// 1ホールドにつき1回だけ true を返す(中間地点のハプティクス用)。
/// 1.5秒到達後の最初の呼び出しで true、以降は false。
mutating func shouldEmitMidwayHaptic(at time: Date = Date()) -> Bool {
guard let heldSince else { return false }
guard !midwayHapticEmitted else { return false }
let elapsed = time.timeIntervalSince(heldSince)
guard elapsed >= Self.midwayThreshold else { return false }
midwayHapticEmitted = true
return true
}
/// 1ホールドにつき1回だけ true を返す。
/// 3秒到達後の最初の呼び出しで true、以降は false。
/// beginHold() / endHold() でリセットされる。
mutating func shouldFireRelease(at time: Date = Date()) -> Bool {
guard !releaseFired else { return false }
guard isCompleted(at: time) else { return false }
releaseFired = true
return true
}
}
肝は shouldFireRelease(at:) です。releaseFired フラグで「もう撃ったか」を覚えておき、3秒到達後の最初の1回だけ true を返す。タイマーは毎フレーム回り続けるので、ガードが無いと完了後に何度も発火してしまいます。beginHold() と endHold() でフラグが戻るので、新しいホールドを始めれば、またちゃんと1回撃てる。progress() を表示にも完了判定にも使うことで、見た目と判定が同じ計算式を共有します。
ボタン側はタイマーから完了を拾うだけ
ビュー側は、毎フレームのタイマーで holdTimer を進めつつ、その中で完了を検知して発火する形にしました。LongPressGesture はもういません。
.gesture(
DragGesture(minimumDistance: 0)
.onChanged { _ in
if !isHolding {
beginHold()
}
}
.onEnded { _ in
if !holdTimer.isCompleted() {
resetHold()
}
}
)
.onReceive(Timer.publish(every: tickInterval, on: .main, in: .common).autoconnect()) { _ in
tick()
}
private func tick() {
guard isHolding else { return }
displayProgress = holdTimer.progress()
if holdTimer.shouldEmitMidwayHaptic() {
UIImpactFeedbackGenerator(style: .light).impactOccurred()
}
if holdTimer.shouldFireRelease() {
UINotificationFeedbackGenerator().notificationOccurred(.success)
onReleased()
resetHold()
}
}
tick() の中で、リングの進捗 displayProgress を更新するのと、shouldFireRelease() で解除を撃つのが、同じ1つの関数・同じ holdTimer から生まれます。リングが満タンになるのと解除が走るのが、もう構造的にズレようがない。DragGesture(minimumDistance: 0) は指のブレを一切気にしないので、3秒押さえてさえいれば、指がどこへ動こうと確実に解けます。中間地点(1.5秒)で軽いハプティクスを返して「あと半分」を指に伝える演出も、同じタイマー経路に乗せました。
直したあとは、ユニットテストを足して挙動を固定しました。3秒未満では発火しないこと、3秒到達でちょうど1回だけ発火すること、ホールドをやり直したらまた発火できること。時間を引数で渡せる struct にしておいたおかげで、実時間を待たずに「3秒経過」をテストから注入できます。
学び: 「2つのソースが揃う」前提は崩れる
今回のバグから持ち帰ったのは、UI の話というより設計の話でした。
ひとつ。見えているものと、実際に起きることは、同じ1つの真実から生やす。進捗バーが「処理が進んでいる風」に見えているのに裏の処理は別管理、という構造は、両者が一致している間は気づけません。ズレた瞬間に、ユーザーには「やったのに反応しない」という、もっとも不信感の強い形で出ます。表示は判定の射影であるべきで、別駆動にしてはいけない。
ふたつ。フレームワークのデフォルト引数は、暗黙の制約として効いている。LongPressGesture(minimumDuration:) とだけ書くと、maximumDistance: 10 が静かに付いてきます。省略した引数は「無い」のではなく「既定値が効いている」。とくにジェスチャや当たり判定まわりの数値デフォルトは、小さい要素では顕在化せず、大きい要素(今回は 88pt のボタン)で初めて牙を剥くので、見落としやすい。
みっつ。間欠バグは、非決定的な入力を探す。解除後の経路は決定的だと切り分けられたので、犯人がジェスチャ層にいると早く絞れました。「たまに失敗する」を見たら、まず処理を決定的な部分と非決定的な部分に割って、後者(ここでは指の物理的なブレ)に当たりをつける。
緊急ボタンは Ima. の中でいちばん地味で、いちばん絶対に動かないといけないパーツです。普段は誰も使わないけれど、いざ必要なときに「たまに反応しない」では命綱になりません。合意で始まり、離れれば自然に解ける——その手前で、解きたいときには必ず解けること。Ima. のコンセプトに興味を持ってもらえたら、LP と App Store をのぞいてみてください。



