Skip to content
ExponaBLOG
SWIFT, SWIFTUI & IOS APPS · DAY 062 / 100

Camera and photos in SwiftUI in Swift, SwiftUI & iOS Apps

Capture and pick images.

Expona Admin10 min read

Camera and photos in SwiftUI in Swift, SwiftUI & iOS Apps

Handling camera and photo selection is one of the most common requirements we encounter in production iOS apps—whether you're building a marketplace listing tool, a social feed, or a user profile editor. SwiftUI gives us two powerful approaches: PhotosPicker for letting users select existing images without broad library access, and AVFoundation for building custom camera capture screens when you need fine-grained control. Let's walk through both patterns, then cover the critical steps around processing, compression, and privacy that make these features production-ready.

PhotosPicker — Select photos without full library access

Starting with iOS 16, Apple introduced PhotosPicker as the recommended way to let users pick images. This view controller is far better than the legacy UIImagePickerController because it grants limited photo library access by default—users choose which photos to share, and the system never exposes your app to the entire library.

Here's how we typically integrate it into a SwiftUI view:

swift
import SwiftUI
import PhotosUI

struct ProfilePhotoScreen: View {
    @State private var selectedItem: PhotosPickerItem?
    @State private var selectedImage: UIImage?
    @State private var isLoading = false

    var body: some View {
        VStack(spacing: 16) {
            if let image = selectedImage {
                Image(uiImage: image)
                    .resizable()
                    .scaledToFill()
                    .frame(width: 120, height: 120)
                    .clipShape(Circle())
            } else {
                Image(systemName: "person.crop.circle.fill")
                    .font(.system(size: 120))
                    .foregroundColor(.gray)
            }

            PhotosPicker(
                selection: $selectedItem,
                matching: .images,
                photoLibrary: .shared()
            ) {
                Label("Select Photo", systemImage: "photo.on.rectangle")
                    .padding(.horizontal, 12)
                    .padding(.vertical, 8)
                    .background(Color.blue)
                    .foregroundColor(.white)
                    .cornerRadius(8)
            }

            if isLoading {
                ProgressView()
            }
        }
        .padding(16)
        .onChange(of: selectedItem) { _, newItem in
            Task {
                isLoading = true
                defer { isLoading = false }
                
                if let data = try await newItem?.loadTransferable(type: Data.self),
                   let uiImage = UIImage(data: data) {
                    selectedImage = uiImage
                }
            }
        }
    }
}

#Preview {
    ProfilePhotoScreen()
}

The key advantage here is that PhotosPicker uses the limited photo library entitlement. Users see a curated picker interface where they select only the photos they want to share. In Info.plist, you only need:

swift
NSPhotoLibraryUsageDescription = "We need access to your photos to set your profile picture"

This is much cleaner than requesting full library access. We've found that limited library access also improves app review acceptance and user trust, especially for consumer-facing features.

Camera — AVFoundation for custom capture screens

When you need to capture photos in real-time—think a document scanner, an AR feature, or a selfie verification flow—you'll reach for AVFoundation. This gives you full control over the camera pipeline and lets you build a custom UI around the capture experience.

Here's a production-ready camera capture view that handles both preview and photo capture:

swift
import AVFoundation
import SwiftUI

class CameraManager: NSObject, ObservableObject {
    @Published var previewLayer: AVCaptureVideoPreviewLayer?
    @Published var capturedImage: UIImage?
    @Published var error: String?
    @Published var isCapturing = false

    private let captureSession = AVCaptureSession()
    private let photoOutput = AVCapturePhotoOutput()
    private var photoDelegate: PhotoCaptureDelegate?

    override init() {
        super.init()
        setupCamera()
    }

    func setupCamera() {
        DispatchQueue.global(qos: .userInitiated).async { [weak self] in
            guard let self = self else { return }

            do {
                let discoverySession = AVCaptureDevice.DiscoverySession(
                    deviceTypes: [.builtInWideAngleCamera],
                    mediaType: .video,
                    position: .front
                )

                guard let camera = discoverySession.devices.first else {
                    DispatchQueue.main.async {
                        self.error = "No camera available"
                    }
                    return
                }

                let input = try AVCaptureDeviceInput(device: camera)
                self.captureSession.addInput(input)
                self.captureSession.addOutput(self.photoOutput)

                let previewLayer = AVCaptureVideoPreviewLayer(session: self.captureSession)
                previewLayer.videoGravity = .resizeAspectFill

                DispatchQueue.main.async {
                    self.previewLayer = previewLayer
                    self.captureSession.startRunning()
                }
            } catch {
                DispatchQueue.main.async {
                    self.error = error.localizedDescription
                }
            }
        }
    }

    func capturePhoto() {
        isCapturing = true
        let settings = AVCapturePhotoSettings()
        photoDelegate = PhotoCaptureDelegate { [weak self] image in
            DispatchQueue.main.async {
                self?.capturedImage = image
                self?.isCapturing = false
            }
        }
        photoOutput.capturePhoto(with: settings, delegate: photoDelegate!)
    }

    deinit {
        captureSession.stopRunning()
    }
}

class PhotoCaptureDelegate: NSObject, AVCapturePhotoCaptureDelegate {
    let completion: (UIImage) -> Void

    init(completion: @escaping (UIImage) -> Void) {
        self.completion = completion
    }

    func photoOutput(
        _ output: AVCapturePhotoOutput,
        didFinishProcessingPhoto photo: AVCapturePhoto,
        error: Error?
    ) {
        guard error == nil,
              let imageData = photo.fileDataRepresentation(),
              let image = UIImage(data: imageData) else {
            return
        }
        completion(image)
    }
}

struct CameraViewRepresentable: UIViewRepresentable {
    let cameraManager: CameraManager

    func makeUIView(context: Context) -> UIView {
        let view = UIView()
        if let previewLayer = cameraManager.previewLayer {
            previewLayer.frame = view.bounds
            view.layer.addSublayer(previewLayer)
        }
        return view
    }

    func updateUIView(_ uiView: UIView, context: Context) {}
}

struct CameraScreen: View {
    @StateObject private var cameraManager = CameraManager()

    var body: some View {
        ZStack {
            CameraViewRepresentable(cameraManager: cameraManager)
                .edgesIgnoringSafeArea(.all)

            VStack {
                HStack {
                    Text("Capture Photo")
                        .font(.headline)
                        .foregroundColor(.white)
                    Spacer()
                }
                .padding(16)
                .background(Color.black.opacity(0.4))

                Spacer()

                HStack(spacing: 20) {
                    Spacer()
                    Button(action: {
                        cameraManager.capturePhoto()
                    }) {
                        Circle()
                            .fill(Color.white)
                            .frame(width: 70, height: 70)
                            .overlay(
                                Circle()
                                    .stroke(Color.white, lineWidth: 3)
                                    .frame(width: 60, height: 60)
                            )
                    }
                    .disabled(cameraManager.isCapturing)
                    Spacer()
                }
                .padding(.bottom, 32)
            }

            if let error = cameraManager.error {
                Text("Error: \(error)")
                    .foregroundColor(.red)
                    .padding()
            }
        }
        .onAppear {
            cameraManager.setupCamera()
        }
    }
}

#Preview {
    CameraScreen()
}

This approach gives you direct control over camera settings, resolution, and the capture flow. You'll need two permissions in Info.plist:

swift
NSCameraUsageDescription = "We need camera access to capture your identity verification photo"
NSPhotoLibraryAddUsageDescription = "We save captured photos to your library"

Process — Resize and compress before uploading

Capturing or picking a photo is only half the battle. In production, you almost always need to resize and compress images before uploading to your backend or cloud storage. Modern phone cameras produce 4-12 MB images; sending those raw creates terrible user experience and burns through bandwidth and server resources.

Here's a utility we rely on across our projects:

swift
import UIKit

struct ImageProcessor {
    static func processForUpload(
        _ image: UIImage,
        maxWidth: CGFloat = 1200,
        maxHeight: CGFloat = 1200,
        compressionQuality: CGFloat = 0.7
    ) -> Data? {
        let resized = resize(image, maxWidth: maxWidth, maxHeight: maxHeight)
        return resized.jpegData(compressionQuality: compressionQuality)
    }

    private static func resize(
        _ image: UIImage,
        maxWidth: CGFloat,
        maxHeight: CGFloat
    ) -> UIImage {
        let scale = min(maxWidth / image.size.width, maxHeight / image.size.height)
        if scale >= 1 {
            return image
        }

        let newSize = CGSize(
            width: image.size.width * scale,
            height: image.size.height * scale
        )

        UIGraphicsBeginImageContextWithOptions(newSize, false, 0)
        image.draw(in: CGRect(origin: .zero, size: newSize))
        let resized = UIGraphicsGetImageFromCurrentImageContext()
        UIGraphicsEndImageContext()

        return resized ?? image
    }

    static func estimateSize(_ data: Data) -> String {
        let bytes = Double(data.count)
        if bytes < 1_000_000 {
            return String(format: "%.1f KB", bytes / 1_000)
        }
        return String(format: "%.1f MB", bytes / 1_000_000)
    }
}

// Usage in your upload flow
@MainActor
class PhotoUploadViewModel: ObservableObject {
    @Published var uploadedURL: String?
    @Published var isUploading = false
    @Published var error: String?

    func uploadPhoto(_ image: UIImage) async {
        isUploading = true
        defer { isUploading = false }

        guard let imageData = ImageProcessor.processForUpload(image) else {
            error = "Failed to process image"
            return
        }

        print("Compressed size: \(ImageProcessor.estimateSize(imageData))")

        do {
            let url = try await uploadToServer(imageData)
            uploadedURL = url
        } catch {
            self.error = error.localizedDescription
        }
    }

    private func uploadToServer(_ data: Data) async throws -> String {
        // Your API call here
        var request = URLRequest(url: URL(string: "https://api.example.com/upload")!)
        request.httpMethod = "POST"
        request.setValue("application/octet-stream", forHTTPHeaderField: "Content-Type")
        request.httpBody = data

        let (_, response) = try await URLSession.shared.data(for: request)
        guard (response as? HTTPURLResponse)?.statusCode == 200 else {
            throw NSError(domain: "Upload failed", code: -1)
        }

        return "https://cdn.example.com/photo.jpg"
    }
}

We compress to 0.6–0.8 quality and cap dimensions at 1200px for typical profile photos and feed content. For document capture, you might want higher quality; for thumbnails, you can be more aggressive. Always measure in your use case.

Privacy — Usage descriptions in Info.plist

iOS requires explicit permission strings in Info.plist for any camera or photo library access. Beyond just making the app work, these descriptions are your chance to explain why you need the permission in user-friendly language. Poor descriptions lead to rejections and user distrust.

Here are the keys you'll typically need:

Key When used Example description
NSCameraUsageDescription Real-time camera capture We use your camera to capture identity verification photos
NSPhotoLibraryUsageDescription Reading any photo library access Select photos for your profile or marketplace listings
NSPhotoLibraryAddUsageDescription Saving photos to library Save captured photos to your photo library

You can set these in Xcode's Info tab or directly in Info.plist as XML:

swift
<key>NSCameraUsageDescription</key>
<string>We use your camera to capture identity verification photos</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>Select photos for your profile or marketplace listings</string>

A best practice we follow: request the minimum permission needed. If users only need to pick from existing photos, use PhotosPicker with limited library access. Only use full camera access if you're actually building a capture UI. This approach passes reviews faster and respects user privacy better.

Common mistakes

  • Not handling permission denials gracefully. Always check PHPhotoLibrary.requestAuthorization before accessing photos, and provide clear error messaging if the user denies access. Don't just crash or show a blank screen.
  • Uploading uncompressed images. We've seen apps send 8 MB JPEGs for profile avatars. Always process images before upload. A 1200px, 70% quality JPEG is almost always imperceptible and 10–20x smaller.
  • Ignoring memory warnings in camera capture. Long-running camera sessions can leak memory if you don't stop the session in deinit or when the view disappears. Use onDisappear to call captureSession.stopRunning().
  • Missing or vague Info.plist descriptions. "We need your photos" fails review. "Use your camera to capture a document for KYC verification" passes. Be specific about the why.
  • Not testing on real devices. The simulator's camera and photo library behavior differs from real hardware. Always test camera flows on a physical iPhone before shipping.

Key takeaways

  • Use PhotosPicker for selecting existing images—it grants limited library access and is the modern standard as of iOS 16.
  • Build custom camera capture with AVFoundation and AVCaptureSession when you need control over the capture pipeline or real-time processing.
  • Always resize and compress images before uploading. Aim for 1200px max dimensions and 0.6–0.8 JPEG quality unless you have specific requirements.
  • Set clear, specific permission descriptions in Info.plist. Users and reviewers need to understand exactly why you need camera or photo access.
  • Test camera features on real devices. The simulator doesn't reflect real-world behavior, especially for photo capture and library access.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles