Supporting device rotation in your camera app
At a glance
| Item | Summary |
|---|---|
| Purpose | Keep the camera preview and captured photos and movies upright by applying the angles that AVCaptureDevice.RotationCoordinator reports. |
| App architecture | One Xcode project (AVCam.xcodeproj) with three targets. A SwiftUI app and a Locked Camera Capture extension each create a @MainActor CameraModel. The model owns a CaptureService actor that owns the AVCaptureSession, the output wrappers, and the rotation coordinator. |
| Main patterns | Actor isolation (with a custom serial executor), protocol-oriented abstraction (Camera, OutputService, PreviewSource/PreviewTarget), delegate callbacks bridged to async, and @Published state consumed as async sequences. |
| Project style | Swift sources grouped by role: Capture/, Model/, Views/, Support/, Preview Content/. CaptureService, CameraModel, CameraView, and the app entry sit at the AVCam/ root. |
| Execution model | CameraModel and the views are @MainActor. CaptureService is an actor whose unownedExecutor is a DispatchSerialQueue named sessionQueue. Preview-rotation writes hop back with Task { @MainActor in ... }. Capture-rotation writes stay on the actor. |
| State/event model | Key-value observation of the rotation coordinator, @Published properties read through .values async sequences, AsyncStream wrappers around KVO and Photos callbacks, and @Observable model state read by SwiftUI. |
| Key frameworks/packages | AVFoundation, SwiftUI, Combine, Photos, LockedCameraCapture, AppIntents, WidgetKit. The project declares no Swift package. |
Platform boundary: the DocC record lists iOS, iPadOS, and Mac Catalyst. In the downloaded project, every target sets SUPPORTS_MACCATALYST = NO and SDKROOT = iphoneos (AVCam.xcodeproj/project.pbxproj:945), so the source shows only an iPhone and iPad build. The project also sets an iOS 26.0 deployment target (AVCam.xcodeproj/project.pbxproj:936), while the DocC record lists 27.0.
Project structure
AVCam.xcodeproj/ (3 targets, no Swift packages)
AVCam/ app target sources (also compiled into the capture extension)
├── AVCamApp.swift @main App, creates CameraModel
├── CameraModel.swift @MainActor @Observable model
├── CaptureService.swift actor; session, devices, rotation coordinator
├── CameraView.swift root view, generic over Camera
├── Capture/
│ ├── PhotoCapture.swift OutputService for AVCapturePhotoOutput
│ ├── MovieCapture.swift OutputService for AVCaptureMovieFileOutput
│ ├── DeviceLookup.swift discovery sessions
│ └── SPCObserver.swift SystemPreferredCameraObserver (KVO -> AsyncStream)
├── Model/
│ ├── Camera.swift @MainActor protocol for the view model
│ ├── CameraState.swift persisted state shared with the extension
│ ├── DataTypes.swift enums, value types, OutputService protocol
│ ├── MediaLibrary.swift actor; Photos saving and thumbnails
│ └── Intent/AVCamCaptureIntent.swift
├── Views/ CameraPreview, PreviewContainer, CameraUI, toolbars, overlays
├── Support/ extensions
└── Preview Content/PreviewCameraModel.swift
AVCamCaptureExtension/ LockedCameraCaptureExtension entry
AVCamControlCenterExtension/ ControlWidget bundle
Structure observations
- The project has three native targets:
AVCam(application),AVCamCaptureExtension(ExtensionKit extension), andAVCamControlCenterExtension(app extension) (AVCam.xcodeproj/project.pbxproj:445). - The capture extension’s Sources phase compiles the shared app files. This includes
CaptureService.swiftandCameraModel.swiftbut notAVCamApp.swift(AVCam.xcodeproj/project.pbxproj:525,AVCam.xcodeproj/project.pbxproj:542). That is howAVCamCaptureExtensioncan build its ownCameraModel(AVCamCaptureExtension/AVCamCaptureExtension.swift:16). - All rotation logic is in one
// MARK: - Rotation handlingsection ofCaptureService(AVCam/CaptureService.swift:365). One protocol-extension method inDataTypes.swiftapplies the capture angle to each output (AVCam/Model/DataTypes.swift:157). No view type computes angles. - The interface-orientation settings differ by device family: iPhone supports only portrait, and iPad supports all four orientations (
AVCam.xcodeproj/project.pbxproj:933,AVCam.xcodeproj/project.pbxproj:934). The view layer also defines its aspect ratios in portrait terms (AVCam/Views/PreviewContainer.swift:10). - This snapshot also includes AVCam features unrelated to rotation: a photo-dimension picker, photo-output readiness tracking, and a menu that toggles deferred processing, responsive capture, and fast capture (
AVCam/CameraModel.swift:135,AVCam/Capture/PhotoCapture.swift:169). This document mentions them only where they affect ownership or concurrency.
Overall architecture
flowchart LR
App["AVCamApp / AVCamCaptureExtension"] --> Model["CameraModel (@MainActor)"]
App --> View["CameraView and CameraUI"]
View --> Model
View --> Preview["CameraPreview / PreviewView (preview layer)"]
Model --> Service["CaptureService actor (sessionQueue executor)"]
Model --> Library["MediaLibrary actor"]
Service --> Session["AVCaptureSession"]
Service --> Rotation["RotationCoordinator + KVO observers"]
Service --> Outputs["PhotoCapture / MovieCapture (OutputService)"]
Service -. "PreviewSource" .-> Preview
Reference code
AVCam/CameraModel.swift:20 — CameraModel
@MainActor
@Observable
final class CameraModel: Camera {
// ...
/// An object that provides the connection between the capture session and the video preview layer.
var previewSource: PreviewSource { captureService.previewSource }
// ...
/// An object that saves captured media to a person's Photos library.
private let mediaLibrary = MediaLibrary()
/// An object that manages the app's capture functionality.
private let captureService = CaptureService()
// ...
}Interpretation
Observed: the dependencies point one way. Views know only the Camera protocol and a PreviewSource. CameraModel privately owns CaptureService and MediaLibrary. CaptureService owns every AVFoundation object, including the rotation coordinator. The only session-related value that reaches the UI is previewSource, which CameraModel forwards from the actor’s nonisolated let (AVCam/CaptureService.swift:30).
Inference: keeping rotation inside the actor means the coordinator, the observers, and output-connection writes all live in the same place as the session reconfiguration that replaces them (changeCaptureDevice(to:)). The view layer never needs to know about device orientation.
Ownership and state
classDiagram
AVCamApp *-- CameraModel : @State camera
AVCamCaptureExtension *-- CameraModel : @State camera
CameraModel *-- CaptureService : private let
CameraModel *-- MediaLibrary : private let
CameraModel *-- CameraState : private var
CaptureService *-- AVCaptureSession : private let
CaptureService *-- PhotoCapture : private let
CaptureService *-- MovieCapture : private let
CaptureService *-- RotationCoordinator : private var, replaced per device
CaptureService *-- DefaultPreviewSource : nonisolated let
CameraView o-- CameraModel : receives
CameraPreview o-- DefaultPreviewSource : receives as PreviewSource
CameraPreview *-- PreviewView : makeUIView
Ownership evidence
AVCam/CaptureService.swift:30 — CaptureService stored properties
actor CaptureService {
// ...
/// A type that connects a preview destination with the capture session.
nonisolated let previewSource: PreviewSource
// The app's capture session.
private let captureSession = AVCaptureSession()
// An object that manages the app's photo capture behavior.
private let photoCapture = PhotoCapture()
// An object that manages the app's video capture behavior.
private let movieCapture = MovieCapture()
// An internal collection of output services.
private var outputServices: [any OutputService] { [photoCapture, movieCapture] }
// ...
// An object that monitors video device rotations.
private var rotationCoordinator: AVCaptureDevice.RotationCoordinator!
private var rotationObservers = [AnyObject]()
// ...
}| Owner | Object or state | Relationship | Mutation authority |
|---|---|---|---|
AVCamApp |
CameraModel (camera) |
Creates it in @State private var (AVCam/AVCamApp.swift:16) |
CameraModel changes its own state; views call its methods |
AVCamCaptureExtension |
A separate CameraModel |
Creates it in @State private var (AVCamCaptureExtension/AVCamCaptureExtension.swift:16) |
Same as above, inside the extension process |
CameraModel |
CaptureService, MediaLibrary |
Creates and retains them in private let (AVCam/CameraModel.swift:64, AVCam/CameraModel.swift:67) |
Each actor changes its own isolated state |
CameraModel |
status, captureActivity, thumbnail, isSwitchingVideoDevices, … |
private(set) observable properties (AVCam/CameraModel.swift:25) |
Only CameraModel, on the main actor |
CaptureService |
AVCaptureSession, PhotoCapture, MovieCapture, DeviceLookup |
Creates them in private let |
Only the actor (its sessionQueue executor) |
CaptureService |
rotationCoordinator, rotationObservers |
private var, replaced on every call to createRotationCoordinator(for:) (AVCam/CaptureService.swift:370) |
Only the actor |
CaptureService |
Preview connection videoRotationAngle |
Reads the connection on the actor, then writes it inside Task { @MainActor in } (AVCam/CaptureService.swift:399) |
Main actor |
PhotoCapture / MovieCapture |
Output connection videoRotationAngle |
Written by the OutputService extension (AVCam/Model/DataTypes.swift:159) that CaptureService calls |
Whatever context calls it; here that is the CaptureService actor |
CameraView and child views |
camera reference |
Store the passed model in @State var camera (AVCam/CameraView.swift:18) |
They only read it or call its methods |
Observed: CameraView stores the model in a @State property but does not create it. The object still comes from the app or extension entry point, so the view’s property is a shared reference, not a second owner. PreviewView uses AVCaptureVideoPreviewLayer as its backing layer class (AVCam/Views/CameraPreview.swift:54), so the UIKit view owns the layer, and the capture session is attached later through PreviewTarget.setSession(_:).
Concurrency, scheduling, and thread safety
Evidence limit: actor isolation, async/await, or Task creation does not by itself prove background-thread execution; Sendable conformance alone does not prove thread-safe mutation.
| Concern | Source mechanism | Verified placement or handoff | Evidence |
|---|---|---|---|
| Main-actor isolation | @MainActor on CameraModel, the Camera protocol, and the views |
All model state that SwiftUI reads is main-actor isolated | AVCam/CameraModel.swift:20, AVCam/Model/Camera.swift:15, AVCam/CameraView.swift:12 |
| Actor executor | actor CaptureService with nonisolated var unownedExecutor returning sessionQueue.asUnownedSerialExecutor() |
Actor-isolated work, including captureSession.startRunning() and every rotation update, runs as jobs on the serial dispatch queue sessionQueue. A queue is not a fixed thread. |
AVCam/CaptureService.swift:70, AVCam/CaptureService.swift:73 |
| Same queue for framework callbacks | setControlsDelegate(_:queue: sessionQueue) and setActionQueue(sessionQueue) |
Capture-control delegate callbacks and lens-slider actions are delivered on the queue that also serves as the actor’s executor | AVCam/CaptureService.swift:218, AVCam/CaptureService.swift:237 |
| KVO callback re-entry | rotationCoordinator.observe(...) { [weak self] ... Task { await self.update...(angle) } } |
The KVO closure is not actor-isolated, so it starts a task and uses await to enter CaptureService. The observation closure weakly captures the actor. |
AVCam/CaptureService.swift:381, AVCam/CaptureService.swift:384, AVCam/CaptureService.swift:392 |
| Preview hop to main actor | Task { @MainActor in connection?.videoRotationAngle = angle } |
The actor reads videoPreviewLayer.connection, then writes the preview angle on the main actor |
AVCam/CaptureService.swift:398, AVCam/CaptureService.swift:399 |
| Capture angle on the actor | outputServices.forEach { $0.setVideoRotationAngle(angle) } |
Photo and movie output connections are updated synchronously inside the actor, with no hop | AVCam/CaptureService.swift:407 |
Plain Task {} inheriting the actor |
Unretained tasks in monitorSystemPreferredCamera() and observeNotifications() |
Each body calls isolated members without await (changeCaptureDevice(to:), isInterrupted = ...), which shows it inherits CaptureService isolation. No property stores these tasks, so nothing cancels them. |
AVCam/CaptureService.swift:353, AVCam/CaptureService.swift:359, AVCam/CaptureService.swift:628 |
| Retained, cancellable task | subjectAreaChangeTask?.cancel(), then a new Task |
The only actor task that is stored and cancelled. It is replaced whenever the device changes. | AVCam/CaptureService.swift:437, AVCam/CaptureService.swift:446 |
| Retained KVO tokens | rotationObservers.removeAll() with the comment “Cancel previous observations.” |
Clearing the array releases the previous coordinator’s observation tokens before new ones are added | AVCam/CaptureService.swift:377 |
Plain Task {} inheriting the main actor |
CameraModel property didSet handlers and observeState() |
Each task mutates main-actor state directly and awaits actor members. The four for await loops in observeState() are not stored, so nothing cancels them. |
AVCam/CameraModel.swift:114, AVCam/CameraModel.swift:250 |
| Caller-isolation inheritance | monitorProgress(of:isolation: isolated (any Actor)? = #isolation) with _ = isolation in the Task |
The progress task captures the caller’s isolation (here the CaptureService actor, which calls PhotoCapture) rather than running nonisolated |
AVCam/Capture/PhotoCapture.swift:111, AVCam/Capture/PhotoCapture.swift:113 |
| Main run-loop timer | Timer.publish(every:on: .main, in: .common), stored in timerCancellable |
Recording-duration ticks are scheduled on the main run loop. stopMonitoringDuration() cancels the stored cancellable. |
AVCam/Capture/MovieCapture.swift:97, AVCam/Capture/MovieCapture.swift:109 |
| SwiftUI-managed task | .task { await camera.start() } |
SwiftUI ties the start-up task to the view’s lifetime. The app’s scenePhase handler separately creates Task { @MainActor in await camera.syncState() }. |
AVCam/AVCamApp.swift:25, AVCam/AVCamApp.swift:33 |
| Preview attachment | nonisolated func setSession(_:) creating Task { @MainActor in previewLayer.session = session } |
The preview layer’s session is assigned on the main actor | AVCam/Views/CameraPreview.swift:62, AVCam/Views/CameraPreview.swift:65 |
| Compiler checking level | SWIFT_STRICT_CONCURRENCY = minimal, SWIFT_VERSION = 5.0 |
The app target builds in Swift 5 mode with minimal concurrency checking. Combined with @preconcurrency import AVFoundation, many of these crossings are not fully checked by the compiler. |
AVCam.xcodeproj/project.pbxproj:949, AVCam/CaptureService.swift:9 |
Reference code
AVCam/CaptureService.swift:70 — the actor’s custom serial executor
actor CaptureService {
// ...
// A serial dispatch queue to use for capture control actions.
private let sessionQueue = DispatchSerialQueue(label: "com.example.apple-samplecode.AVCam.sessionQueue")
// Sets the session queue as the actor's executor.
nonisolated var unownedExecutor: UnownedSerialExecutor {
sessionQueue.asUnownedSerialExecutor()
}
// ...
}Interpretation: the custom executor is the specific source evidence that moves capture work off the main actor. actor alone would not establish that. The README states the goal directly: the actor exists “to ensure that AVFoundation capture API calls don’t occur on the main thread” (README.md:13). Because AVFoundation callbacks for controls use the same queue, those callbacks and actor jobs are serialized by one queue. The rotation KVO callbacks do not use that queue, which is why they re-enter through Task { await ... }.
Not established by source: which thread delivers the rotation coordinator’s KVO notifications, and whether MovieCapture.captureActivity, which the main-run-loop timer writes, is protected against the actor’s other accesses. The source shows no lock for that property.
State propagation, frameworks, and dependencies
Evidence limit: an import proves a source-level compilation dependency at the cited line; it does not prove runtime use, architectural adoption, or whether a Swift package is a direct application dependency.
| Category | Mechanism or module | Verified role | Evidence |
|---|---|---|---|
| Rotation signal | KVO on videoRotationAngleForHorizonLevelPreview and videoRotationAngleForHorizonLevelCapture |
The coordinator’s two angle properties are the only rotation inputs. The app reads no device or interface orientation. | AVCam/CaptureService.swift:381, AVCam/CaptureService.swift:389 |
| Actor-to-model state | @Published private(set) on the actor, read through await captureService.$captureActivity.values |
CameraModel loops over async sequences built from actor publishers |
AVCam/CaptureService.swift:17, AVCam/CameraModel.swift:259 |
| Output-to-actor state | Publishers.Merge(...).assign(to: &$captureActivity) |
Photo and movie activity publishers feed the actor’s own published property | AVCam/CaptureService.swift:609 |
| Callback to stream | AsyncStream.makeStream in SystemPreferredCameraObserver, MediaLibrary, and PhotoCaptureDelegate |
KVO, PHImageManager callbacks, and photo-delegate events become async sequences |
AVCam/Capture/SPCObserver.swift:18, AVCam/Model/MediaLibrary.swift:27, AVCam/Capture/PhotoCapture.swift:220 |
| Model-to-view state | @Observable CameraModel |
SwiftUI views read model properties directly | AVCam/CameraModel.swift:21 |
| Persisted state | CameraState property observers call save(), which starts a Task that updates the capture intent’s app context |
Shares capture mode and feature toggles with the extension | AVCam/Model/CameraState.swift:34 |
| Framework import | @preconcurrency import AVFoundation, import Combine |
Capture APIs, plus @Published, Publishers.Merge, and .values |
AVCam/CaptureService.swift:9, AVCam/CaptureService.swift:10 |
| Framework import | Photos, UIKit |
Library saving and thumbnail creation | AVCam/Model/MediaLibrary.swift:9 |
| Framework import | LockedCameraCapture, SwiftUI |
Capture-extension entry point | AVCamCaptureExtension/AVCamCaptureExtension.swift:9 |
| Package declaration | None | The project file declares no Swift package dependency | AVCam.xcodeproj/project.pbxproj:445 |
Combine scheduling: the source never calls receive(on:) or subscribe(on:). Publisher values reach CameraModel through .values async sequences inside main-actor tasks. Observed: this is the only path by which actor-owned publishers update model state.
Class and protocol design
classDiagram
class Camera {
<<protocol>>
}
class OutputService {
<<protocol>>
setVideoRotationAngle(angle)
}
class PreviewSource {
<<protocol>>
}
class PreviewTarget {
<<protocol>>
}
CameraModel ..|> Camera
PreviewCameraModel ..|> Camera
PhotoCapture ..|> OutputService
MovieCapture ..|> OutputService
DefaultPreviewSource ..|> PreviewSource
PreviewView ..|> PreviewTarget
CaptureService --> OutputService : rotates all outputs
CameraView --> Camera : generic constraint
Reference code
AVCam/Model/DataTypes.swift:147 — OutputService and its default rotation method
protocol OutputService {
associatedtype Output: AVCaptureOutput
var output: Output { get }
var captureActivity: CaptureActivity { get }
var capabilities: CaptureCapabilities { get }
func updateConfiguration(for device: AVCaptureDevice)
func setVideoRotationAngle(_ angle: CGFloat)
}
extension OutputService {
func setVideoRotationAngle(_ angle: CGFloat) {
// Set the rotation angle on the output object's video connection.
output.connection(with: .video)?.videoRotationAngle = angle
}
func updateConfiguration(for device: AVCaptureDevice) {}
}| Type | Responsibility | Depends on |
|---|---|---|
CaptureService (actor) |
Session configuration, device switching, rotation coordinator lifetime, angle fan-out | AVCaptureSession, OutputService conformers, DeviceLookup, SystemPreferredCameraObserver |
CameraModel (@MainActor class) |
Turns UI intents into actor calls; mirrors actor state for SwiftUI | CaptureService, MediaLibrary, CameraState |
Camera (protocol) |
The view-facing model contract; lets previews use PreviewCameraModel |
Domain value types |
PhotoCapture, MovieCapture |
Own one AVCaptureOutput each; adapt delegate callbacks to async |
OutputService |
PreviewSource / PreviewTarget |
Connect a session to a preview view without exposing the session to view code | AVCaptureSession |
MediaLibrary (actor) |
Saves photos and movies to Photos; streams thumbnails | Photos |
Observed: OutputService declares setVideoRotationAngle(_:) as a requirement and provides it in an extension, so both outputs inherit one implementation. CaptureService rotates through [any OutputService] (AVCam/CaptureService.swift:42), so adding another output to that array would include it in rotation updates. Views are generic over CameraModel: Camera (AVCam/CameraView.swift:13), and PreviewCameraModel conforms to Camera (AVCam/Preview Content/PreviewCameraModel.swift:13).
Access control
| Symbol | Access | Verified effect | Likely rationale |
|---|---|---|---|
CameraModel.captureService (AVCam/CameraModel.swift:67) |
private |
Views cannot reach the actor. They see only the forwarded previewSource. |
Inference: keeps capture operations behind the model’s API. |
CameraModel.status and siblings (AVCam/CameraModel.swift:25) |
private(set) |
Readable module-wide; writable only inside CameraModel |
Inference: views observe status but cannot fake it. |
CaptureService.captureActivity (AVCam/CaptureService.swift:17) |
@Published private(set) |
Other types can read the projected publisher but cannot assign the value | Inference: the actor stays the single writer. |
CaptureService.previewSource (AVCam/CaptureService.swift:30) |
implicit internal, nonisolated let |
Readable synchronously from the main actor without await |
Inference: lets CameraPreview connect the layer during view construction. |
rotationCoordinator, rotationObservers, createRotationCoordinator(for:), updatePreviewRotation(_:) (AVCam/CaptureService.swift:57, AVCam/CaptureService.swift:368) |
private |
Rotation can be changed only from inside CaptureService |
Inference: rotation follows the session and device lifecycle, not UI events. |
PhotoCaptureDelegate, ReadinessDelegate (AVCam/Capture/PhotoCapture.swift:202, AVCam/Capture/PhotoCapture.swift:180) |
private class |
Usable only within PhotoCapture.swift |
Inference: delegate bridging is an implementation detail. |
MovieCaptureDelegate (AVCam/Capture/MovieCapture.swift:77) |
private class, nested |
Visible only inside MovieCapture |
Same as above. |
DefaultPreviewSource.session (AVCam/Views/CameraPreview.swift:92) |
private let |
The session is reachable only through connect(to:) |
Matches the protocol comment about not exposing capture objects to the UI layer (AVCam/Views/CameraPreview.swift:76). |
Reference code
AVCam/CaptureService.swift:368 — private rotation API
actor CaptureService {
// ...
/// Create a new rotation coordinator for the specified device and observe its state to monitor rotation changes.
private func createRotationCoordinator(for device: AVCaptureDevice) {
// Create a new rotation coordinator for this device.
rotationCoordinator = AVCaptureDevice.RotationCoordinator(device: device, previewLayer: videoPreviewLayer)
// ...
}
// ...
}All types are in a single module, so most declarations use Swift’s implicit internal. The private and private(set) modifiers above are the boundaries that matter.
Logic ownership and placement
| Logic | Owning type or file | Placement rationale |
|---|---|---|
| Create and replace the rotation coordinator | CaptureService.createRotationCoordinator(for:) |
Called after setup (AVCam/CaptureService.swift:152) and after every device change (AVCam/CaptureService.swift:335), because the coordinator is tied to one device and one preview layer. |
| Find the preview layer | CaptureService.videoPreviewLayer |
Derived from captureSession.connections, and calls fatalError if no preview layer is connected (AVCam/CaptureService.swift:412). Inference: this assumes the SwiftUI preview has attached its layer before setup finishes; the source shows no explicit synchronization for that ordering. |
| Apply the preview angle | CaptureService.updatePreviewRotation(_:) |
Writes on the main actor, matching where the preview layer is attached. |
| Apply the capture angle | OutputService.setVideoRotationAngle(_:) extension |
One shared implementation for every output connection. |
| Device switching | CaptureService.changeCaptureDevice(to:) |
Bracketed by beginConfiguration and commitConfiguration, and re-creates the controls, rotation coordinator, and subject-area observation (AVCam/CaptureService.swift:319). |
| UI intent to capture call | CameraModel |
Main-actor methods await the actor (AVCam/CameraModel.swift:126). |
| Layout | PreviewContainer, CameraUI |
Aspect ratio and toolbar padding. CameraUI.bottomPadding reads UIScreen.main.bounds (AVCam/Views/CameraUI.swift:85). |
Design patterns
| Pattern | Source evidence | Purpose or tradeoff |
|---|---|---|
| Actor isolation | actor CaptureService with a sessionQueue custom executor (AVCam/CaptureService.swift:14, AVCam/CaptureService.swift:73); actor MediaLibrary (AVCam/Model/MediaLibrary.swift:13) |
Serializes session, device, and rotation mutation on one queue, away from the main actor. KVO and main-actor work must hop explicitly. |
| Protocol-oriented abstraction | Camera with CameraModel and PreviewCameraModel; OutputService with a default setVideoRotationAngle (AVCam/Model/DataTypes.swift:156); PreviewSource/PreviewTarget |
Swappable view models for previews, one rotation implementation for all outputs, and a session handoff that hides the session from views. |
| Delegate or data-source callbacks | PhotoCaptureDelegate, MovieCaptureDelegate, ReadinessDelegate, CaptureControlsDelegate adopting AVFoundation delegate protocols (AVCam/CaptureService.swift:653) |
Framework callbacks are bridged into checked continuations, async streams, or @Published values. |
| Publisher-backed observable state | @Published private(set) on CaptureService, PhotoCapture, MovieCapture, CaptureControlsDelegate, consumed through .values (AVCam/CameraModel.swift:259) |
The actor publishes, and the main-actor model iterates. Only the owner writes. |
Removed from the draft: “Service object”. It came from the Service suffix. Here OutputService is a protocol for output wrappers, and CaptureService is better described by the verified actor-isolation and protocol boundaries above. No MVVM, Coordinator, or Adapter label is claimed: the source neither names those roles nor establishes them.
Main application flow
sequenceDiagram
participant Coord as RotationCoordinator
participant KVO as KVO closure
participant Service as CaptureService (sessionQueue)
participant Main as MainActor
participant Outputs as OutputService connections
Service->>Coord: init(device:previewLayer:)
Service->>Main: initial preview angle
Service->>Outputs: initial capture angle
Service->>Coord: observe preview and capture angles
Coord-->>KVO: angle changed
KVO->>Service: Task { await updatePreviewRotation }
Service->>Main: Task { @MainActor } set preview angle
KVO->>Service: Task { await updateCaptureRotation }
Service->>Outputs: setVideoRotationAngle(angle)
Reference code
AVCam/CaptureService.swift:368 — coordinator creation and observation
private func createRotationCoordinator(for device: AVCaptureDevice) {
// Create a new rotation coordinator for this device.
rotationCoordinator = AVCaptureDevice.RotationCoordinator(device: device, previewLayer: videoPreviewLayer)
// Set initial rotation state on the preview and output connections.
updatePreviewRotation(rotationCoordinator.videoRotationAngleForHorizonLevelPreview)
updateCaptureRotation(rotationCoordinator.videoRotationAngleForHorizonLevelCapture)
// Cancel previous observations.
rotationObservers.removeAll()
// Add observers to monitor future changes.
rotationObservers.append(
rotationCoordinator.observe(\.videoRotationAngleForHorizonLevelPreview, options: .new) { [weak self] _, change in
guard let self, let angle = change.newValue else { return }
// Update the capture preview rotation.
Task { await self.updatePreviewRotation(angle) }
}
)
// ...
}AVCam/CaptureService.swift:397 — the two appliers
private func updatePreviewRotation(_ angle: CGFloat) {
let connection = videoPreviewLayer.connection
Task { @MainActor in
// Set initial rotation angle on the video preview.
connection?.videoRotationAngle = angle
}
}
private func updateCaptureRotation(_ angle: CGFloat) {
// Update the orientation for all output services.
outputServices.forEach { $0.setVideoRotationAngle(angle) }
}Observed: preview and capture angles are separate inputs with separate destinations. The preview angle goes to the preview layer’s connection, and the capture angle goes to every output’s video connection. Inference, consistent with Apple’s abstract: separating them lets the iPhone build, whose interface is portrait-only, keep a correct preview while photos and movies still record upright in any device orientation.
Naming conventions
- Types: role nouns that pair a domain with a responsibility:
CaptureService(actor),CameraModel(concrete model),PhotoCaptureandMovieCapture(output wrappers),DeviceLookup,MediaLibrary,SystemPreferredCameraObserver. AVFoundation delegate adopters end inDelegate(PhotoCaptureDelegate,CaptureControlsDelegate). The protocol default implementation isDefaultPreviewSource, and the preview stub isPreviewCameraModel. - Protocols:
Cameranames the capability that views consume.OutputService,PreviewSource, andPreviewTargetname collaborator roles. The concrete class is namedCameraModel, and views useCameraModelas their generic parameter name (CameraView<CameraModel: Camera>), which shadows the concrete type inside those views. - Methods: rotation verbs mirror the AVFoundation vocabulary:
createRotationCoordinator(for:),updatePreviewRotation(_:),updateCaptureRotation(_:),setVideoRotationAngle(_:). Long-lived subscriptions useobserve…/monitor…(observeNotifications,monitorSystemPreferredCamera,observeSubjectAreaChanges(of:)). Booleans useis…,prefers…,should…(isSwitchingVideoDevices,prefersMinimizedUI,shouldFlashScreen). - Files and extensions: generally one primary type per file, named after it. Exceptions:
SPCObserver.swiftholdsSystemPreferredCameraObserver, andDataTypes.swiftgroups enums, value types, and theOutputServiceprotocol. Folders group by role (Capture/,Model/,Views/Toolbars/...).
Architecture takeaways
- A custom actor executor is the verified piece that puts capture work on a dedicated serial queue (
AVCam/CaptureService.swift:73). Without it,actorwould not say where the work runs. - Rotation is owned by the object that owns the device. The coordinator is rebuilt inside every device change, inside the same configuration transaction, so rotation state cannot drift from the active input.
- Preview and capture rotation are separate data flows with different isolation: preview writes go to the main actor, and output writes stay on the actor.
- A protocol extension (
OutputService.setVideoRotationAngle) gives every output wrapper the same rotation behavior, and fan-out over[any OutputService]keepsCaptureServicefree of output-specific code. - Task lifetime is mostly implicit: one subject-area task is stored and cancelled, while the notification, preferred-camera, and model-observation loops are unretained. That is a deliberate reading point for anyone adapting this code.
Source map
| Source file | Relevant symbols |
|---|---|
AVCam/CaptureService.swift |
CaptureService, sessionQueue, unownedExecutor, createRotationCoordinator(for:), updatePreviewRotation(_:), updateCaptureRotation(_:), videoPreviewLayer, changeCaptureDevice(to:), CaptureControlsDelegate |
AVCam/Model/DataTypes.swift |
OutputService, setVideoRotationAngle(_:), CaptureActivity, CaptureCapabilities |
AVCam/CameraModel.swift |
CameraModel, start(), observeState(), captureMode |
AVCam/Model/Camera.swift |
Camera |
AVCam/AVCamApp.swift |
AVCamApp |
AVCamCaptureExtension/AVCamCaptureExtension.swift |
AVCamCaptureExtension |
AVCam/CameraView.swift |
CameraView |
AVCam/Views/CameraPreview.swift |
CameraPreview, PreviewView, PreviewSource, PreviewTarget, DefaultPreviewSource |
AVCam/Views/PreviewContainer.swift |
PreviewContainer, photoAspectRatio, movieAspectRatio |
AVCam/Views/CameraUI.swift |
CameraUI, bottomPadding |
AVCam/Capture/PhotoCapture.swift |
PhotoCapture, monitorProgress(of:isolation:), ReadinessDelegate, PhotoCaptureDelegate |
AVCam/Capture/MovieCapture.swift |
MovieCapture, MovieCaptureDelegate, startMonitoringDuration() |
AVCam/Capture/SPCObserver.swift |
SystemPreferredCameraObserver |
AVCam/Model/MediaLibrary.swift |
MediaLibrary, thumbnails |
AVCam/Model/CameraState.swift |
CameraState, save() |
AVCam/Preview Content/PreviewCameraModel.swift |
PreviewCameraModel |
AVCam.xcodeproj/project.pbxproj |
Targets, Sources phases, interface orientations, SWIFT_STRICT_CONCURRENCY, SUPPORTS_MACCATALYST |
README.md |
Rationale for the CaptureService actor |