SwiftUI Blog

Mastering SwiftUI: Your Guide to Building Beautiful, Intuitive Apps.

Camera

Sometimes, a mobile application needs to take a photo to either send it to a server, modify it locally, or both (like Instagram). In this post, we’ll see how to take a photo. The process is a bit long and may seem complex, but I’ll do my best to explain everything clearly.

Here’s how it works: we open the camera, display the live camera stream, and then capture a frame as an image.

Let’s start with the ContentView:

import SwiftUI

struct ContentView: View {
    @State
    private var viewModel = ViewModel()
    
    var body: some View {
        CameraView(image: $viewModel.currentFrame)
            .ignoresSafeArea()
    }
}

#Preview {
    ContentView()
}

We create a model that holds the frame in which we store the image:

import Foundation
import CoreImage

@Observable
class ViewModel {
    var currentFrame: CGImage?
    private let cameraManager = CameraManager()
    
    init() {
        Task {
            await handleCameraPreviews()
        }
    }
    
    func handleCameraPreviews() async {
        for await image in cameraManager.previewStream {
            Task { @MainActor in
                currentFrame = image
            }
        }
    }
}

The ViewModel then calls the Camera Manager, which returns the current frame.

Now, let’s take a look at the Camera Manager.

import AVFoundation
import CoreImage


class CameraManager: NSObject {
    private let captureSession = AVCaptureSession()
    private var deviceInput: AVCaptureDeviceInput?
    private var videoOutput: AVCaptureVideoDataOutput?
    private let systemPreferredCamera = AVCaptureDevice.default(for: .video)

    private var sessionQueue = DispatchQueue(label: "video.preview.session")
    
    private var addToPreviewStream: ((CGImage) -> Void)?
    
    lazy var previewStream: AsyncStream<CGImage> = {
        AsyncStream { continuation in
            addToPreviewStream = { cgImage in
                continuation.yield(cgImage)
            }
        }
    }()
    
    private var isAuthorized: Bool {
        get async {
            let status = AVCaptureDevice.authorizationStatus(for: .video)
            
            // Determine if the user previously authorized camera access.
            var isAuthorized = status == .authorized
            
            // If the system hasn't determined the user's authorization status,
            // explicitly prompt them for approval.
            if status == .notDetermined {
                isAuthorized = await AVCaptureDevice.requestAccess(for: .video)
            }
            return isAuthorized
        }
    }
    
    override init() {
        super.init()
        
        Task {
            await configureSession()
            await startSession()
        }
    }
    
    private func configureSession() async {
        guard await isAuthorized,
              let systemPreferredCamera,
              let deviceInput = try? AVCaptureDeviceInput(device: systemPreferredCamera)
        else { return }
        
        captureSession.beginConfiguration()
        
        defer {
            self.captureSession.commitConfiguration()
        }
        
        let videoOutput = AVCaptureVideoDataOutput()
       
        videoOutput.setSampleBufferDelegate(self, queue: sessionQueue)
        
        guard captureSession.canAddInput(deviceInput) else {
            return
        }
        
        guard captureSession.canAddOutput(videoOutput) else {
            return
        }
        
        captureSession.addInput(deviceInput)
        captureSession.addOutput(videoOutput)

        //For a vertical orientation of the camera stream
        videoOutput.connection(with: .video)?.videoRotationAngle = 90
    }
    
    private func startSession() async {
        guard await isAuthorized else { return }
        captureSession.startRunning()
    }
    
    private func rotate(by angle: CGFloat, from connection: AVCaptureConnection) {
        guard connection.isVideoRotationAngleSupported(angle) else { return }
        connection.videoRotationAngle = angle
    }

}

extension CameraManager: AVCaptureVideoDataOutputSampleBufferDelegate {
    
    func captureOutput(_ output: AVCaptureOutput, didOutput sampleBuffer: CMSampleBuffer, from connection: AVCaptureConnection) {
        guard let currentFrame = sampleBuffer.cgImage else {
            print("Can't translate to CGImage")
            return
        }
        addToPreviewStream?(currentFrame)
    }
}

extension CMSampleBuffer {
    var cgImage: CGImage? {
        let pixelBuffer: CVPixelBuffer? = CMSampleBufferGetImageBuffer(self)
        guard let imagePixelBuffer = pixelBuffer else { return nil }
        return CIImage(cvPixelBuffer: imagePixelBuffer).cgImage
    }
}

extension CIImage {
    var cgImage: CGImage? {
        let ciContext = CIContext()
        guard let cgImage = ciContext.createCGImage(self, from: self.extent) else { return nil }
        return cgImage
    }
}

The CameraManager starts the streaming, but first it checks for authorization.

Note: to use the camera, you must set NSCameraUsageDescription in the Info.plist — otherwise, the app will crash when trying to access the camera.

So, what happens in the camera view?

import SwiftUI

struct CameraView: View {
    @Binding var image: CGImage?
    @State var isPresented: Bool = false
    
    var body: some View {
        GeometryReader { geometry in
            if let image = image {
                NavigationStack {
                    ZStack {
                        Image(decorative: image, scale: 1)
                            .resizable()
                            .scaledToFit()
                            .frame(width: geometry.size.width,
                                   height: geometry.size.height)
                            .overlay(alignment: .top) {
                                Color.black
                                    .opacity(0.75)
                                    .frame(height: geometry.size.height * 0.15)
                            }
                            .overlay(alignment: .bottom) {
                                buttonsView()
                                    .frame(height: geometry.size.height * 0.15)
                                    .background(.black.opacity(0.75))
                            }
                        
                        }
                    }
            } else {
                ContentUnavailableView("Camera not available", systemImage: "camera.fill")
                                        .frame(width: geometry.size.width,
                           height: geometry.size.height)
            }
        }.fullScreenCover(isPresented: $isPresented) {
            PhotoPreview(image: image)
        }
    }
    
    private func buttonsView() -> some View {
        HStack(spacing: 50) {
            Spacer()
            Button {
                self.isPresented.toggle()
            } label: {
                Label {
                    Text("")
                } icon: {
                    ZStack {
                        Circle()
                            .strokeBorder(.white, lineWidth: 3)
                            .frame(width: 62, height: 62)
                        Circle()
                            .fill(.white)
                            .frame(width: 50, height: 50)
                    }
                }
            }.labelStyle(.iconOnly)
            Spacer()
        }
        .buttonStyle(.plain)
        .labelStyle(.iconOnly)
        .padding()
    }
}

#Preview {
    CameraView(image: .constant(nil))
}

The image contains the frame captured by the camera. When we tap the circle button, we open a sheet and display the image inside it. From here, we can do whatever we want with the image — send it, save it, or something else. For now, I’ve added a ‘Send’ button without any action.

Leave a Reply

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