- Published on
- 9 min read Advanced
> Handling iPhone Duo's Front Cameras with AVCaptureDeviceDirectionCoordinator
Most camera code rests on a simple assumption: the front camera faces the person holding the phone, and the back cameras face away. iPhone Duo breaks that. It has a display on each side with a front camera above each one, and when someone opens or closes it, your app moves to the other display. The camera that was pointing at the person a second ago is now pointing at the wall, and a rear camera can end up pointing straight at them.
Apple's answer comes in two parts. A virtual front camera keeps existing apps working without changes, and a new AVKit class, AVCaptureDeviceDirectionCoordinator, reports which way each camera actually faces for apps that want full control. Both are part of the iOS 27.1 SDK, and Apple's article Choosing a camera by the direction it faces is the reference for everything below.
Position No Longer Means Direction
AVCaptureDevice.position has always done double duty. It describes where a camera sits relative to the display, and on a phone with one display that also told you where it points. On iPhone Duo those two meanings split apart. Position still reports where the camera is physically mounted, which never changes, but the direction it faces depends on which display is showing your interface.
That matters for more than picking a camera. Anything your app derives from position, like whether to mirror the preview or which icon to show on a flip button, can be wrong on Duo. Apple's guidance is to stop inferring direction from a camera's type or position entirely and ask a coordinator instead.
The Virtual Front Camera Keeps Existing Apps Working
If your app discovers its front camera the usual way, you may not need to do anything to keep it working. On iPhone Duo, asking a discovery session for a front-positioned builtInWideAngleCamera or builtInUltraWideCamera returns a virtual front camera. It streams from whichever physical camera sits above the display your app is on, and the system switches between the two as the device opens and closes.
Since it's a virtual device, isVirtualDevice is true, and activePrimaryConstituent tells you which physical camera it's using at the moment:
func describeFrontCamera() -> String {
let discovery = AVCaptureDevice.DiscoverySession(
deviceTypes: [.builtInWideAngleCamera, .builtInUltraWideCamera],
mediaType: .video,
position: .front
)
guard let camera = discovery.devices.first else { return "No front camera" }
guard camera.isVirtualDevice else { return camera.localizedName }
// activePrimaryConstituent stays nil until a capture session is running.
let streaming = camera.activePrimaryConstituent?.localizedName ?? "not streaming yet"
return "\(camera.localizedName), streaming from \(streaming)"
}
The trade-off is that the virtual camera only offers the capabilities both physical cameras share, and the system decides which one streams. That's a fine default for a messaging app with a selfie button. If capture is the whole point of your app, the iOS 27.1 SDK also exposes each physical front camera through two new device types, builtInOuterUltraWideCamera and builtInInnerUltraWideCamera. They can only be found through a discovery session:
func physicalFrontCameras() -> [AVCaptureDevice] {
AVCaptureDevice.DiscoverySession(
deviceTypes: [.builtInOuterUltraWideCamera, .builtInInnerUltraWideCamera],
mediaType: .video,
position: .unspecified
).devices
}
Once you capture from the physical cameras directly, keeping track of which one faces the person becomes your job. That's what the coordinator is for.
Tracking Direction With a Coordinator
A coordinator takes a view as its frame of reference, normally the one that shows your camera preview, along with the device types you capture from. It sorts those cameras into two groups on an AVCaptureDeviceDirectionMap: forward facing cameras point the same way as the view, toward the person looking at it, and backward facing cameras point away.
There are a few rules worth knowing before writing any code. Create the coordinator on the main actor and keep a strong reference to it for as long as the view is onscreen. List your rear cameras too, since a rear camera can become the forward-facing one. Only built-in types count, so external cameras, Continuity Camera, and Desk View are ignored, and the virtual front camera is left out because the system already moves it for you. Here's a small main actor tracker that follows those rules and picks a camera whenever the current one stops facing the person:
@MainActor
final class CameraDirectionTracker {
var onCameraChange: ((AVCaptureDeviceDescriptor) -> Void)?
private(set) var directions: AVCaptureDeviceDirectionMap?
private var coordinator: AVCaptureDeviceDirectionCoordinator?
private var selectedCameraID: String?
// Earlier entries win when more than one camera faces the person.
private let preferredTypes: [AVCaptureDevice.DeviceType] = [
.builtInWideAngleCamera,
.builtInOuterUltraWideCamera,
.builtInInnerUltraWideCamera,
]
func startTracking(relativeTo previewView: UIView) {
coordinator = AVCaptureDeviceDirectionCoordinator(
view: previewView,
deviceTypes: preferredTypes
) { [weak self] directions in
self?.directionsDidChange(directions)
}
}
func stopTracking() {
coordinator = nil
}
private func directionsDidChange(_ directions: AVCaptureDeviceDirectionMap) {
self.directions = directions
let facingViewer = directions.forwardFacingDeviceDescriptors
// Nothing to do if the current camera still faces the person.
if let selectedCameraID, facingViewer.contains(where: { $0.uniqueID == selectedCameraID }) {
return
}
let replacement = preferredTypes.lazy
.compactMap { type in facingViewer.first { $0.deviceType == type } }
.first ?? facingViewer.first
guard let replacement else { return }
selectedCameraID = replacement.uniqueID
onCameraChange?(replacement)
}
}
The coordinator calls its handler on the main actor soon after you create it, with the directions in effect at that moment, and again on every change. Until that first call arrives, deviceDirections is an empty map, so treat the first callback as the point where you know the layout rather than reading the property right after initialization.
One subtle point: if your app shows a preview on both displays at once, create one coordinator per preview view. Direction is always relative to a view, so the same camera can be forward facing for one and backward facing for the other.
Switching Cameras on Your Capture Actor
The map doesn't hand you AVCaptureDevice objects. It hands you AVCaptureDeviceDescriptor values, which carry a camera's type, media types, position, unique ID, and localized name. Descriptors and maps are both Sendable, and that's deliberate: Apple's documentation says not to call AVFoundation from the change handler. Instead, pass the descriptor to whatever owns your capture session and resolve it there.
actor CameraController {
private let session = AVCaptureSession()
private var videoInput: AVCaptureDeviceInput?
func switchToCamera(_ descriptor: AVCaptureDeviceDescriptor) throws {
// A descriptor names a camera without reserving it, so the lookup can fail.
guard let device = AVCaptureDevice(uniqueID: descriptor.uniqueID) else { return }
let newInput = try AVCaptureDeviceInput(device: device)
session.beginConfiguration()
defer { session.commitConfiguration() }
let previousInput = videoInput
if let previousInput {
session.removeInput(previousInput)
}
if session.canAddInput(newInput) {
session.addInput(newInput)
videoInput = newInput
} else if let previousInput {
session.addInput(previousInput)
}
}
}
The failable lookup matters. The set of cameras can change again while your request hops to the capture actor, so a descriptor that was valid on the main actor might not resolve by the time you use it. Apple also recommends swapping a single video input like this rather than running both cameras in a multi-camera session, since reconfiguring one input is cheaper and covers what most apps need.
Connecting the two pieces is a small closure. The error is handled inside the task so a failed switch doesn't disappear silently:
@MainActor
func connect(_ tracker: CameraDirectionTracker, to controller: CameraController, previewView: UIView) {
tracker.onCameraChange = { descriptor in
Task {
do {
try await controller.switchToCamera(descriptor)
} catch {
print("Couldn't switch cameras: \(error)")
}
}
}
tracker.startTracking(relativeTo: previewView)
}
Mirroring and Rotation
A camera switch takes a moment, and frames from the outgoing camera can reach the screen while the new one starts. Apple suggests masking the preview when the handler fires and revealing it once the new camera delivers frames. A quick blur or fade works well.
Mirroring needs more care, because a capture connection mirrors the preview automatically for any camera whose position is .front. On iPhone Duo that's wrong in two cases: a rear camera facing the person should look mirrored like a selfie, and a front-positioned camera facing away shouldn't. The fix is to decide from the direction map and only take over when position and direction disagree:
@MainActor
func applyMirroring(
to previewLayer: AVCaptureVideoPreviewLayer,
camera: AVCaptureDeviceDescriptor,
directions: AVCaptureDeviceDirectionMap
) {
guard let connection = previewLayer.connection, connection.isVideoMirroringSupported else { return }
let facesViewer = directions.forwardFacingDeviceDescriptors.contains { $0.uniqueID == camera.uniqueID }
let facesAway = directions.backwardFacingDeviceDescriptors.contains { $0.uniqueID == camera.uniqueID }
guard facesViewer || facesAway else { return }
// Automatic mirroring follows position, so only step in when position and direction disagree.
let mirroredByDefault = camera.position == .front
guard facesViewer != mirroredByDefault else { return }
connection.automaticallyAdjustsVideoMirroring = false
connection.isVideoMirrored = facesViewer
}
The order of those last two lines matters. Setting isVideoMirrored while automaticallyAdjustsVideoMirroring is still on raises an exception. Call this after every new map and after every camera switch, since replacing the input gives you a fresh connection without your override.
Rotation follows the same "per device" rule. An AVCaptureDevice.RotationCoordinator reports the angles that keep your preview and captured media level, and it's tied to the device you create it with. On Duo the angle can also change as your app moves between displays, so create a new rotation coordinator each time you switch cameras.
One Code Path for Every iPhone
You don't need a separate branch for iPhone Duo. On an iPhone with a single display, the coordinator reports what you'd expect: front cameras in the forward-facing array, back cameras in the backward-facing array, and cameras with an unspecified position in neither. Those directions never change, so your handler runs once and the rest of the code sits idle.
That's the real shift here. Asking the coordinator instead of reading position gives correct answers on every iPhone, and it keeps working when someone opens their Duo halfway through a video call. If you want to go further and use the outer display while the inner one shows your capture interface, the next step is a camera capture accessory. And for the broader list of Duo preparation work, see how to get your app ready for iPhone Duo.
Sample Project
Want to see this code in action? Check out the complete sample project on GitHub:
The repository includes a working Xcode project with all the examples from this article, plus unit tests you can run to verify the behavior.
// Continue_Learning
Building a Teleprompter for iPhone Duo's Outer Display with CameraCaptureAccessory
When iPhone Duo is open, its outer display faces the same way as the rear camera. CameraCaptureAccessory lets your app put content there while it records. Here's how to build a scrolling teleprompter with it in SwiftUI and UIKit.
Reading the iPhone Duo Hinge Angle with onHingeChange in SwiftUI
iOS 27.1 lets you read iPhone Duo's hinge angle and fold status with onHingeChange in SwiftUI and UIHingeInteraction in UIKit. Here's how the APIs work, why they're for interactive effects rather than layout, and a fold-to-scrub example.
Keeping Content Clear of the iPhone Duo Fold with ReservedRegion
iPhone Duo reserves parts of its displays for the fold and the front cameras. Learn which regions exist, when system views handle them for you, and how to query ReservedRegion in SwiftUI and UIKit to move custom content out of the way.
// Stay Updated
Get notified when I publish new tutorials on Swift, SwiftUI, and iOS development. No spam, unsubscribe anytime.