この記事ではiOS標準のカメラにもある 画面をタップしてフォーカスを当てる機能
をAVFoundationを使って実装する方法を紹介します。
また、よりiOS標準のカメラの機能に近づけるために、フォーカスを当てた後に端末を動かし、フォーカスが外れる機能を実装する方法も紹介します。
確認した環境
iOS11
前提知識:フォーカスと露出
画面をタップした際にカメラはフォーカスと露出を調整しています。
露出とは明るさを調整するものです。
暗い場所をタップすると多くの光を取り入れようとしたり、明るい場所をタップすると取り入れる光の量を少なくします。
本記事ではフォーカスと露出の制御について紹介していきます。
フォーカスと露出のモード
// フォーカス
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で明るさを調整するときに使うモードだと考えられます。(今回実装しなかったため考えられます。という表現にとどめています)
状態遷移図
これまでの内容をもとに状態遷移図を作成しました。
この状態遷移図では、カメラの状態ではなく実装に必要な状態で書いています。
ポイントは2つです。
-
auto
からlocked
に自動で変わるためautoAndLocked
状態にしました -
auto
からcontinuous
に直ぐにならないことからlocked
状態を2つ用意しました。一定時間経過前でcontinuous
になれないforce
と一定時間後にcontinuous
になれるunlockable
です。
実装の紹介
ステートマシン
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を設定しています。
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
アクション
端末が動いたことの検知には加速度センサーを使いました。
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
アクション
カメラ画面表示時に発行します。
override func viewDidAppear(_ animated: Bool) {
super.viewDidAppear(animated)
cameraManager.focusExposeStateMachine.onAction(.showedView)
}
フォーカスと露出の設定
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
}
ポイントは3つです。
-
lockForConfiguration
とunlockForConfiguration
を呼ぶ - UIKitのCGPointではなく
captureDevicePointConverted
で変換した0~1の値の設定する - フォーカスと露出のモードが対応しているモードか確認してから設定する
レイアウトの実装
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
メソッドを用意しています。
// 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の状態を変えています。
コード全容
コードの断片を紹介してきたので、最後にコードの全容を記載します。
今回の記事に関係がある部分のみ記載しているため、コピペして直ぐに動くものではないではありません。
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)
}
}