iOS, SwiftUI, Swift

SwiftUIでAVFundationを導入する【Video Capture偏】

はじめに

こんにちは。たけしです。
この記事は自分のAVFundationの勉強のために書かれた記事です。
AVFundationはiOS/macOSともに使えるフレームワークで、端末のカメラやオーディオ機器にアクセスするためのフレームワークです。

今回の記事ではAVFundationをつかって、iPhoneのカメラ映像をアプリに表示する方法を解説します。

開発環境

サンプルアプリは以下の環境で開発されました。

  • Xcode 11.5
  • iPhone 11 Pro (iOS 13.4.1)

アプリ概要

iPhoneのカメラの映像をスクリーンに全画面表示するだけのアプリです。

アーキテクチャはVIPERで作ります。
VIPERに馴染みがない方は以前書いた記事があるので御覧ください。

/2020/05/31/viper-architecture-in-swiftui/

ソース

https://github.com/SatoTakeshiX/SwiftUI-AVFundation

カメラのアクセス許可

Video映像をアプリから取得するにはカメラのアクセス許可が必要です。
アプリのInfo.plistNSCameraUsageDescriptionを追加しましょう。

<key>NSCameraUsageDescription</key>
<string>take your video for sample swiftUI app</string>

Viewの作成

まずはViewから作成します。
任意のCALayerを表示するCALayerViewとLayerを全画面表示させるSimpleVideoCaptureViewの2つを作ります。

CALayerView

AVFundationではカメラからの映像をキャプチャするのにAVCaptureVideoPreviewLayerという型が用意されています。
これはCALayerのサブクラスで、これを通してカメラの映像をViewに表示させることができます。
しかし、CALayerなので、そのままではSwiftUIに表示させることができません。

任意のCALayerをSwiftUIで表示するため、UIViewControllerRepresentableに準拠したCALayerViewを作成します。

struct CALayerView: UIViewControllerRepresentable {
    typealias UIViewControllerType = UIViewController
    var caLayer: CALayer

    func makeUIViewController(context: Context) -> UIViewController {
        let viewController = UIViewController()
        viewController.view.layer.addSublayer(caLayer)
        caLayer.frame = viewController.view.layer.frame
        return viewController
    }

    func updateUIViewController(_ uiViewController: UIViewController, context: Context) {
        caLayer.frame = uiViewController.view.layer.frame
    }
}

イニシャライザーにCALayerを取ります。
makeUIViewControllerでView Controllerを作成してCALayerを追加しています。
calayerのframeはviewのlayerに合わせています。
View Controllerのviewは特に指定がない限り、自動で端末の大きさと同じになるはずなので、caLayerもその大きさに合わせています。

UIViewRepresentableで作ったところWidth,Heightの指定が必要?

ところでUIViewControllerRepresentableではなくUIViewRepresentableで一度作ったところ、Layerのframeがwidth:0, height:0になり、Viewに表示されないことがありました。
SwiftUIのほうでFrameを指定する必要があるかもしません。
UIViewControllerRepresentableだとvc.view.frameが自動的に端末サイズになるので、サイズに悩まなくてもよさそうです。

struct CALayerView: UIViewRepresentable {
    typealias UIViewType = UIView
    var caLayer: CALayer
    func makeUIView(context: Context) -> UIView {
        let view = UIView()
        view.layer.addSublayer(caLayer)
        caLayer.frame = view.layer.frame
        view.layer.masksToBounds = true
        return view
    }
    func updateUIView(_ uiView: UIView, context: Context) {
        caLayer.frame = uiView.layer.bounds
    }
}

SimpleVideoCaptureView

アプリの大本となる画面をSimpleVideoCaptureViewという名前で作ります。

struct SimpleVideoCaptureView: View {
    @ObservedObject
    var presenter: SimpleVideoCapturePresenter
    var body: some View {
        ZStack {
            CALayerView(caLayer: presenter.previewLayer)
        }
        .edgesIgnoringSafeArea(.all)
        .onAppear {
            self.presenter.apply(inputs: .onAppear)
        }
        .onDisappear {
            self.presenter.apply(inputs: .onDisappear)
        }
    }
}

ZStack.edgesIgnoringSafeArea(.all)にすることで、SafeAreaを無視した全画面表示にしています。
さきほど作ったCALayerViewをZStackに入れます。
presenterについてはまた後で解説します。

SceneDelegateSimpleVideoCaptureViewが最初に読み込まれるように修正します。

func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
    let contentView = SimpleVideoCaptureView(presenter: SimpleVideoCapturePresenter())
    if let windowScene = scene as? UIWindowScene {
        let window = UIWindow(windowScene: windowScene)
        window.rootViewController = UIHostingController(rootView: contentView)
        self.window = window
        window.makeKeyAndVisible()
    }
}

Presenterの作成

Presenterの役割はViewからのイベントを受けて、Interactorにデータ作成を依頼し、作られたデータをViewに通知することです。

final class SimpleVideoCapturePresenter: ObservableObject {

    var previewLayer: CALayer {
        return interactor.previewLayer!
    }

    enum Inputs {
        case onAppear
        case tappedCameraButton
        case onDisappear
    }

    init() {
        interactor.setupAVCaptureSession()
    }

    func apply(inputs: Inputs) {
        switch inputs {
            case .onAppear:
                interactor.startSettion()
            break
            case .tappedCameraButton:
            break
            case .onDisappear:
              interactor.stopSettion()
        }
    }

    // MARK: Privates
    private let interactor = SimpleVideoCaptureInteractor()
}

previewLayerプロパティでInteractorpreviewLayerを返してます。
applyメソッドでViewのイベント、onAppearやonDisappearをハンドリングしています。

Interactorの作成

さて、いよいよInteractorを使ってVideoキャプチャのデータを取得します。

import Foundation
import AVKit
final class SimpleVideoCaptureInteractor: NSObject, ObservableObject {
    private let captureSession = AVCaptureSession()
    @Published var previewLayer: AVCaptureVideoPreviewLayer?
    private var captureDevice: AVCaptureDevice?
}

AVCaptureSession/AVCaptureDevice

AVCaptureSessionはiOS/macOSのOSのメディア装置を扱うクラスです。入力デバイスからメディアを出力するデータの流れを管理します。

Setting Up a Capture Session | Apple Developer Documentation

b9c65b62-3728-43f1-8d25-08fd42bc6bb7
Setting Up a Capture Session Figure 1 から引用

AVCaptureSessionAVCaptureDeviceAVCaptureDeviceInputとして登録することで、入力デバイスの登録をすることができます。図ではback cameraとmicrophoneを登録することで、画像と音声を出力できるようにしています。

setupAVCaptureSessionメソッド

setupAVCaptureSessionメソッドを定義してCapture Sessionを準備しましょう。

func setupAVCaptureSession() {
    captureSession.sessionPreset = .photo
    if let availableDevice = AVCaptureDevice.DiscoverySession(deviceTypes: [.builtInWideAngleCamera], mediaType: .video, position: .back).devices.first {
        captureDevice = availableDevice
    }

AVCaptureDevice.DiscoverySessionメソッドで端末で利用できるデバイスを取得します。
今回は組み込みの広角カメラ、builtInWideAngleCameraを選択しています。

builtInWideAngleCameraは多くの利用目的にあったカメラなのでこれを指定しておけばまず大丈夫です。

mediaTypevideo、positionに.backを選ぶことで背面カメラを取得しています。
positionに.frontを選ぶと全面カメラが取得できます。

AVCaptureDeviceInputの作成

続いてAVCaptureDeviceInputを作成してAVCaptureSessionに登録します。
さきほど作ったAVCaptureDeviceをもとにAVCaptureDeviceInputを作ります。
そして、addInputメソッドでcaptureSessionに登録します。

do {
    let captureDeviceInput = try AVCaptureDeviceInput(device: captureDevice!)
    captureSession.addInput(captureDeviceInput)
} catch let error {
    print(error.localizedDescription)
}

AVCaptureVideoPreviewLayerの作成

続いてAVCaptureVideoPreviewLayerを作成します。
AVCaptureVideoPreviewLayerはcaptureSessionの映像をCALayerとして表示するものです。
カメラの映像をアプリに表示したいときに利用します。

let previewLayer = AVCaptureVideoPreviewLayer(session: captureSession)
previewLayer.name = "CameraPreview"
previewLayer.videoGravity = AVLayerVideoGravity.resizeAspectFill
previewLayer.backgroundColor = UIColor.black.cgColor
self.previewLayer = previewLayer

ここで注意すべきなのはvideoGravityAVLayerVideoGravity.resizeAspectFillを指定することです。
videoGravityはAVCaptureVideoPreviewLayerの矩形領域をどう表示するかを決定するプロパティで次の3つが指定できます。

  • resizeAspect: アスペクト比をレイヤー内に収まるように保持する。デフォルト値。
  • resizeAspectFill: アスペクト比を保ったままレイヤー矩形いっぱいに表示する。
  • resize: アスペクト比を無視し、レイヤー矩形いっぱいに表示する。

3つそれぞれ指定した場合のAVCaptureVideoPreviewLayerの表示をみてみます。

今回は、カメラ映像をView全面に表示したいのでresizeAspectFillを指定します。

最後にcommitConfiguration()メソッドでSessionの編集をコミットすればOKです。
SimpleVideoCaptureInteractorの全体コードはこちらです。

final class SimpleVideoCaptureInteractor: NSObject, ObservableObject {
    private let captureSession = AVCaptureSession()
    @Published var previewLayer: AVCaptureVideoPreviewLayer?
    private var captureDevice: AVCaptureDevice?

    /// - Tag: CreateCaptureSession
     func setupAVCaptureSession() {
         print(#function)
         captureSession.sessionPreset = .photo
         if let availableDevice = AVCaptureDevice.DiscoverySession(deviceTypes: [.builtInWideAngleCamera], mediaType: .video, position: .back).devices.first {
             captureDevice = availableDevice
         }

         do {
             let captureDeviceInput = try AVCaptureDeviceInput(device: captureDevice!)
             captureSession.addInput(captureDeviceInput)
         } catch let error {
             print(error.localizedDescription)
         }

         let previewLayer = AVCaptureVideoPreviewLayer(session: captureSession)
        previewLayer.name = "CameraPreview"
        previewLayer.videoGravity = AVLayerVideoGravity.resizeAspectFill
        previewLayer.backgroundColor = UIColor.black.cgColor
         self.previewLayer = previewLayer

         let dataOutput = AVCaptureVideoDataOutput()
         dataOutput.videoSettings = [kCVPixelBufferPixelFormatTypeKey as String:kCVPixelFormatType_32BGRA]

         if captureSession.canAddOutput(dataOutput) {
             captureSession.addOutput(dataOutput)
         }
         captureSession.commitConfiguration()
     }

    func startSettion() {
        if captureSession.isRunning { return }
        captureSession.startRunning()
    }

    func stopSettion() {
        if !captureSession.isRunning { return }
        captureSession.stopRunning()
    }
}

まとめ

iPhoneのカメラ映像をアプリに表示するところまでを実装しました。
UIViewControllerRepresentableCALayerを表示するViewをSwiftUIで扱えるようにしました。
AVCaptureSessionを作成し、AVCaptureDeviceでカメラを選択してAVCaptureDeviceInputを通してSessionに登録する方法を解説しました。

AVCaptureVideoPreviewLayerではvideoGravityプロパティでアスペクト比表示の指定ができるので、アスペクト比を保ったまま全画面表示をするresizeAspectFillを指定しました。

次回はカメラ映像から画像キャプチャを取得する方法を実装します。

参考

画像

宣伝

SwiftUIでアプリを作り方を解説した「1人でアプリを作る人を支えるSwiftUI開発レシピ」がBOOTHで発売中です。
SwiftUIでアプリを作りたい方、ぜひチェックしてください!

https://personal-factory.booth.pm/items/1920812



Author image

About 佐藤 剛士

  • Japan Tokyo