```html SwiftUI: How to Build a Camera View (iOS 17+, 2026)

How to Build a Camera View in SwiftUI

iOS 17+ Xcode 16+ Advanced APIs: AVFoundation / UIViewRepresentable Updated: May 11, 2026
TL;DR

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

  1. Session lifecycle on a background queue. configureSession() always runs on sessionQueue. Calling session.startRunning() on the main thread causes a brief UI freeze — the serial queue isolates that work entirely from SwiftUI's render loop.
  2. Custom layerClass override for resize. PreviewUIView overrides layerClass to return AVCaptureVideoPreviewLayer.self. This means the layer is the view's backing layer and automatically resizes with Auto Layout — no manual frame updates needed inside layoutSubviews.
  3. @MainActor + nonisolated delegate bridging. The photo capture delegate callback didFinishProcessingPhoto arrives on an arbitrary thread. Marking it nonisolated satisfies the compiler, and a Task { @MainActor in … } hop safely publishes the result to SwiftUI.
  4. Async permission request. requestAccess() is async and called via .task. This avoids blocking the view body and handles all AVAuthorizationStatus cases: .authorized, .notDetermined, and denied/restricted states show a ContentUnavailableView.
  5. Photo overlay tap-to-dismiss. When capturedImage is non-nil an overlay fades in over the live preview. Tapping it sets capturedImage = 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

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.

```