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