BS
BleepingSwift
Published on
9 min read
Advanced

> Handling iPhone Duo's Front Cameras with AVCaptureDeviceDirectionCoordinator

Share:

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:

Swift
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:

Swift
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:

Swift
@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.

Swift
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:

Swift
@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:

Swift
@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:

View 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.

subscribe.sh

// Stay Updated

Get notified when I publish new tutorials on Swift, SwiftUI, and iOS development. No spam, unsubscribe anytime.

>

By subscribing, you agree to our Privacy Policy.