カメラアプリを作るなら、AVCaptureEventInteraction はハードウェアボタン対応をほぼ無料で提供してくれます。しかし、特定のボタンが正常に動作するかを検証する診断アプリを作るとなると、同じAPIが驚くほど手強い相手に変わります。
これは、Camera Controlのテストを実装する過程で学んだことのまとめです。どこにも文書化されておらず、実機を手にしたデバッグセッションを費やす羽目になった事柄もいくつか含まれています。
基本#
AVCaptureEventInteraction(iOS 17.2+)は、ビューにアタッチする UIInteraction です。2つのハンドラを提供します:
let interaction = AVCaptureEventInteraction(
primary: { event in
guard event.phase == .ended else { return }
capturePhoto()
},
secondary: { event in
guard event.phase == .ended else { return }
// ...
}
)
interaction.isEnabled = true
view.addInteraction(interaction)各イベントは phase を持ちます:ボタンが押し込まれた瞬間の .began、離された時の .ended — 撮影をトリガーするのはここです — そして、アプリがバックグラウンドに移行したり撮影が利用不能になった時の .cancelled です。
どのボタンがどのハンドラに届くか#
ここが間違えやすいポイントで、間違えると実害があります:
| ハンドラ | トリガーするもの |
|---|---|
primary | 音量下げ、Action button、Camera Control、AirPodsのステムクリック(iOS 26) |
secondary | 音量上げ — それ以外は何もなし |
ここから直ちに2つの結論が導かれます。
Camera Controlは決して secondary を発火しません。 Camera Controlのジェスチャーだと思ってsecondaryハンドラに機能を紐付けたなら、実際には音量上げボタンだけがそれを駆動しています。私たちはまさにこのバグを踏みました:secondary に割り当てた「カメラ切り替え」アクションが、音量上げにしか反応しなかったのです。
secondary を省略することは、それを無視することと同じではありません。 secondaryハンドラを渡さないと、音量上げのクリックは primary に流れ落ちます。音量上げを何もせずに握りつぶしたい場合でも、ハンドラ自体は提供する必要があります — 中身を空にしておくだけです。
ボタンは区別できない#
AVCaptureEvent にはソースを識別するプロパティが存在しません。iOS 26 SDKの機械生成diffで確認しました:この型が公開しているのは phase と shouldPlaySound だけで、それ以上はありません。この抽象化は意図的なものです — Appleが伝えたいのは「ユーザーが撮影を要求した」であって、「ユーザーがキーXを押した」ではないのです。
カメラアプリならこれで問題ありません。しかし、特定の1つのボタンが動作することを証明しなければならないテストにとっては致命的です:音量下げの押下とCamera ControlのクリックはAPIレベルで区別がつきません。
唯一の例外がAirPodsです。shouldPlaySound が true になるのは、イベントがAirPodsのステムクリック由来であり、かつ AVCaptureEventInteraction.defaultCaptureSoundDisabled でデフォルトの撮影音を無効化している場合だけです。これでAirPodsをフィルタリングするには十分ですが、他のソースについては何も語ってくれません。
実際にCamera Controlを特定できるもの#
イベントが匿名である以上、信頼できるシグナルはCamera Controlハードウェア専用のcontrols APIからしか得られません:
guard session.supportsControls else { return }
let zoom = AVCaptureSystemZoomSlider(device: device) { factor in
// これを動かせるのはCamera Controlだけ — 音量ボタンには動かせない。
}
if session.canAddControl(zoom) { session.addControl(zoom) }
session.setControlsDelegate(self, queue: sessionQueue)これで2つのシグナルが手に入ります:
sessionControlsDidBecomeActive(_:)— コントロールのオーバーレイが表示された、というシグナル。これはCamera Controlの軽い押し込みでしか発生しません。- スライダーのコールバック — Camera Controlの表面をなぞるスワイプ。音量ボタンではスライダーは動かせません。
この2つを組み合わせると、実用的なヒューリスティックになります:オーバーレイがアクティブな間、または閉じた直後に限って primary イベントを受け入れるのです。フルクリックはイベント配信の前にオーバーレイを畳んでしまうため、sessionControlsDidBecomeInactive の後に数秒の猶予ウィンドウを設けてください。
落とし穴#
controlsデリゲートにはアクティブなevent interactionが必要です。 コントロールを追加してデリゲートを設定するだけでは足りません — ビュー階層に生きた AVCaptureEventInteraction がなければ、デリゲートは一切呼ばれず、ボタンは何もしません。「AVCaptureSessionControlsDelegateが呼ばれない」という問題の最も一般的な原因がこれです。
アプリにはLocked Camera Capture拡張が必要です。 これがないと、あなたのアプリはCamera Controlの設定リストにすら現れません。拡張はスタブでは済みません — 独自のcapture interactionを持ち、本当にカメラを使わなければ、システムに終了させられます。
supportsControls は unrecognized selector を投げることがあります — Camera Controlハードウェアのないデバイスで発生します。ガードしましょう:
guard probe.responds(to: NSSelectorFromString("supportsControls")) else { return false }ユーザーはあなたの検出をオフにできます。 設定 → Camera Control → Camera Adjustments はユーザー向けのトグルです。これをオフにすると、オーバーレイは表示されなくなり、スライダーも動かなくなります — Camera Controlの2つのシグナルが両方とも消え、匿名の primary だけが残ります。このトグルの状態を読み取る公開APIは存在しないので、この可能性を織り込んで設計してください。
スライダーのコールバックは連続的に発火します。 1回のスワイプが数十回の呼び出しを生みます — 微小な動きごとに1回です。「テスト合格」や「撮影」のようなアクションはフラグでガードしないと、繰り返し実行されてしまいます。
deinit 内で [weak self] 経由でセッションに触らないでください。 deinit の中で [weak self] をキャプチャするクロージャは、すでに解放処理中のオブジェクトへのweak参照を形成しようとし、ランタイムが "Cannot form weak reference to instance … in the process of deallocation." でアボートします。代わりにセッションとキューを直接キャプチャしましょう:
deinit {
let session = session
let queue = sessionQueue
queue.async {
if session.isRunning { session.stopRunning() }
}
}iOS 26で変わったこと#
Capture controlsがAirPodsのステムクリックに対応しました — AVCaptureEventInteraction を使うアプリはコード変更なしで自動的にサポートされます。加えて、システムのシャッター音を無効化した際に手動で音を扱うための AVCaptureEventSound と playSound(_:) が登場しました。
変わらなかったこと:どの物理ボタンがイベントを生成したかを識別する方法は、依然として存在しません。
実践的なアドバイス#
カメラアプリを書いているなら、上記のすべてを忘れて primary だけを処理してください。抽象化は意図どおりに機能しています。
イベントを特定の1つのボタンに帰属させなければならないテストや診断を書いているなら、制約を早めに受け入れましょう:音量上げは完全に識別可能、AirPodsは1つの条件付きで識別可能、Camera Controlはそのadjustmentsが有効な間だけ識別可能です。それ以外はすべてコイントスであり、オーディオセッションの音量監視をどれだけ工夫しても回避できません — capture interactionはアクティブな間、音量変化を抑制するため、観測できるものが何も残らないのです。



