AVFoundationを使ったカメラの実装：画面タップでフォーカス当てる実装

この記事ではiOS標準のカメラにもある `画面をタップしてフォーカスを当てる機能` をAVFoundationを使って実装する方法を紹介します。
また、よりiOS標準のカメラの機能に近づけるために、フォーカスを当てた後に端末を動かし、フォーカスが外れる機能を実装する方法も紹介します。

# 確認した環境
iOS11

# 前提知識：フォーカスと露出

画面をタップした際にカメラはフォーカスと露出を調整しています。
露出とは明るさを調整するものです。
暗い場所をタップすると多くの光を取り入れようとしたり、明るい場所をタップすると取り入れる光の量を少なくします。

本記事ではフォーカスと露出の制御について紹介していきます。

# フォーカスと露出のモード

```swift
    // フォーカス
    public enum FocusMode : Int {        
        case locked
        case autoFocus
        case continuousAutoFocus
    }
    // 露出
    public enum ExposureMode : Int {   
        case locked
        case autoExpose
        case continuousAutoExposure
        @available(iOS 8.0, *)
        case custom
    }
```

AVFoundationで用意されているフォーカスと露出のモードはこの通りです。
フォーカスと露出には、`locked` `auto` `continuous` の3つのモードがあることがわかります。

`locked`：フォーカスと露出を現在の状態から変えないモードです。
`auto`：指定の位置に適切なフォーカスと露出を一度設定するモードです。一度設定した後は `locked` になります。
`continuous`：必要に応じて継続的に適切なフォーカスと露出を設定するモードです。

露出の `custom` はユーザが明るさを任意に調整する時に使用するモードです。

# iOS標準のカメラとフォーカスと露出のモード

フォーカスと露出のモードを理解した上で、iOS標準のカメラの機能を確認します。
まず、画面をタップすると `auto` になり、一度だけその位置に適切なフォーカスと露出が設定されます。
その後端末を動かすと `continuous` になり、継続的に適切なフォーカスと露出が設定されます。
また、 `auto` で適切な設定がされた直後に端末を動かしても、直ぐに `continuous` にならないことも確認できます。一定時間後に `continuous` になります。
露出の `custom` は画面をタップしたあとに表示されるViewで明るさを調整するときに使うモードだと考えられます。（今回実装しなかったため考えられます。という表現にとどめています）

# 状態遷移図

![カメラの状態遷移図.png](https://qiita-image-store.s3.amazonaws.com/0/40689/9e621f91-1193-b8bf-4c69-1620ae2dee66.png)

これまでの内容をもとに状態遷移図を作成しました。
この状態遷移図では、カメラの状態ではなく実装に必要な状態で書いています。

ポイントは２つです。

- `auto` から　`locked` に自動で変わるため `autoAndLocked` 状態にしました
- `auto` から `continuous` に直ぐにならないことから `locked` 状態を2つ用意しました。一定時間経過前で `continuous` になれない `force` と一定時間後に `continuous` になれる `unlockable` です。

# 実装の紹介

## ステートマシン

```swift
import RxCocoa
import UIKit

class CameraFocusExposeStateMachine {
    private let subject = BehaviorRelay<State>(value: .continuousAuto)
    private var isMoved = false

    var stateStream: Driver<State> { get { return subject.asDriver() }}

    func onAction(_ action: Action) {

        if case .moved = action {
            isMoved = true
        }

        let currentState = subject.value
        let newState: State

        switch (currentState, action) {
        case (_, .showedView):
            newState = .continuousAuto

        case (_, .tappedView(let point)):
            newState = .autoAndForceLocked(tappedPoint: point)

        case (.autoAndForceLocked, .elapsedForceLockedTime):
            if isMoved {
                newState = .continuousAuto
            } else {
                newState = .unlockableLocked
            }

        case (.unlockableLocked, .moved):
            newState = .continuousAuto

        default:
            return
        }

        subject.accept(newState)
        isMoved = false
    }

    enum State {
        case continuousAuto, autoAndForceLocked(tappedPoint: CGPoint), unlockableLocked
    }

    enum Action {
        case tappedView(point: CGPoint), elapsedForceLockedTime, moved, showedView
    }
}
```

状態は `continuousAuto` `autoAndForceLocked` `unlockableLocked` の3つを用意しました。

`continuousAuto` ：AVFoundationの `continuous` と同じです。
`autoAndForceLocked` ：AVFoundationの `auto` と その後の `continuous` にはならないロック状態です。
`unlockableLocked` ：`continuous` になれるロック状態です。

受け取るアクションは `tappedView` `elapsedForceLockedTime` `moved` `showedView` の4つを用意しました。

`tappedView` ：画面をタップするアクションです。
`elapsedForceLockedTime` ：`continuous` にはならないロック状態から一定時間経過したアクションです。
`moved` ：端末が動いたアクションです。
`showedView` ：カメラ画面が表示されたアクションです。

`state` の変更は `RxCocoa` の `Driver` を使って、状態の変更を監視できるようにしています。

## `tappedView` と `elapsedForceLockedTime` アクション

画面がタップされると `tappedView` を発行し、合わせて `elapsedForceLockedTime` を発行するtimerを設定しています。

```swift
    private var focusExposeLockTimer: Timer? {
        willSet {
            guard let oldValue = focusExposeLockTimer else {
                return
            }
            if oldValue.isValid {
                oldValue.invalidate()
            }
        }
    }

    func focus(tappedPoint: CGPoint) {
        guard setMode(point: tappedPoint, focusMode: .autoFocus, exposureMode: .autoExpose) else {
            return
        }

        focusExposeStateMachine.onAction(.tappedView(point: tappedPoint))

        focusExposeLockTimer = Timer.scheduledTimer(withTimeInterval: 3, repeats: false) { [weak self] _ in
            self?.focusExposeStateMachine.onAction(.elapsedForceLockedTime)
        }
    }
```

## `moved` アクション

端末が動いたことの検知には加速度センサーを使いました。

```swift
import CoreMotion

    private let motionManager = CMMotionManager()

    func startToObserve() {
        guard motionManager.isAccelerometerAvailable else {
            return
        }
        motionManager.accelerometerUpdateInterval = 1 / 10

        guard let queue = OperationQueue.current else {
            return
        }
        motionManager.startAccelerometerUpdates(to: queue) { [weak self] data, error in
            guard let wself = self else {
                return
            }

            guard let acceleration = data?.acceleration else {
                return
            }

            let displacement = abs(acceleration.x) + abs(acceleration.y) + abs(acceleration.z)
            if displacement > 1.5 {
                wself.focusExposeStateMachine.onAction(.moved)
            }
        }
    }

    func stopToObserve() {
        if motionManager.isAccelerometerAvailable {
            motionManager.stopAccelerometerUpdates()
        }
    }
```

## `showedView` アクション

カメラ画面表示時に発行します。

```swift
    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        cameraManager.focusExposeStateMachine.onAction(.showedView)
    }
```

## フォーカスと露出の設定

```swift
    private func setMode(point: CGPoint, focusMode: AVCaptureDevice.FocusMode, exposureMode: AVCaptureDevice.ExposureMode) -> Bool {
        guard let device = cameraDevice else {
            return false
        }

        do {
            try device.lockForConfiguration()
        } catch {
            return false
        }

        let devicePoint = cameraPreviewLayer.captureDevicePointConverted(fromLayerPoint: point)

        if device.isFocusPointOfInterestSupported && device.isFocusModeSupported(focusMode) {
            device.focusPointOfInterest = devicePoint
            device.focusMode = focusMode
        }

        if device.isExposurePointOfInterestSupported && device.isExposureModeSupported(exposureMode) {
            device.exposurePointOfInterest = devicePoint
            device.exposureMode = exposureMode
        }

        device.unlockForConfiguration()
        return true

    }
```

ポイントは３つです。

- `lockForConfiguration` と `unlockForConfiguration` を呼ぶ
- UIKitのCGPointではなく `captureDevicePointConverted` で変換した0~1の値の設定する
- フォーカスと露出のモードが対応しているモードか確認してから設定する

## レイアウトの実装

```swift
import UIKit

private let maxSquareSize = CGSize(width: 187.5, height: 187.5)
private let centerSquareSize = CGSize(width: 112.5, height: 112.5)
private let squareSize = CGSize(width: 75, height: 75)

class CameraFocusView: UIView {

    override init(frame: CGRect) {
        super.init(frame: frame)
        setup()
    }

    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        setup()
    }

    private func setup() {
        backgroundColor = .clear
        layer.borderColor = UIColor.white.cgColor
        layer.borderWidth = 2
    }

    func showCenter(at centerPoint: CGPoint) {
        show(centerPoint: centerPoint, isCenter: true)
    }

    func show(at centerPoint: CGPoint) {
        show(centerPoint: centerPoint, isCenter: false)
    }

    func weaken() {
        alpha = 0.5
    }

    private func show(centerPoint: CGPoint, isCenter: Bool) {
        frame.size = maxSquareSize
        center = centerPoint
        alpha = 0

        let animations: ()->() = { [weak self] in
            guard let wself = self else {
                return
            }

            wself.frame.size = isCenter ? centerSquareSize : squareSize
            wself.center = centerPoint
            wself.alpha = 1
        }

        let completion: (Bool)->() = { [weak self] _ in
            guard let wself = self else {
                return
            }
            if isCenter {
                UIView.animate(withDuration: 0.5, delay: 2, animations: {
                    wself.alpha = 0
                })
            }
        }

        UIView.animate(withDuration: 0.5, animations: animations, completion: completion)
    }
}
```

カスタムViewを用意しました。
`unlockableLocked` になると色が薄くなるので `weak` メソッドを用意しています。


```swift
// ViewController
    override func viewDidLoad() {
        super.viewDidLoad()

        cameraManager.focusExposeStateMachine.stateStream.drive(onNext: { [weak self] state in
            guard let wself = self else {
                return
            }

            switch state {
            case .continuousAuto:
                let centerPoint = wself.cameraFoundationView.center
                wself.focusView.showCenter(at: centerPoint)
                wself.cameraManager.continuousAutoFocus(point: centerPoint)
            case .autoAndForceLocked(let tappedPoint):
                wself.focusView.show(at: tappedPoint)
            case .unlockableLocked:
                wself.focusView.weaken()
            }

        }).disposed(by: disposeBag)
    }
```

ステートマシンの状態を監視して、Viewの状態を変えています。

## コード全容

コードの断片を紹介してきたので、最後にコードの全容を記載します。
今回の記事に関係がある部分のみ記載しているため、コピペして直ぐに動くものではないではありません。

```Swift
import UIKit
import RxSwift

class CameraViewController: UIViewController {
    fileprivate let disposeBag = DisposeBag()
    fileprivate let cameraManager = CameraManager()

    override func viewDidLoad() {
        super.viewDidLoad()

        cameraManager.focusExposeStateMachine.stateStream.drive(onNext: { [weak self] state in
            guard let wself = self else {
                return
            }

            switch state {
            case .continuousAuto:
                let centerPoint = wself.cameraFoundationView.center
                wself.focusView.showCenter(at: centerPoint)
                wself.cameraManager.continuousAutoFocus(point: centerPoint)
            case .autoAndForceLocked(let tappedPoint):
                wself.focusView.show(at: tappedPoint)
            case .unlockableLocked:
                wself.focusView.weaken()
            }

        }).disposed(by: disposeBag)
    }

    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)

        cameraManager.startToObserve()
        cameraManager.focusExposeStateMachine.onAction(.showedView)
    }

    override func viewWillDisappear(_ animated: Bool) {
        super.viewWillDisappear(animated)

        cameraManager.stopToObserve()
    }

    @objc private func tappedCameraFoundationView(gestureRecognizer: UITapGestureRecognizer) {
        let tappedPoint = gestureRecognizer.location(in: gestureRecognizer.view)
        cameraManager.focus(tappedPoint: tappedPoint)
    }

}

import AVFoundation
import CoreMotion

class CameraManager {
    
    private lazy var cameraPreviewLayer : AVCaptureVideoPreviewLayer = {
        let cameraPreviewLayer = AVCaptureVideoPreviewLayer(session: captureSession)
        cameraPreviewLayer.videoGravity = .resizeAspectFill
        return cameraPreviewLayer
    }()
    
    private lazy var captureSession: AVCaptureSession = {
        let session = AVCaptureSession()
        session.sessionPreset = .photo
        return session
    }()
    
    fileprivate lazy var cameraDevice: AVCaptureDevice? = {
        
        let session = AVCaptureDevice.DiscoverySession(deviceTypes: [.builtInWideAngleCamera], mediaType: .video, position: .back)
        let devices = session.devices
        
        for device in devices {
            if device.position == AVCaptureDevice.Position.back {
                return device
            }
        }
        return nil
    }()
    
    fileprivate let photoOutput = AVCapturePhotoOutput()
    private let motionManager = CMMotionManager()

    let focusExposeStateMachine = CameraFocusExposeStateMachine()
    private var focusExposeLockTimer: Timer? {
        willSet {
            guard let oldValue = focusExposeLockTimer else {
                return
            }
            if oldValue.isValid {
                oldValue.invalidate()
            }
        }
    }
    
    func startToObserve() {
        guard motionManager.isAccelerometerAvailable else {
            return
        }
        motionManager.accelerometerUpdateInterval = 1 / 10

        guard let queue = OperationQueue.current else {
            return
        }
        motionManager.startAccelerometerUpdates(to: queue) { [weak self] data, error in
            guard let wself = self else {
                return
            }

            guard let acceleration = data?.acceleration else {
                return
            }

            let displacement = abs(acceleration.x) + abs(acceleration.y) + abs(acceleration.z)
            if displacement > 1.5 {
                wself.focusExposeStateMachine.onAction(.moved)
            }
        }
    }

    func stopToObserve() {
        if motionManager.isAccelerometerAvailable {
            motionManager.stopAccelerometerUpdates()
        }
    }
}

extension CameraManager {

    func focus(tappedPoint: CGPoint) {
        guard setMode(point: tappedPoint, focusMode: .autoFocus, exposureMode: .autoExpose) else {
            return
        }

        focusExposeStateMachine.onAction(.tappedView(point: tappedPoint))

        focusExposeLockTimer = Timer.scheduledTimer(withTimeInterval: 3, repeats: false) { [weak self] _ in
            self?.focusExposeStateMachine.onAction(.elapsedForceLockedTime)
        }
    }

    func continuousAutoFocus(point: CGPoint) {
        _ = setMode(point: point, focusMode: .continuousAutoFocus, exposureMode: .continuousAutoExposure)
    }

    private func setMode(point: CGPoint, focusMode: AVCaptureDevice.FocusMode, exposureMode: AVCaptureDevice.ExposureMode) -> Bool {
        guard let device = cameraDevice else {
            return false
        }

        do {
            try device.lockForConfiguration()
        } catch {
            return false
        }

        let devicePoint = cameraPreviewLayer.captureDevicePointConverted(fromLayerPoint: point)

        if device.isFocusPointOfInterestSupported && device.isFocusModeSupported(focusMode) {
            device.focusPointOfInterest = devicePoint
            device.focusMode = focusMode
        }

        if device.isExposurePointOfInterestSupported && device.isExposureModeSupported(exposureMode) {
            device.exposurePointOfInterest = devicePoint
            device.exposureMode = exposureMode
        }

        device.unlockForConfiguration()
        return true

    }

}

import RxCocoa
import UIKit

class CameraFocusExposeStateMachine {
    private let subject = BehaviorRelay<State>(value: .continuousAuto)
    private var isMoved = false

    var stateStream: Driver<State> { get { return subject.asDriver() }}

    func onAction(_ action: Action) {

        if case .moved = action {
            isMoved = true
        }

        let currentState = subject.value
        let newState: State

        switch (currentState, action) {
        case (_, .showedView):
            newState = .continuousAuto

        case (_, .tappedView(let point)):
            newState = .autoAndForceLocked(tappedPoint: point)

        case (.autoAndForceLocked, .elapsedForceLockedTime):
            if isMoved {
                newState = .continuousAuto
            } else {
                newState = .unlockableLocked
            }

        case (.unlockableLocked, .moved):
            newState = .continuousAuto

        default:
            return
        }

        subject.accept(newState)
        isMoved = false
    }

    enum State {
        case continuousAuto, autoAndForceLocked(tappedPoint: CGPoint), unlockableLocked
    }

    enum Action {
        case tappedView(point: CGPoint), elapsedForceLockedTime, moved, showedView
    }
}

import UIKit

private let maxSquareSize = CGSize(width: 187.5, height: 187.5)
private let centerSquareSize = CGSize(width: 112.5, height: 112.5)
private let squareSize = CGSize(width: 75, height: 75)

class CameraFocusView: UIView {

    override init(frame: CGRect) {
        super.init(frame: frame)
        setup()
    }

    required init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
        setup()
    }

    private func setup() {
        backgroundColor = .clear
        layer.borderColor = UIColor.white.cgColor
        layer.borderWidth = 2
    }

    func showCenter(at centerPoint: CGPoint) {
        show(centerPoint: centerPoint, isCenter: true)
    }

    func show(at centerPoint: CGPoint) {
        show(centerPoint: centerPoint, isCenter: false)
    }

    func weaken() {
        alpha = 0.5
    }

    private func show(centerPoint: CGPoint, isCenter: Bool) {
        frame.size = maxSquareSize
        center = centerPoint
        alpha = 0

        let animations: ()->() = { [weak self] in
            guard let wself = self else {
                return
            }

            wself.frame.size = isCenter ? centerSquareSize : squareSize
            wself.center = centerPoint
            wself.alpha = 1
        }

        let completion: (Bool)->() = { [weak self] _ in
            guard let wself = self else {
                return
            }
            if isCenter {
                UIView.animate(withDuration: 0.5, delay: 2, animations: {
                    wself.alpha = 0
                })
            }
        }

        UIView.animate(withDuration: 0.5, animations: animations, completion: completion)
    }
}



```