【Swift/SwiftUI】AVCaptureでカメラを起動する

概要

前回はOSのカメラ(フォトアプリに近いもの)を実装したが、以下のメリット・デメリットがある。

メリット

  • とにかく手軽に実装できる

デメリット

  • カメラから取得した映像の範囲が狭い
  • 見栄えが悪い
  • カスタマイズしづらい

そこで、今回はAVCapture関連のクラスを使用してカスタマイズしやすいカメラの実装をしていく。
調べれば色々な記事は出てくるが、処理部分とView部分を分けて、かつSwiftUIで実装している記事はほとんどなかったため、ここに記す。

ソースコード

アプリ起動部分

import SwiftUI

@main
struct TestApp: App {
    @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
    
    var body: some Scene {
        WindowGroup {
            CaptureCameraPrepareView()
        }
    }
}

画面遷移前(撮影した画像を表示する画面)

View部分

import SwiftUI

struct CaptureCameraPrepareView: View {
    @ObservedObject private var viewModel = CaptureCameraPrepareViewModel()
    
    var body: some View {
        NavigationView {
            VStack(alignment: .center) {
                ZStack {
                    Rectangle()
                        .fill(Color.gray.opacity(0.5))
                        .padding()
                    Text("P h o t o")
                        .font(.system(size: 30))
                        .bold()
                        .foregroundColor(Color.white)
                    if  let uiImage = viewModel.imageData {
                        Image(uiImage: uiImage)
                            .resizable()
                            .aspectRatio(contentMode: .fit)
                            .padding()
                    }
                }
                NavigationLink {
                    CaptureCameraView(delegate: self)
                } label: {
                    Text("カメラ起動")
                }
            }
        }
    }
}

extension CaptureCameraPrepareView: CaptureCameraViewDelegate {
    func passImage(image: UIImage) {
        print(image)
        viewModel.imageData = image
    }
}

処理部分

import Foundation
import UIKit

class CaptureCameraPrepareViewModel: ObservableObject {
    @Published var imageData: UIImage?
}

画面遷移後(カメラを起動して撮影を行う画面)

View部分

import SwiftUI

protocol CaptureCameraViewDelegate {
    func passImage(image: UIImage)
}

struct CaptureCameraView: View {
    @Environment(\.presentationMode) var presentation
    
    @ObservedObject private(set) var viewModel = CaptureCameraViewModel()
    
    var delegate: CaptureCameraViewDelegate?
    
    var body: some View {
        ZStack {
            CapturePreview(captureCameraViewModel: viewModel)
            VStack {
                Spacer()
                Button {
                    viewModel.capture()
                } label: {
                    Image(systemName: "circle.inset.filled")
                        .resizable(resizingMode: .stretch)
                        .foregroundColor(.white)
                }
                .frame(width: 60, height: 60)
            }
        }
        .onChange(of: viewModel.imageData) { imageData in
            guard let imageData = imageData else {
                return
            }
            delegate?.passImage(image: imageData)
            presentation.wrappedValue.dismiss()
        }
    }
}

struct CapturePreview: UIViewRepresentable {
    private var captureCameraViewModel: CaptureCameraViewModel
    
    init(captureCameraViewModel: CaptureCameraViewModel) {
        self.captureCameraViewModel = captureCameraViewModel
    }
    
    public func makeUIView(context: Context) -> some UIView {
        
        let cameraView = captureCameraViewModel.previewView
        cameraView.frame = UIScreen.main.bounds
        captureCameraViewModel.launchCamera()
        
        return cameraView
    }
    
    func updateUIView(_ uiView: UIViewType, context: Context) {
        
    }
}

処理部分

import AVFoundation
import Foundation
import UIKit

protocol CaptureCameraViewModelDelegate {
    func afterCaptureProcess(image: UIImage)
}

class CaptureCameraViewModel: NSObject, ObservableObject {
    
    @Published private(set) var imageData: UIImage?
    var previewView: UIView = UIView()
    
    private var session: AVCaptureSession?
    private var photoOutput: AVCapturePhotoOutput?
    private var captureSetting: AVCapturePhotoSettings?
    
    func launchCamera() {
        if let session = self.session {
            DispatchQueue.global(qos: .background).async {
                session.startRunning()
            }
            return
        }
        
        guard let device = AVCaptureDevice.default(.builtInDualWideCamera, for: .video, position: .back),
              let deviceInput = try? AVCaptureDeviceInput(device: device) else {
            return
        }
        
        let photoOutput = AVCapturePhotoOutput()
        let session = AVCaptureSession()
        switch UIDevice.current.userInterfaceIdiom {
        case .phone:
            session.sessionPreset = .high
        default:
            session.sessionPreset = .photo
        }
        session.addInput(deviceInput)
        session.addOutput(photoOutput)
        
        let videoPreviewLayer = AVCaptureVideoPreviewLayer(session: session)
        videoPreviewLayer.videoGravity = .resizeAspectFill
        videoPreviewLayer.connection?.videoOrientation = .portrait
        videoPreviewLayer.frame = previewView.bounds
        
        
        DispatchQueue.global(qos: .background).async {
            session.startRunning()
        }
        
        self.photoOutput = photoOutput
        self.session = session
        
        previewView.layer.insertSublayer(videoPreviewLayer, at: 0)
    }
    
    func capture() {
        Task { @MainActor in
            let captureSetting = AVCapturePhotoSettings()
            captureSetting.flashMode = .off
            photoOutput?.capturePhoto(with: captureSetting, delegate: self)
            self.captureSetting = captureSetting
        }
    }
}

extension CaptureCameraViewModel: AVCapturePhotoCaptureDelegate {
    func photoOutput(_ output: AVCapturePhotoOutput, didFinishProcessingPhoto photo: AVCapturePhoto, error: Error?) {
        if error != nil {
            print("error")
            return
        }
        
        guard let fileData = photo.fileDataRepresentation(),
              let image = UIImage(data: fileData) else {
            return
        }
        print("image")
        imageData = image
    }
}

デモ動画

詳細

画面遷移後(カメラを起動して撮影を行う画面)

カメラ起動の準備、そして撮影した時の処理を行う。
詳細は【Swift/SwiftUI】AVCaptureを使用してカメラ起動(準備編)を参照。

画面遷移前(撮影した画像を表示する画面)

こちらは特に難しい実装はない。
撮影した画像を表示する以下の部分。内容も単純で、画像のデータ(viewModel.imageData)が格納されていたら、その画像を表示する。

ZStack {
    Rectangle()
        .fill(Color.gray.opacity(0.5))
        .padding()
    Text("P h o t o")
        .font(.system(size: 30))
        .bold()
        .foregroundColor(Color.white)
    if  let uiImage = viewModel.imageData {
        Image(uiImage: uiImage)
            .resizable()
            .aspectRatio(contentMode: .fit)
            .padding()
    }
}

ただし、撮影画面に写る際に少し工夫が必要。
画面遷移する際、自身をデリゲートとして渡すことで、カメラ撮影後に画面遷移前であるこの画面に画像データを渡せるようにしている。

NavigationLink {
    CaptureCameraView(delegate: self)
} label: {
    Text("カメラ起動")
}

こうすることで、画面遷移後にあるpassImageメソッドが呼び出されるようになる。

.onChange(of: viewModel.imageData) { imageData in
    guard let imageData = imageData else {
        return
    }
    delegate?.passImage(image: imageData)
    presentation.wrappedValue.dismiss()
}
extension CaptureCameraPrepareView: CaptureCameraViewDelegate {
    func passImage(image: UIImage) {
        print(image)
        viewModel.imageData = image
    }
}

参考ページ

Super Hahnah「[Swift] AVFoundation による動画撮影の設定: カメラ種類 / ズーム / 録画時間 / 画質」
zenn「AVFoundationでカメラ種類判定(二眼・三眼など)」