Если вы пишете приложение-камеру, AVCaptureEventInteraction даёт вам поддержку аппаратных кнопок почти бесплатно. Если вы пишете диагностику, которая должна проверить исправность конкретной кнопки, тот же самый API внезапно оказывается враждебным.
Это конспект того, что мы выяснили, реализуя тест кнопки Camera Control. Часть выводов не описана нигде и стоила нам отладочной сессии с устройством в руках.
Основы#
AVCaptureEventInteraction (iOS 17.2+) — это UIInteraction, который вешается на view. Он даёт два обработчика:
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)У каждого события есть phase: .began в момент нажатия, .ended при отпускании — именно здесь запускают съёмку — и .cancelled, когда приложение уходит в фон или съёмка становится недоступна.
Какая кнопка в какой обработчик попадает#
Именно здесь легко ошибиться, и последствия вполне осязаемые:
| Обработчик | Что его вызывает |
|---|---|
primary | Громкость вниз, Action button, Camera Control, клик ножки AirPods (iOS 26) |
secondary | Громкость вверх — и больше ничего |
Отсюда сразу два вывода.
Camera Control никогда не шлёт secondary. Если вы повесили функцию на secondary, считая это жестом Camera Control, на деле она управляется исключительно кнопкой громкости вверх. У нас была ровно такая ошибка: переключение камеры на secondary откликалось только на громкость.
Не задать secondary — не то же самое, что игнорировать его. Если обработчик не указан, клики громкости вверх начинают проваливаться в primary. Чтобы поглотить громкость вверх без действий, обработчик всё равно нужно объявить — просто оставить пустым.
Различить кнопки нельзя#
У AVCaptureEvent нет свойства, идентифицирующего источник. Мы сверились с машинно-сгенерированными диффами SDK iOS 26: тип предоставляет phase и shouldPlaySound, и это всё. Абстракция сделана намеренно — Apple передаёт «пользователь попросил снять», а не «пользователь нажал клавишу X».
Для камеры это нормально. Для теста, который обязан доказать работоспособность конкретной кнопки, — фатально: нажатие громкости вниз и клик Camera Control неразличимы на уровне API.
Единственное исключение — AirPods. shouldPlaySound равно true только если событие пришло от клика ножки AirPods и вы отключили системный звук затвора через AVCaptureEventInteraction.defaultCaptureSoundDisabled. Этого хватает, чтобы отфильтровать AirPods, но об остальных источниках свойство ничего не говорит.
Что действительно опознаёт Camera Control#
Раз события анонимны, надёжные сигналы даёт только API контролов, эксклюзивный для железа Camera Control:
guard session.supportsControls else { return }
let zoom = AVCaptureSystemZoomSlider(device: device) { factor in
// Сдвинуть слайдер может только Camera Control — кнопки громкости не могут.
}
if session.canAddControl(zoom) { session.addControl(zoom) }
session.setControlsDelegate(self, queue: sessionQueue)После этого доступны два сигнала:
sessionControlsDidBecomeActive(_:)— появилась панель контролов, а вызвать её может только лёгкое нажатие на Camera Control.- Колбэки слайдеров — свайп по поверхности Camera Control. Кнопки громкости слайдер не двигают.
Их комбинация даёт рабочую эвристику: принимать primary только пока панель активна или сразу после её закрытия. Полный клик схлопывает панель до доставки события, поэтому после sessionControlsDidBecomeInactive нужно оставлять окно в несколько секунд.
Ловушки#
Делегату контролов нужна активная интеракция событий. Добавить контролы и назначить делегата недостаточно: без живого AVCaptureEventInteraction в иерархии view делегат не вызывается вообще, а кнопка ничего не делает. Это самая частая причина жалоб «AVCaptureSessionControlsDelegate не вызывается».
Приложению нужно расширение Locked Camera Capture. Без него оно даже не появится в списке настроек Camera Control. Расширение не может быть заглушкой — оно обязано реально работать с камерой и иметь собственную интеракцию, иначе система его завершает.
supportsControls может выбросить unrecognized selector на устройствах без соответствующего железа. Защищайтесь:
guard probe.responds(to: NSSelectorFromString("supportsControls")) else { return false }Пользователь может выключить ваш детект. Настройки → Camera Control → Camera Adjustments — пользовательский тумблер. Если его выключить, панель не появляется и слайдеры не двигаются: оба ваших сигнала исчезают, остаётся только анонимный primary. Публичного API для чтения состояния тумблера нет, так что закладывайтесь на такую возможность заранее.
Колбэки слайдера сыплются непрерывно. Один свайп порождает десятки вызовов, по одному на микродвижение пальца. Защищайте флагом любое действие вроде «тест пройден» или «снять кадр», иначе оно выполнится многократно.
Не трогайте сессию в deinit через [weak self]. Замыкание с [weak self] внутри deinit формирует weak-ссылку на уже деаллоцируемый объект, и рантайм падает с «Cannot form weak reference to instance … in the process of deallocation». Захватывайте сессию и очередь напрямую:
deinit {
let session = session
let queue = sessionQueue
queue.async {
if session.isRunning { session.stopRunning() }
}
}Что изменилось в iOS 26#
Capture controls научились кликам ножки AirPods — приложения на AVCaptureEventInteraction поддерживают их автоматически, без изменений кода, — плюс появились AVCaptureEventSound и playSound(_:) для ручного воспроизведения звука затвора, когда системный отключён.
Что не изменилось: способа определить, какая физическая кнопка породила событие, по-прежнему нет.
Практический вывод#
Если вы пишете камеру — забудьте всё вышесказанное и просто обрабатывайте primary. Абстракция работает как задумано.
Если вы пишете тест или диагностику, которой нужно привязать событие к конкретной кнопке, примите ограничения сразу: громкость вверх опознаётся полностью, AirPods — при одном условии, Camera Control — только пока включены её adjustments. Всё остальное — гадание, и никакие ухищрения с наблюдением за громкостью аудиосессии тут не помогут: пока интеракция активна, она подавляет изменение громкости, и наблюдать становится нечего.



