BS
BleepingSwift
Published on
8 min read
Intermediate

> Building a Teleprompter for iPhone Duo's Outer Display with CameraCaptureAccessory

Share:

Anyone who has filmed themselves reading a script knows the problem. The words are on the phone, the phone is behind the camera, and every glance down to read ruins the take. iPhone Duo happens to fix this in hardware. When the device is open and you're recording with the rear camera, the outer display faces the same direction as the lens, which means the person being filmed can see it.

The iOS 27.1 SDK gives you a way to put content on that display while your app records: CameraCaptureAccessory. Apple's own documentation uses a teleprompter as the example, and it's a good one, so that's what we'll build. It scrolls a script on the outer display while your capture interface keeps running on the inner one.

How Camera Capture Accessories Work

A camera capture accessory is a kind of scene accessory. You declare the content, and the system decides whether and where to present it. You never pass it a display, a capture session, or a camera. According to Apple's article on registering a camera capture accessory, presentation happens only while your app is in the foreground, a capture session is running, and your capture interface is on the inner display, which in practice means the device is open.

Two things follow from that. First, your app has to work perfectly well without the accessory, since plenty of the time it won't be shown. Second, the content should stay simple. The outer display does accept touch, but Apple recommends limiting interaction to a single capture-related task and keeping every essential control in your main interface, because the system can take the accessory away at any time.

A Model That Scrolls by the Clock

The teleprompter's state lives in a small observable model that both displays share. Rather than moving the text with a timer, the model computes the scroll offset from elapsed time. That keeps the scrolling smooth and makes pausing and speed changes easy to get right:

Swift
@MainActor
@Observable
final class ScriptPrompter {
    var script: String
    private(set) var isRolling = false

    /// Scroll speed in points per second.
    var speed: Double = 45 {
        didSet {
            // Bank the distance covered at the old speed before the new one applies.
            guard let rollStart else { return }
            rolledDistance += Date.now.timeIntervalSince(rollStart) * oldValue
            self.rollStart = .now
        }
    }

    private var rolledDistance: Double = 0
    private var rollStart: Date?

    init(script: String) {
        self.script = script
    }

    func offset(at date: Date) -> Double {
        guard let rollStart else { return rolledDistance }
        return rolledDistance + date.timeIntervalSince(rollStart) * speed
    }

    func roll() {
        guard !isRolling else { return }
        rollStart = .now
        isRolling = true
    }

    func pause() {
        rolledDistance = offset(at: .now)
        rollStart = nil
        isRolling = false
    }

    func rewind() {
        rolledDistance = 0
        rollStart = isRolling ? .now : nil
    }
}

The didSet on speed is the one subtle part. Without it, dragging the speed slider mid-take would recompute the whole run at the new speed and make the text jump.

The View on the Outer Display

The view that appears on the outer display is ordinary SwiftUI. A TimelineView with an animation schedule redraws every frame while the script is rolling and stops when it's paused:

Swift
struct ScrollingScriptView: View {
    let model: ScriptPrompter

    var body: some View {
        TimelineView(.animation(paused: !model.isRolling)) { context in
            Text(model.script)
                .font(.system(size: 34, weight: .semibold))
                .multilineTextAlignment(.center)
                .fixedSize(horizontal: false, vertical: true)
                .padding(.horizontal, 24)
                .padding(.top, 120)
                .offset(y: -model.offset(at: context.date))
        }
        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
        .clipped()
        .foregroundStyle(.white)
        .background(.black)
        .contentShape(.rect)
        .onTapGesture {
            if model.isRolling {
                model.pause()
            } else {
                model.roll()
            }
        }
    }
}

The fixedSize(horizontal:vertical:) call lets the text take its full height instead of truncating with an ellipsis, and clipped() hides whatever has scrolled out of view. The only interaction is tap to pause, which fits Apple's advice about keeping accessory input minimal. The person reading can stop if they stumble, and everything else stays on the inner display.

Registering It on the Capture Screen

You attach the accessory with the sceneAccessory(content:) modifier, which arrived in iOS 27.0 for external display content. Put it on the same view that shows your capture interface. That scopes the accessory to the camera: the system only presents it while that view is onscreen and stops when someone navigates away.

Swift
struct RecordingScreen: View {
    @State private var teleprompter = ScriptPrompter(script: "Welcome back to the channel...")
    @State private var showsTeleprompter = true
    @State private var outerDisplayAvailable = false

    var body: some View {
        CameraPreview()
            .overlay(alignment: .bottom) {
                if outerDisplayAvailable {
                    PrompterControls(model: teleprompter, isShowing: $showsTeleprompter)
                }
            }
            .sceneAccessory {
                CameraCaptureAccessory(isEnabled: $showsTeleprompter) {
                    ScrollingScriptView(model: teleprompter)
                }
                .onAvailabilityChange { isAvailable in
                    outerDisplayAvailable = isAvailable
                }
            }
    }
}

CameraPreview stands in for your existing preview view. Both closures capture the same model, so a tap on the outer display pauses the script, and the Pause button on the inner display updates to match with no message passing in between. The controls themselves only appear when the system says the accessory can be shown:

Swift
struct PrompterControls: View {
    @Bindable var model: ScriptPrompter
    @Binding var isShowing: Bool

    var body: some View {
        HStack(spacing: 16) {
            Toggle("Show Script", systemImage: "text.alignleft", isOn: $isShowing)
                .toggleStyle(.button)

            Button(model.isRolling ? "Pause" : "Roll", systemImage: model.isRolling ? "pause.fill" : "play.fill") {
                if model.isRolling {
                    model.pause()
                } else {
                    model.roll()
                }
            }

            Button("Rewind", systemImage: "backward.end.fill") {
                model.rewind()
            }

            Slider(value: $model.speed, in: 20...120)
                .frame(maxWidth: 160)
        }
        .labelStyle(.iconOnly)
        .padding()
        .background(.regularMaterial, in: .capsule)
        .padding(.bottom)
    }
}

Availability Versus Enabled

The two flags in that code mean different things, and mixing them up is the easiest way to get this wrong. Availability belongs to the system. It reports whether the accessory can be presented right now, and it changes for reasons your app doesn't control: capture stopping, the app leaving the foreground, or someone folding the device closed. Only the top-most registration of a given kind is presented, so pushing another screen that registers its own camera capture accessory makes this one unavailable until you navigate back. Accessories of different kinds don't compete, so external display content and camera capture content can both be showing at once.

Enabled belongs to your app. It's how the person using your app turns the teleprompter off, and Apple's guidance is to offer a control for it rather than unregistering the accessory. Accessory content is enabled by default. Disabling it dismisses the content the same way a scene goes away, which is one more reason to keep state like the scroll position in the model and not in the view.

The UIKit Version

In UIKit, you build the accessory from a scene configuration and register it on the view controller that shows your capture interface. The userInfo parameter hands your model to the accessory's scene, and since ScrollingScriptView is plain SwiftUI, a hosting controller lets UIKit apps reuse it:

Swift
final class RecordingViewController: UIViewController {
    private let teleprompter = ScriptPrompter(script: "Welcome back to the channel...")
    private var teleprompterRegistration: UISceneAccessoryRegistration?
    private let scriptButton = UIButton(configuration: .gray())

    override func viewDidLoad() {
        super.viewDidLoad()

        let configuration = UISceneConfiguration()
        configuration.delegateClass = PrompterSceneDelegate.self

        let accessory = UISceneAccessory.cameraCapture(
            sceneConfiguration: configuration,
            userInfo: teleprompter
        )
        teleprompterRegistration = registerSceneAccessory(accessory)

        scriptButton.configuration?.image = UIImage(systemName: "text.alignleft")
        scriptButton.addAction(UIAction { [weak self] _ in
            self?.teleprompterRegistration?.isEnabled.toggle()
        }, for: .primaryActionTriggered)
        view.addSubview(scriptButton)
    }

    override func updateProperties() {
        super.updateProperties()
        // isAvailable is observable, so UIKit calls this again when it changes.
        scriptButton.isHidden = !(teleprompterRegistration?.isAvailable ?? false)
    }
}

final class PrompterSceneDelegate: UIResponder, UIWindowSceneDelegate {
    var window: UIWindow?

    func scene(
        _ scene: UIScene,
        willConnectTo session: UISceneSession,
        options connectionOptions: UIScene.ConnectionOptions
    ) {
        guard let windowScene = scene as? UIWindowScene,
              session.role == .windowCameraCaptureAccessory,
              let model = connectionOptions.sceneAccessoryUserInfo as? ScriptPrompter
        else { return }

        let window = UIWindow(windowScene: windowScene)
        window.rootViewController = UIHostingController(rootView: ScrollingScriptView(model: model))
        window.makeKeyAndVisible()
        self.window = window
    }
}

Keep a strong reference to the UISceneAccessoryRegistration, since it's how you read availability and toggle isEnabled. The system assigns the scene's windowCameraCaptureAccessory role itself, and accessory scenes don't use Info.plist scene manifest entries at all. The role check in the delegate only matters if one delegate class handles several kinds of scenes. When your app stops offering the content altogether, call unregisterSceneAccessory(_:) instead of just hiding it.

Testing Before the Hardware Ships

Because the accessory content is regular SwiftUI, most of the work can happen in Xcode previews. Xcode 27.1 beta adds a Display group to the canvas overrides picker for previewing content on a device's alternative display, which is a quick way to check how the script reads at outer display sizes.

The accessory itself is another story. The Simulator has no camera, so there's no running capture session to bring the accessory onscreen, and Apple recommends testing anything that depends on capture on a real device. iPhone Duo arrives on October 23. Until then, build and preview the content, make sure the recording screen works with no accessory at all, and treat the outer display as the bonus it's meant to be. If your app also needs to track which camera faces the viewer as the device opens and closes, the direction coordinator covers that half of the capture story.

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.