WWDC Quick Look đź’“ By SwiftGGTeam
Enhancing your camera experience with capture controls

Enhancing your camera experience with capture controls

Watch original video

Highlight

On iOS 26, AirPods with the H2 chip can fire a remote camera capture by pressing the stem. Apps that already adopt AVCaptureEventInteraction get this for free, with no code changes.


Overview

Third-party camera apps have always had an awkward gap. Users learn to take a photo by pressing the volume button, hold it to record video, or turn the Camera Control to focus. None of that works in third-party apps. The on-screen shutter button feels far worse than the physical key on the side of the iPhone. Self-portraits make it worse: prop the phone up far away, and there is no way to fire the shutter.

This WWDC25 session is presented by Vitaliy from the AVKit team and Nick from the camera experience team. It covers two APIs. AVCaptureEventInteraction turns physical button presses on the volume keys, the Action button, and the Camera Control into in-app capture callbacks. Six lines of SwiftUI give a third-party app the same physical-button feel as the system camera. AVCaptureControl exposes the iPhone 16 Camera Control as a programmable hardware interface, with three control types: continuous slider, discrete slider, and picker. iOS 26 adds one more thing on top: AirPods with the H2 chip can fire the primary capture event by a stem press. Any app that already adopts AVCaptureEventInteraction gets this with zero code changes.


Details

AVCaptureEventInteraction: mapping physical buttons to capture

Physical buttons fall into two groups, primary and secondary (03:37). The volume-down key, the Action button, and the Camera Control fire the primary action. The volume-up key fires the secondary action. The same button cannot fire both handlers. The secondary handler is optional. If you skip it, volume-up also goes to primary.

Each press sends an event with three phases (03:12):

  • began: the moment of press-down. A good time to warm up the camera.
  • cancelled: the app went to the background, or capture became unavailable.
  • ended: the moment of release. This is when you take the photo.

In SwiftUI, you wire it up with the onCameraCaptureEvent modifier. A minimal camera view with physical-button support takes only six new lines (06:14):

import AVKit
import SwiftUI

struct PhotoCapture: View {
    let camera = CameraModel()
    var body: some View {
        VStack {
            CameraView()
            .onCameraCaptureEvent { event in
                if event.phase == .ended {
                   camera.capturePhoto()
                }
            }
            Button(action: camera.capturePhoto) {
                Text("Take a photo")
            }
        }
    }
}

Key points:

  • import AVKit. onCameraCaptureEvent ships in AVKit, not in the default SwiftUI modifier set.
  • .onCameraCaptureEvent { event in ... } hangs off the CameraView, so events are only consumed while the camera view is active.
  • Only call capturePhoto() when event.phase == .ended. Don’t fire on began.
  • The on-screen Button stays. The physical key and the on-screen button share the same camera.capturePhoto method.

A note: the system only sends capture events to the app that is “actively using the camera” (04:22). When the app goes to the background, or there is no active AVCaptureSession, the buttons fall back to system behavior (volume, launching the camera). Once you adopt this API, you must respond to every event you receive. If you don’t, the buttons feel dead, and the experience is bad.

iOS 26: remote capture from AirPods stem

AirPods with the H2 chip get remote camera capture on iOS 26 (06:38). The user picks “Press once” or “Press and hold” under Settings → Remote Camera Capture, then presses the stem inside any camera app that adopts AVCaptureEventInteraction to fire the primary capture. This is fully transparent to the developer. Apps that already adopt the API get it for free.

Because the user may not be looking at the screen when they take the photo, audio feedback matters. Apple adds a default shutter sound, and a new API to customize or disable it (09:13):

.onCameraCaptureEvent(defaultSoundDisabled: true) { event in
    if event.phase == .ended {
        if event.shouldPlaySound {
            event.play(.cameraShutter)
        }
    }
    camera.capturePhoto()
}

Key points:

  • defaultSoundDisabled: true turns off the system tone, so it doesn’t stack with the app’s own sound.
  • event.shouldPlaySound is true only when the event came from an AirPod stem press. Volume keys, Action button, and Camera Control all return false.
  • event.play(.cameraShutter) plays the system shutter sound. You can also play an audio file shipped in the app bundle.
  • Even with the default sound off, you should still play some kind of feedback on the AirPod path. The user is not looking at the screen and relies on hearing.

AVCaptureControl: programming the Camera Control

The Camera Control is the physical control on the side of the iPhone 16. It supports a press for capture, a light press to bring up a preview, a swipe to adjust, and a double press to switch settings (11:02). AVFoundation abstracts it as AVCaptureControl, in two flavors:

  • Slider (numeric): continuous sliders take any value in a range (such as zoom factor). Discrete sliders snap to fixed steps (such as exposure bias, ±2 EV in 1/3-stop steps).
  • Picker (list): each index maps to a named state (such as Flash On/Off, or the names of Photographic Styles).

The system ships two predefined subclasses: AVCaptureSystemZoomSlider and AVCaptureSystemExposureBiasSlider. They behave like the system camera. There are also two generic subclasses, AVCaptureSlider and AVCaptureIndexPicker, for your own controls.

Adding a system zoom slider to a session (14:46):

captureSession.beginConfiguration()

// configure device inputs and outputs

if captureSession.supportsControls {
    let zoomControl = AVCaptureSystemZoomSlider(device: device)

    if captureSession.canAddControl(zoomControl) {
        captureSession.addControl(zoomControl)
    }
}

captureSession.commitConfiguration()

Key points:

  • Check captureSession.supportsControls first to see if the device has a Camera Control (only iPhone 16 models). Skip the whole block on older devices.
  • AVCaptureSystemZoomSlider(device: device) is one line. The system control takes a device reference and drives videoZoomFactor on its own. The app does not have to set it manually.
  • canAddControl is a needed check. For example, if the device already has a zoom control attached, adding a second one will fail.
  • Wrap everything in beginConfiguration / commitConfiguration, the standard AVCaptureSession transaction pattern.

But this alone has a sync problem. After the user zooms with the Camera Control, the pinch-gesture model in the UI still holds the old value. The fix is to pass an action closure to the system control (15:40):

let zoomControl = AVCaptureSystemZoomSlider(device: device) { [weak self] zoomFactor in
    self?.updateUI(zoomFactor: zoomFactor)
}

Key points:

  • The closure runs on the main thread (a contract of system controls), so you can update the UI directly.
  • [weak self] keeps the control from holding the view controller and creating a retain cycle.
  • If you already use KVO on videoZoomFactor, you can skip this closure and keep your existing code.

Custom controls: a Reaction Effects picker

For an app’s own picker, the action callback runs on the queue you pick. The standard choice is the session queue, the isolated context where device state is managed (16:46):

let reactions = device.availableReactionTypes.sorted { $0.rawValue < $1.rawValue }
let titles = reactions.map { localizedTitle(reaction: $0) }
let picker = AVCaptureIndexPicker("Reactions", symbolName: "face.smiling.inverted",
    localizedIndexTitles: titles)

picker.isEnabled = device.canPerformReactionEffects
picker.setActionQueue(sessionQueue) { index in
    device.performEffect(for: reactions[index])
}

let controls: [AVCaptureControl] = [zoomControl, picker]

for control in controls {
    if captureSession.canAddControl(control) {
        captureSession.addControl(control)
    }
}

Key points:

  • availableReactionTypes is an unordered set. Sort it first so the picker has a stable order.
  • AVCaptureIndexPicker needs a name and an SF Symbol icon. With several controls, the user relies on the icon to tell which picker is which.
  • picker.isEnabled = device.canPerformReactionEffects: when the control is unavailable, set it to disabled, don’t drop it. Otherwise the system falls back to another control and confuses the user.
  • setActionQueue(sessionQueue) runs the action on the session queue, so the device.performEffect call is serialized with other device configuration. No race conditions.
  • maxControlsCount caps the total number of controls on a session. Past that, canAddControl returns false (mentioned at 16:46).

Takeaways

  • What to do: adopt onCameraCaptureEvent in an existing camera app to get iOS 26 AirPods remote capture for free.

    • Why it’s worth it: six lines of code buy a full remote-trigger scenario (selfies, group photos, sports). It is one of the rare zero-cost new features on iOS 26.
    • Where to start: hang .onCameraCaptureEvent on the main capture view, route event.phase == .ended to the existing capturePhoto() method, and make sure the AVCaptureSession is active while the app is in the foreground.
  • What to do: customize the shutter sound, with different feedback for the AirPods path.

    • Why it’s worth it: when the user takes a photo with AirPods, they aren’t looking at the screen. The sound is the only confirmation. The default tone won’t fit every app (a pro camera and a filter app want different sounds).
    • Where to start: turn off the default with defaultSoundDisabled: true, then check event.shouldPlaySound inside the closure to decide whether to play. shouldPlaySound is true only on the AirPod path, so other paths won’t double up.
  • What to do: on iPhone 16, add a Photographic Style or filter picker tied to the Camera Control.

    • Why it’s worth it: the user already has the muscle memory of “double-light-press to switch control → swipe to pick → light-press to confirm” on the Camera Control. A custom picker reuses that interaction directly.
    • Where to start: wrap the filter list with AVCaptureIndexPicker. Use setActionQueue(sessionQueue) so the switching action runs on the session queue alongside device configuration. Set isEnabled = false for unavailable filters instead of dropping them.
  • What to do: replace home-grown zoom and exposure bias controls with the system predefined ones.

    • Why it’s worth it: AVCaptureSystemZoomSlider is one line, and its behavior matches the system camera exactly, including step size, snap-back, and HUD. It’s hard to match that consistency by hand.
    • Where to start: delete the custom zoom control and replace it with AVCaptureSystemZoomSlider(device: device) { factor in updateUI(...) }. Sync the UI through the closure.

Comments

GitHub Issues · utterances