Si desarrollas una app de cámara, AVCaptureEventInteraction te da los botones físicos casi gratis. Si desarrollas una app de diagnóstico que debe verificar que un botón concreto funciona, la misma API se vuelve sorprendentemente hostil.
Este es un resumen de lo que aprendimos implementando una prueba de Camera Control — incluidas varias cosas que no están documentadas en ninguna parte y que nos costaron una sesión de depuración con el dispositivo en la mano.
Lo básico#
AVCaptureEventInteraction (iOS 17.2+) es una UIInteraction que se adjunta a una vista. Te entrega dos manejadores:
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)Cada evento lleva una phase: .began cuando el botón se pulsa, .ended al soltarlo — ahí es donde disparas la captura — y .cancelled cuando la app pasa a segundo plano o la captura deja de estar disponible.
Qué botón llega a qué manejador#
Esta es la parte en la que es fácil equivocarse, y las consecuencias son reales:
| Manejador | Lo dispara |
|---|---|
primary | Volumen abajo, Action button, Camera Control, clic en el tallo de los AirPods (iOS 26) |
secondary | Volumen arriba — y nada más |
De aquí se siguen dos conclusiones inmediatas.
Camera Control nunca dispara secondary. Si conectaste una función al manejador secondary pensando que era un gesto de Camera Control, en realidad la controla exclusivamente el botón de subir volumen. Nosotros tuvimos exactamente ese bug: una acción de «cambiar de cámara» en secondary que solo respondía a subir el volumen.
Omitir secondary no es lo mismo que ignorarlo. Si no proporcionas un manejador secondary, los clics de subir volumen caen en primary. Para absorber el volumen arriba sin actuar sobre él, aun así debes declarar el manejador — simplemente déjalo vacío.
No puedes distinguir los botones#
No existe ninguna propiedad en AVCaptureEvent que identifique la fuente. Lo verificamos contra los diffs generados automáticamente del SDK de iOS 26: el tipo expone phase y shouldPlaySound, y eso es todo. La abstracción es deliberada — Apple quiere «el usuario pidió capturar», no «el usuario pulsó la tecla X».
Para una app de cámara esto está bien. Para una prueba que debe demostrar que un botón específico funciona, es fatal: una pulsación de bajar volumen y un clic de Camera Control son indistinguibles a nivel de API.
La única excepción son los AirPods. shouldPlaySound es true solo cuando el evento vino de un clic en el tallo de los AirPods y has desactivado el sonido de captura por defecto mediante AVCaptureEventInteraction.defaultCaptureSoundDisabled. Eso basta para filtrar los AirPods, pero no dice nada sobre las demás fuentes.
Qué identifica realmente a Camera Control#
Como los eventos son anónimos, las únicas señales fiables vienen de la API de controls, exclusiva del hardware de Camera Control:
guard session.supportsControls else { return }
let zoom = AVCaptureSystemZoomSlider(device: device) { factor in
// Solo Camera Control puede mover esto — los botones de volumen no.
}
if session.canAddControl(zoom) { session.addControl(zoom) }
session.setControlsDelegate(self, queue: sessionQueue)A partir de ahí hay dos señales disponibles:
sessionControlsDidBecomeActive(_:)— apareció el overlay de controles, y eso solo puede causarlo una pulsación ligera sobre Camera Control.- Los callbacks del slider — un deslizamiento sobre la superficie de Camera Control. Los botones de volumen no pueden mover un slider.
Combinarlas da una heurística viable: aceptar un evento primary solo mientras el overlay está activo o poco después de cerrarse. Un clic completo colapsa el overlay antes de entregar el evento, así que deja una ventana de unos segundos tras sessionControlsDidBecomeInactive.
Las trampas#
El delegate de controles requiere una event interaction activa. Añadir controles y asignar un delegate no basta — sin una AVCaptureEventInteraction viva en la jerarquía de vistas, el delegate nunca se dispara y el botón no hace nada. Esta es la causa más común de «AVCaptureSessionControlsDelegate no se está llamando».
Tu app necesita una extensión Locked Camera Capture. Sin ella, tu app ni siquiera aparece en la lista de ajustes de Camera Control. La extensión no puede ser un stub — tiene que usar la cámara de verdad con su propia capture interaction, o el sistema la termina.
supportsControls puede lanzar unrecognized selector en dispositivos sin hardware de Camera Control. Protégete:
guard probe.responds(to: NSSelectorFromString("supportsControls")) else { return false }El usuario puede apagar tu detección. Ajustes → Camera Control → Camera Adjustments es un interruptor visible para el usuario. Si lo desactiva, el overlay nunca aparece y los sliders nunca se mueven — tus dos señales de Camera Control desaparecen y solo queda el primary anónimo. No hay API pública para leer el estado de ese interruptor, así que diseña contando con esa posibilidad.
Los callbacks del slider llegan de forma continua. Un solo deslizamiento produce docenas de llamadas, una por micro-movimiento. Protege cualquier acción de «prueba superada» o «capturar» con un flag, o la dispararás repetidamente.
No toques la sesión en deinit mediante [weak self]. Un closure que captura [weak self] dentro de deinit forma una referencia weak a un objeto que ya se está desasignando, y el runtime aborta con "Cannot form weak reference to instance … in the process of deallocation." Captura la sesión y la cola directamente:
deinit {
let session = session
let queue = sessionQueue
queue.async {
if session.isRunning { session.stopRunning() }
}
}Qué cambió en iOS 26#
Los capture controls ganaron los clics en el tallo de los AirPods — las apps que usan AVCaptureEventInteraction los soportan automáticamente, sin cambios de código — además de AVCaptureEventSound y playSound(_:) para gestionar manualmente el sonido del obturador cuando el del sistema está desactivado.
Lo que no cambió: sigue sin haber forma de identificar qué botón físico produjo un evento.
Consejo práctico#
Si estás escribiendo una app de cámara, ignora todo lo anterior y limítate a manejar primary. La abstracción funciona como fue diseñada.
Si estás escribiendo una prueba o un diagnóstico que debe atribuir un evento a un botón específico, acepta los límites desde el principio: el volumen arriba es totalmente identificable, los AirPods lo son bajo una condición, y Camera Control solo mientras sus adjustments están habilitados. Todo lo demás es un cara o cruz, y ninguna astucia con la observación del volumen de la sesión de audio lo soluciona — la capture interaction suprime los cambios de volumen mientras está activa, así que no queda nada que observar.



