Swift and SwiftUI tutorials for Swift Developers

How to use Camera in SwiftUI

If you are an iOS Developer looking to create immersive and custom experiences, sooner or later you will face a fascinating challenge: integrating and controlling the camera in SwiftUI.

Historically, many developers opted for the quick solution: using UIImagePickerController or PhotosUI. While these are excellent for basic tasks, they do not allow you to create a custom user interface (a custom viewfinder), apply real-time filters, or scan barcodes natively. To have total control in Swift programming, we need to go down a level and use the powerful AVFoundation framework.

In this extensive tutorial, you will learn how to build a fully customized camera from scratch using AVFoundation and how to seamlessly integrate it into SwiftUI. We will explore how to structure this code in Swift and how to adapt it in Xcode for iOS, macOS, and the technical realities of watchOS.


1. Why use AVFoundation for the Camera in SwiftUI?

In the SwiftUI ecosystem, Apple pushes us toward declarative solutions. However, camera hardware is inherently imperative and based on continuous data streams.

As an iOS Developer, using AVFoundation gives you the superpowers to:

  • Design 100% custom interfaces: You can place exact buttons, overlays, and animations on top of the live camera preview.
  • Frame Processing: Extract every single video frame in real-time to use CoreML (Artificial Intelligence) or Vision (text/face recognition).
  • Hardware Control: Manually adjust focus, exposure, white balance, and flash.

2. Capture Session Architecture

Before writing code in Swift, we need to understand how AVFoundation works. Think of it as a plumbing system.

The architecture consists of four main elements:

  1. AVCaptureSession: It is the “brain” or engine. It manages the flow of data from inputs to outputs.
  2. AVCaptureDevice: Represents the physical hardware (the front camera, the wide-angle rear camera, the microphone).
  3. AVCaptureDeviceInput: It is the adapter that connects the hardware device (the camera) to the capture session.
  4. AVCaptureOutput: It is the final destination of the data. It can be a photo output (AVCapturePhotoOutput), a raw video output (AVCaptureVideoDataOutput), or a movie file output (AVCaptureMovieFileOutput).

3. Step 0: Permissions in Xcode (The Info.plist)

No Swift application can access the camera without the user’s explicit permission. If you try to run the code without this step, your application will unexpectedly crash.

  1. Open your project in Xcode.
  2. Go to your Target settings, Info tab.
  3. Add a new key named Privacy - Camera Usage Description (NSCameraUsageDescription).
  4. In the value, write a clear message for the user, for example: “We need access to the camera so you can take amazing photos of your surroundings.”

4. Building the Engine: CameraManager

We are going to create a class that handles all the heavy lifting of AVFoundation. We will use the ObservableObject protocol so that our SwiftUI interface can react to its changes.

Create a new Swift file named CameraManager.swift.

import Foundation
import AVFoundation
import Combine

class CameraManager: ObservableObject {
    // 1. The main capture session
    let session = AVCaptureSession()
    
    // 2. Output for taking photos
    private let photoOutput = AVCapturePhotoOutput()
    
    // 3. States for the UI
    @Published var isCameraAuthorized = false
    @Published var capturedImage: Data?
    
    // Dedicated thread to avoid blocking the User Interface
    private let sessionQueue = DispatchQueue(label: "com.yourdomain.cameraQueue")
    
    init() {
        checkPermissions()
    }
    
    // MARK: - Permissions
    private func checkPermissions() {
        switch AVCaptureDevice.authorizationStatus(for: .video) {
        case .authorized:
            self.isCameraAuthorized = true
            self.setupCamera()
        case .notDetermined:
            AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in
                DispatchQueue.main.async {
                    self?.isCameraAuthorized = granted
                    if granted { self?.setupCamera() }
                }
            }
        default:
            self.isCameraAuthorized = false
        }
    }
    
    // MARK: - Camera Setup
    private func setupCamera() {
        sessionQueue.async { [weak self] in
            guard let self = self else { return }
            
            self.session.beginConfiguration()
            
            // a. Find the device (Default rear camera)
            guard let videoDevice = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back) else {
                print("No camera was found")
                self.session.commitConfiguration()
                return
            }
            
            // b. Create the input
            do {
                let videoDeviceInput = try AVCaptureDeviceInput(device: videoDevice)
                if self.session.canAddInput(videoDeviceInput) {
                    self.session.addInput(videoDeviceInput)
                }
            } catch {
                print("Error creating input: \(error.localizedDescription)")
                self.session.commitConfiguration()
                return
            }
            
            // c. Create the photo output
            if self.session.canAddOutput(self.photoOutput) {
                self.session.addOutput(self.photoOutput)
            }
            
            self.session.commitConfiguration()
        }
    }
    
    // MARK: - Session Control
    func startSession() {
        sessionQueue.async {
            if !self.session.isRunning {
                self.session.startRunning()
            }
        }
    }
    
    func stopSession() {
        sessionQueue.async {
            if self.session.isRunning {
                self.session.stopRunning()
            }
        }
    }
    
    // MARK: - Photo Capture
    func capturePhoto() {
        let settings = AVCapturePhotoSettings()
        // Here you can configure the flash, high resolution, etc.
        self.photoOutput.capturePhoto(with: settings, delegate: self)
    }
}

// Delegate implementation to receive the photo
extension CameraManager: AVCapturePhotoCaptureDelegate {
    func photoOutput(_ output: AVCapturePhotoOutput, didFinishProcessingPhoto photo: AVCapturePhoto, error: Error?) {
        guard let data = photo.fileDataRepresentation() else { return }
        
        DispatchQueue.main.async {
            self.capturedImage = data
        }
    }
}

Key Swift programming notes in this module:

  • Threading: session.startRunning() is a blocking operation. If you call it on the Main Thread, your application will freeze momentarily. That is why, as a good iOS Developer, we created a dedicated sessionQueue.
  • Permission Handling: Before trying to access the hardware, we must check the authorization status asynchronously.

5. The Bridge to SwiftUI: CameraPreviewView

SwiftUI does not have a native view (yet) to display the camera stream. We need to use a specialized CoreAnimation layer called AVCaptureVideoPreviewLayer and wrap it using the UIViewRepresentable protocol (on iOS) or NSViewRepresentable (on macOS).

Create a file named CameraPreviewView.swift. Here we will use compiler directives to ensure cross-platform functionality in Xcode.

import SwiftUI
import AVFoundation

#if os(iOS)
struct CameraPreviewView: UIViewRepresentable {
    let session: AVCaptureSession
    
    // We create a UIView subclass that is backed by a video layer
    class VideoPreviewView: UIView {
        override class var layerClass: AnyClass {
            AVCaptureVideoPreviewLayer.self
        }
        
        var videoPreviewLayer: AVCaptureVideoPreviewLayer {
            return layer as! AVCaptureVideoPreviewLayer
        }
    }
    
    func makeUIView(context: Context) -> VideoPreviewView {
        let view = VideoPreviewView()
        view.videoPreviewLayer.session = session
        view.videoPreviewLayer.videoGravity = .resizeAspectFill
        return view
    }
    
    func updateUIView(_ uiView: VideoPreviewView, context: Context) {
        // Here we would handle screen rotations if necessary
    }
}
#elseif os(macOS)
struct CameraPreviewView: NSViewRepresentable {
    let session: AVCaptureSession
    
    class VideoPreviewView: NSView {
        var videoPreviewLayer: AVCaptureVideoPreviewLayer!
        
        override init(frame frameRect: NSRect) {
            super.init(frame: frameRect)
            self.wantsLayer = true
        }
        
        required init?(coder: NSCoder) {
            fatalError("init(coder:) has not been implemented")
        }
        
        override func makeBackingLayer() -> CALayer {
            videoPreviewLayer = AVCaptureVideoPreviewLayer()
            videoPreviewLayer.videoGravity = .resizeAspectFill
            return videoPreviewLayer
        }
    }
    
    func makeNSView(context: Context) -> VideoPreviewView {
        let view = VideoPreviewView()
        view.videoPreviewLayer.session = session
        return view
    }
    
    func updateNSView(_ nsView: VideoPreviewView, context: Context) {
    }
}
#endif

With this code, we have created a perfect bridge that injects the visual data generated by AVFoundation directly into our declarative SwiftUI ecosystem.


6. Building the Main Camera Interface in SwiftUI

Now comes the fun part: assembling the blocks. We are going to use a ZStack to place our custom buttons over the live preview of the camera in SwiftUI.

Navigate to your ContentView.swift or create a new view named CameraMainView.swift.

import SwiftUI

struct CameraMainView: View {
    @StateObject private var cameraManager = CameraManager()
    
    var body: some View {
        ZStack {
            // Dark background for the camera
            Color.black.ignoresSafeArea()
            
            if cameraManager.isCameraAuthorized {
                // 1. The preview layer in the background
                CameraPreviewView(session: cameraManager.session)
                    .ignoresSafeArea()
                    .onAppear {
                        cameraManager.startSession()
                    }
                    .onDisappear {
                        cameraManager.stopSession()
                    }
                
                // 2. Overlaid User Interface elements
                VStack {
                    Spacer()
                    
                    // Camera controls
                    HStack {
                        Spacer()
                        
                        // Capture Button
                        Button(action: {
                            cameraManager.capturePhoto()
                        }) {
                            Circle()
                                .stroke(Color.white, lineWidth: 3)
                                .frame(width: 70, height: 70)
                                .overlay(
                                    Circle()
                                        .fill(Color.white)
                                        .frame(width: 60, height: 60)
                                )
                        }
                        .padding(.bottom, 30)
                        
                        Spacer()
                    }
                }
                
                // 3. Show the captured photo if it exists
                if let capturedImage = cameraManager.capturedImage, 
                   #available(iOS 16.0, macOS 13.0, *),
                   let image = platformImage(from: capturedImage) {
                   
                    VStack {
                        HStack {
                            Spacer()
                            Image(nsImageOrUIImage: image)
                                .resizable()
                                .scaledToFill()
                                .frame(width: 100, height: 150)
                                .cornerRadius(10)
                                .shadow(radius: 5)
                                .padding()
                                .onTapGesture {
                                    // Discard the photo
                                    withAnimation {
                                        cameraManager.capturedImage = nil
                                    }
                                }
                        }
                        Spacer()
                    }
                }
                
            } else {
                // Denied Permissions Screen
                VStack(spacing: 20) {
                    Image(systemName: "camera.slash")
                        .font(.system(size: 50))
                        .foregroundColor(.gray)
                    Text("No camera access")
                        .font(.title3)
                        .foregroundColor(.white)
                    Text("Please enable access in Settings.")
                        .foregroundColor(.gray)
                }
            }
        }
    }
    
    // Cross-platform helper function to process Data to Image
    #if os(iOS)
    func platformImage(from data: Data) -> UIImage? {
        UIImage(data: data)
    }
    #elseif os(macOS)
    func platformImage(from data: Data) -> NSImage? {
        NSImage(data: data)
    }
    #endif
}

// Extension to facilitate cross-platform initialization in SwiftUI
extension Image {
    #if os(iOS)
    init(nsImageOrUIImage image: UIImage) {
        self.init(uiImage: image)
    }
    #elseif os(macOS)
    init(nsImageOrUIImage image: NSImage) {
        self.init(nsImage: image)
    }
    #endif
}

Analyzing our View:

  1. Lifecycle Control: We use .onAppear and .onDisappear to turn the camera engine on and off. This is critical for saving battery and not keeping the hardware locked when the user navigates to another screen.
  2. Native Cross-Platform: We have integrated helpers with #if os(iOS) and #elseif os(macOS) so that UIImage and NSImage can coexist peacefully. This is the mark of a true senior iOS Developer operating in Xcode.

7. The Technical Reality: iOS vs. macOS vs. watchOS

The title of this article promised to cover iOS, macOS, and watchOS. We have implemented a solid architecture and a representable bridge that works beautifully on both an iPhone and a Mac.

However, as a developer, you must know the limitations of Apple’s hardware. Here is a vital dose of technical reality:

On iOS and iPadOS

The code we just wrote works perfectly. You have total control, access to ultra-wide lenses, telephoto lenses, LiDAR sensors, and deep processing using Swift.

On macOS

The Mac supports AVFoundation with a very similar architecture. The NSViewRepresentable bridge we wrote ensures that your SwiftUI app will detect your MacBook’s built-in webcam or any Continuity Camera (like using your iPhone as a webcam) natively.

On watchOS: The Big Hurdle

Here we must make a very important technical correction: watchOS DOES NOT support AVCaptureSession or AVFoundation’s real-time capture APIs in the same way iOS does.

The Apple Watch does not have a built-in camera. Native apps on watchOS interact with the camera in two exclusive ways:

  1. Remote Camera App: Controlling the viewfinder of the paired iPhone’s camera (this is handled by the system, not your direct AVFoundation code).
  2. Third-Party Band Cameras (Very rare): And even then, direct video streaming via AVFoundation to create a custom viewfinder (AVCaptureVideoPreviewLayer) is heavily locked down or nonexistent in the public watchOS API.

If Apple releases an Apple Watch with a built-in camera in the future, this same Swift programming paradigm will adapt, but currently, AVFoundation for live video capture must be discarded when compiling for watchOS in Xcode.


8. Best Practices for Mastering the Camera

To wrap up this extensive tutorial, here is a mental checklist you should follow when integrating a camera in SwiftUI:

  • Avoid Memory Leaks: A video stream is heavy. Make sure to always invalidate or stop (stopRunning()) your AVFoundation session when the SwiftUI view disappears.
  • Device Rotation: By default, the AVCaptureVideoPreviewLayer does not rotate automatically when you turn the phone. You will have to detect orientation changes in your updateUIView and apply the transformation to videoPreviewLayer.connection?.videoOrientation.
  • Pinch to Zoom: You can add a MagnificationGesture to your main SwiftUI view and update the videoZoomFactor parameter of your AVCaptureDevice. Try it as a personal challenge!
  • Exhaustive Error Handling: Cameras can fail. Another app might be using it, the device might be too hot, and the system could block access. In a production environment, ensure you catch and show friendly notifications in your UI when something fails.

Conclusion

Building a camera in SwiftUI by combining the underlying power of AVFoundation is one of the most rewarding projects in Swift programming. It forces you to understand how the declarative world (fast, reactive user interfaces) interacts with the imperative world (real-time hardware data streams).

If you have any questions about this article, please contact me and I will be happy to help you 🙂. You can contact me on my X profile or on my Instagram profile.

Leave a Reply

Your email address will not be published. Required fields are marked *

Previous Article

Customize TabView in SwiftUI

Next Article

How to use Face ID in SwiftUI

Related Posts