If you build a camera app, AVCaptureEventInteraction gives you hardware buttons almost for free. If you build a diagnostic app that has to verify a specific button works, the same API becomes surprisingly hostile.
This is a write-up of what we learned implementing a Camera Control test — including several things that are documented nowhere and cost us a device-in-hand debugging session.
The basics#
AVCaptureEventInteraction (iOS 17.2+) is a UIInteraction you attach to a view. It hands you two handlers:
let interaction = AVCaptureEventInteraction(
primary: { event in
guard event.phase == .ended else { return }
capturePhoto()
},
secondary: { event in
guard event.phase == .ended else { return }
// ...
}
)
interaction.isEnabled = true
view.addInteraction(interaction)Each event carries a phase: .began when the button goes down, .ended on release — that is where you trigger capture — and .cancelled when the app backgrounds or capture becomes unavailable.
Which button maps to which handler#
This is the part that is easy to get wrong, and it has real consequences:
| Handler | Triggered by |
|---|---|
primary | Volume down, Action button, Camera Control, AirPods stem click (iOS 26) |
secondary | Volume up — and nothing else |
Two conclusions follow immediately.
Camera Control never fires secondary. If you wired a feature to the secondary handler thinking it was a Camera Control gesture, it is in fact driven exclusively by the volume-up button. We had precisely this bug: a "switch camera" action on secondary that only ever responded to volume up.
Omitting secondary is not the same as ignoring it. If you do not supply a secondary handler, volume-up clicks fall through to primary. To swallow volume up without acting on it, you must still provide the handler — just leave it empty.
You cannot tell the buttons apart#
There is no property on AVCaptureEvent identifying the source. We verified this against the machine-generated iOS 26 SDK diffs: the type exposes phase and shouldPlaySound, and that is all. The abstraction is deliberate — Apple wants "the user asked to capture", not "the user pressed key X".
For a camera app this is fine. For a test that must prove one specific button works, it is fatal: a volume-down press and a Camera Control click are indistinguishable at the API level.
The one exception is AirPods. shouldPlaySound is true only when the event came from an AirPods stem click and you have disabled the default capture sound via AVCaptureEventInteraction.defaultCaptureSoundDisabled. That is enough to filter AirPods out, but it says nothing about the other sources.
What actually identifies Camera Control#
Since events are anonymous, the only reliable signals come from the controls API, which is exclusive to the Camera Control hardware:
guard session.supportsControls else { return }
let zoom = AVCaptureSystemZoomSlider(device: device) { factor in
// Only Camera Control can move this — volume buttons cannot.
}
if session.canAddControl(zoom) { session.addControl(zoom) }
session.setControlsDelegate(self, queue: sessionQueue)Two signals are then available:
sessionControlsDidBecomeActive(_:)— the control overlay appeared, which only a light press on Camera Control can cause.- Slider callbacks — a swipe along the Camera Control surface. Volume buttons cannot move a slider.
Combining them gives a workable heuristic: accept a primary event only while the overlay is active or shortly after it closed. A full click collapses the overlay before delivering the event, so allow a window of a few seconds after sessionControlsDidBecomeInactive.
The traps#
The controls delegate requires an active event interaction. Adding controls and setting a delegate is not enough — without a live AVCaptureEventInteraction in the view hierarchy, the delegate never fires and the button does nothing. This is the most common cause of "AVCaptureSessionControlsDelegate is not being called".
Your app needs a Locked Camera Capture extension. Without one, your app does not even appear in the Camera Control settings list. The extension cannot be a stub — it has to genuinely use the camera with its own capture interaction, or the system terminates it.
supportsControls can raise unrecognized selector on devices without Camera Control hardware. Guard it:
guard probe.responds(to: NSSelectorFromString("supportsControls")) else { return false }The user can switch your detection off. Settings → Camera Control → Camera Adjustments is a user-facing toggle. Turn it off and the overlay never appears and the sliders never move — both of your Camera Control signals vanish, leaving only the anonymous primary. There is no public API to read that toggle's state, so design for the possibility.
Slider callbacks fire continuously. A single swipe produces dozens of calls, one per micro-movement. Guard any "test passed" or "capture" action with a flag, or you will fire it repeatedly.
Do not touch the session in deinit via [weak self]. A closure capturing [weak self] inside deinit forms a weak reference to an object that is already deallocating, and the runtime aborts with "Cannot form weak reference to instance … in the process of deallocation." Capture the session and queue directly instead:
deinit {
let session = session
let queue = sessionQueue
queue.async {
if session.isRunning { session.stopRunning() }
}
}What iOS 26 changed#
Capture controls gained AirPods stem clicks — apps using AVCaptureEventInteraction support them automatically, with no code changes — plus AVCaptureEventSound and playSound(_:) for handling the shutter sound manually when the system one is disabled.
What did not change: there is still no way to identify which physical button produced an event.
Practical advice#
If you are writing a camera app, ignore all of the above and just handle primary. The abstraction is working as intended.
If you are writing a test or a diagnostic that must attribute an event to one specific button, accept the limits early: volume up is fully identifiable, AirPods is identifiable under one condition, and Camera Control is identifiable only while its adjustments are enabled. Everything else is a coin toss, and no amount of cleverness with audio-session volume observation gets around it — the capture interaction suppresses volume changes while it is active, so there is nothing left to observe.



