How to Build a Camera View in SwiftUI
SwiftUI has no native camera view, so you bridge AVFoundation into SwiftUI via UIViewRepresentable. Wrap an AVCaptureVideoPreviewLayer in a UIView, drive the session from an ObservableObject, and capture stills with AVCapturePhotoOutput.
struct CameraPreview: UIViewRepresentable {
let session: AVCaptureSession
func makeUIView(context: Context) -> UIView {
let view = UIView()
let layer = AVCaptureVideoPreviewLayer(session: session)
layer.videoGravity = .resizeAspectFill
layer.frame = UIScreen.main.bounds
view.layer.addSublayer(layer)
return view
}
func updateUIView(_ uiView: UIView, context: Context) {}
}
Full implementation
The architecture separates concerns cleanly: a CameraViewModel owns the AVCaptureSession lifecycle and exposes captured images via @Published state, while a UIViewRepresentable bridges the preview layer into the SwiftUI view tree. The session is started and stopped on a background serial queue to avoid blocking the main thread. A dedicated AVCapturePhotoCaptureDelegate implementation converts the raw CMSampleBuffer into a UIImage and publishes it back on the main actor.
import SwiftUI
import AVFoundation
// MARK: - ViewModel
@MainActor
final class CameraViewModel: NSObject, ObservableObject {
@Published var capturedImage: UIImage?
@Published var isAuthorized = false
@Published var errorMessage: String?
let session = AVCaptureSession()
private let photoOutput = AVCapturePhotoOutput()
private let sessionQueue = DispatchQueue(label: "camera.session")
override init() {
super.init()
}
func requestAccess() async {
let status = AVCaptureDevice.authorizationStatus(for: .video)
switch status {
case .authorized:
isAuthorized = true
sessionQueue.async { self.configureSession() }
case .notDetermined:
let granted = await AVCaptureDevice.requestAccess(for: .video)
isAuthorized = granted
if granted { sessionQueue.async { self.configureSession() } }
default:
errorMessage = "Camera access denied. Enable it in Settings."
}
}
private func configureSession() {
session.beginConfiguration()
session.sessionPreset = .photo
guard
let device = AVCaptureDevice.default(.builtInWideAngleCamera,
for: .video,
position: .back),
let input = try? AVCaptureDeviceInput(device: device),
session.canAddInput(input)
else {
session.commitConfiguration()
return
}
session.addInput(input)
guard session.canAddOutput(photoOutput) else {
session.commitConfiguration()
return
}
session.addOutput(photoOutput)
session.commitConfiguration()
session.startRunning()
}
func capturePhoto() {
let settings = AVCapturePhotoSettings()
settings.flashMode = .auto
sessionQueue.async {
self.photoOutput.capturePhoto(with: settings, delegate: self)
}
}
func stop() {
sessionQueue.async { self.session.stopRunning() }
}
}
// MARK: - Photo Capture Delegate
extension CameraViewModel: AVCapturePhotoCaptureDelegate {
nonisolated func photoOutput(
_ output: AVCapturePhotoOutput,
didFinishProcessingPhoto photo: AVCapturePhoto,
error: Error?
) {
guard
error == nil,
let data = photo.fileDataRepresentation(),
let image = UIImage(data: data)
else { return }
Task { @MainActor in
self.capturedImage = image
}
}
}
// MARK: - UIViewRepresentable Bridge
struct CameraPreviewView: UIViewRepresentable {
let session: AVCaptureSession
func makeUIView(context: Context) -> PreviewUIView {
let view = PreviewUIView()
view.previewLayer.session = session
view.previewLayer.videoGravity = .resizeAspectFill
return view
}
func updateUIView(_ uiView: PreviewUIView, context: Context) {}
// Custom UIView so the preview layer auto-resizes
final class PreviewUIView: UIView {
override class var layerClass: AnyClass { AVCaptureVideoPreviewLayer.self }
var previewLayer: AVCaptureVideoPreviewLayer {
layer as! AVCaptureVideoPreviewLayer
}
}
}
// MARK: - SwiftUI View
struct CameraView: View {
@StateObject private var camera = CameraViewModel()
@State private var showCapture = false
var body: some View {
ZStack {
if camera.isAuthorized {
CameraPreviewView(session: camera.session)
.ignoresSafeArea()
VStack {
Spacer()
HStack {
Spacer()
Button(action: { camera.capturePhoto() }) {
Circle()
.fill(.white)
.frame(width: 72, height: 72)
.overlay(Circle().stroke(.white.opacity(0.4), lineWidth: 4))
}
.accessibilityLabel("Capture photo")
Spacer()
}
.padding(.bottom, 40)
}
if let img = camera.capturedImage {
Image(uiImage: img)
.resizable()
.scaledToFill()
.ignoresSafeArea()
.onTapGesture { camera.capturedImage = nil }
.transition(.opacity)
}
} else if let error = camera.errorMessage {
ContentUnavailableView(error,
systemImage: "camera.slash",
description: Text("Open Settings to allow camera access."))
} else {
ProgressView("Requesting camera access…")
}
}
.task { await camera.requestAccess() }
.onDisappear { camera.stop() }
}
}
// MARK: - Preview
#Preview {
CameraView()
}
How it works
-
Session lifecycle on a background queue.
configureSession()always runs onsessionQueue. Callingsession.startRunning()on the main thread causes a brief UI freeze — the serial queue isolates that work entirely from SwiftUI's render loop. -
Custom
layerClassoverride for resize.PreviewUIViewoverrideslayerClassto returnAVCaptureVideoPreviewLayer.self. This means the layer is the view's backing layer and automatically resizes with Auto Layout — no manualframeupdates needed insidelayoutSubviews. -
@MainActor+nonisolateddelegate bridging. The photo capture delegate callbackdidFinishProcessingPhotoarrives on an arbitrary thread. Marking itnonisolatedsatisfies the compiler, and aTask { @MainActor in … }hop safely publishes the result to SwiftUI. -
Async permission request.
requestAccess()isasyncand called via.task. This avoids blocking the view body and handles allAVAuthorizationStatuscases:.authorized,.notDetermined, and denied/restricted states show aContentUnavailableView. -
Photo overlay tap-to-dismiss. When
capturedImageis non-nil an overlay fades in over the live preview. Tapping it setscapturedImage = nil, returning to live view. Use a.animation(.easeInOut, value: camera.capturedImage)modifier to animate the transition.
Variants
Front camera toggle
func switchCamera() {
sessionQueue.async {
self.session.beginConfiguration()
// Remove existing input
if let current = self.session.inputs.first {
self.session.removeInput(current)
}
// Flip position
let newPosition: AVCaptureDevice.Position =
(self.session.inputs.isEmpty) ? .front :
((self.session.inputs.first as? AVCaptureDeviceInput)?
.device.position == .back ? .front : .back)
guard
let device = AVCaptureDevice.default(
.builtInWideAngleCamera,
for: .video,
position: newPosition),
let input = try? AVCaptureDeviceInput(device: device),
self.session.canAddInput(input)
else {
self.session.commitConfiguration(); return
}
self.session.addInput(input)
self.session.commitConfiguration()
}
}
// In the view:
Button(action: { camera.switchCamera() }) {
Image(systemName: "camera.rotate")
.font(.title2)
.foregroundStyle(.white)
}
.accessibilityLabel("Switch camera")
Pinch-to-zoom
Attach a MagnificationGesture to the CameraPreviewView and clamp the value to the active device's minAvailableVideoZoomFactor / maxAvailableVideoZoomFactor. Write to device.videoZoomFactor inside a device.lockForConfiguration() / unlockForConfiguration() block on the session queue. Store the last zoom value so it persists across camera switches.
Common pitfalls
- Missing Info.plist key crashes silently on device. Add
NSCameraUsageDescriptionin your target's Info settings — without it the app crashes with a non-obviousSIGABRTwhen authorization is requested on a real device (the simulator never prompts). - Calling
startRunning()on the main thread. AVFoundation will log a warning and may stutter the UI. Always dispatch session configuration andstartRunning/stopRunningto a dedicated serialDispatchQueue. - Preview layer frame stuck at zero on SwiftUI layout. Using a plain
UIViewand settinglayer.frameinmakeUIViewoften produces a zero-size preview on first layout. ThelayerClassoverride pattern avoids this entirely — the preview layer is the backing layer and resizes automatically. - Memory leak when dismissing the camera view. If the
AVCaptureSessionis running when the view disappears, it keeps the camera LED lit and wastes battery. Always callsession.stopRunning()in.onDisappear. - VoiceOver and camera views. The live preview has no meaningful accessibility content. Set
.accessibilityHidden(true)on theCameraPreviewViewand ensure the shutter button has a clear.accessibilityLabel.
Prompt this with Claude Code
When using Soarias or Claude Code directly to implement this:
Implement a camera view in SwiftUI for iOS 17+. Use AVFoundation and UIViewRepresentable. Bridge AVCaptureVideoPreviewLayer via a layerClass override UIView. Manage AVCaptureSession on a background serial DispatchQueue. Add AVCapturePhotoOutput for still capture. Handle authorization states: authorized, notDetermined, denied. Make it accessible (VoiceOver labels on shutter and switch buttons). Add a #Preview with realistic sample data.
In Soarias' Build phase, drop this prompt into the implementation prompt field alongside your screen spec — Claude Code will scaffold the full camera module, wire it into your SwiftData model, and generate a passing preview in one pass.
Related
FAQ
Does this work on iOS 16?
Most of the code runs on iOS 16, but the #Preview macro requires Xcode 15+ / iOS 17 SDK. The ContentUnavailableView used in the permission-denied state is also iOS 17+. To deploy to iOS 16 swap it for a plain VStack with a Label and #if available guard. The AVFoundation bridging itself is backward-compatible to iOS 14.
How do I save a captured photo to the Photos library?
Add NSPhotoLibraryAddUsageDescription to Info.plist, then call PHPhotoLibrary.shared().performChanges with a PHAssetChangeRequest.creationRequestForAsset(from: image) inside the photoOutput(_:didFinishProcessingPhoto:error:) delegate. Alternatively, use the newer UIImageWriteToSavedPhotosAlbum for a simpler one-liner when you don't need album targeting.
What is the UIKit equivalent?
In UIKit you would use UIImagePickerController for a simple one-shot flow, or build a custom UIViewController with an AVCaptureVideoPreviewLayer added as a sublayer for full control — which is exactly what we're bridging here. In SwiftUI you can also present PHPickerViewController wrapped in a UIViewControllerRepresentable for picking from the library, but it does not provide live camera access.
Last reviewed: 2026-05-11 by the Soarias team.