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:
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:
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:
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:
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:
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:
<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.requestAuthorizationbefore 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
deinitor when the view disappears. UseonDisappearto callcaptureSession.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
PhotosPickerfor selecting existing images—it grants limited library access and is the modern standard as of iOS 16. - Build custom camera capture with
AVFoundationandAVCaptureSessionwhen 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.






Comments
Loading comments…