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.onCameraCaptureEventships in AVKit, not in the default SwiftUI modifier set..onCameraCaptureEvent { event in ... }hangs off theCameraView, so events are only consumed while the camera view is active.- Only call
capturePhoto()whenevent.phase == .ended. Don’t fire onbegan. - The on-screen
Buttonstays. The physical key and the on-screen button share the samecamera.capturePhotomethod.
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: trueturns off the system tone, so it doesn’t stack with the app’s own sound.event.shouldPlaySoundistrueonly when the event came from an AirPod stem press. Volume keys, Action button, and Camera Control all returnfalse.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.supportsControlsfirst 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 adevicereference and drivesvideoZoomFactoron its own. The app does not have to set it manually.canAddControlis 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 standardAVCaptureSessiontransaction 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:
availableReactionTypesis an unordered set. Sort it first so the picker has a stable order.AVCaptureIndexPickerneeds 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 thedevice.performEffectcall is serialized with other device configuration. No race conditions.maxControlsCountcaps the total number of controls on a session. Past that,canAddControlreturns false (mentioned at 16:46).
Takeaways
-
What to do: adopt
onCameraCaptureEventin 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
.onCameraCaptureEventon the main capture view, routeevent.phase == .endedto the existingcapturePhoto()method, and make sure theAVCaptureSessionis 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 checkevent.shouldPlaySoundinside the closure to decide whether to play.shouldPlaySoundis 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. UsesetActionQueue(sessionQueue)so the switching action runs on the session queue alongside device configuration. SetisEnabled = falsefor 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:
AVCaptureSystemZoomSlideris 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.
- Why it’s worth it:
Related Sessions
- Capture cinematic video in your app — the Cinematic Video API lets an app record cinema-grade video with shallow depth of field
- Enhance your app with machine-learning-based video effects — frame rate conversion, super resolution, denoising, and other machine-learning-based video effects
- Build a great Lock Screen camera capture experience — configure a Lock Screen capture extension so the Camera Control can launch your app from the Lock Screen
- Bring your camera app to Mac with Continuity Camera — extending the camera to the Mac, related to multi-device capture strategy
Comments
GitHub Issues · utterances