Camera Capture with AVFoundation
Guides you through implementing camera capture: session setup, photo capture, video recording, responsive capture UX, rotation handling, and session lifecycle management.
When to Use This Skill
Use when you need to:
-
☑ Build a custom camera UI (not system picker)
-
☑ Capture photos with quality/speed tradeoffs
-
☑ Record video with audio
-
☑ Handle device rotation correctly (RotationCoordinator)
-
☑ Make capture feel responsive (zero-shutter-lag)
-
☑ Handle session interruptions (phone calls, multitasking)
-
☑ Switch between front/back cameras
-
☑ Configure capture quality and resolution
Example Prompts
"How do I set up a camera preview in SwiftUI?" "My camera freezes when I get a phone call" "The photo preview is rotated wrong on front camera" "How do I make photo capture feel instant?" "Should I use deferred processing?" "My camera takes too long to capture" "How do I switch between front and back cameras?" "How do I record video with audio?"
Red Flags
Signs you're making this harder than it needs to be:
-
❌ Calling startRunning() on main thread (blocks UI for seconds)
-
❌ Using deprecated videoOrientation instead of RotationCoordinator (iOS 17+)
-
❌ Not observing session interruptions (app freezes on phone call)
-
❌ Creating new AVCaptureSession for each capture (expensive)
-
❌ Using .photo preset for video (wrong format)
-
❌ Ignoring photoQualityPrioritization (slow captures)
-
❌ Not handling .notAuthorized permission state
-
❌ Modifying session without beginConfiguration() /commitConfiguration()
-
❌ Using UIImagePickerController for custom camera UI (limited control)
Mandatory First Steps
Before implementing any camera feature:
- Choose Your Capture Mode
What do you need?
┌─ Just let user pick a photo? │ └─ Don't use AVFoundation - use PHPicker or PhotosPicker │ See: /skill axiom-photo-library │ ├─ Simple photo/video capture with system UI? │ └─ UIImagePickerController (but limited customization) │ ├─ Custom camera UI with photo capture? │ └─ AVCaptureSession + AVCapturePhotoOutput │ → Continue with this skill │ ├─ Custom camera UI with video recording? │ └─ AVCaptureSession + AVCaptureMovieFileOutput │ → Continue with this skill │ └─ Both photo and video in same session? └─ AVCaptureSession + both outputs → Continue with this skill
- Request Camera Permission
import AVFoundation
func requestCameraAccess() async -> Bool { let status = AVCaptureDevice.authorizationStatus(for: .video)
switch status {
case .authorized:
return true
case .notDetermined:
return await AVCaptureDevice.requestAccess(for: .video)
case .denied, .restricted:
// Show settings prompt
return false
@unknown default:
return false
}
}
Info.plist required:
<key>NSCameraUsageDescription</key> <string>Take photos and videos</string>
For audio (video recording):
<key>NSMicrophoneUsageDescription</key> <string>Record audio with video</string>
- Understand Session Architecture
AVCaptureSession ├─ Inputs │ ├─ AVCaptureDeviceInput (camera) │ └─ AVCaptureDeviceInput (microphone, for video) │ ├─ Outputs │ ├─ AVCapturePhotoOutput (photos) │ ├─ AVCaptureMovieFileOutput (video files) │ └─ AVCaptureVideoDataOutput (raw frames) │ └─ Connections (automatic between compatible input/output)
Key rule: All session configuration happens on a dedicated serial queue, never main thread.
Core Patterns
Pattern 1: Basic Session Setup
Use case: Set up camera preview with photo capture capability.
import AVFoundation
class CameraManager: NSObject { let session = AVCaptureSession() let photoOutput = AVCapturePhotoOutput()
// CRITICAL: Dedicated serial queue for session work
private let sessionQueue = DispatchQueue(label: "camera.session")
func setupSession() {
sessionQueue.async { [self] in
session.beginConfiguration()
defer { session.commitConfiguration() }
// 1. Set session preset
session.sessionPreset = .photo
// 2. Add camera input
guard let camera = AVCaptureDevice.default(.builtInWideAngleCamera,
for: .video,
position: .back),
let input = try? AVCaptureDeviceInput(device: camera),
session.canAddInput(input) else {
return
}
session.addInput(input)
// 3. Add photo output
guard session.canAddOutput(photoOutput) else { return }
session.addOutput(photoOutput)
// 4. Configure photo output
photoOutput.isHighResolutionCaptureEnabled = true
photoOutput.maxPhotoQualityPrioritization = .quality
}
}
func startSession() {
sessionQueue.async { [self] in
if !session.isRunning {
session.startRunning() // Blocking call - never on main thread!
}
}
}
func stopSession() {
sessionQueue.async { [self] in
if session.isRunning {
session.stopRunning()
}
}
}
}
Cost: 30 min implementation
Pattern 2: SwiftUI Camera Preview
Use case: Display camera preview in SwiftUI view.
import SwiftUI import AVFoundation
struct CameraPreview: UIViewRepresentable { let session: AVCaptureSession
func makeUIView(context: Context) -> PreviewView {
let view = PreviewView()
view.previewLayer.session = session
view.previewLayer.videoGravity = .resizeAspectFill
return view
}
func updateUIView(_ uiView: PreviewView, context: Context) {}
class PreviewView: UIView {
override class var layerClass: AnyClass { AVCaptureVideoPreviewLayer.self }
var previewLayer: AVCaptureVideoPreviewLayer { layer as! AVCaptureVideoPreviewLayer }
}
}
// Usage in SwiftUI struct CameraView: View { @StateObject private var camera = CameraManager()
var body: some View {
CameraPreview(session: camera.session)
.ignoresSafeArea()
.onAppear { camera.startSession() }
.onDisappear { camera.stopSession() }
}
}
Cost: 20 min implementation
Pattern 3: Rotation Handling with RotationCoordinator (iOS 17+)
Use case: Keep preview and captured photos correctly oriented regardless of device rotation.
Why RotationCoordinator: Deprecated videoOrientation requires manual observation of device orientation. RotationCoordinator automatically tracks gravity and provides angles.
import AVFoundation
class CameraManager { private var rotationCoordinator: AVCaptureDevice.RotationCoordinator? private var rotationObservation: NSKeyValueObservation?
func setupRotationCoordinator(device: AVCaptureDevice, previewLayer: AVCaptureVideoPreviewLayer) {
// Create coordinator with device and preview layer
rotationCoordinator = AVCaptureDevice.RotationCoordinator(
device: device,
previewLayer: previewLayer
)
// Observe preview rotation changes
rotationObservation = rotationCoordinator?.observe(
\.videoRotationAngleForHorizonLevelPreview,
options: [.new]
) { [weak previewLayer] coordinator, _ in
// Update preview layer rotation on main thread
DispatchQueue.main.async {
previewLayer?.connection?.videoRotationAngle = coordinator.videoRotationAngleForHorizonLevelPreview
}
}
// Set initial rotation
previewLayer.connection?.videoRotationAngle = rotationCoordinator!.videoRotationAngleForHorizonLevelPreview
}
func captureRotationAngle() -> CGFloat {
// Use this angle when capturing photos
rotationCoordinator?.videoRotationAngleForHorizonLevelCapture ?? 0
}
}
When capturing:
func capturePhoto() { let settings = AVCapturePhotoSettings()
// Apply rotation angle from coordinator
if let connection = photoOutput.connection(with: .video) {
connection.videoRotationAngle = captureRotationAngle()
}
photoOutput.capturePhoto(with: settings, delegate: self)
}
Cost: 45 min implementation, prevents 2+ hours debugging rotation issues
Pattern 4: Responsive Capture Pipeline (iOS 17+)
Use case: Make photo capture feel instant with zero-shutter-lag, overlapping captures, and responsive button states.
iOS 17+ introduces four complementary APIs that work together for maximum responsiveness:
4a. Zero Shutter Lag
Uses a ring buffer of recent frames to "time travel" back to the exact moment you tapped the shutter. Enabled automatically for iOS 17+ apps.
// Check if supported for current format if photoOutput.isZeroShutterLagSupported { // Enabled by default for apps linking iOS 17+ // Opt out if causing issues: // photoOutput.isZeroShutterLagEnabled = false }
Why it matters: Without ZSL, there's a delay between tap and frame capture. For action shots, the moment is already over.
Requirements: iPhone XS and newer. Does NOT apply to flash captures, manual exposure, bracketed captures, or constituent photo delivery.
4b. Responsive Capture (Overlapping Captures)
Allows a new capture to start while the previous one is still processing:
// Check support first if photoOutput.isZeroShutterLagSupported { photoOutput.isZeroShutterLagEnabled = true // Required for responsive capture
if photoOutput.isResponsiveCaptureSupported {
photoOutput.isResponsiveCaptureEnabled = true
}
}
Tradeoff: Increases peak memory usage. If your app is memory-constrained, consider leaving disabled.
Requirements: A12 Bionic (iPhone XS) and newer.
4c. Fast Capture Prioritization
Automatically adapts quality when taking multiple photos rapidly (like burst mode):
if photoOutput.isFastCapturePrioritizationSupported { photoOutput.isFastCapturePrioritizationEnabled = true // When enabled, rapid captures use "balanced" quality instead of "quality" // to maintain consistent shot-to-shot time }
When to enable: User-facing toggle ("Prioritize Faster Shooting" in Camera.app). Off by default because it reduces quality.
4d. Readiness Coordinator (Button State Management)
Critical for UX: Provides synchronous updates for shutter button state without async lag.
class CameraManager { private var readinessCoordinator: AVCapturePhotoOutputReadinessCoordinator!
func setupReadinessCoordinator() {
readinessCoordinator = AVCapturePhotoOutputReadinessCoordinator(photoOutput: photoOutput)
readinessCoordinator.delegate = self
}
func capturePhoto() {
var settings = AVCapturePhotoSettings()
settings.photoQualityPrioritization = .balanced
// Tell coordinator to track this capture BEFORE calling capturePhoto
readinessCoordinator.startTrackingCaptureRequest(using: settings)
photoOutput.capturePhoto(with: settings, delegate: self)
}
}
extension CameraManager: AVCapturePhotoOutputReadinessCoordinatorDelegate { func readinessCoordinator(_ coordinator: AVCapturePhotoOutputReadinessCoordinator, captureReadinessDidChange captureReadiness: AVCapturePhotoOutput.CaptureReadiness) { DispatchQueue.main.async { switch captureReadiness { case .ready: self.shutterButton.isEnabled = true self.shutterButton.alpha = 1.0
case .notReadyMomentarily:
// Brief delay - disable to prevent double-tap
self.shutterButton.isEnabled = false
case .notReadyWaitingForCapture:
// Flash is firing - dim button
self.shutterButton.alpha = 0.5
case .notReadyWaitingForProcessing:
// Processing previous photo - show spinner
self.showProcessingIndicator()
case .sessionNotRunning:
self.shutterButton.isEnabled = false
@unknown default:
break
}
}
}
}
Why use Readiness Coordinator: Without it, you'd need to track capture state manually and users might spam the shutter button during processing.
Quality Prioritization (Baseline)
Still useful even without the new APIs:
func capturePhoto() { var settings = AVCapturePhotoSettings()
// Speed vs Quality tradeoff
// .speed - Fastest capture, lower quality
// .balanced - Good default
// .quality - Best quality, may have delay
settings.photoQualityPrioritization = .speed
// For specific use cases:
// - Social sharing: .speed (users expect instant)
// - Document scanning: .quality (accuracy matters)
// - General photography: .balanced
photoOutput.capturePhoto(with: settings, delegate: self)
}
Deferred Processing (iOS 17+):
For maximum responsiveness, capture returns immediately with proxy image, full Deep Fusion processing happens in background:
// Check support and enable deferred processing if photoOutput.isAutoDeferredPhotoDeliverySupported { photoOutput.isAutoDeferredPhotoDeliveryEnabled = true }
Delegate callbacks with deferred processing:
// Called for BOTH regular photos AND deferred proxies func photoOutput(_ output: AVCapturePhotoOutput, didFinishProcessingPhoto photo: AVCapturePhoto, error: Error?) { guard error == nil else { return }
// Non-deferred photo - save directly
if !photo.isRawPhoto, let data = photo.fileDataRepresentation() {
savePhotoToLibrary(data)
}
}
// Called ONLY for deferred proxies - save to PhotoKit for later processing func photoOutput(_ output: AVCapturePhotoOutput, didFinishCapturingDeferredPhotoProxy deferredPhotoProxy: AVCaptureDeferredPhotoProxy, error: Error?) { guard error == nil else { return }
// CRITICAL: Save proxy to library ASAP before app is backgrounded
// App may be force-quit if memory pressure is high during backgrounding
guard let proxyData = deferredPhotoProxy.fileDataRepresentation() else { return }
Task {
try await PHPhotoLibrary.shared().performChanges {
let request = PHAssetCreationRequest.forAsset()
// Use .photoProxy resource type - triggers deferred processing in Photos
request.addResource(with: .photoProxy, data: proxyData, options: nil)
}
}
}
When final processing happens:
-
On-demand when image is requested from PhotoKit
-
Or automatically when device is idle (plugged in, not in use)
Fetching images with deferred processing awareness:
// Request with secondary degraded image for smoother UX let options = PHImageRequestOptions() options.allowSecondaryDegradedImage = true // New in iOS 17
PHImageManager.default().requestImage( for: asset, targetSize: targetSize, contentMode: .aspectFill, options: options ) { image, info in let isDegraded = info?[PHImageResultIsDegradedKey] as? Bool ?? false
if isDegraded {
// First: Low quality (immediate)
// Second: Medium quality (new - while processing)
// Third callback will be final quality
self.showTemporaryImage(image)
} else {
// Final quality - processing complete
self.showFinalImage(image)
}
}
Requirements: iPhone 11 Pro and newer. Not used for flash captures or formats that don't benefit from extended processing.
Important considerations:
-
Can't apply pixel buffer customizations (filters, metadata changes) to deferred photos
-
Use PhotoKit adjustments after processing for edits
-
Get proxy into library ASAP - limited time when backgrounded
Cost: 1 hour implementation, prevents "camera feels slow" complaints
Pattern 5: Session Interruption Handling
Use case: Handle phone calls, multitasking, system camera usage.
class CameraManager { private var interruptionObservers: [NSObjectProtocol] = []
func setupInterruptionHandling() {
// Session was interrupted
let interruptedObserver = NotificationCenter.default.addObserver(
forName: .AVCaptureSessionWasInterrupted,
object: session,
queue: .main
) { [weak self] notification in
guard let reason = notification.userInfo?[AVCaptureSessionInterruptionReasonKey] as? Int,
let interruptionReason = AVCaptureSession.InterruptionReason(rawValue: reason) else {
return
}
switch interruptionReason {
case .videoDeviceNotAvailableInBackground:
// App went to background - normal, will resume
self?.showPausedOverlay()
case .audioDeviceInUseByAnotherClient:
// Another app using audio
self?.showInterruptedBanner("Audio in use by another app")
case .videoDeviceInUseByAnotherClient:
// Another app using camera
self?.showInterruptedBanner("Camera in use by another app")
case .videoDeviceNotAvailableWithMultipleForegroundApps:
// Split View/Slide Over - camera not available
self?.showInterruptedBanner("Camera unavailable in Split View")
case .videoDeviceNotAvailableDueToSystemPressure:
// Thermal state - reduce quality or stop
self?.handleThermalPressure()
@unknown default:
self?.showInterruptedBanner("Camera interrupted")
}
}
interruptionObservers.append(interruptedObserver)
// Session interruption ended
let endedObserver = NotificationCenter.default.addObserver(
forName: .AVCaptureSessionInterruptionEnded,
object: session,
queue: .main
) { [weak self] _ in
self?.hideInterruptedBanner()
self?.hidePausedOverlay()
// Session automatically resumes - no need to call startRunning()
}
interruptionObservers.append(endedObserver)
}
deinit {
interruptionObservers.forEach { NotificationCenter.default.removeObserver($0) }
}
}
Cost: 30 min implementation, prevents "camera freezes" bug reports
Pattern 6: Camera Switching (Front/Back)
Use case: Toggle between front and back cameras.
func switchCamera() { sessionQueue.async { [self] in guard let currentInput = session.inputs.first as? AVCaptureDeviceInput else { return }
let currentPosition = currentInput.device.position
let newPosition: AVCaptureDevice.Position = currentPosition == .back ? .front : .back
guard let newDevice = AVCaptureDevice.default(
.builtInWideAngleCamera,
for: .video,
position: newPosition
) else {
return
}
session.beginConfiguration()
defer { session.commitConfiguration() }
// Remove old input
session.removeInput(currentInput)
// Add new input
do {
let newInput = try AVCaptureDeviceInput(device: newDevice)
if session.canAddInput(newInput) {
session.addInput(newInput)
// Update rotation coordinator for new device
if let previewLayer = previewLayer {
setupRotationCoordinator(device: newDevice, previewLayer: previewLayer)
}
} else {
// Fallback: restore old input
session.addInput(currentInput)
}
} catch {
session.addInput(currentInput)
}
}
}
Front camera mirroring: Front camera preview is mirrored by default (matches user expectation). Captured photos are NOT mirrored (correct for sharing). This is intentional.
Cost: 20 min implementation
Pattern 7: Video Recording
Use case: Record video with audio to file.
class CameraManager: NSObject { let movieOutput = AVCaptureMovieFileOutput() private var currentRecordingURL: URL?
func setupVideoRecording() {
sessionQueue.async { [self] in
session.beginConfiguration()
defer { session.commitConfiguration() }
// Set video preset
session.sessionPreset = .high // Or .hd1920x1080, .hd4K3840x2160
// Add microphone input
if let microphone = AVCaptureDevice.default(for: .audio),
let audioInput = try? AVCaptureDeviceInput(device: microphone),
session.canAddInput(audioInput) {
session.addInput(audioInput)
}
// Add movie output
if session.canAddOutput(movieOutput) {
session.addOutput(movieOutput)
}
}
}
func startRecording() {
guard !movieOutput.isRecording else { return }
let outputURL = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString)
.appendingPathExtension("mov")
currentRecordingURL = outputURL
// Apply rotation
if let connection = movieOutput.connection(with: .video) {
connection.videoRotationAngle = captureRotationAngle()
}
movieOutput.startRecording(to: outputURL, recordingDelegate: self)
}
func stopRecording() {
guard movieOutput.isRecording else { return }
movieOutput.stopRecording()
}
}
extension CameraManager: AVCaptureFileOutputRecordingDelegate { func fileOutput(_ output: AVCaptureFileOutput, didFinishRecordingTo outputFileURL: URL, from connections: [AVCaptureConnection], error: Error?) { if let error = error { print("Recording error: (error)") return }
// Video saved to outputFileURL
saveVideoToPhotoLibrary(outputFileURL)
}
}
Cost: 45 min implementation
Anti-Patterns
Anti-Pattern 1: Session Work on Main Thread
Wrong:
func startCamera() { session.startRunning() // Blocks UI for 1-3 seconds! }
Right:
func startCamera() { sessionQueue.async { [self] in session.startRunning() } }
Why it matters: startRunning() is blocking. On main thread, UI freezes.
Anti-Pattern 2: Using Deprecated videoOrientation
Wrong (pre-iOS 17):
// Manually tracking orientation NotificationCenter.default.addObserver( forName: UIDevice.orientationDidChangeNotification, object: nil, queue: .main ) { _ in // Manual rotation logic... }
Right (iOS 17+):
let coordinator = AVCaptureDevice.RotationCoordinator(device: camera, previewLayer: preview) // Automatically tracks gravity, provides angles
Why it matters: RotationCoordinator handles edge cases (face-up, face-down) that manual tracking misses.
Anti-Pattern 3: Ignoring Session Interruptions
Wrong:
// No interruption handling - camera freezes on phone call
Right:
NotificationCenter.default.addObserver( forName: .AVCaptureSessionWasInterrupted, object: session, queue: .main ) { notification in // Show UI feedback }
Why it matters: Without handling, camera appears frozen when interrupted.
Anti-Pattern 4: Modifying Session Without Configuration Block
Wrong:
session.removeInput(oldInput) session.addInput(newInput) // May fail mid-stream
Right:
session.beginConfiguration() session.removeInput(oldInput) session.addInput(newInput) session.commitConfiguration() // Atomic change
Why it matters: Without configuration block, session may enter invalid state between calls.
Pressure Scenarios
Scenario 1: "Just Make the Camera Work by Friday"
Context: Product wants camera feature shipped. You're considering skipping interruption handling.
Pressure: "It works when I test it, let's ship."
Reality: First user who gets a phone call while using camera will see frozen UI. App Store review may catch this.
Correct action:
-
Implement interruption handling (30 min)
-
Test by calling your test device during camera use
-
Verify UI shows appropriate feedback
Push-back template: "Camera captures work, but the app freezes if a phone call comes in. I need 30 minutes to handle interruptions properly and avoid 1-star reviews."
Scenario 2: "The Camera is Too Slow"
Context: QA reports photo capture feels sluggish. PM wants it "instant like the system camera."
Pressure: "Just make it faster somehow."
Reality: Default settings prioritize quality over speed. System camera uses deferred processing.
Correct action:
-
Set photoQualityPrioritization = .speed for social/sharing use cases
-
Consider deferred processing for maximum responsiveness
-
Show capture animation immediately (before processing completes)
Push-back template: "We're currently optimizing for image quality. I can make capture feel instant by prioritizing speed and showing the preview immediately while processing continues in background. This is what the system Camera app does."
Scenario 3: "Why is the Front Camera Photo Mirrored?"
Context: Designer reports front camera photos look "wrong" - they're not mirrored like the preview.
Pressure: "The preview shows it one way, the photo should match."
Reality: Preview is mirrored (user expectation - like a mirror). Photo is NOT mirrored (correct for sharing - text reads correctly). This is intentional behavior matching system camera.
Correct action:
-
Explain this is Apple's standard behavior
-
If business requires mirrored photos (selfie apps), manually mirror in post-processing
-
Never mirror the preview differently than expected
Push-back template: "This is intentional Apple behavior. The preview is mirrored like a mirror so users can frame themselves, but the captured photo is unmirrored so text reads correctly when shared. We can add optional mirroring in post-processing if our use case requires it."
Checklist
Before shipping camera features:
Session Setup:
-
☑ All session work on dedicated serial queue
-
☑ startRunning() never called on main thread
-
☑ Session preset matches use case (.photo for photos, .high for video)
-
☑ Configuration changes wrapped in beginConfiguration() /commitConfiguration()
Permissions:
-
☑ Camera permission requested before session setup
-
☑ NSCameraUsageDescription in Info.plist
-
☑ NSMicrophoneUsageDescription if recording audio
-
☑ Graceful handling of denied permission
Rotation:
-
☑ RotationCoordinator used (not deprecated videoOrientation)
-
☑ Preview layer rotation updated via observation
-
☑ Capture rotation angle applied when taking photos
-
☑ Tested in all orientations (portrait, landscape, face-up)
Responsiveness:
-
☑ photoQualityPrioritization set appropriately for use case
-
☑ Capture button shows immediate feedback
-
☑ Deferred processing considered for maximum speed
Interruptions:
-
☑ Session interruption observer registered
-
☑ UI feedback shown when interrupted
-
☑ Tested with incoming phone call
-
☑ Tested in Split View (iPad)
Camera Switching:
-
☑ Front/back switch updates rotation coordinator
-
☑ Switch happens on session queue
-
☑ Fallback if new camera unavailable
Video Recording (if applicable):
-
☑ Microphone input added
-
☑ Recording delegate handles completion
-
☑ File cleanup for temporary recordings
Resources
WWDC: 2021-10247, 2023-10105
Docs: /avfoundation/avcapturesession, /avfoundation/avcapturedevice/rotationcoordinator, /avfoundation/avcapturephotosettings, /avfoundation/avcapturephotooutputreadinesscoordinator
Skills: axiom-camera-capture-ref, axiom-camera-capture-diag, axiom-photo-library