自分のiOSアプリで物理カメラボタンに対応しよう (SwiftUI対応、ズーム・露出・カスタムコントロール)

本記事では、iPhone物理カメラボタンを自分のiOSアプリ内で活用する方法を解説します。

![time-travel-with-mszpro.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/635330/0bc26673-9ac2-4424-cabf-8388950dd306.png)

![qiita-switch.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/635330/51db9d4f-feee-5322-c009-53cfe20042d0.png)

[English Version](https://mszpro.com/ios-camera-control-button)

# なぜ対応が必要なのか？
何もしない場合、ユーザがアプリ内で物理カメラボタンを押すと、デフォルトでシステム標準の「カメラ」アプリが起動してしまいます。しかし、少しの対応で、物理カメラボタンがアプリ独自の写真撮影アクションを実行するようになり、さらには物理ボタン上でのスワイプ操作でフィルタ変更などの独自コントロールを提供できます。

また、上記のような高度なカスタマイズや、ユニークなコントロールオプション（たとえば物理カメラボタンを使った新しいUI）を簡単に追加可能です。

# さっそく始めよう！

初期状態：
まずは非常にシンプルなSwiftUIビューから始めます。ここではカメラプレビューと、撮影用のボタンを表示する簡易な実装例です。

以下は、カメラアクセス許可や撮影アクション、撮影した画像を受け取り変数へ保存するためのViewModel例です。

```swift
// MARK: - Unified Camera ViewModel
class CameraViewModel: NSObject, ObservableObject {
    
    // Session states
    enum CameraSetupState {
        case idle
        case configured
        case permissionDenied
        case failed
    }
    
    @Published var setupState: CameraSetupState = .idle
    @Published var capturedPhoto: UIImage? = nil
    @Published var permissionGranted: Bool = false
    
    let session = AVCaptureSession()
    private let photoOutput = AVCapturePhotoOutput()
    private var videoInput: AVCaptureDeviceInput?
    
    // Dispatch queue for configuring the session
    private let configurationQueue = DispatchQueue(label: "com.example.camera.config")
    
    override init() {
        super.init()
    }
    
    deinit {
        stopSession()
    }
    
    // MARK: - Public API
    
    /// Checks camera permissions and configures session if authorized.
    func requestAccessIfNeeded() {
        let authStatus = AVCaptureDevice.authorizationStatus(for: .video)
        switch authStatus {
            case .authorized:
                permissionGranted = true
                configureSessionIfIdle()
            case .notDetermined:
                AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in
                    guard let self = self else { return }
                    DispatchQueue.main.async {
                        if granted {
                            self.permissionGranted = true
                            self.configureSessionIfIdle()
                        } else {
                            self.setupState = .permissionDenied
                        }
                    }
                }
            default:
                // Denied or Restricted
                setupState = .permissionDenied
        }
    }
    
    /// Initiate photo capture.
    func capturePhoto() {
        guard setupState == .configured else { return }
        let settings = AVCapturePhotoSettings()
        photoOutput.capturePhoto(with: settings, delegate: self)
    }
    
    // MARK: - Session Configuration
    
    private func configureSessionIfIdle() {
        configurationQueue.async { [weak self] in
            guard let self = self, self.setupState == .idle else { return }
            
            self.session.beginConfiguration()
            self.session.sessionPreset = .photo
            
            self.addCameraInput()
            self.addPhotoOutput()
            
            self.session.commitConfiguration()
            self.startSessionIfReady()
        }
    }
    
    private func addCameraInput() {
        do {
            guard let backCamera = AVCaptureDevice.default(.builtInWideAngleCamera,
                                                           for: .video,
                                                           position: .back) else {
                print("CameraViewModel: Back camera is unavailable.")
                setupState = .idle
                session.commitConfiguration()
                return
            }
            
            let cameraInput = try AVCaptureDeviceInput(device: backCamera)
            if session.canAddInput(cameraInput) {
                session.addInput(cameraInput)
                videoInput = cameraInput
                DispatchQueue.main.async {
                    self.setupState = .configured
                }
            } else {
                print("CameraViewModel: Unable to add camera input to session.")
                setupState = .idle
                session.commitConfiguration()
            }
        } catch {
            print("CameraViewModel: Error creating camera input - \(error)")
            setupState = .failed
            session.commitConfiguration()
        }
    }
    
    private func addPhotoOutput() {
        guard session.canAddOutput(photoOutput) else {
            print("CameraViewModel: Cannot add photo output.")
            setupState = .failed
            session.commitConfiguration()
            return
        }
        session.addOutput(photoOutput)
        photoOutput.maxPhotoQualityPrioritization = .quality
        DispatchQueue.main.async {
            self.setupState = .configured
        }
    }
    
    private func startSessionIfReady() {
        guard setupState == .configured else { return }
        session.startRunning()
    }
    
    private func stopSession() {
        configurationQueue.async { [weak self] in
            guard let self = self else { return }
            if self.session.isRunning {
                self.session.stopRunning()
            }
        }
    }
}

// MARK: - AVCapturePhotoCaptureDelegate
extension CameraViewModel: AVCapturePhotoCaptureDelegate {
    func photoOutput(_ output: AVCapturePhotoOutput,
                     didFinishProcessingPhoto photo: AVCapturePhoto,
                     error: Error?) {
        
        guard error == nil else {
            print("CameraViewModel: Error capturing photo - \(error!)")
            return
        }
        guard let photoData = photo.fileDataRepresentation() else {
            print("CameraViewModel: No photo data found.")
            return
        }
        self.capturedPhoto = UIImage(data: photoData)
    }
}
```

次に、SwiftUIでUIKitのカメラプレビューを表示するためのUIViewRepresentableを用意します。

```swift
// MARK: - SwiftUI Representable for Camera Preview
struct CameraLayerView: UIViewRepresentable {
    
    let cameraSession: AVCaptureSession
    
    func makeUIView(context: Context) -> CameraContainerView {
        let container = CameraContainerView()
        container.backgroundColor = .black
        container.previewLayer.session = cameraSession
        container.previewLayer.videoGravity = .resizeAspect
        return container
    }
    
    func updateUIView(_ uiView: CameraContainerView, context: Context) {
        // No dynamic updates needed
    }
    
    // A UIView subclass that hosts an AVCaptureVideoPreviewLayer
    class CameraContainerView: UIView {
        
        override class var layerClass: AnyClass {
            AVCaptureVideoPreviewLayer.self
        }
        
        var previewLayer: AVCaptureVideoPreviewLayer {
            guard let layer = self.layer as? AVCaptureVideoPreviewLayer else {
                fatalError("CameraContainerView: Failed casting layer to AVCaptureVideoPreviewLayer.")
            }
            return layer
        }
    }
}
```

そして、メインとなるSwiftUIのビュー例です。

```swift
// MARK: - SwiftUI Main View
struct ContentView: View {
    
    @ObservedObject var viewModel = CameraViewModel()
    
    var body: some View {
        GeometryReader { _ in
            ZStack(alignment: .bottom) {
                CameraLayerView(cameraSession: viewModel.session)
                    .onAppear {
                        viewModel.requestAccessIfNeeded()
                    }
                    .edgesIgnoringSafeArea(.all)
                
                // Capture button
                VStack {
                    Spacer()
                    
                    Button {
                        viewModel.capturePhoto()
                    } label: {
                        Text("Take Photo")
                            .font(.headline)
                            .foregroundColor(.white)
                            .padding()
                            .background(Color.blue)
                            .cornerRadius(10)
                    }
                    .padding(.bottom, 20)
                }
                
                // Thumbnail of the captured photo
                if let image = viewModel.capturedPhoto {
                    Image(uiImage: image)
                        .resizable()
                        .aspectRatio(contentMode: .fit)
                        .frame(width: 120, height: 90)
                        .padding(.bottom, 80)
                }
            }
        }
    }
}

// MARK: - SwiftUI Preview
#Preview {
    ContentView()
}
```

# 物理カメラボタンでの写真撮影に対応する

![image.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/635330/1eab86fd-62b8-08a6-0056-83ae3c6b2d08.jpeg)

デフォルトの状態では、ユーザが物理カメラボタンを押すと、システムのカメラアプリが起動します。
これを自前のカメラ撮影処理へ置き換えるには、AVKitフレームワークをインポートし、CameraLayerViewにonCameraCaptureEvent()という専用のビュー修飾子を付けます。

```swift
CameraLayerView(cameraSession: viewModel.session)
    .onAppear {
        viewModel.requestAccessIfNeeded()
    }
    .edgesIgnoringSafeArea(.all)
    .onCameraCaptureEvent() { event in
        if event.phase == .began {
            self.viewModel.capturePhoto()
        }
    }
```

こうすることで、ユーザが物理カメラボタンを押した際、提供したクロージャが呼ばれ、その中でViewModelの撮影関数を呼び出し、アプリ内で写真をキャプチャできるようになります。

# 対応デバイスかどうかの確認

物理カメラボタン対応の有無は、AVCaptureSessionのsupportsControlsで確認可能です。
以下は先ほどの例内でサポートを確認する方法の一例です。

```swift
struct ContentView: View {
    
    @ObservedObject var viewModel = CameraViewModel()
    
    var body: some View {
        GeometryReader { _ in
            // ... //
        }
        .task {
            let supportsCameraButton = self.viewModel.session.supportsControls
            
        }
    }
}
```

# ズームコントロールの追加

![image.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/635330/b6884f1a-3dde-c8d3-da93-ce5ccbbd3332.jpeg)

次に、ズームコントロールを追加してみましょう。iOS側がズームイン・アウトを自動で処理し、あなたのアプリは現在のズームレベルに応じたUI表示を行うことができます。

| | |
|--|--|
| ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/635330/7fe9c6ad-fb84-5af1-189c-4007e10949e3.png) | ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/635330/57435ba5-38c5-f3a3-1f86-e1ba8f2ad83e.png) |

まず、カメラセッションにコントロール用デリゲートを設定します。

```swift
self.session.setControlsDelegate(self, queue: self.cameraControlQueue)
```

次に、AVCaptureSessionControlsDelegateプロトコルへ準拠し、必要な関数を実装します。

```swift
// MARK: - AVCaptureSessionControlsDelegate
extension CameraViewModel: AVCaptureSessionControlsDelegate {
    
    func sessionControlsDidBecomeActive(_ session: AVCaptureSession) {
        return
    }
    
    func sessionControlsWillEnterFullscreenAppearance(_ session: AVCaptureSession) {
        return
    }
    
    func sessionControlsWillExitFullscreenAppearance(_ session: AVCaptureSession) {
        return
    }
    
    func sessionControlsDidBecomeInactive(_ session: AVCaptureSession) {
        return
    }
    
}
```

これでカメラコントロール機能が有効になります。

続いて、システムズームコントロールを初期化します。

```swift
let systemZoomSlider = AVCaptureSystemZoomSlider(device: backCamera) { zoomFactor in
    // Calculate and display a zoom value.
    let displayZoom = backCamera.displayVideoZoomFactorMultiplier * zoomFactor
    // Update the user interface.
    print(displayZoom)
}
```

ここではカメラデバイスを渡し、ズームレベルが変化するたびにコードブロックが呼び出されます。
現在あるコントロールをすべて削除し、システムズームスライダーを追加します。

```swift
/// remove existing camera controls first
self.session.controls.forEach({ self.session.removeControl($0) })

/// add new ones
let controlsToAdd: [AVCaptureControl] = [systemZoomSlider]

for control in controlsToAdd {
    if self.session.canAddControl(control) {
        self.session.addControl(control)
    }
}
```

以下は更新したCameraViewModelです。

```swift
// MARK: - Unified Camera ViewModel
class CameraViewModel: NSObject, ObservableObject {
    
    // Session states
    enum CameraSetupState {
        case idle
        case configured
        case permissionDenied
        case failed
    }
    
    @Published var setupState: CameraSetupState = .idle
    @Published var capturedPhoto: UIImage? = nil
    @Published var permissionGranted: Bool = false
    
    let session = AVCaptureSession()
    private let photoOutput = AVCapturePhotoOutput()
    private var videoInput: AVCaptureDeviceInput?
    
    // Dispatch queue for configuring the session
    private let configurationQueue = DispatchQueue(label: "com.example.camera.config")
    private let cameraControlQueue = DispatchQueue(label: "com.example.camera.control")
    
    override init() {
        super.init()
    }
    
    deinit {
        stopSession()
    }
    
    // MARK: - Public API
    
    /// Checks camera permissions and configures session if authorized.
    func requestAccessIfNeeded() { ... }
    
    /// Initiate photo capture.
    func capturePhoto() { ... }
    
    // MARK: - Session Configuration
    
    private func configureSessionIfIdle() { ... }
    
    private func addCameraInput() {
        do {
            guard let backCamera = AVCaptureDevice.default(.builtInWideAngleCamera,
                                                           for: .video,
                                                           position: .back) else {
                print("CameraViewModel: Back camera is unavailable.")
                setupState = .idle
                session.commitConfiguration()
                return
            }
            
            let cameraInput = try AVCaptureDeviceInput(device: backCamera)
            if session.canAddInput(cameraInput) {
                session.addInput(cameraInput)
                videoInput = cameraInput
                DispatchQueue.main.async {
                    self.setupState = .configured
                }
            } else {
                print("CameraViewModel: Unable to add camera input to session.")
                setupState = .idle
                session.commitConfiguration()
            }
            
            // configure for camera control button
            let systemZoomSlider = AVCaptureSystemZoomSlider(device: backCamera) { zoomFactor in
                // Calculate and display a zoom value.
                let displayZoom = backCamera.displayVideoZoomFactorMultiplier * zoomFactor
                // Update the user interface.
                print(displayZoom)
            }
            
            /// remove existing camera controls first
            self.session.controls.forEach({ self.session.removeControl($0) })
            
            /// add new ones
            let controlsToAdd: [AVCaptureControl] = [systemZoomSlider]
            
            for control in controlsToAdd {
                if self.session.canAddControl(control) {
                    self.session.addControl(control)
                }
            }
            
            /// set delegate
            self.session.setControlsDelegate(self, queue: self.cameraControlQueue)
            //
        } catch {
            print("CameraViewModel: Error creating camera input - \(error)")
            setupState = .failed
            session.commitConfiguration()
        }
    }
    
    private func addPhotoOutput() { ... }
    
    private func startSessionIfReady() { ... }
    
    private func stopSession() { ... }
}

// MARK: - AVCaptureSessionControlsDelegate
extension CameraViewModel: AVCaptureSessionControlsDelegate {
    
    func sessionControlsDidBecomeActive(_ session: AVCaptureSession) {
        return
    }
    
    func sessionControlsWillEnterFullscreenAppearance(_ session: AVCaptureSession) {
        return
    }
    
    func sessionControlsWillExitFullscreenAppearance(_ session: AVCaptureSession) {
        return
    }
    
    func sessionControlsDidBecomeInactive(_ session: AVCaptureSession) {
        return
    }
    
}
```

addCameraInput関数内でcameraControlQueueやデリゲート、システムズームスライダーの設定を行います。

これでアプリを実行すると、物理カメラボタン操作からアプリ内でズーム操作が可能になったことが確認できます。

![image.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/635330/99ffe21b-5356-d278-9684-7105839cf3f4.gif)

# 露出コントロールの追加

露出コントロールも同様に、追加で1行ほどコントロールを配列に追加するだけで実装できます。

```swift
// configure for camera control button
let systemZoomSlider = AVCaptureSystemZoomSlider(device: backCamera) { zoomFactor in
    // Calculate and display a zoom value.
    let displayZoom = backCamera.displayVideoZoomFactorMultiplier * zoomFactor
    // Update the user interface.
    print(displayZoom)
}

let systemBiasSlider = AVCaptureSystemExposureBiasSlider(device: backCamera)

/// remove existing camera controls first
self.session.controls.forEach({ self.session.removeControl($0) })

/// add new ones
let controlsToAdd: [AVCaptureControl] = [systemZoomSlider, systemBiasSlider]

for control in controlsToAdd {
    if self.session.canAddControl(control) {
        self.session.addControl(control)
    }
}
```

アプリを起動し、カメラボタンを素早くダブルタップ（押し込まず表面を軽く叩くような操作）すると、ズームと露出コントロールの2つが表示され、スライダーで値を調整できます。

| | |
|---|---|
| ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/635330/d4218aa2-822c-3054-65b0-4274a64ccb71.png) | ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/635330/e2cac4ec-9de3-007d-beb2-ce2d8518deeb.png) |

# カスタムスライダーの追加

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/635330/ac924280-c5e9-1703-d7e5-0a3943850c1b.png)

カスタムスライダーも用意できます。setActionQueueを使って値変更時に通知を受け取り、独自の機能を実装できます。

たとえば、冗談めかした例として「4次元(時間)コントロール」をスライダーで行うようなことも可能です（あくまでサンプル的な発想ですが）。

```swift
let timeTravelSlider = AVCaptureSlider("MszProと時間旅行", symbolName: "pawprint.fill", in: -10...10)
// Perform the slider's action on the session queue.
timeTravelSlider.setActionQueue(self.cameraControlQueue) { position in
    print(position)
}
```

# カスタムピッカーの追加

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/635330/dd934546-b357-8e79-8857-97cb55636f2c.png)

さらに、ユーザが複数のオプションから1つを選ぶためのカスタムピッカーを提供することも可能です。たとえば、フィルター一覧をピッカーで選択させる機能などが考えられます。

```swift
let indexPicker = AVCaptureIndexPicker("Post to",
                                       symbolName: "square.and.arrow.up",
                                       localizedIndexTitles: [
                                        "Qiita",
                                        "Twitter",
                                        "SoraSNS"
                                       ])
indexPicker.setActionQueue(self.cameraControlQueue) { value in
    print(value)
}
```

取得した値はインデックスとして渡されますので、それに応じた処理を行いましょう。

# まとめ

以上の手順で、物理カメラボタンを活用してアプリ内で写真撮影を行ったり、ズームや露出、カスタムコントロールを搭載することができます。新しいカメラ体験をユーザに提供してみてください。

参考：
以下は、本記事で説明した内容をすべてまとめた完全なコード例です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/635330/c000a32a-bf78-434c-cadf-34f081211a1e.png)

Please follow me!

English version: https://mszpro.com/ios-camera-control-button

- Twitter: [https://twitter.com/mszpro](https://twitter.com/mszpro)  
- YouTube: [https://www.youtube.com/@MszPro6](https://www.youtube.com/@MszPro6)  
- Mastodon, Misskey: [@me@mszpro.com](mailto:me@mszpro.com)  
- Bluesky: [@mszpro.com](https://mszpro.com)  
- Webサイト: [https://mszpro.com](https://mszpro.com)

```swift
import SwiftUI
import AVFoundation
import Combine
import AVKit

// MARK: - SwiftUI Representable for Camera Preview
struct CameraLayerView: UIViewRepresentable {
    
    let cameraSession: AVCaptureSession
    
    func makeUIView(context: Context) -> CameraContainerView {
        let container = CameraContainerView()
        container.backgroundColor = .black
        container.previewLayer.session = cameraSession
        container.previewLayer.videoGravity = .resizeAspect
        return container
    }
    
    func updateUIView(_ uiView: CameraContainerView, context: Context) {
        // No dynamic updates needed
    }
    
    // A UIView subclass that hosts an AVCaptureVideoPreviewLayer
    class CameraContainerView: UIView {
        
        override class var layerClass: AnyClass {
            AVCaptureVideoPreviewLayer.self
        }
        
        var previewLayer: AVCaptureVideoPreviewLayer {
            guard let layer = self.layer as? AVCaptureVideoPreviewLayer else {
                fatalError("CameraContainerView: Failed casting layer to AVCaptureVideoPreviewLayer.")
            }
            return layer
        }
    }
}

// MARK: - Unified Camera ViewModel
class CameraViewModel: NSObject, ObservableObject {
    
    // Session states
    enum CameraSetupState {
        case idle
        case configured
        case permissionDenied
        case failed
    }
    
    @Published var setupState: CameraSetupState = .idle
    @Published var capturedPhoto: UIImage? = nil
    @Published var permissionGranted: Bool = false
    
    let session = AVCaptureSession()
    private let photoOutput = AVCapturePhotoOutput()
    private var videoInput: AVCaptureDeviceInput?
    
    // Dispatch queue for configuring the session
    private let configurationQueue = DispatchQueue(label: "com.example.camera.config")
    private let cameraControlQueue = DispatchQueue(label: "com.example.camera.control")
    
    override init() {
        super.init()
    }
    
    deinit {
        stopSession()
    }
    
    // MARK: - Public API
    
    /// Checks camera permissions and configures session if authorized.
    func requestAccessIfNeeded() {
        let authStatus = AVCaptureDevice.authorizationStatus(for: .video)
        switch authStatus {
            case .authorized:
                permissionGranted = true
                configureSessionIfIdle()
            case .notDetermined:
                AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in
                    guard let self = self else { return }
                    DispatchQueue.main.async {
                        if granted {
                            self.permissionGranted = true
                            self.configureSessionIfIdle()
                        } else {
                            self.setupState = .permissionDenied
                        }
                    }
                }
            default:
                // Denied or Restricted
                setupState = .permissionDenied
        }
    }
    
    /// Initiate photo capture.
    func capturePhoto() {
        guard setupState == .configured else { return }
        let settings = AVCapturePhotoSettings()
        photoOutput.capturePhoto(with: settings, delegate: self)
    }
    
    // MARK: - Session Configuration
    
    private func configureSessionIfIdle() {
        configurationQueue.async { [weak self] in
            guard let self = self, self.setupState == .idle else { return }
            
            self.session.beginConfiguration()
            
            self.session.sessionPreset = .photo
            
            self.addCameraInput()
            self.addPhotoOutput()
            
            // save configuration and start camera session
            self.session.commitConfiguration()
            self.startSessionIfReady()
        }
    }
    
    private func addCameraInput() {
        do {
            guard let backCamera = AVCaptureDevice.default(.builtInWideAngleCamera,
                                                           for: .video,
                                                           position: .back) else {
                print("CameraViewModel: Back camera is unavailable.")
                setupState = .idle
                session.commitConfiguration()
                return
            }
            
            let cameraInput = try AVCaptureDeviceInput(device: backCamera)
            if session.canAddInput(cameraInput) {
                session.addInput(cameraInput)
                videoInput = cameraInput
                DispatchQueue.main.async {
                    self.setupState = .configured
                }
            } else {
                print("CameraViewModel: Unable to add camera input to session.")
                setupState = .idle
                session.commitConfiguration()
            }
            
            // configure for camera control button
            
            /// zoom slider
            let systemZoomSlider = AVCaptureSystemZoomSlider(device: backCamera) { zoomFactor in
                // Calculate and display a zoom value.
                let displayZoom = backCamera.displayVideoZoomFactorMultiplier * zoomFactor
                // Update the user interface.
                print(displayZoom)
            }
            
            /// exposure slider
            let systemBiasSlider = AVCaptureSystemExposureBiasSlider(device: backCamera)
            
            /// custom slider, learn time travel with MszPro
            let timeTravelSlider = AVCaptureSlider("MszProと時間旅行", symbolName: "pawprint.fill", in: -10...10)
            // Perform the slider's action on the session queue.
            timeTravelSlider.setActionQueue(self.cameraControlQueue) { position in
                print(position)
            }
            
            /// custom index picker
            let indexPicker = AVCaptureIndexPicker("Post to",
                                                   symbolName: "square.and.arrow.up",
                                                   localizedIndexTitles: [
                                                    "Qiita",
                                                    "Twitter",
                                                    "SoraSNS"
                                                   ])
            indexPicker.setActionQueue(self.cameraControlQueue) { value in
                print(value)
            }
            
            /// remove existing camera controls first
            self.session.controls.forEach({ self.session.removeControl($0) })
            
            /// add new ones
            let controlsToAdd: [AVCaptureControl] = [systemZoomSlider, systemBiasSlider, timeTravelSlider, indexPicker]
            
            for control in controlsToAdd {
                if self.session.canAddControl(control) {
                    self.session.addControl(control)
                }
            }
            
            /// set delegate
            self.session.setControlsDelegate(self, queue: self.cameraControlQueue)
            //
        } catch {
            print("CameraViewModel: Error creating camera input - \(error)")
            setupState = .failed
            session.commitConfiguration()
        }
    }
    
    private func addPhotoOutput() {
        guard session.canAddOutput(photoOutput) else {
            print("CameraViewModel: Cannot add photo output.")
            setupState = .failed
            session.commitConfiguration()
            return
        }
        session.addOutput(photoOutput)
        photoOutput.maxPhotoQualityPrioritization = .quality
        DispatchQueue.main.async {
            self.setupState = .configured
        }
    }
    
    private func startSessionIfReady() {
        guard setupState == .configured else { return }
        session.startRunning()
    }
    
    private func stopSession() {
        configurationQueue.async { [weak self] in
            guard let self = self else { return }
            if self.session.isRunning {
                self.session.stopRunning()
            }
        }
    }
}

// MARK: - AVCaptureSessionControlsDelegate
extension CameraViewModel: AVCaptureSessionControlsDelegate {
    
    func sessionControlsDidBecomeActive(_ session: AVCaptureSession) {
        return
    }
    
    func sessionControlsWillEnterFullscreenAppearance(_ session: AVCaptureSession) {
        return
    }
    
    func sessionControlsWillExitFullscreenAppearance(_ session: AVCaptureSession) {
        return
    }
    
    func sessionControlsDidBecomeInactive(_ session: AVCaptureSession) {
        return
    }
    
}

// MARK: - AVCapturePhotoCaptureDelegate
extension CameraViewModel: AVCapturePhotoCaptureDelegate {
    func photoOutput(_ output: AVCapturePhotoOutput,
                     didFinishProcessingPhoto photo: AVCapturePhoto,
                     error: Error?) {
        
        guard error == nil else {
            print("CameraViewModel: Error capturing photo - \(error!)")
            return
        }
        guard let photoData = photo.fileDataRepresentation() else {
            print("CameraViewModel: No photo data found.")
            return
        }
        self.capturedPhoto = UIImage(data: photoData)
    }
}

// MARK: - SwiftUI Main View
struct ContentView: View {
    
    @ObservedObject var viewModel = CameraViewModel()
    
    var body: some View {
        GeometryReader { _ in
            ZStack(alignment: .bottom) {
                CameraLayerView(cameraSession: viewModel.session)
                    .onAppear {
                        viewModel.requestAccessIfNeeded()
                    }
                    .edgesIgnoringSafeArea(.all)
                    .onCameraCaptureEvent() { event in
                        if event.phase == .began {
                            self.viewModel.capturePhoto()
                        }
                    }
                
                // Capture button
                VStack {
                    Spacer()
                    
                    Button {
                        viewModel.capturePhoto()
                    } label: {
                        Text("Take Photo")
                            .font(.headline)
                            .foregroundColor(.white)
                            .padding()
                            .background(Color.blue)
                            .cornerRadius(10)
                    }
                    .padding(.bottom, 20)
                }
                
                // Thumbnail of the captured photo
                if let image = viewModel.capturedPhoto {
                    Image(uiImage: image)
                        .resizable()
                        .aspectRatio(contentMode: .fit)
                        .frame(width: 120, height: 90)
                        .padding(.bottom, 80)
                }
            }
        }
        .task {
            let supportsCameraButton = self.viewModel.session.supportsControls
            
        }
    }
}

// MARK: - SwiftUI Preview
#Preview {
    ContentView()
}
```