本記事では、iPhone物理カメラボタンを自分のiOSアプリ内で活用する方法を解説します。
なぜ対応が必要なのか?
何もしない場合、ユーザがアプリ内で物理カメラボタンを押すと、デフォルトでシステム標準の「カメラ」アプリが起動してしまいます。しかし、少しの対応で、物理カメラボタンがアプリ独自の写真撮影アクションを実行するようになり、さらには物理ボタン上でのスワイプ操作でフィルタ変更などの独自コントロールを提供できます。
また、上記のような高度なカスタマイズや、ユニークなコントロールオプション(たとえば物理カメラボタンを使った新しいUI)を簡単に追加可能です。
さっそく始めよう!
初期状態:
まずは非常にシンプルなSwiftUIビューから始めます。ここではカメラプレビューと、撮影用のボタンを表示する簡易な実装例です。
以下は、カメラアクセス許可や撮影アクション、撮影した画像を受け取り変数へ保存するためのViewModel例です。
// 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を用意します。
// 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のビュー例です。
// 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()
}
物理カメラボタンでの写真撮影に対応する
デフォルトの状態では、ユーザが物理カメラボタンを押すと、システムのカメラアプリが起動します。
これを自前のカメラ撮影処理へ置き換えるには、AVKitフレームワークをインポートし、CameraLayerViewにonCameraCaptureEvent()という専用のビュー修飾子を付けます。
CameraLayerView(cameraSession: viewModel.session)
.onAppear {
viewModel.requestAccessIfNeeded()
}
.edgesIgnoringSafeArea(.all)
.onCameraCaptureEvent() { event in
if event.phase == .began {
self.viewModel.capturePhoto()
}
}
こうすることで、ユーザが物理カメラボタンを押した際、提供したクロージャが呼ばれ、その中でViewModelの撮影関数を呼び出し、アプリ内で写真をキャプチャできるようになります。
対応デバイスかどうかの確認
物理カメラボタン対応の有無は、AVCaptureSessionのsupportsControlsで確認可能です。
以下は先ほどの例内でサポートを確認する方法の一例です。
struct ContentView: View {
@ObservedObject var viewModel = CameraViewModel()
var body: some View {
GeometryReader { _ in
// ... //
}
.task {
let supportsCameraButton = self.viewModel.session.supportsControls
}
}
}
ズームコントロールの追加
次に、ズームコントロールを追加してみましょう。iOS側がズームイン・アウトを自動で処理し、あなたのアプリは現在のズームレベルに応じたUI表示を行うことができます。
まず、カメラセッションにコントロール用デリゲートを設定します。
self.session.setControlsDelegate(self, queue: self.cameraControlQueue)
次に、AVCaptureSessionControlsDelegateプロトコルへ準拠し、必要な関数を実装します。
// 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
}
}
これでカメラコントロール機能が有効になります。
続いて、システムズームコントロールを初期化します。
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)
}
}
以下は更新したCameraViewModelです。
// 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やデリゲート、システムズームスライダーの設定を行います。
これでアプリを実行すると、物理カメラボタン操作からアプリ内でズーム操作が可能になったことが確認できます。
露出コントロールの追加
露出コントロールも同様に、追加で1行ほどコントロールを配列に追加するだけで実装できます。
// 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つが表示され、スライダーで値を調整できます。
カスタムスライダーの追加
カスタムスライダーも用意できます。setActionQueueを使って値変更時に通知を受け取り、独自の機能を実装できます。
たとえば、冗談めかした例として「4次元(時間)コントロール」をスライダーで行うようなことも可能です(あくまでサンプル的な発想ですが)。
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)
}
カスタムピッカーの追加
さらに、ユーザが複数のオプションから1つを選ぶためのカスタムピッカーを提供することも可能です。たとえば、フィルター一覧をピッカーで選択させる機能などが考えられます。
let indexPicker = AVCaptureIndexPicker("Post to",
symbolName: "square.and.arrow.up",
localizedIndexTitles: [
"Qiita",
"Twitter",
"SoraSNS"
])
indexPicker.setActionQueue(self.cameraControlQueue) { value in
print(value)
}
取得した値はインデックスとして渡されますので、それに応じた処理を行いましょう。
まとめ
以上の手順で、物理カメラボタンを活用してアプリ内で写真撮影を行ったり、ズームや露出、カスタムコントロールを搭載することができます。新しいカメラ体験をユーザに提供してみてください。
参考:
以下は、本記事で説明した内容をすべてまとめた完全なコード例です。
Please follow me!
English version: https://mszpro.com/ios-camera-control-button
- Twitter: https://twitter.com/mszpro
- YouTube: https://www.youtube.com/@MszPro6
- Mastodon, Misskey: @me@mszpro.com
- Bluesky: @mszpro.com
- Webサイト: https://mszpro.com
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()
}