🎬 Why Camera Control is the Future of Mobile Photography?#
Imagine: you're at your favorite band's concert. The crowd is pressing from all sides, your hands are busy, and you're trying to take the perfect shot. Before, you'd have to awkwardly tap the screen, risking dropping your phone. Now, one press of the iPhone 16 side button — and the moment is captured.
This isn't science fiction. This is Camera Control — one of Apple's most significant innovations in mobile photography in recent years.
What You'll Learn#
In this guide, we'll walk through the entire journey from zero to a fully working Camera Control integration:
- How Camera Control architecture works and why it works that way
- Step-by-step project setup from scratch
- Creating a Lock Screen Camera Extension (required to appear in settings)
- Adding system zoom and exposure controls
- Handling shutter button events
- Integration for both UIKit and SwiftUI
- Solving common problems and debugging
📱 What is Camera Control and How Does it Work?#
Camera Control isn't just "another button." It's an entire ecosystem combining hardware, software APIs, and an intuitive user interface.
Hardware Foundation#
In iPhone 16 and newer models, the side button is equipped with special sensors:
- Force Touch — detects press pressure
- Capacitive sensing — recognizes touches and swipes
- Haptic feedback — provides tactile response
This combination allows the button to work as:
- Camera shutter (full press)
- Settings slider (swipe on button)
- Mode switcher (double press)
Three Main Camera Control Features#
- App Launch — Camera Control can open your app directly from the lock screen
- Shutter Button — physical press for photo capture or video start/stop
- Parameter Adjustment — swipe on button to adjust zoom, exposure, and other parameters
🎯 Requirements and Compatibility#
Before writing code, make sure your project meets the requirements.
System Requirements#
| Component | Minimum Version |
|---|---|
| iOS | 18.0+ |
| Device | iPhone 16, 16 Plus, 16 Pro, 16 Pro Max |
| Xcode | 16.0+ |
| Swift | 5.9+ |
Required Frameworks#
import AVFoundation // Main framework for camera work
import AVKit // For AVCaptureEventInteraction
import Photos // For saving photos to galleryChecking Compatibility in Code#
Always check Camera Control availability before use:
func checkCameraControlSupport() -> Bool {
// Check iOS version
guard #available(iOS 18.0, *) else {
print("❌ Requires iOS 18.0 or higher")
return false
}
// Check device support
let session = AVCaptureSession()
guard session.supportsControls else {
print("❌ Device doesn't support Camera Control")
return false
}
print("✅ Camera Control fully supported")
return true
}⚠️ Important: The
supportsControlsproperty returnstrueonly on iPhone 16 and newer. On simulator and older devices, it's alwaysfalse.
🏗️ Project Architecture#
Recommended File Structure#
CameraControlApp/
├── CameraControlApp.xcodeproj
├── CameraControlApp/
│ ├── App/
│ │ ├── CameraControlApp.swift // Entry point (SwiftUI)
│ │ └── AppDelegate.swift // (optional, for UIKit)
│ ├── Camera/
│ │ ├── CameraController.swift // Main camera logic
│ │ ├── CameraPreviewView.swift // Preview for UIKit
│ │ └── CameraModel.swift // ObservableObject for SwiftUI
│ ├── Views/
│ │ ├── CameraView.swift // SwiftUI camera view
│ │ └── CameraViewController.swift // UIKit controller
│ └── Extensions/
│ └── AVCaptureSession+Extensions.swift
├── LockScreenCameraExtension/ // Lock Screen Extension
│ ├── LockScreenCameraExtension.swift
│ └── Info.plist
└── Tests/
└── CameraControlTests.swiftRequired Info.plist Entries#
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- Camera usage description (required!) -->
<key>NSCameraUsageDescription</key>
<string>App needs camera access for photo capture</string>
<!-- Photo library save description -->
<key>NSPhotoLibraryAddUsageDescription</key>
<string>To save photos to gallery</string>
</dict>
</plist>🔧 Step 1: Creating CameraController#
CameraController is the class that manages all camera and Camera Control logic.
import AVFoundation
import AVKit
import Photos
/// Main camera controller with Camera Control support
class CameraController: NSObject, ObservableObject {
// MARK: - Public Properties
/// Current zoom factor
@Published var currentZoomFactor: CGFloat = 1.0
/// Current exposure bias
@Published var currentExposureBias: Float = 0.0
/// Whether Camera Control overlay is active
@Published var isCameraControlActive: Bool = false
/// Last captured image (for preview)
@Published var lastCapturedImage: UIImage?
/// Camera permission status
@Published var permissionStatus: AVAuthorizationStatus = .notDetermined
// MARK: - Internal Properties
/// Capture session — central AVFoundation object
let session = AVCaptureSession()
/// Queue for session configuration (never on main thread!)
private let sessionQueue = DispatchQueue(
label: "com.yourapp.camera.session",
qos: .userInitiated
)
/// Photo output
private let photoOutput = AVCapturePhotoOutput()
/// Camera input
private var deviceInput: AVCaptureDeviceInput?
/// Storage for KVO observers
private var observations: [NSKeyValueObservation] = []
// MARK: - Initialization
override init() {
super.init()
checkPermissions()
}
// MARK: - Permission Handling
/// Checks and requests camera permission
func checkPermissions() {
switch AVCaptureDevice.authorizationStatus(for: .video) {
case .authorized:
permissionStatus = .authorized
configureSession()
case .notDetermined:
sessionQueue.suspend()
AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in
guard let self = self else { return }
DispatchQueue.main.async {
self.permissionStatus = granted ? .authorized : .denied
}
if granted {
self.configureSession()
}
self.sessionQueue.resume()
}
case .denied, .restricted:
permissionStatus = .denied
print("⚠️ Camera access denied")
@unknown default:
break
}
}
// MARK: - Session Configuration
/// Configures AVCaptureSession
private func configureSession() {
sessionQueue.async { [weak self] in
guard let self = self else { return }
self.session.beginConfiguration()
self.session.sessionPreset = .photo
// Configure camera input
guard self.setupCameraInput() else {
self.session.commitConfiguration()
return
}
// Configure photo output
guard self.setupPhotoOutput() else {
self.session.commitConfiguration()
return
}
// Configure Camera Control (iOS 18+)
if #available(iOS 18.0, *) {
self.setupCameraControls()
}
self.session.commitConfiguration()
self.session.startRunning()
print("✅ Camera session started")
}
}
/// Configures camera input
private func setupCameraInput() -> Bool {
guard let camera = AVCaptureDevice.default(
.builtInWideAngleCamera,
for: .video,
position: .back
) else {
print("❌ Camera unavailable")
return false
}
do {
let input = try AVCaptureDeviceInput(device: camera)
if session.canAddInput(input) {
session.addInput(input)
deviceInput = input
setupDeviceObservers(device: camera)
return true
} else {
print("❌ Failed to add camera input")
return false
}
} catch {
print("❌ Error: \(error)")
return false
}
}
/// Configures photo output
private func setupPhotoOutput() -> Bool {
guard session.canAddOutput(photoOutput) else {
print("❌ Failed to add photo output")
return false
}
session.addOutput(photoOutput)
photoOutput.isHighResolutionCaptureEnabled = true
if let connection = photoOutput.connection(with: .video),
connection.isVideoStabilizationSupported {
connection.preferredVideoStabilizationMode = .auto
}
return true
}
/// Sets up device change observers
private func setupDeviceObservers(device: AVCaptureDevice) {
// Observe zoom changes
let zoomObserver = device.observe(
\.videoZoomFactor,
options: [.new]
) { [weak self] device, change in
guard let newValue = change.newValue else { return }
DispatchQueue.main.async {
self?.currentZoomFactor = newValue
}
}
observations.append(zoomObserver)
// Observe exposure changes
let exposureObserver = device.observe(
\.exposureTargetBias,
options: [.new]
) { [weak self] device, change in
guard let newValue = change.newValue else { return }
DispatchQueue.main.async {
self?.currentExposureBias = newValue
}
}
observations.append(exposureObserver)
}
// MARK: - Photo Capture
/// Captures a photo
func capturePhoto() {
guard let connection = photoOutput.connection(with: .video),
connection.isEnabled else {
print("❌ Camera connection inactive")
return
}
let settings = AVCapturePhotoSettings()
settings.flashMode = .auto
settings.isHighResolutionPhotoEnabled = true
photoOutput.capturePhoto(with: settings, delegate: self)
print("📸 Photo capture started")
}
// MARK: - Controls
/// Sets zoom programmatically
func setZoom(_ factor: CGFloat) {
guard let device = deviceInput?.device else { return }
let clampedFactor = min(
max(factor, device.minAvailableVideoZoomFactor),
device.maxAvailableVideoZoomFactor
)
sessionQueue.async {
do {
try device.lockForConfiguration()
device.videoZoomFactor = clampedFactor
device.unlockForConfiguration()
} catch {
print("❌ Zoom error: \(error)")
}
}
}
/// Sets exposure bias programmatically
func setExposureBias(_ bias: Float) {
guard let device = deviceInput?.device else { return }
let clampedBias = min(
max(bias, device.minExposureTargetBias),
device.maxExposureTargetBias
)
sessionQueue.async {
do {
try device.lockForConfiguration()
device.setExposureTargetBias(clampedBias) { _ in }
device.unlockForConfiguration()
} catch {
print("❌ Exposure error: \(error)")
}
}
}
// MARK: - Lifecycle
func startSession() {
sessionQueue.async { [weak self] in
guard let self = self, !self.session.isRunning else { return }
self.session.startRunning()
}
}
func stopSession() {
sessionQueue.async { [weak self] in
guard let self = self, self.session.isRunning else { return }
self.session.stopRunning()
}
}
deinit {
observations.removeAll()
stopSession()
}
}🎛️ Step 2: Adding Camera Control (iOS 18+)#
Now let's add Camera Control support:
// MARK: - Camera Control Setup (iOS 18+)
@available(iOS 18.0, *)
extension CameraController: AVCaptureSessionControlsDelegate {
/// Configures Camera Control
func setupCameraControls() {
// Check support
guard session.supportsControls else {
print("⚠️ Camera Control not supported")
return
}
guard let device = deviceInput?.device else {
print("❌ Camera device not found")
return
}
// Remove existing controls
for control in session.controls {
session.removeControl(control)
}
var controls: [AVCaptureControl] = []
// 1. System zoom slider
let zoomSlider = AVCaptureSystemZoomSlider(
device: device
) { [weak self] zoomFactor in
DispatchQueue.main.async {
self?.currentZoomFactor = zoomFactor
}
print("🔍 Zoom: \(zoomFactor)x")
}
controls.append(zoomSlider)
// 2. System exposure slider
let exposureSlider = AVCaptureSystemExposureBiasSlider(
device: device
) { [weak self] bias in
DispatchQueue.main.async {
self?.currentExposureBias = bias
}
print("☀️ Exposure: \(bias)")
}
controls.append(exposureSlider)
// Add controls to session
for control in controls {
if session.canAddControl(control) {
session.addControl(control)
print("✅ Added: \(type(of: control))")
}
}
// Set delegate
session.setControlsDelegate(self, queue: sessionQueue)
print("✅ Camera Control configured")
}
// MARK: - AVCaptureSessionControlsDelegate
func sessionControlsDidBecomeActive(_ session: AVCaptureSession) {
DispatchQueue.main.async { [weak self] in
self?.isCameraControlActive = true
print("🎮 Camera Control activated")
}
}
func sessionControlsDidBecomeInactive(_ session: AVCaptureSession) {
DispatchQueue.main.async { [weak self] in
self?.isCameraControlActive = false
print("🎮 Camera Control deactivated")
}
}
func sessionControlsWillEnterFullscreenAppearance(
_ session: AVCaptureSession
) {
print("📺 Entering fullscreen mode")
// Hide custom UI elements
}
func sessionControlsWillExitFullscreenAppearance(
_ session: AVCaptureSession
) {
print("📺 Exiting fullscreen mode")
// Show UI elements back
}
}📸 Step 3: Photo Capture Handling#
// MARK: - AVCapturePhotoCaptureDelegate
extension CameraController: AVCapturePhotoCaptureDelegate {
func photoOutput(
_ output: AVCapturePhotoOutput,
didFinishProcessingPhoto photo: AVCapturePhoto,
error: Error?
) {
if let error = error {
print("❌ Error: \(error.localizedDescription)")
return
}
guard let imageData = photo.fileDataRepresentation(),
let image = UIImage(data: imageData) else {
print("❌ Failed to get image")
return
}
DispatchQueue.main.async { [weak self] in
self?.lastCapturedImage = image
}
saveToPhotoLibrary(imageData: imageData)
print("✅ Photo captured")
}
private func saveToPhotoLibrary(imageData: Data) {
PHPhotoLibrary.requestAuthorization(for: .addOnly) { status in
guard status == .authorized else {
print("⚠️ No permission to save")
return
}
PHPhotoLibrary.shared().performChanges {
let request = PHAssetCreationRequest.forAsset()
request.addResource(with: .photo, data: imageData, options: nil)
} completionHandler: { success, error in
if success {
print("✅ Saved to gallery")
} else if let error = error {
print("❌ Error: \(error.localizedDescription)")
}
}
}
}
}🔘 Step 4: Physical Button Event Handling#
// MARK: - Capture Event Handling
extension CameraController {
/// Creates AVCaptureEventInteraction
@available(iOS 17.2, *)
func createCaptureEventInteraction(
for view: UIView
) -> AVCaptureEventInteraction {
let interaction = AVCaptureEventInteraction(
primary: { [weak self] event in
self?.handleCaptureEvent(event, isPrimary: true)
},
secondary: { [weak self] event in
self?.handleCaptureEvent(event, isPrimary: false)
}
)
interaction.isEnabled = true
view.addInteraction(interaction)
print("✅ AVCaptureEventInteraction configured")
return interaction
}
private func handleCaptureEvent(
_ event: AVCaptureEvent,
isPrimary: Bool
) {
let type = isPrimary ? "Primary" : "Secondary"
switch event.phase {
case .began:
print("🔵 \(type): began")
case .ended:
print("🟢 \(type): ended — capturing!")
capturePhoto()
case .cancelled:
print("🔴 \(type): cancelled")
@unknown default:
break
}
}
}🖼️ Step 5: Creating CameraPreviewView#
import UIKit
import AVFoundation
/// UIView for displaying camera preview
class CameraPreviewView: UIView {
override class var layerClass: AnyClass {
return AVCaptureVideoPreviewLayer.self
}
var videoPreviewLayer: AVCaptureVideoPreviewLayer {
return layer as! AVCaptureVideoPreviewLayer
}
override init(frame: CGRect) {
super.init(frame: frame)
setupPreviewLayer()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
setupPreviewLayer()
}
private func setupPreviewLayer() {
videoPreviewLayer.videoGravity = .resizeAspectFill
backgroundColor = .black
}
func setSession(_ session: AVCaptureSession) {
videoPreviewLayer.session = session
}
}🎨 Step 6: SwiftUI Integration#
CameraPreviewRepresentable#
import SwiftUI
import AVFoundation
struct CameraPreviewRepresentable: UIViewRepresentable {
let session: AVCaptureSession
let onViewCreated: ((UIView) -> Void)?
init(
session: AVCaptureSession,
onViewCreated: ((UIView) -> Void)? = nil
) {
self.session = session
self.onViewCreated = onViewCreated
}
func makeUIView(context: Context) -> CameraPreviewView {
let view = CameraPreviewView()
view.setSession(session)
onViewCreated?(view)
return view
}
func updateUIView(_ uiView: CameraPreviewView, context: Context) {}
}Main CameraView#
import SwiftUI
import AVKit
struct CameraView: View {
@StateObject private var cameraController = CameraController()
@State private var captureInteraction: AVCaptureEventInteraction?
var body: some View {
GeometryReader { geometry in
ZStack {
// Camera preview
CameraPreviewRepresentable(
session: cameraController.session,
onViewCreated: { view in
setupCaptureInteraction(on: view)
}
)
.ignoresSafeArea()
// Control overlay
VStack {
topInfoBar
Spacer()
bottomControlsBar
}
.padding()
}
}
.onAppear { cameraController.startSession() }
.onDisappear { cameraController.stopSession() }
}
// MARK: - UI Components
private var topInfoBar: some View {
HStack {
// Zoom indicator
Label(
String(format: "%.1fx", cameraController.currentZoomFactor),
systemImage: "magnifyingglass"
)
.font(.caption)
.padding(8)
.background(.ultraThinMaterial)
.clipShape(Capsule())
Spacer()
// Exposure indicator
Label(
String(format: "%+.1f", cameraController.currentExposureBias),
systemImage: "sun.max"
)
.font(.caption)
.padding(8)
.background(.ultraThinMaterial)
.clipShape(Capsule())
Spacer()
// Camera Control indicator
if cameraController.isCameraControlActive {
Label("Active", systemImage: "button.horizontal.top.press")
.font(.caption)
.padding(8)
.background(Color.blue.opacity(0.8))
.foregroundColor(.white)
.clipShape(Capsule())
}
}
}
private var bottomControlsBar: some View {
HStack(spacing: 50) {
// Last photo preview
if let lastImage = cameraController.lastCapturedImage {
Image(uiImage: lastImage)
.resizable()
.aspectRatio(contentMode: .fill)
.frame(width: 60, height: 60)
.clipShape(RoundedRectangle(cornerRadius: 10))
} else {
RoundedRectangle(cornerRadius: 10)
.fill(Color.gray.opacity(0.3))
.frame(width: 60, height: 60)
}
// Capture button
Button(action: { cameraController.capturePhoto() }) {
Circle()
.strokeBorder(.white, lineWidth: 4)
.frame(width: 80, height: 80)
.overlay(
Circle()
.fill(.white)
.padding(8)
)
}
// Placeholder
Color.clear.frame(width: 60, height: 60)
}
.padding(.bottom, 30)
}
private func setupCaptureInteraction(on view: UIView) {
if #available(iOS 17.2, *) {
captureInteraction = cameraController
.createCaptureEventInteraction(for: view)
}
}
}🔐 Step 7: Lock Screen Camera Extension#
This is a critical step! Without a Lock Screen Extension, your app won't appear in Camera Control settings.
Creating Extension Target#
- In Xcode: File → New → Target
- Select "Locked Camera Capture Extension"
- Name it:
YourAppLockScreenCamera - Click Create
Extension Implementation#
import LockedCameraCapture
import AVFoundation
import Photos
import UIKit
@main
class LockScreenCameraExtension: NSObject, LockedCameraCaptureExtension {
private var captureSession: LockedCameraCaptureSession?
private var photoOutput: LockedCameraCapturePhotoOutput?
func beginCapture(
with device: LockedCameraCaptureDevice,
completion: @escaping (Error?) -> Void
) {
print("📷 Lock Screen Camera: starting capture")
captureSession = LockedCameraCaptureSession()
photoOutput = LockedCameraCapturePhotoOutput()
guard let session = captureSession,
let output = photoOutput else {
completion(LockScreenCameraError.initializationFailed)
return
}
do {
try session.addInput(device)
try session.addOutput(output)
session.startRunning()
print("✅ Lock Screen Camera: session started")
completion(nil)
} catch {
print("❌ Lock Screen Camera: \(error)")
completion(error)
}
}
func endCapture(completion: @escaping (Error?) -> Void) {
print("📷 Lock Screen Camera: ending")
captureSession?.stopRunning()
captureSession = nil
photoOutput = nil
completion(nil)
}
func captureOutput(
_ output: LockedCameraCaptureOutput,
didOutput sampleBuffer: CMSampleBuffer,
from connection: LockedCameraCaptureConnection
) {
guard let imageBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else {
return
}
let ciImage = CIImage(cvImageBuffer: imageBuffer)
let context = CIContext()
guard let cgImage = context.createCGImage(
ciImage,
from: ciImage.extent
) else {
return
}
let uiImage = UIImage(cgImage: cgImage)
guard let jpegData = uiImage.jpegData(compressionQuality: 0.9) else {
return
}
saveToPhotoLibrary(imageData: jpegData)
}
private func saveToPhotoLibrary(imageData: Data) {
PHPhotoLibrary.shared().performChanges {
let request = PHAssetCreationRequest.forAsset()
request.addResource(with: .photo, data: imageData, options: nil)
} completionHandler: { success, error in
if success {
print("✅ Lock Screen: photo saved")
}
}
}
}
enum LockScreenCameraError: Error {
case initializationFailed
}🧪 Step 8: Diagnostics and Debugging#
struct CameraControlDiagnostics {
static func runDiagnostics() -> [String: Any] {
var report: [String: Any] = [:]
report["Device"] = UIDevice.current.model
report["iOS Version"] = UIDevice.current.systemVersion
if #available(iOS 18.0, *) {
report["iOS 18+"] = true
let session = AVCaptureSession()
report["supportsControls"] = session.supportsControls
if session.supportsControls {
report["Status"] = "✅ Full support"
} else {
report["Status"] = "⚠️ Device not supported"
}
} else {
report["iOS 18+"] = false
report["Status"] = "❌ Requires iOS 18+"
}
let authStatus = AVCaptureDevice.authorizationStatus(for: .video)
switch authStatus {
case .authorized:
report["Permission"] = "✅ Authorized"
case .denied:
report["Permission"] = "❌ Denied"
case .restricted:
report["Permission"] = "⚠️ Restricted"
case .notDetermined:
report["Permission"] = "⏳ Not determined"
@unknown default:
report["Permission"] = "❓ Unknown"
}
return report
}
static func printDiagnostics() {
let report = runDiagnostics()
print("\n" + String(repeating: "=", count: 50))
print("📊 CAMERA CONTROL DIAGNOSTICS")
print(String(repeating: "=", count: 50))
for (key, value) in report.sorted(by: { $0.key < $1.key }) {
print(" \(key): \(value)")
}
print(String(repeating: "=", count: 50) + "\n")
}
}⚠️ Common Problems and Solutions#
Problem 1: Camera Control Doesn't Appear#
Symptoms: Side button doesn't respond.
Solutions:
- Check
session.supportsControls— needs iPhone 16+ - Make sure session is running
- Preview must be visible on screen
- Controls must be added
Problem 2: App Not in Camera Control Settings#
Cause: No Lock Screen Camera Extension.
Solution: Create Extension (Step 7).
Problem 3: UI Doesn't Update#
Cause: Updates not on main thread.
// ✅ Correct
let zoomSlider = AVCaptureSystemZoomSlider(device: device) { [weak self] factor in
DispatchQueue.main.async {
self?.currentZoomFactor = factor
}
}🎯 Best Practices#
1. Always Check Compatibility#
if #available(iOS 18.0, *), session.supportsControls {
setupCameraControls()
} else {
showTraditionalControls()
}2. Hide Your Controls When Camera Control is Active#
func sessionControlsDidBecomeActive(_ session: AVCaptureSession) {
DispatchQueue.main.async {
self.zoomSlider.isHidden = true
}
}3. Use Background Queue#
sessionQueue.async {
self.session.beginConfiguration()
// ... configuration ...
self.session.commitConfiguration()
}📋 Pre-Release Checklist#
- Lock Screen Camera Extension created
- Info.plist contains all permissions
- Code handles lack of support
- UI updates on main thread
- Session configured on background thread
- System controls added
- AVCaptureEventInteraction configured
- Tested on real iPhone 16
- Graceful degradation works
🎬 Conclusion#
Camera Control is a fundamental change in how users interact with the camera on iPhone.
Key Points#
- Lock Screen Extension is required — without it, app won't appear in settings
- Check
supportsControls— works only on iPhone 16+ - Use system controls —
AVCaptureSystemZoomSliderandAVCaptureSystemExposureBiasSlider - Handle events —
AVCaptureEventInteractionfor shutter button - Sync UI — through
AVCaptureSessionControlsDelegatedelegate - Test on real device — simulator doesn't support Camera Control
Useful links:

