Привет, Хабр! Меня зовут Илья, и последние несколько недель я провёл в дебрях документации Apple, исходников AVFoundation и видео с WWDC, разбираясь с новой фичей iPhone 16 — Camera Control. В этой статье расскажу, как устроена архитектура этой системы, почему интеграция сложнее, чем кажется, и дам рабочий код для интеграции в ваше приложение.
Что такое Camera Control и зачем он нужен
Camera Control — это физическая кнопка на боковой грани iPhone 16 (всех моделей линейки), которая:
Запускает камерное приложение одним нажатием
Работает как кнопка затвора для съёмки
Позволяет регулировать параметры (зум, экспозиция) свайпом по поверхности кнопки
Под капотом это комбинация Force Touch сенсора, ёмкостного датчика и Taptic Engine, которая обеспечивает распознавание силы нажатия, свайпов и тактильную обратную связь.
С точки зрения пользователя — возможность снимать одной рукой, не касаясь экрана. С точки зрения разработчика — новый API, который требует понимания архитектуры для корректной интеграции.
Архитектура: почему без Extension ничего не работает
Первое, что я сделал — добавил AVCaptureSystemZoomSlider в свою сессию захвата. Код скомпилировался, запустился на iPhone 16, но в настройках Camera Control (Settings → Camera → Camera Control) моего приложения не было.
После изучения документации и WWDC-сессий стало понятно: Camera Control работает только с приложениями, у которых есть Lock Screen Camera Capture Extension.
Логика Apple следующая:
┌─────────────────────────────────────────────────────────────┐
│ Camera Control Button │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ SpringBoard (системный процесс) │
│ Проверяет: есть ли у приложения │
│ LockedCameraCaptureExtension? │
└─────────────────────────────────────────────────────────────┘
│
┌───────────────┴───────────────┐
▼ ▼
┌──────────────────────┐ ┌──────────────────────┐
│ Extension │ │ Нет Extension │
│ существует │ │ │
└──────────────────────┘ └──────────────────────┘
│ │
▼ ▼
┌──────────────────────┐ ┌──────────────────────┐
│ Приложение доступно │ │ Приложение невидимо │
│ в настройках Camera │ │ для Camera Control │
│ Control │ │ │
└──────────────────────┘ └──────────────────────┘
Extension нужен даже если вы не планируете использовать камеру с заблокированного экрана. Это входной билет в экосистему Camera Control.
Стек технологий
Для полной интеграции потребуются:
Компонент |
Фреймворк |
Минимальная версия |
|---|---|---|
Сессия захвата |
AVFoundation |
iOS 4.0 |
Системные контролы |
AVFoundation |
iOS 18.0 |
Обработка событий кнопки |
AVKit |
iOS 17.2 |
Lock Screen Extension |
LockedCameraCapture |
iOS 18.0 |
Ключевые классы:
AVCaptureSession— управление потоком данных от камерыAVCaptureControl— базовый класс для контролов Camera ControlAVCaptureSystemZoomSlider— системный слайдер зумаAVCaptureSystemExposureBiasSlider— системный слайдер экспозицииAVCaptureEventInteraction— обработка событий физических кнопокAVCaptureSessionControlsDelegate— делегат для отслеживания состояния контролов
Шаг 1: Создание Lock Screen Camera Extension
В Xcode: File → New → Target → Locked Camera Capture Extension.
Минимальная реализация:
import LockedCameraCapture
import AVFoundation
import Photos
@main
class LockScreenCameraExtension: NSObject, LockedCameraCaptureExtension {
private var captureSession: LockedCameraCaptureSession?
private var photoOutput: LockedCameraCapturePhotoOutput?
func beginCapture(
with device: LockedCameraCaptureDevice,
completion: @escaping (Error?) -> Void
) {
captureSession = LockedCameraCaptureSession()
photoOutput = LockedCameraCapturePhotoOutput()
guard let session = captureSession,
let output = photoOutput else {
completion(ExtensionError.initializationFailed)
return
}
do {
try session.addInput(device)
try session.addOutput(output)
session.startRunning()
completion(nil)
} catch {
completion(error)
}
}
func endCapture(completion: @escaping (Error?) -> Void) {
captureSession?.stopRunning()
captureSession = nil
photoOutput = nil
completion(nil)
}
func captureOutput(
_ output: LockedCameraCaptureOutput,
didOutput sampleBuffer: CMSampleBuffer,
from connection: LockedCameraCaptureConnection
) {
guard let imageBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else {
return
}
let ciImage = CIImage(cvImageBuffer: imageBuffer)
let context = CIContext()
guard let cgImage = context.createCGImage(ciImage, from: ciImage.extent),
let jpegData = UIImage(cgImage: cgImage).jpegData(compressionQuality: 0.9) else {
return
}
PHPhotoLibrary.shared().performChanges {
let request = PHAssetCreationRequest.forAsset()
request.addResource(with: .photo, data: jpegData, options: nil)
}
}
}
enum ExtensionError: Error {
case initializationFailed
}
Info.plist для Extension:
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.camera.lock-screen</string>
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).LockScreenCameraExtension</string>
</dict>
<key>NSCameraUsageDescription</key>
<string>Камера для съёмки с заблокированного экрана</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>Сохранение снимков в галерею</string>
Важный момент: Extension будет убит системой, если не запросит доступ к камере или не настроит AVCaptureEventInteraction. Пустой Extension не пройдёт.
Шаг 2: Базовый CameraController
Создаём класс, который инкапсулирует всю работу с камерой:
import AVFoundation
import AVKit
import Combine
final class CameraController: NSObject, ObservableObject {
// MARK: - Published Properties
@Published private(set) var zoomFactor: CGFloat = 1.0
@Published private(set) var exposureBias: Float = 0.0
@Published private(set) var isCameraControlActive = false
@Published private(set) var authorizationStatus: AVAuthorizationStatus = .notDetermined
// MARK: - Internal Properties
let session = AVCaptureSession()
// MARK: - Private Properties
private let sessionQueue = DispatchQueue(
label: "com.app.camera.session",
qos: .userInitiated
)
private let photoOutput = AVCapturePhotoOutput()
private var deviceInput: AVCaptureDeviceInput?
private var keyValueObservations: [NSKeyValueObservation] = []
// MARK: - Initialization
override init() {
super.init()
checkAuthorization()
}
// MARK: - Authorization
private func checkAuthorization() {
switch AVCaptureDevice.authorizationStatus(for: .video) {
case .authorized:
authorizationStatus = .authorized
sessionQueue.async { self.configureSession() }
case .notDetermined:
sessionQueue.suspend()
AVCaptureDevice.requestAccess(for: .video) { [weak self] granted in
guard let self else { return }
DispatchQueue.main.async {
self.authorizationStatus = granted ? .authorized : .denied
}
if granted {
self.configureSession()
}
self.sessionQueue.resume()
}
case .denied, .restricted:
authorizationStatus = .denied
@unknown default:
break
}
}
// MARK: - Session Configuration
private func configureSession() {
session.beginConfiguration()
defer { session.commitConfiguration() }
session.sessionPreset = .photo
// Input
guard let camera = AVCaptureDevice.default(
.builtInWideAngleCamera,
for: .video,
position: .back
) else {
print("[CameraController] Camera device not available")
return
}
do {
let input = try AVCaptureDeviceInput(device: camera)
guard session.canAddInput(input) else {
print("[CameraController] Cannot add input to session")
return
}
session.addInput(input)
deviceInput = input
} catch {
print("[CameraController] Failed to create input: \(error)")
return
}
// Output
guard session.canAddOutput(photoOutput) else {
print("[CameraController] Cannot add output to session")
return
}
session.addOutput(photoOutput)
photoOutput.isHighResolutionCaptureEnabled = true
photoOutput.maxPhotoQualityPrioritization = .quality
// Camera Control (iOS 18+)
if #available(iOS 18.0, *) {
configureCameraControls()
}
// KVO
setupObservers(for: camera)
// Start
session.startRunning()
}
private func setupObservers(for device: AVCaptureDevice) {
let zoomObservation = device.observe(\.videoZoomFactor, options: .new) { [weak self] _, change in
guard let value = change.newValue else { return }
DispatchQueue.main.async {
self?.zoomFactor = value
}
}
let exposureObservation = device.observe(\.exposureTargetBias, options: .new) { [weak self] _, change in
guard let value = change.newValue else { return }
DispatchQueue.main.async {
self?.exposureBias = value
}
}
keyValueObservations = [zoomObservation, exposureObservation]
}
// MARK: - Lifecycle
func start() {
sessionQueue.async { [weak self] in
guard let self, !self.session.isRunning else { return }
self.session.startRunning()
}
}
func stop() {
sessionQueue.async { [weak self] in
guard let self, self.session.isRunning else { return }
self.session.stopRunning()
}
}
deinit {
keyValueObservations.removeAll()
}
}
Шаг 3: Интеграция Camera Control
Добавляем extension к CameraController для работы с Camera Control:
// MARK: - Camera Control (iOS 18+)
@available(iOS 18.0, *)
extension CameraController: AVCaptureSessionControlsDelegate {
func configureCameraControls() {
guard session.supportsControls else {
print("[CameraController] Camera Control not supported on this device")
return
}
guard let device = deviceInput?.device else {
print("[CameraController] No camera device for controls")
return
}
// Удаляем старые контролы (если были)
session.controls.forEach { session.removeControl($0) }
// Zoom Slider
let zoomSlider = AVCaptureSystemZoomSlider(device: device) { [weak self] factor in
DispatchQueue.main.async {
self?.zoomFactor = factor
}
}
// Exposure Slider
let exposureSlider = AVCaptureSystemExposureBiasSlider(device: device) { [weak self] bias in
DispatchQueue.main.async {
self?.exposureBias = bias
}
}
// Добавляем контролы
for control in [zoomSlider, exposureSlider] {
if session.canAddControl(control) {
session.addControl(control)
} else {
print("[CameraController] Cannot add control: \(type(of: control))")
}
}
// Устанавливаем делегат
session.setControlsDelegate(self, queue: sessionQueue)
print("[CameraController] Camera Control configured with \(session.controls.count) controls")
}
// MARK: - AVCaptureSessionControlsDelegate
func sessionControlsDidBecomeActive(_ session: AVCaptureSession) {
DispatchQueue.main.async { [weak self] in
self?.isCameraControlActive = true
}
}
func sessionControlsDidBecomeInactive(_ session: AVCaptureSession) {
DispatchQueue.main.async { [weak self] in
self?.isCameraControlActive = false
}
}
func sessionControlsWillEnterFullscreenAppearance(_ session: AVCaptureSession) {
// Скрываем кастомные UI-элементы
NotificationCenter.default.post(name: .cameraControlWillEnterFullscreen, object: nil)
}
func sessionControlsWillExitFullscreenAppearance(_ session: AVCaptureSession) {
// Показываем кастомные UI-элементы
NotificationCenter.default.post(name: .cameraControlWillExitFullscreen, object: nil)
}
}
extension Notification.Name {
static let cameraControlWillEnterFullscreen = Notification.Name("cameraControlWillEnterFullscreen")
static let cameraControlWillExitFullscreen = Notification.Name("cameraControlWillExitFullscreen")
}
Шаг 4: Обработка событий кнопки затвора
AVCaptureEventInteraction доступен с iOS 17.2 и позволяет обрабатывать нажатия физических кнопок:
// MARK: - Capture Event Interaction
extension CameraController {
@available(iOS 17.2, *)
func setupCaptureEventInteraction(on view: UIView) -> AVCaptureEventInteraction {
let interaction = AVCaptureEventInteraction(
primary: { [weak self] event in
self?.handleCaptureEvent(event)
},
secondary: { [weak self] event in
self?.handleCaptureEvent(event)
}
)
interaction.isEnabled = true
view.addInteraction(interaction)
return interaction
}
private func handleCaptureEvent(_ event: AVCaptureEvent) {
switch event.phase {
case .began:
// Визуальная обратная связь (анимация кнопки и т.д.)
NotificationCenter.default.post(name: .captureEventBegan, object: nil)
case .ended:
// Делаем снимок только на ended, не на began
capturePhoto()
NotificationCenter.default.post(name: .captureEventEnded, object: nil)
case .cancelled:
NotificationCenter.default.post(name: .captureEventCancelled, object: nil)
@unknown default:
break
}
}
}
extension Notification.Name {
static let captureEventBegan = Notification.Name("captureEventBegan")
static let captureEventEnded = Notification.Name("captureEventEnded")
static let captureEventCancelled = Notification.Name("captureEventCancelled")
}
Шаг 5: Захват фото
// MARK: - Photo Capture
extension CameraController: AVCapturePhotoCaptureDelegate {
func capturePhoto() {
guard let connection = photoOutput.connection(with: .video),
connection.isEnabled else {
print("[CameraController] Photo connection not available")
return
}
let settings = AVCapturePhotoSettings()
settings.flashMode = .auto
settings.isHighResolutionPhotoEnabled = true
photoOutput.capturePhoto(with: settings, delegate: self)
}
func photoOutput(
_ output: AVCapturePhotoOutput,
didFinishProcessingPhoto photo: AVCapturePhoto,
error: Error?
) {
if let error {
print("[CameraController] Capture error: \(error)")
return
}
guard let data = photo.fileDataRepresentation() else {
print("[CameraController] No image data")
return
}
// Сохраняем в галерею
PHPhotoLibrary.requestAuthorization(for: .addOnly) { status in
guard status == .authorized else { return }
PHPhotoLibrary.shared().performChanges {
let request = PHAssetCreationRequest.forAsset()
request.addResource(with: .photo, data: data, options: nil)
}
}
}
}
Шаг 6: SwiftUI View
import SwiftUI
struct CameraView: View {
@StateObject private var camera = CameraController()
@State private var captureInteraction: AVCaptureEventInteraction?
var body: some View {
GeometryReader { geometry in
ZStack {
CameraPreviewView(session: camera.session) { view in
if #available(iOS 17.2, *) {
captureInteraction = camera.setupCaptureEventInteraction(on: view)
}
}
.ignoresSafeArea()
VStack {
controlsOverlay
Spacer()
captureButton
}
.padding()
}
}
.onAppear { camera.start() }
.onDisappear { camera.stop() }
}
private var controlsOverlay: some View {
HStack {
// Зум
Text(String(format: "%.1fx", camera.zoomFactor))
.font(.system(.caption, design: .monospaced))
.padding(8)
.background(.ultraThinMaterial)
.clipShape(Capsule())
Spacer()
// Экспозиция
Text(String(format: "%+.1f EV", camera.exposureBias))
.font(.system(.caption, design: .monospaced))
.padding(8)
.background(.ultraThinMaterial)
.clipShape(Capsule())
Spacer()
// Индикатор Camera Control
if camera.isCameraControlActive {
Image(systemName: "button.horizontal.top.press")
.padding(8)
.background(Color.blue)
.foregroundColor(.white)
.clipShape(Circle())
}
}
}
private var captureButton: some View {
Button(action: { camera.capturePhoto() }) {
Circle()
.strokeBorder(.white, lineWidth: 4)
.frame(width: 72, height: 72)
.overlay(
Circle()
.fill(.white)
.padding(6)
)
}
.opacity(camera.isCameraControlActive ? 0.3 : 1.0)
.animation(.easeInOut(duration: 0.2), value: camera.isCameraControlActive)
}
}
// MARK: - Camera Preview
struct CameraPreviewView: UIViewRepresentable {
let session: AVCaptureSession
let onViewCreated: ((UIView) -> Void)?
func makeUIView(context: Context) -> PreviewView {
let view = PreviewView()
view.videoPreviewLayer.session = session
view.videoPreviewLayer.videoGravity = .resizeAspectFill
onViewCreated?(view)
return view
}
func updateUIView(_ uiView: PreviewView, context: Context) {}
}
final class PreviewView: UIView {
override class var layerClass: AnyClass { AVCaptureVideoPreviewLayer.self }
var videoPreviewLayer: AVCaptureVideoPreviewLayer { layer as! AVCaptureVideoPreviewLayer }
}
Кастомные контролы
Помимо системных AVCaptureSystemZoomSlider и AVCaptureSystemExposureBiasSlider, можно создавать свои контролы:
@available(iOS 18.0, *)
func createCustomControls(for device: AVCaptureDevice) -> [AVCaptureControl] {
var controls: [AVCaptureControl] = []
// Слайдер ISO (если поддерживается)
if device.isExposureModeSupported(.custom) {
let isoRange = device.activeFormat.minISO...device.activeFormat.maxISO
let isoSlider = AVCaptureSlider(
"ISO",
symbolName: "camera.aperture",
in: Float(isoRange.lowerBound)...Float(isoRange.upperBound)
)
isoSlider.setActionQueue(sessionQueue) { [weak self] value in
guard let self else { return }
do {
try device.lockForConfiguration()
device.setExposureModeCustom(
duration: device.exposureDuration,
iso: value
)
device.unlockForConfiguration()
} catch {
print("[CameraController] ISO error: \(error)")
}
}
controls.append(isoSlider)
}
// Переключатель вспышки
if device.hasFlash {
let flashPicker = AVCaptureIndexPicker(
"Flash",
symbolName: "bolt.fill",
localizedIndexTitles: ["Off", "On", "Auto"]
)
flashPicker.setActionQueue(sessionQueue) { [weak self] index in
// Сохраняем выбор для следующего снимка
DispatchQueue.main.async {
self?.selectedFlashMode = AVCaptureDevice.FlashMode(rawValue: index) ?? .auto
}
}
controls.append(flashPicker)
}
return controls
}
Диагностика и отладка
При возникновении проблем полезно иметь диагностическую функцию:
struct CameraControlDiagnostics {
static func run() -> String {
var report = """
═══════════════════════════════════════
Camera Control Diagnostics
═══════════════════════════════════════
"""
// Device Info
report += "Device: \(UIDevice.current.name)\n"
report += "Model: \(UIDevice.current.model)\n"
report += "iOS: \(UIDevice.current.systemVersion)\n\n"
// iOS 18 Check
if #available(iOS 18.0, *) {
report += "✓ iOS 18.0+ available\n"
let session = AVCaptureSession()
if session.supportsControls {
report += "✓ Camera Control hardware supported\n"
} else {
report += "✗ Camera Control hardware NOT supported (iPhone 16+ required)\n"
}
} else {
report += "✗ iOS 18.0+ NOT available\n"
}
// Camera Check
if let camera = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back) {
report += "✓ Camera available\n"
report += " - Zoom range: \(camera.minAvailableVideoZoomFactor)x - \(camera.maxAvailableVideoZoomFactor)x\n"
report += " - Exposure range: \(camera.minExposureTargetBias) - \(camera.maxExposureTargetBias) EV\n"
} else {
report += "✗ Camera NOT available\n"
}
// Authorization
let status = AVCaptureDevice.authorizationStatus(for: .video)
switch status {
case .authorized: report += "✓ Camera authorized\n"
case .denied: report += "✗ Camera denied\n"
case .restricted: report += "✗ Camera restricted\n"
case .notDetermined: report += "? Camera not determined\n"
@unknown default: report += "? Camera unknown status\n"
}
report += "\n═══════════════════════════════════════\n"
return report
}
}
Частые ошибки
1. Обновление UI не на main thread
// ❌ Краш или некорректное поведение
let slider = AVCaptureSystemZoomSlider(device: device) { zoom in
self.zoomLabel.text = "\(zoom)x"
}
// ✓ Корректно
let slider = AVCaptureSystemZoomSlider(device: device) { zoom in
DispatchQueue.main.async {
self.zoomLabel.text = "\(zoom)x"
}
}
2. Конфигурация сессии на main thread
// ❌ Блокирует UI
session.beginConfiguration()
// ...
session.commitConfiguration()
// ✓ Корректно
sessionQueue.async {
self.session.beginConfiguration()
// ...
self.session.commitConfiguration()
}
3. Отсутствие проверки supportsControls
// ❌ Падение на старых устройствах
@available(iOS 18.0, *)
func setup() {
let slider = AVCaptureSystemZoomSlider(device: device)
session.addControl(slider) // Может не поддерживаться!
}
// ✓ Корректно
@available(iOS 18.0, *)
func setup() {
guard session.supportsControls else { return }
let slider = AVCaptureSystemZoomSlider(device: device)
if session.canAddControl(slider) {
session.addControl(slider)
}
}
4. Дублирование контролов
// ❌ Ошибка при повторном вызове
func setupControls() {
let slider = AVCaptureSystemZoomSlider(device: device)
session.addControl(slider) // Второй вызов упадёт
}
// ✓ Корректно
func setupControls() {
// Удаляем существующие
session.controls.forEach { session.removeControl($0) }
let slider = AVCaptureSystemZoomSlider(device: device)
if session.canAddControl(slider) {
session.addControl(slider)
}
}
Заключение
Camera Control — интересная технология, но её интеграция требует понимания архитектуры. Ключевые моменты:
Lock Screen Extension обязателен — без него приложение невидимо для Camera Control
Проверяйте
supportsControls— работает только на iPhone 16+Используйте фоновую очередь для конфигурации сессии
Обновляйте UI на main thread — колбэки контролов приходят на фоновой очереди
Реализуйте graceful degradation — приложение должно работать и без Camera Control
Полный проект с примерами для SwiftUI и UIKit, Lock Screen Extension и диагностическими утилитами я выложил на сайте:
dodecaidr.pro/ru/articles/camera-control
Там же есть готовый чек-лист для тестирования перед релизом.
Если есть вопросы по реализации — пишите в комментариях, постараюсь помочь.
Теги: iOS, Swift, AVFoundation, Camera Control, iPhone 16, iOS 18, Разработка под iOS