Camera, Media and Photos

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production; see the section bibliography for the reference material consulted while preparing these pages.

A field note is often more photo than text: a plant, a trail marker, a page of a notebook. This page gives FieldNotes its media features. The user can attach photos from the library, take a photo with the camera without leaving the note, see thumbnails of what is attached, play back video and audio recordings, and control that playback from the Lock Screen. Everything targets the 27 releases (iOS 27, iPadOS 27, macOS 27, watchOS 27 and visionOS 27), built with Xcode 27 and Swift 6.4. FieldNotes keeps a deployment minimum of 26, so APIs that exist only on the 27 releases are marked "new in 27" and guarded with if #available or @available.

Media work leans on Swift concurrency more than most features: capture sessions block while they configure, delegates call back on their own queues, and decoded images are large. The language side is covered in the Swift reference, and this page only applies it:

The examples extend the FieldNotes types from App Architecture the Apple Way and the part 2 model additions: Note gains attachments: [PhotoAttachment], and the image bytes live in PhotoRecord, a SwiftData model with external storage, defined on Persisting Models with SwiftData. This page adds three canonical views and models: NotePhotoPicker, CameraCaptureModel and AttachmentThumbnail.

Choosing the Right Media API

Apple offers several ways to get a picture into an app, and each asks the user for a different amount of trust. The rule is to choose the least access that does the job.

Need API Permission prompt FieldNotes uses it for

Let the user pick existing photos

PhotosPicker (PhotosUI)

None: the picker runs out of process and returns only what the user selects

NotePhotoPicker, the default way to attach

Save a new photo to the library

PHPhotoLibrary with PHAccessLevel.addOnly

Add-only prompt (NSPhotoLibraryAddUsageDescription)

An optional "Also save to Photos" setting

Browse or edit the whole library

PhotoKit (PHAsset, PHImageManager) with .readWrite

Full or limited library prompt (NSPhotoLibraryUsageDescription)

A "Recent photos near this note" strip

Take a photo inside the app

AVFoundation capture (AVCaptureSession, AVCapturePhotoOutput)

Camera prompt (NSCameraUsageDescription), plus microphone for video

CameraCaptureModel in the note editor

Play video or audio

AVKit (VideoPlayer, AVPlayerViewController), AVAudioSession

None

Attached clips and field recordings

Show playback on the Lock Screen

Now Playing (new in 27); MediaPlayer’s MPNowPlayingInfoCenter before 27

None

FieldRecordingPlayer

Figure: Four Levels of Photo Access

Four columns from least to most access. PhotosPicker shows no prompt, runs out of process and hands the app only the photos the user picked. Add-only access asks once and lets the app save new photos without reading any. Limited access lets the app read a subset of photos the user chose. Full access lets the app read and change the whole library. An arrow underneath shows that each step to the right adds user friction and privacy cost

Most apps never need to move past the first column. FieldNotes uses the picker for attaching, add-only access for the optional save to Photos, and full access only in one opt-in feature.

Attaching Photos Without a Permission Prompt: NotePhotoPicker

PhotosPicker is the SwiftUI view for picking photos and videos. The picker’s interface runs in a separate process that the system owns, so the app never sees the library: it receives only the items the user selects, as PhotosPickerItem values. Because nothing else leaks out, the picker needs no permission prompt and no usage-description key. This is why it is the right default for attaching photos to a note.

A PhotosPickerItem is a promise, not the data. The app asks for the content through loadTransferable(type:), which accepts any Transferable type. Data is the simplest choice and keeps the original encoding (HEIF or JPEG), which is what FieldNotes stores.

FieldNotes saves the bytes in two places, matching the model split on the SwiftData page. The Note value gets a small PhotoAttachment (an identifier and a file name) so lists and widgets stay light. The bytes go to a PhotoRecord in SwiftData, whose @Attribute(.externalStorage) keeps large blobs outside the SQLite file. A small page-local protocol, PhotoDataStoring, gives views a way to reach that storage without knowing about SwiftData:

// Packages/FieldNotesKit/Sources/FieldNotesServices/Persistence/PhotoDataStoring.swift
import Foundation
import SwiftData
import FieldNotesModel

/// Stores and loads the bytes behind a `PhotoAttachment`.
public protocol PhotoDataStoring: Sendable {
    func savePhoto(_ data: Data, as attachment: PhotoAttachment, toNote noteID: Note.ID) async throws
    func photoData(for attachmentID: PhotoAttachment.ID) async throws -> Data?
}

public enum PhotoStoreError: Error {
    case noteNotFound
    case storeUnavailable
}

// SwiftDataNotePersistence is the @ModelActor defined on the SwiftData page; this extension adds photo storage.
extension SwiftDataNotePersistence: PhotoDataStoring {
    public func savePhoto(_ data: Data, as attachment: PhotoAttachment, toNote noteID: Note.ID) async throws {
        var descriptor = FetchDescriptor<NoteRecord>(predicate: #Predicate { $0.id == noteID })
        descriptor.fetchLimit = 1
        guard let note = try modelContext.fetch(descriptor).first else { throw PhotoStoreError.noteNotFound }
        note.photos.append(PhotoRecord(id: attachment.id, fileName: attachment.fileName, data: data))
        try modelContext.save()
    }

    public func photoData(for attachmentID: PhotoAttachment.ID) async throws -> Data? {
        var descriptor = FetchDescriptor<PhotoRecord>(predicate: #Predicate { $0.id == attachmentID })
        descriptor.fetchLimit = 1
        return try modelContext.fetch(descriptor).first?.data
    }
}

The work runs inside the model actor, so fetching and saving never touch the main thread. #Predicate is explained on Macros. The app hands the store to its views through the environment, next to the NoteStore:

// FieldNotes/Photos/PhotoStoreEnvironment.swift
import SwiftUI
import FieldNotesServices

extension EnvironmentValues {
    @Entry var photoStore: (any PhotoDataStoring)? = nil
}

// FieldNotes/FieldNotesApp.swift (excerpt: the Settings, MenuBarExtra and ImmersiveSpace scenes are unchanged)
@main
struct FieldNotesApp: App {
    // AppServices (Background Execution) owns the store and the SwiftData persistence it writes through.
    @State private var store = AppServices.shared.store
    private let persistence = AppServices.shared.persistence

    var body: some Scene {
        WindowGroup {
            ContentView()
                .environment(store)
                .environment(\.photoStore, persistence)
                .task { await store.load() }
        }
    }
}

Both the picker and the camera attach a photo the same way, so the steps live in one place. The bytes are saved first and the attachment appears in the note only once they are stored. Before saving, the helper waits for NoteStore's queued writes (see SwiftData), so a photo added to a note created a moment ago doesn’t fail with noteNotFound:

// FieldNotes/Photos/NoteStore+Photos.swift
import Foundation
import FieldNotesModel
import FieldNotesServices

extension NoteStore {
    func attachPhoto(_ data: Data, as attachment: PhotoAttachment, toNote noteID: Note.ID,
                     using photoStore: (any PhotoDataStoring)?) async throws {
        guard let photoStore else { throw PhotoStoreError.storeUnavailable }
        await waitForPendingWrites()
        try await photoStore.savePhoto(data, as: attachment, toNote: noteID)
        if let index = notes.firstIndex(where: { $0.id == noteID }) {
            notes[index].attachments.append(attachment)
        }
    }
}

Now the picker itself. NotePhotoPicker takes the note’s identifier, lets the user choose up to five images, and turns each choice into an attachment. The selection is cleared afterwards, so picking the same photo again later still triggers onChange:

// FieldNotes/Photos/NotePhotoPicker.swift
import SwiftUI
import PhotosUI
import FieldNotesModel
import FieldNotesServices

struct NotePhotoPicker: View {
    @Environment(NoteStore.self) private var store
    @Environment(\.photoStore) private var photoStore
    @State private var selection: [PhotosPickerItem] = []
    @State private var isImporting = false
    @State private var failedImports = 0
    let noteID: Note.ID

    var body: some View {
        PhotosPicker(selection: $selection,
                     maxSelectionCount: 5,
                     selectionBehavior: .ordered,
                     matching: .any(of: [.images, .screenshots]),
                     preferredItemEncoding: .current) {
            Label("Add Photos", systemImage: "photo.badge.plus")
        }
        .disabled(isImporting)
        .onChange(of: selection) { _, items in
            guard !items.isEmpty else { return }
            Task { await importPhotos(items) }
        }
        .alert("\(failedImports) photos couldn't be added", isPresented: .constant(failedImports > 0)) {
            Button("OK") { failedImports = 0 }
        }
    }

    private func importPhotos(_ items: [PhotosPickerItem]) async {
        isImporting = true
        defer { isImporting = false; selection = [] }
        for item in items {
            do {
                guard let data = try await item.loadTransferable(type: Data.self) else { continue }
                let fileExtension = item.supportedContentTypes.first?.preferredFilenameExtension ?? "jpg"
                let attachment = PhotoAttachment(id: UUID(), fileName: "\(UUID().uuidString).\(fileExtension)")
                try await store.attachPhoto(data, as: attachment, toNote: noteID, using: photoStore)
            } catch {
                // A failed item is counted and reported; the others still import.
                failedImports += 1
            }
        }
    }
}

#Preview {
    let note = Note(title: "Oak by the river")
    NotePhotoPicker(noteID: note.id)
        .environment(NoteStore(notes: [note]))
}

A few details are worth knowing:

  • matching: takes a PHPickerFilter. Filters combine with .any(of:), .all(of:) and .not(_:), so "images but not screenshots" is .all(of: [.images, .not(.screenshots)]). Filters also exist for videos, Live Photos, bursts, panoramas and spatial media.

  • preferredItemEncoding: .current asks for the current representation (HEIF on most iPhones) instead of a transcoded JPEG. Use .compatible when the bytes must open anywhere.

  • loadTransferable(type:) can return nil, when the item can’t be represented as the requested type, and can throw, for example when an iCloud Photos original fails to download. Handle both per item.

  • Memory: Data loads the full original into memory. For large videos, load a custom Transferable type that uses a FileRepresentation, which hands you a temporary file URL instead.

  • The store: attachPhoto adds the attachment to the note in memory only after savePhoto has stored the bytes, so what’s on screen never refers to a photo that isn’t saved; the SwiftData page maps NoteRecord.photos back to Note.attachments on load. Failures are counted and reported instead of being skipped silently.

Embedding the Picker Inline

The picker can also be embedded in the app’s own layout, which suits an iPad editor where the library sits next to the note. photosPickerStyle(.inline) embeds it, and photosPickerDisabledCapabilities and photosPickerAccessoryVisibility trim its chrome so it looks like part of the screen:

// FieldNotes/Photos/InlinePhotoStrip.swift
#if os(iOS) || os(macOS)
import SwiftUI
import PhotosUI

struct InlinePhotoStrip: View {
    @Binding var selection: [PhotosPickerItem]

    var body: some View {
        PhotosPicker("Photos", selection: $selection, maxSelectionCount: 5, matching: .images)
            .photosPickerStyle(.inline)
            .photosPickerDisabledCapabilities([.selectionActions, .collectionNavigation])
            .photosPickerAccessoryVisibility(.hidden, edges: .all)
            .frame(height: 220)
    }
}
#endif

Even inline, the picker still runs out of process, so it still needs no permission. The inline style exists on iOS, iPadOS and macOS only, hence the guard; on watchOS and visionOS the picker is always presented.

Showing Attachments: AttachmentThumbnail

A photo from a modern iPhone decodes to tens of megabytes of pixels. Decoding the full image just to draw a 64-point thumbnail wastes memory and time, and a list of them can get the app terminated for memory pressure. The fix is downsampling: ImageIO can decode an image straight to a small size without ever building the full bitmap. CGImageSourceCreateThumbnailAtIndex does this, and it works the same on every platform, so AttachmentThumbnail needs no UIImage/NSImage split.

// FieldNotes/Photos/AttachmentThumbnail.swift
import SwiftUI
import ImageIO
import FieldNotesModel

struct AttachmentThumbnail: View {
    @Environment(\.photoStore) private var photoStore
    @Environment(\.displayScale) private var displayScale
    @State private var image: CGImage?
    let attachment: PhotoAttachment
    var size: CGFloat = 72

    var body: some View {
        Group {
            if let image {
                Image(decorative: image, scale: displayScale)
                    .resizable()
                    .scaledToFill()
            } else {
                Rectangle().fill(.quaternary)
                    .overlay { ProgressView() }
            }
        }
        .frame(width: size, height: size)
        .clipShape(.rect(cornerRadius: 10))
        .accessibilityLabel("Photo attachment")
        .task(id: attachment.id) {
            guard let data = try? await photoStore?.photoData(for: attachment.id) else { return }
            image = await Self.downsample(data, maxPixelSize: size * displayScale)
        }
    }

    /// Decodes a thumbnail off the main actor without building the full-size bitmap.
    @concurrent
    private static func downsample(_ data: Data, maxPixelSize: CGFloat) async -> CGImage? {
        let sourceOptions = [kCGImageSourceShouldCache: false] as CFDictionary
        guard let source = CGImageSourceCreateWithData(data as CFData, sourceOptions) else { return nil }
        let thumbnailOptions = [
            kCGImageSourceCreateThumbnailFromImageAlways: true,
            kCGImageSourceCreateThumbnailWithTransform: true,   // respect the EXIF orientation
            kCGImageSourceShouldCacheImmediately: true,         // decode now, not on first draw
            kCGImageSourceThumbnailMaxPixelSize: maxPixelSize
        ] as CFDictionary
        return CGImageSourceCreateThumbnailAtIndex(source, 0, thumbnailOptions)
    }
}

.task(id:) restarts the load when a reused view shows a different attachment, and cancels it when the view disappears. @concurrent moves the decoding to the global concurrent executor, as described on Swift Concurrency in Apps. NoteDetail shows the attachments in a horizontal strip. The ForEach uses the attachments' stable id, never an index or the value itself:

// FieldNotes/Views/NoteDetail.swift (excerpt)
if !note.attachments.isEmpty {
    ScrollView(.horizontal) {
        LazyHStack(spacing: 8) {
            ForEach(note.attachments) { attachment in
                AttachmentThumbnail(attachment: attachment)
            }
        }
        .padding(.horizontal)
    }
    .scrollIndicators(.hidden)
}

The full-size view reuses PhotoAttachmentView from Liquid Glass and the Design System, fed with a larger maxPixelSize.

Full and Limited Library Access with PhotoKit

PhotoKit is the framework behind the Photos app: PHAsset for items, PHAssetCollection for albums, PHImageManager for images, and PHPhotoLibrary for authorization and changes. An app needs it only to read the library without the picker, to organize albums, or to edit assets in place.

Reading needs NSPhotoLibraryUsageDescription in the target’s Info properties and a call to requestAuthorization(for: .readWrite). Since iOS 14 the user can answer with limited access: the app sees only the photos the user chose, and it cannot tell how many others exist. Treat .limited as a normal, successful answer.

// FieldNotes/Photos/PhotoLibraryAccess.swift
#if os(iOS) || os(macOS) || os(visionOS)   // PhotoKit's library APIs aren't on watchOS
import Photos

enum PhotoLibraryAccess {
    /// Asks for read access once; later calls return the stored answer without a prompt.
    static func requestReadAccess() async -> PHAuthorizationStatus {
        let current = PHPhotoLibrary.authorizationStatus(for: .readWrite)
        guard current == .notDetermined else { return current }
        return await PHPhotoLibrary.requestAuthorization(for: .readWrite)
    }

    /// Photos taken within `interval` seconds of the note's date, newest first.
    static func photos(near date: Date, within interval: TimeInterval = 3_600) -> PHFetchResult<PHAsset> {
        let options = PHFetchOptions()
        options.predicate = NSPredicate(format: "mediaType == %d AND creationDate > %@ AND creationDate < %@",
                                        PHAssetMediaType.image.rawValue,
                                        date.addingTimeInterval(-interval) as NSDate,
                                        date.addingTimeInterval(interval) as NSDate)
        options.sortDescriptors = [NSSortDescriptor(key: "creationDate", ascending: false)]
        return PHAsset.fetchAssets(with: options)
    }
}
#endif

The statuses mean:

PHAuthorizationStatus Meaning for FieldNotes

.notDetermined

Not asked yet: show the "Recent photos near this note" feature’s explainer, then ask.

.authorized

Full access to the library.

.limited

Access to the user’s chosen subset; offer a "Select More Photos" button.

.denied

The user said no: hide the feature and link to Settings.

.restricted

Parental controls or a device profile forbid access; the user can’t change it.

With limited access, UIKit’s PHPhotoLibrary.shared().presentLimitedLibraryPicker(from:) lets the user extend the selection. The system shows its own "select more" prompt at most once per app launch; set PHPhotoLibraryPreventAutomaticLimitedAccessAlert to YES in the Info properties to suppress it when the app offers its own button.

To show many assets, PHCachingImageManager prefetches thumbnails for the rows about to scroll on screen. It calls back with a degraded image first and the final one later, so the handler runs more than once:

// FieldNotes/Photos/NearbyPhotosLoader.swift
#if os(iOS) || os(macOS) || os(visionOS)
import Photos
#if canImport(UIKit)
import UIKit
typealias PlatformImage = UIImage
#else
import AppKit
typealias PlatformImage = NSImage
#endif

struct NearbyThumbnail {
    let image: PlatformImage?
    let isFinal: Bool
}

@MainActor
final class NearbyPhotosLoader {
    private let imageManager = PHCachingImageManager()
    private let targetSize = CGSize(width: 200, height: 200)

    func prefetch(_ assets: [PHAsset]) {
        imageManager.startCachingImages(for: assets, targetSize: targetSize,
                                        contentMode: .aspectFill, options: nil)
    }

    func requestThumbnail(for asset: PHAsset, update: @escaping @MainActor (NearbyThumbnail) -> Void) {
        imageManager.requestImage(for: asset, targetSize: targetSize,
                                  contentMode: .aspectFill, options: nil) { image, info in
            let isDegraded = (info?[PHImageResultIsDegradedKey] as? Bool) ?? false
            MainActor.assumeIsolated { update(NearbyThumbnail(image: image, isFinal: !isDegraded)) }
        }
    }
}
#endif

With the default options, requestImage delivers its results on the main thread when it is called from the main thread, which is what MainActor.assumeIsolated asserts. PhotoKit’s image requests are not available on watchOS; the watch app only shows thumbnails synced through SwiftData.

Saving a Captured Photo with Add-Only Access

Saving to the library needs only PHAccessLevel.addOnly and NSPhotoLibraryAddUsageDescription. The app can write new assets but can never read any. A PHAssetCreationRequest inside performChanges adds the photo exactly as captured, keeping its metadata:

// FieldNotes/Photos/PhotoLibrarySaver.swift
#if os(iOS) || os(macOS) || os(visionOS)   // PhotoKit's library APIs aren't on watchOS
import Photos

enum PhotoLibrarySaver {
    static func save(_ photoData: Data) async throws {
        var status = PHPhotoLibrary.authorizationStatus(for: .addOnly)
        if status == .notDetermined {
            status = await PHPhotoLibrary.requestAuthorization(for: .addOnly)
        }
        guard status == .authorized || status == .limited else { return }

        try await PHPhotoLibrary.shared().performChanges {
            let request = PHAssetCreationRequest.forAsset()
            request.addResource(with: .photo, data: photoData, options: nil)
        }
    }
}
#endif

Capturing Photos with AVFoundation: CameraCaptureModel

The picker handles photos that already exist; the camera takes new ones without leaving the note. AVFoundation’s capture architecture has three parts. Inputs wrap a device (a camera or microphone) as an AVCaptureDeviceInput. Outputs turn the stream into something useful: AVCapturePhotoOutput for stills, AVCaptureMovieFileOutput for video files, AVCaptureVideoDataOutput for raw frames. An AVCaptureSession connects them, creating an AVCaptureConnection for each compatible input-output pair, and controls the flow with startRunning() and stopRunning(). A preview layer, AVCaptureVideoPreviewLayer, shows the live feed.

Figure: The Capture Pipeline

flowchart LR subgraph IN["Inputs"] CAM["AVCaptureDevice
builtInWideAngleCamera, back"] MIC["AVCaptureDevice
microphone"] DI["AVCaptureDeviceInput
(camera)"] MI["AVCaptureDeviceInput
(microphone)"] CAM --> DI MIC --> MI end S["AVCaptureSession
owned by CameraSessionActor
preset: photo
deferred start: automatic"] subgraph OUT["Outputs"] PL["AVCaptureVideoPreviewLayer
starts first, never deferred"] PO["AVCapturePhotoOutput
deferred start"] MO["AVCaptureMovieFileOutput
video mode only"] end DI --> S MI -. "video mode only" .-> S S -- "AVCaptureConnection" --> PL S -- "AVCaptureConnection" --> PO S -. "AVCaptureConnection" .-> MO PO -- "AVCapturePhoto
fileDataRepresentation()" --> D["Data
HEIF or JPEG"] D --> PR["PhotoRecord
SwiftData external storage"] D -. "optional, add-only access" .-> PH["PHPhotoLibrary"]

FieldNotes follows the structure of Apple’s AVCam sample, refreshed for Xcode 27. Configuring and starting a session are blocking calls, so all capture work lives in an actor, CameraSessionActor, that keeps it off the main thread. An @Observable @MainActor model, CameraCaptureModel, is what SwiftUI sees.

Authorization and Configuration

Camera access needs NSCameraUsageDescription (and NSMicrophoneUsageDescription for video with sound) in the target’s Info properties; without the key the app is terminated on first access.

<!-- FieldNotes/Info.plist (excerpt) -->
<key>NSCameraUsageDescription</key>
<string>FieldNotes uses the camera to attach photos to your notes.</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>FieldNotes can save the photos you take to your library.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>FieldNotes can show photos you took near a note.</string>

The actor owns the session, the photo output and a rotation coordinator:

// FieldNotes/Camera/CameraSessionActor.swift
#if os(iOS)
import AVFoundation

actor CameraSessionActor {
    enum CameraError: Error { case noCamera, cannotAddInput, cannotAddOutput, noPhotoData }

    // AVCaptureSession isn't Sendable. The main-actor preview only hands it to its preview layer, as AVCam's
    // preview source does; every configuration call happens inside this actor.
    nonisolated(unsafe) let session = AVCaptureSession()
    private let photoOutput = AVCapturePhotoOutput()
    private var rotationCoordinator: AVCaptureDevice.RotationCoordinator?
    private var inFlightCaptures: [Int64: NotePhotoCaptureDelegate] = [:]
    private var isConfigured = false

    var isAuthorized: Bool {
        get async {
            let status = AVCaptureDevice.authorizationStatus(for: .video)
            if status == .notDetermined {
                return await AVCaptureDevice.requestAccess(for: .video)   // shows the camera prompt once
            }
            return status == .authorized
        }
    }

    func configure() throws {
        guard !isConfigured else { return }
        session.beginConfiguration()
        defer { session.commitConfiguration() }

        guard let camera = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back) else {
            throw CameraError.noCamera
        }
        let input = try AVCaptureDeviceInput(device: camera)
        guard session.canAddInput(input) else { throw CameraError.cannotAddInput }
        session.addInput(input)

        session.sessionPreset = .photo
        // Deferred start (iOS 26+) shows the preview first and prepares the photo output a moment later.
        // Linking the 26 SDK turns it on by default; the assignments document the choice.
        session.automaticallyRunsDeferredStart = true
        photoOutput.isDeferredStartEnabled = photoOutput.isDeferredStartSupported
        photoOutput.maxPhotoQualityPrioritization = .quality
        guard session.canAddOutput(photoOutput) else { throw CameraError.cannotAddOutput }
        session.addOutput(photoOutput)

        rotationCoordinator = AVCaptureDevice.RotationCoordinator(device: camera, previewLayer: nil)
        addCameraControl(for: camera)
        isConfigured = true
    }

    func start() {
        guard !session.isRunning else { return }
        session.startRunning()
    }

    func stop() {
        guard session.isRunning else { return }
        session.stopRunning()
    }
}
#endif

The pieces, in the order AVCam uses them:

  • beginConfiguration()/commitConfiguration() batch changes so the session applies them atomically; the defer makes sure the commit happens even when a throw leaves early.

  • canAddInput(_:)/canAddOutput(_:) must be checked first; adding something the session can’t take raises an Objective-C exception, not a Swift error.

  • sessionPreset = .photo tunes the pipeline for high-resolution stills. Video mode would switch to .high and add an AVCaptureMovieFileOutput.

  • Deferred start (iOS 26 and later) splits start-up: the preview path starts first, and the photo output prepares after the first frame is on screen. Enabling it on an output that doesn’t support it throws, hence the isDeferredStartSupported check. Apple’s Building a responsive camera app that launches quickly sample, updated for Xcode 27, covers the rest, such as hiding capture controls until the session runs.

  • RotationCoordinator reports the angle that keeps photos level with the horizon, whatever way the device is held.

Capturing a Photo as an async Call

AVCapturePhotoOutput.capturePhoto(with:delegate:) reports progress to an AVCapturePhotoCaptureDelegate over several callbacks. AVCam wraps it in a checked throwing continuation so the caller simply awaits the photo. The delegate is a nonisolated class, because AVFoundation calls it on its own queue, not the main actor:

// FieldNotes/Camera/NotePhotoCaptureDelegate.swift
#if os(iOS)
import AVFoundation

nonisolated final class NotePhotoCaptureDelegate: NSObject, AVCapturePhotoCaptureDelegate {
    private let continuation: CheckedContinuation<Data, any Error>
    private var photoData: Data?

    init(continuation: CheckedContinuation<Data, any Error>) {
        self.continuation = continuation
    }

    func photoOutput(_ output: AVCapturePhotoOutput, didFinishProcessingPhoto photo: AVCapturePhoto,
                     error: (any Error)?) {
        if error == nil { photoData = photo.fileDataRepresentation() }
    }

    // Always the last callback for a capture: resume the continuation exactly once, here.
    func photoOutput(_ output: AVCapturePhotoOutput,
                     didFinishCaptureFor resolvedSettings: AVCaptureResolvedPhotoSettings, error: (any Error)?) {
        if let error {
            continuation.resume(throwing: error)
        } else if let photoData {
            continuation.resume(returning: photoData)
        } else {
            continuation.resume(throwing: CameraSessionActor.CameraError.noPhotoData)
        }
    }
}
#endif

// FieldNotes/Camera/CameraSessionActor.swift (continued; same file, so it can use the private properties)
#if os(iOS)
extension CameraSessionActor {
    func capturePhoto() async throws -> Data {
        let settings = AVCapturePhotoSettings()
        settings.photoQualityPrioritization = .balanced
        if let connection = photoOutput.connection(with: .video), let rotationCoordinator {
            connection.videoRotationAngle = rotationCoordinator.videoRotationAngleForHorizonLevelCapture
        }
        let id = settings.uniqueID
        defer { inFlightCaptures[id] = nil }
        return try await withCheckedThrowingContinuation { continuation in
            let delegate = NotePhotoCaptureDelegate(continuation: continuation)
            inFlightCaptures[id] = delegate   // keep the delegate alive until the capture finishes
            photoOutput.capturePhoto(with: settings, delegate: delegate)
        }
    }
}
#endif

didFinishCaptureFor is the one callback that always arrives last, whether the capture succeeded or not, so it is the safe place to resume. Resuming twice, or never, is a bug that CheckedContinuation reports at run time; see Async/Await and Tasks. photoOutput, rotationCoordinator and inFlightCaptures are private, so capturePhoto() sits in the actor’s own file. The continuation’s closure runs on the actor, so storing the delegate in inFlightCaptures needs no await.

The Observable Model and the Preview

CameraCaptureModel is the main-actor face of the camera. Views read its status, and it forwards every blocking call to the actor with await:

// FieldNotes/Camera/CameraCaptureModel.swift
#if os(iOS)
import AVFoundation
import Observation

@Observable
@MainActor
final class CameraCaptureModel {
    enum Status: Equatable { case idle, unauthorized, running, failed(String) }

    private(set) var status: Status = .idle
    private(set) var isCapturing = false
    @ObservationIgnored private let service = CameraSessionActor()

    /// The session, for the preview layer only.
    var session: AVCaptureSession { service.session }

    func start() async {
        guard await service.isAuthorized else { status = .unauthorized; return }
        do {
            try await service.configure()
            await service.start()
            status = .running
        } catch {
            status = .failed(error.localizedDescription)
        }
    }

    func stop() async {
        await service.stop()
        status = .idle
    }

    func capturePhoto() async -> Data? {
        isCapturing = true
        defer { isCapturing = false }
        do {
            return try await service.capturePhoto()
        } catch {
            status = .failed(error.localizedDescription)
            return nil
        }
    }
}
#endif

SwiftUI has no view for a capture preview, so, like AVCam, FieldNotes hosts an AVCaptureVideoPreviewLayer as the backing layer of a UIView and wraps it in UIViewRepresentable (the representable pattern is covered on UIKit and AppKit Interop):

// FieldNotes/Camera/CameraPreview.swift
#if os(iOS)
import SwiftUI
import AVFoundation

final class CameraPreviewUIView: UIView {
    override class var layerClass: AnyClass { AVCaptureVideoPreviewLayer.self }
    var previewLayer: AVCaptureVideoPreviewLayer { layer as! AVCaptureVideoPreviewLayer }
}

struct CameraPreview: UIViewRepresentable {
    let session: AVCaptureSession

    func makeUIView(context: Context) -> CameraPreviewUIView {
        let view = CameraPreviewUIView()
        view.previewLayer.session = session
        view.previewLayer.videoGravity = .resizeAspectFill
        return view
    }

    func updateUIView(_ uiView: CameraPreviewUIView, context: Context) {}
}
#endif

The capture screen combines the preview, a shutter button and the save path. It starts the session in .task, which also cancels cleanly if the screen closes, and stops it when the screen goes away:

// FieldNotes/Camera/CameraCaptureView.swift
#if os(iOS)
import SwiftUI
import FieldNotesModel
import FieldNotesServices

struct CameraCaptureView: View {
    @Environment(NoteStore.self) private var store
    @Environment(\.photoStore) private var photoStore
    @Environment(\.dismiss) private var dismiss
    @AppStorage("savePhotosToLibrary") private var savePhotosToLibrary = false
    @State private var camera = CameraCaptureModel()
    @State private var captureError: String?
    let noteID: Note.ID

    var body: some View {
        ZStack(alignment: .bottom) {
            CameraPreview(session: camera.session)
                .ignoresSafeArea()
            if camera.status == .unauthorized {
                ContentUnavailableView("Camera Access Off", systemImage: "camera.fill",
                                       description: Text("Allow camera access in Settings to take photos."))
            }
            Button("Take Photo", systemImage: "camera.circle.fill") {
                Task { await takePhoto() }
            }
            .labelStyle(.iconOnly)
            .font(.system(size: 64))
            .disabled(camera.status != .running || camera.isCapturing)
            .opacity(camera.status == .running ? 1 : 0)   // hidden until the session runs, as in AVCam
            .padding(.bottom, 32)
        }
        .overlay(alignment: .topLeading) {
            Button("Close", systemImage: "xmark") { dismiss() }
                .padding()
        }
        .task { await camera.start() }
        .onDisappear { Task { await camera.stop() } }
        .alert(captureError ?? "", isPresented: .constant(captureError != nil)) {
            Button("OK") { captureError = nil }
        }
    }

    private func takePhoto() async {
        guard let data = await camera.capturePhoto() else { return }
        let attachment = PhotoAttachment(id: UUID(), fileName: "\(UUID().uuidString).heic")
        do {
            try await store.attachPhoto(data, as: attachment, toNote: noteID, using: photoStore)
        } catch {
            captureError = "The photo couldn't be saved to this note."   // shown by the view's alert
            return
        }
        if savePhotosToLibrary { try? await PhotoLibrarySaver.save(data) }
    }
}
#endif

The note editor presents it full screen on iOS only, as announced on Navigation and Presentation. The editor already reads store and photoStore from its environment; passing them to the cover explicitly keeps CameraCaptureView independent of where it is presented from:

// FieldNotes/Views/NoteEditor.swift (excerpt)
#if os(iOS)
@Environment(NoteStore.self) private var store
@Environment(\.photoStore) private var photoStore
@State private var isShowingCamera = false

Button("Camera", systemImage: "camera") { isShowingCamera = true }
    .fullScreenCover(isPresented: $isShowingCamera) {
        CameraCaptureView(noteID: noteID)
            .environment(store)
            .environment(\.photoStore, photoStore)
    }
#endif

Running the camera needs a physical iPhone or iPad: the simulator has no camera, so AVCaptureDevice.default returns nil there and the view shows its failure state. A free Apple Account is enough to run it on your own device.

Camera Control, Interruptions and the Lock Screen

Three platform features round out a camera screen:

  • Camera Control. On iPhones with the Camera Control button, a session can add system controls such as AVCaptureSystemZoomSlider (iOS 18 and later). The session requires a controls delegate before it accepts any control.

  • Interruptions. A phone call, Split View on iPad or another app taking the camera interrupts the session. Observe AVCaptureSession.wasInterruptedNotification and interruptionEndedNotification to show a paused state instead of a frozen preview. On iPad, isMultitaskingCameraAccessSupported tells whether the camera can keep running in Split View or Stage Manager.

  • Lock Screen capture. The LockedCameraCapture framework lets a camera app launch from the Lock Screen, Control Center, the Action button or Camera Control, through a capture extension. AVCam shows how; FieldNotes instead offers a "New photo note" control, see Widgets, Complications and Controls.

// FieldNotes/Camera/CameraSessionActor+Controls.swift
#if os(iOS)
import AVFoundation

// Stateless, so it can be Sendable and shared from a static property.
nonisolated final class CameraControlsDelegate: NSObject, AVCaptureSessionControlsDelegate, Sendable {
    func sessionControlsDidBecomeActive(_ session: AVCaptureSession) {}
    func sessionControlsWillEnterFullscreenAppearance(_ session: AVCaptureSession) {}
    func sessionControlsWillExitFullscreenAppearance(_ session: AVCaptureSession) {}
    func sessionControlsDidBecomeInactive(_ session: AVCaptureSession) {}
}

extension CameraSessionActor {
    private static let controlsDelegate = CameraControlsDelegate()
    private static let controlsQueue = DispatchQueue(label: "com.example.fieldnotes.camera.controls")

    /// Called from `configure()`, inside the begin/commit configuration pair.
    func addCameraControl(for camera: AVCaptureDevice) {
        guard session.supportsControls else { return }
        session.setControlsDelegate(Self.controlsDelegate, queue: Self.controlsQueue)
        let zoom = AVCaptureSystemZoomSlider(device: camera)
        if session.canAddControl(zoom) { session.addControl(zoom) }
    }
}
#endif

Interruptions are handled in the capture view: two .task loops listen for the session’s notifications, and SwiftUI cancels both when the screen closes. On iPad, the actor also opts in to camera use while multitasking when the device supports it:

// FieldNotes/Camera/CameraCaptureView.swift (excerpt)
#if os(iOS)
@State private var isInterrupted = false

CameraPreview(session: camera.session)
    .overlay {
        if isInterrupted {
            ContentUnavailableView("Camera Paused", systemImage: "pause.circle")
        }
    }
    .task {
        for await _ in NotificationCenter.default.notifications(named: AVCaptureSession.wasInterruptedNotification,
                                                                 object: camera.session) {
            isInterrupted = true
        }
    }
    .task {
        for await _ in NotificationCenter.default.notifications(named: AVCaptureSession.interruptionEndedNotification,
                                                                 object: camera.session) {
            isInterrupted = false
        }
    }

// FieldNotes/Camera/CameraSessionActor.swift, inside configure()
if session.isMultitaskingCameraAccessSupported {
    session.isMultitaskingCameraAccessEnabled = true   // keep the camera running in Split View and Slide Over
}
#endif

On macOS, the same AVCaptureSession API captures from built-in, external and Continuity cameras; the preview is hosted with NSViewRepresentable instead. On visionOS, camera access through AVFoundation is limited to the Persona front camera for most apps, and main-camera access is an enterprise entitlement; FieldNotes uses the photo picker there. watchOS has no camera API.

Playing Video: VideoPlayer and AVPlayerViewController

AVKit provides the standard playback interface. In SwiftUI it is VideoPlayer, which wraps an AVPlayer and shows the system transport controls on every platform. An overlay closure can place views over the video (not over the controls):

// FieldNotes/Media/AttachmentVideoPlayer.swift
import SwiftUI
import AVKit

struct AttachmentVideoPlayer: View {
    let url: URL
    let caption: String
    @State private var player: AVPlayer?

    var body: some View {
        VideoPlayer(player: player) {
            Text(caption)
                .font(.footnote)
                .padding(8)
                .background(.thinMaterial, in: .capsule)
                .padding()
                .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
        }
        .aspectRatio(16 / 9, contentMode: .fit)
        .task(id: url) { player = AVPlayer(url: url) }
        .onDisappear { player?.pause() }
    }
}

VideoPlayer covers most needs. For Picture in Picture, custom info panels on tvOS or the full visionOS immersive viewing experience, wrap AVPlayerViewController, which is available on iOS, iPadOS, tvOS and visionOS but not on macOS or watchOS (the Mac uses AVPlayerView in AppKit):

// FieldNotes/Media/SystemVideoPlayer.swift
#if os(iOS) || os(visionOS)
import SwiftUI
import AVKit

struct SystemVideoPlayer: UIViewControllerRepresentable {
    let player: AVPlayer

    func makeUIViewController(context: Context) -> AVPlayerViewController {
        let controller = AVPlayerViewController()
        controller.player = player
        controller.allowsPictureInPicturePlayback = true
        controller.canStartPictureInPictureAutomaticallyFromInline = true
        return controller
    }

    func updateUIViewController(_ controller: AVPlayerViewController, context: Context) {
        controller.player = player
    }
}
#endif

Picture in Picture also needs the audio session set to .playback (below) and the Audio, AirPlay and Picture in Picture background mode, set in the target’s Signing & Capabilities tab; background modes are covered on Background Execution.

Audio Sessions

On iOS, iPadOS, watchOS, tvOS and visionOS, every app shares the device’s audio hardware with the rest of the system through AVAudioSession. The app states its intent with a category, and the system decides mixing, ducking, routing and whether the silent switch mutes it. macOS has no AVAudioSession; apps there simply play.

Category Behavior FieldNotes use

.ambient

Mixes with other audio; silenced by the Ring/Silent switch and the screen lock.

UI sound effects

.soloAmbient (default)

Stops other audio; silenced by the switch and lock.

Not used

.playback

Plays with the screen locked and the switch on silent; interrupts others unless mixing is set.

Field recording and video playback

.record

Records only; silences playback.

Not used

.playAndRecord

Records and plays at once.

Recording a voice memo while listening

// FieldNotes/Media/AudioSessionConfigurator.swift
#if os(iOS) || os(watchOS) || os(visionOS)
import AVFAudio

enum AudioSessionConfigurator {
    static func activateForPlayback() throws {
        let session = AVAudioSession.sharedInstance()
        try session.setCategory(.playback, mode: .spokenAudio)
        try session.setActive(true)
    }

    static func activateForRecording() throws {
        let session = AVAudioSession.sharedInstance()
        try session.setCategory(.playAndRecord, mode: .default, options: [.defaultToSpeaker, .allowBluetoothHFP])
        try session.setActive(true)
    }

    /// Lets music or a podcast that FieldNotes interrupted resume.
    static func deactivate() throws {
        try AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
    }
}
#endif

Two notifications complete the picture: AVAudioSession.interruptionNotification (a call or alarm took the audio; pause, then resume if the shouldResume option is set) and routeChangeNotification (headphones were unplugged; pause, as Apple’s guidelines ask). Recording also needs NSMicrophoneUsageDescription. A capture session configures the app’s audio session by itself unless automaticallyConfiguresApplicationAudioSession is turned off.

For apps that stream microphone audio to a live conversational agent, see ElevenLabs Agents for the vendor SDK route and Voice in the Browser for the web-client counterpart.

Now Playing (New in 27)

When FieldNotes plays a field recording (birdsong at the trailhead, say), the user expects to pause it from the Lock Screen, Control Center, headphones or a car. The Now Playing framework, new in iOS, iPadOS, macOS, watchOS, tvOS and visionOS 27, publishes that session to the system from an @Observable model. The app conforms a type to MediaSessionRepresentable, which provides four things: an id, the content (metadata), a playbackSnapshot (state and position) and the commands it supports. The framework observes the model and updates the system whenever those properties change, with no manual dictionary pushing.

// FieldNotes/Media/FieldRecordingPlayer.swift
import AVFoundation
import NowPlaying
import Observation

@available(iOS 27, macOS 27, watchOS 27, visionOS 27, *)
@Observable
@MainActor
final class FieldRecordingPlayer: MediaSessionRepresentable {
    let id = UUID().uuidString
    var recordingTitle: String
    var noteTitle: String
    private(set) var isPlaying = false
    private(set) var elapsedTime: TimeInterval = 0
    private(set) var timestamp = Date.now
    @ObservationIgnored private let player: AVPlayer
    @ObservationIgnored private let duration: TimeInterval
    @ObservationIgnored private var session: MediaSession<FieldRecordingPlayer>?

    init(url: URL, recordingTitle: String, noteTitle: String, duration: TimeInterval) {
        self.player = AVPlayer(url: url)
        self.recordingTitle = recordingTitle
        self.noteTitle = noteTitle
        self.duration = duration
    }

    // What the Lock Screen shows.
    var content: (any MediaContentRepresentable)? {
        GenericContent(id: id, title: recordingTitle, subtitle: noteTitle,
                       type: .audio, duration: .finite(duration), artwork: nil)
    }

    // State plus a timestamp, so the system can extrapolate the position between updates.
    var playbackSnapshot: MediaPlaybackSnapshot? {
        MediaPlaybackSnapshot(state: isPlaying ? .playing() : .paused,
                              elapsedTime: elapsedTime, timestamp: timestamp)
    }

    // The controls the system offers.
    var commands: [MediaCommand] {
        [
            .play { self.play() },
            .pause { self.pause() },
            .seekToPosition { position in self.seek(to: position) },
        ]
    }

    func activate() async throws {
        #if os(iOS) || os(watchOS) || os(visionOS)
        try AudioSessionConfigurator.activateForPlayback()   // macOS has no AVAudioSession
        #endif
        let session = MediaSession(self)
        self.session = session
        try await session.requestToBecomeSystemPrimary()   // must be called while in the foreground
        play()
    }

    func deactivate() {
        pause()
        session = nil   // removes it from the Lock Screen and Control Center
    }

    func play() { player.play(); isPlaying = true; markTime() }
    func pause() { player.pause(); isPlaying = false; markTime() }

    func seek(to position: TimeInterval) {
        player.seek(to: CMTime(seconds: position, preferredTimescale: 600))
        elapsedTime = position
        timestamp = .now
    }

    private func markTime() {
        elapsedTime = player.currentTime().seconds
        timestamp = .now
    }
}

The rules are simple. requestToBecomeApplicationPrimary() makes a session the app’s main one; requestToBecomeSystemPrimary() also puts it on the Lock Screen and in Control Center, and works only while the app is in the foreground. Setting the session reference to nil removes it. Commands are opt-in: add .next, .previous, .skipForward(preferredIntervals:_:) and others only when they make sense, and use .enabled(_:) to grey one out. Content types other than GenericContent (MusicContent, PodcastContent, MovieContent, BookContent and more) unlock richer system layouts.

Because the deployment minimum is 26, the call site checks availability and falls back to MediaPlayer’s MPNowPlayingInfoCenter and MPRemoteCommandCenter, the pre-27 way of doing the same thing:

// FieldNotes/Media/RecordingPlaybackController.swift
import AVFoundation
import MediaPlayer
import Observation
import FieldNotesModel

@Observable
@MainActor
final class RecordingPlaybackController {
    @ObservationIgnored private var nowPlayingPlayer: AnyObject?   // a FieldRecordingPlayer on 27
    @ObservationIgnored private var legacyPlayer: AVPlayer?

    func play(_ recording: URL, title: String, in note: Note, duration: TimeInterval) async {
        if #available(iOS 27, macOS 27, watchOS 27, visionOS 27, *) {
            let player = FieldRecordingPlayer(url: recording, recordingTitle: title,
                                              noteTitle: note.title, duration: duration)
            try? await player.activate()
            nowPlayingPlayer = player
        } else {
            let player = AVPlayer(url: recording)
            legacyPlayer = player
            MPNowPlayingInfoCenter.default().nowPlayingInfo = [
                MPMediaItemPropertyTitle: title,
                MPMediaItemPropertyArtist: note.title,
                MPMediaItemPropertyPlaybackDuration: duration,
                MPNowPlayingInfoPropertyElapsedPlaybackTime: 0.0,
                MPNowPlayingInfoPropertyPlaybackRate: 1.0,
            ]
            let commands = MPRemoteCommandCenter.shared()
            commands.playCommand.addTarget { _ in player.play(); return .success }
            commands.pauseCommand.addTarget { _ in player.pause(); return .success }
            player.play()
        }
    }
}

Before 27 the app has to keep nowPlayingInfo up to date itself (elapsed time and rate on every play, pause and seek), which is exactly the bookkeeping the Now Playing framework’s observation removes. Apps that publish sessions for media playing on another device (a speaker, say) use the framework’s remote sessions, RemoteMediaSession, instead.

Augmented Reality on iOS

The camera is also the input for augmented reality. On iPhone and iPad, ARKit’s ARSession fuses the camera with motion data to track the device in the world, and RealityKit’s RealityView renders 3D content over the camera feed when its camera is set to .spatialTracking (iOS 18 and later). A minimal check and view look like this:

// FieldNotes/AR/ARCapabilityCheck.swift
#if os(iOS)
import SwiftUI
import ARKit
import RealityKit

struct ARPreviewLauncher: View {
    var body: some View {
        if ARWorldTrackingConfiguration.isSupported {
            RealityView { content in
                content.camera = .spatialTracking   // ARKit world tracking under the hood
                let marker = ModelEntity(mesh: .generateSphere(radius: 0.03),
                                         materials: [SimpleMaterial(color: .orange, isMetallic: false)])
                let anchor = AnchorEntity(.plane(.horizontal, classification: .any, minimumBounds: [0.2, 0.2]))
                anchor.addChild(marker)
                content.add(anchor)
            }
            .ignoresSafeArea()
        } else {
            ContentUnavailableView("AR Not Supported", systemImage: "arkit")
        }
    }
}
#endif

AR needs the camera prompt too, and a physical device with an A12 chip or later. The full AR feature of FieldNotes, pinning notes to places with FieldNotesARView, NotePinComponent and NotePinSystem, is on Augmented Reality with ARKit and RealityKit. On Apple Vision Pro, ARKit works differently (ARKitSession with data providers, inside an immersive space) and is covered on visionOS: Windows, Volumes, Immersive Spaces and RealityKit.

Requirements and Platform Availability

What each feature on this page needs before it runs:

  • PhotosPicker, AttachmentThumbnail, VideoPlayer, Now Playing: nothing beyond the SDK; they run in the simulator.

  • PhotoKit reads: NSPhotoLibraryUsageDescription; saving to Photos: NSPhotoLibraryAddUsageDescription.

  • Camera capture: NSCameraUsageDescription and a physical iPhone or iPad (a free Apple Account is enough to run on your own device).

  • Recording audio: NSMicrophoneUsageDescription; Picture in Picture and background audio: the Audio, AirPlay and Picture in Picture background mode.

  • AR: NSCameraUsageDescription and a physical device with an A12 chip or later.

Platform availability

API or feature iOS iPadOS macOS watchOS visionOS

PhotosPicker (PhotosUI)

Yes

Yes

Yes

Yes

Yes

.photosPickerStyle(.inline)

Yes

Yes

Yes

Not available

Not available

PhotoKit PHPhotoLibrary, PHAsset

Yes

Yes

Yes

Not available

Yes

PHCachingImageManager

Yes

Yes

Yes

Not available

Yes

AVCaptureSession

Yes

Yes

Yes

Not available

Limited (Persona camera; main camera for enterprise)

AVCapturePhotoOutput

Yes

Yes

Yes

Not available

Not available

Deferred start (isDeferredStartEnabled)

New in 26

New in 26

New in 26

Not available

Not available

Camera Control (AVCaptureSystemZoomSlider)

Yes (18+, devices with the button)

Not available

Not available

Not available

Not available

ImageIO downsampling

Yes

Yes

Yes

Yes

Yes

VideoPlayer

Yes

Yes

Yes

Yes

Yes

AVPlayerViewController

Yes

Yes

Not available (AVPlayerView)

Not available

Yes

AVAudioSession

Yes

Yes

Not available

Yes

Yes

Now Playing (MediaSession)

New in 27

New in 27

New in 27

New in 27

New in 27

MPNowPlayingInfoCenter

Yes

Yes

Yes

Yes

Yes

ARKit ARSession, ARWorldTrackingConfiguration

Yes

Yes

Not available

Not available

Not available (ARKitSession instead)

RealityView with .spatialTracking camera

Yes

Yes

Not available

Not available

Not available (immersive spaces instead)

For image loading libraries, HLS playback, cover frames and compressing and uploading captured media, see Media Optimization in Apps.

References