📸

音量ボタン撮影をAVCaptureEventInteractionで実装する

2024/10/23に公開

概要

カメラで撮影する時、画面のボタンをタップする代わりにデバイスの音量ボタンを操作するのが一般的です。

iOS17.2から利用可能なAVCaptureEventInteractionをSwiftUI製アプリに統合する方法を紹介します。

環境

  • iOS 17.2以降(macOS, tvOS, watchOS, visionOSは現在非対応)
  • UIKitまたはSwiftUI
  • Xcode 16.1 Beta2

インタラクションをビューに追加する

UIKit

UIKitの世界で利用するにはAVCaptureEventInteractionを初期化し、UIViewControllerviewに追加します。

import AVKit
import UIKit

@available(iOS 17.2, *)
class ViewController: UIViewController {
    // カメラセッションを管理するモデルは別で用意してください
    private let camera = CameraModel()

    private var eventInteraction: AVCaptureEventInteraction?

    override func viewDidLoad() {
        super.viewDidLoad()

        // ハードウェアボタン(音量ボタン等)の処理をハンドリング
        configureHardwareInteraction()
    }

    private func configureHardwareInteraction() {
        let interaction = AVCaptureEventInteraction { [weak self] (event: AVCaptureEvent) -> Void in
            // ボタンを離した("press up")時は`event.phase == .ended`になる
            if event.phase == .ended {
          // `camera.capturePhoto()` で写真を撮影する
                self?.camera.capturePhoto()
            }
        }

        view.addInteraction(interaction)
        eventInteraction = interaction
    }
}

SwiftUI

カメラを利用する場合、
AVCaptureVideoPreviewLayerUIViewでラップし、UIViewRepresentable(またはUIViewContollerRepresentableを使用してSwiftUIのビュー階層に統合します。

この時点でAVCaptureEventInteractionを差し込むシンプルな実装ができれば楽なのですが、どうしてもSwiftUIで扱いたい場合は以下のようなViewModifierで扱いやすく整えることもできます。

import AVKit
import SwiftUI

@available(iOS 17.2, *)
struct CaptureInteractionModifier: ViewModifier {
    let beganAction: () -> Void
    let endedAction: () -> Void
    let cancelledAction: () -> Void

    func body(content: Content) -> some View {
        content
            .background {
                CaptureInteractionWrapper(beganAction: beganAction, endedAction: endedAction, cancelledAction: cancelledAction)
            }
    }

    struct CaptureInteractionWrapper: UIViewRepresentable {
        let beganAction: () -> Void
        let endedAction: () -> Void
        let cancelledAction: () -> Void

        func makeUIView(context: Context) -> UIView {
            let view = UIView()
            let interaction = AVCaptureEventInteraction { event in
                switch event.phase {
                case .began:
                    beganAction()
                case .ended:
                    endedAction()
                case .cancelled:
                    cancelledAction()
                @unknown default:
                    break
                }
            }
            view.addInteraction(interaction)

            return view
        }

        func updateUIView(_ uiView: UIView, context: Context) {}
    }
}

extension View {
    @ViewBuilder
    func captureInteractionIfAvailable(beganAction: @escaping () -> Void = {}, endedAction: @escaping () -> Void = {}, cancelledAction: @escaping () -> Void = {}) -> some View {
        if #available(iOS 17.2, *) {
            captureInteraction(beganAction: beganAction, endedAction: endedAction, cancelledAction: cancelledAction)
        } else {
            self
        }
    }

    @available(iOS 17.2, *)
    private func captureInteraction(beganAction: @escaping () -> Void, endedAction: @escaping () -> Void, cancelledAction: @escaping () -> Void) -> some View {
        modifier(CaptureInteractionModifier(beganAction: beganAction, endedAction: endedAction, cancelledAction: cancelledAction))
    }
}

SwiftUI, iOS18.0 以降

iOS18.0以降は標準ViewModifierが生えているのでそちらを利用します。

// 1種類のアクションを受け取るシンプルなAPI
func onCameraCaptureEvent(
    isEnabled: Bool = true,
    action: @escaping (AVCaptureEvent) -> Void
) -> some View

// primary actionとsecondary actionを受け取るAPI
func onCameraCaptureEvent(
    isEnabled: Bool = true,
    primaryAction: @escaping (AVCaptureEvent) -> Void,
    secondaryAction: @escaping (AVCaptureEvent) -> Void
) -> some View

実際には以下のように利用します。

import AVKit
import SwiftUI

@available(iOS 18.0, *)
struct TestView: View {
    let camera = CameraModel()
    @State private var isInteractionEnabled = true
    var body: some View {
        CameraPreview()
            .onCameraCaptureEvent(isEnabled: isInteractionEnabled) { event in
                if event.phase == .ended {
                    camera.capturePhoto()
                }
            }
    }
}

実装上の注意点

  • このAPIはカメラを実際に使用しているアプリでのみ動作します。
  • isEnabledの切り替え
    • カメラが使用できない状況ではisEnabledプロパティをfalseに設定し、デフォルトの音量ボタン動作が使えるようにしてください
  • iOS17.2以降でのみ利用可能な点
    • AVCaptureEventInteractionはiOS 17.2以降でのみ利用可能です。それ以前でのバージョンでは従来の方法(音量通知の監視など)へのフォールバックを検討してください。

関連資料

https://qiita.com/TechBK/items/7ce849ec9ea666ba835a
https://developer.apple.com/documentation/avkit/avcaptureeventinteraction
https://developer.apple.com/documentation/swiftui/view/oncameracaptureevent(isenabled:action:)

Discussion