Хаптик-фидбек в SwiftUI: sensoryFeedback, Core Haptics и AHAP-паттерны в iOS 26
Разбираем хаптик-фидбек в SwiftUI: когда хватает .sensoryFeedback, а когда пора идти в Core Haptics. Параметры intensity и sharpness, декларативные AHAP-паттерны, синхронизация с анимациями и правила accessibility для iOS 26.
Хаптик-фидбек в SwiftUI добавляется двумя способами: модификатор .sensoryFeedback (iOS 17+) для типовых случаев вроде подтверждения тапа или ошибки формы, и фреймворк Core Haptics, когда нужны собственные паттерны с точным контролем интенсивности и «резкости». В iOS 26 sensoryFeedback обзавёлся новыми пресетами, а Core Haptics научился грузить AHAP-файлы прямо из Asset Catalog. Ниже я покажу оба API на реальных примерах, разберу параметры intensity и sharpness, и объясню, когда какая опция уместна с точки зрения UX-инженера.
.sensoryFeedback подходит для 90% задач: успех и ошибка формы, выбор в пикере, нажатие кнопки. Код в одну строку.
Core Haptics нужен, когда требуется собственный паттерн: игровой удар, тактильная волна для слайдера, вибрация «в такт» звуку.
Параметры intensity (0.0–1.0) и sharpness (0.0–1.0), это амплитуда и «острота» ощущения. Sharpness = 1.0 читается как металлический щелчок, 0.0 — как мягкий толчок.
Формат AHAP (JSON) хранит паттерны декларативно, что позволяет менять их без ребилда.
Core Haptics требует iPhone 8 и новее. На iPad и Mac API вернёт false из CHHapticEngine.capabilitiesForHardware().
Не забывайте про Reduce Motion и системную настройку System Haptics: уважение к этим флагам отличает продуктовый код от «прототипа».
Что такое хаптик-фидбек в SwiftUI
Хаптик-фидбек, это тактильные ощущения, которые генерирует Taptic Engine iPhone в ответ на действия пользователя. В отличие от старомодной «вибрации» (короткого, дребезжащего импульса), Taptic Engine (линейный резонансный привод, введённый в iPhone 7) умеет воспроизводить точечные, дозированные толчки и длительные текстуры. В SwiftUI это ощущение можно вызвать буквально одной строкой: .sensoryFeedback(.success, trigger: value). Под капотом фреймворк дергает системный UIFeedbackGenerator, но с декларативной привязкой к состоянию, а не императивным вызовом в обработчике действия.
С точки зрения UX-инженера хаптик выполняет одну из трёх ролей: подтверждает действие (успех платежа), обозначает границу (щелчок пикера при переходе через значение) или усиливает физическую метафору (пружина возвращается на место с лёгким толчком). Если фидбек не выполняет ни одной из этих функций, его не должно быть. Честно говоря, хаптический «шум» раздражает быстрее визуального: у пальца нет фокуса, он всё чувствует.
Модификатор sensoryFeedback: простой API
Модификатор sensoryFeedback появился в iOS 17 и в iOS 26 остаётся рекомендованным API для 90% задач. Он принимает тип ощущения и триггер: любое Equatable-значение, изменение которого запускает хаптик. Ниже базовый пример с добавлением товара в корзину:
import SwiftUI
struct AddToCartButton: View {
@State private var itemsInCart = 0
var body: some View {
Button("В корзину") {
itemsInCart += 1
}
.sensoryFeedback(.success, trigger: itemsInCart)
}
}
Хаптик сработает при каждом изменении itemsInCart. Никаких UIImpactFeedbackGenerator, никаких prepare() и impactOccurred(). Доступные пресеты в iOS 26: .success, .warning, .error, .selection, .increase, .decrease, .start, .stop, .alignment, .levelChange, а также .impact(flexibility:intensity:) и .impact(weight:intensity:) для настраиваемых ударов.
Условный триггер
Часто хаптик должен срабатывать не на каждое изменение, а только когда новое значение удовлетворяет условию (например, лайк добавлен, но не убран). Для этого есть перегрузка с замыканием condition:
@State private var isLiked = false
Button {
isLiked.toggle()
} label: {
Image(systemName: isLiked ? "heart.fill" : "heart")
}
.sensoryFeedback(.impact(weight: .light, intensity: 0.7),
trigger: isLiked) { oldValue, newValue in
newValue == true // хаптик только при добавлении лайка
}
Такой pattern я использую всегда, когда действие обратимо: сохранение/удаление, подписка/отписка, включение/выключение. Пользователю нужно тактильно почувствовать «взял на себя обязательство», а не «откатил». В моей практике spring response 0.4 с damping 0.75 для визуальной анимации и .impact(weight: .light, intensity: 0.65) для хаптика, это самая частая связка для toggle-элементов.
Чем sensoryFeedback отличается от Core Haptics
Параметр
.sensoryFeedback
Core Haptics
Минимальная версия
iOS 17
iOS 13
Строк кода на базовый случай
1
~15–20
Пользовательские паттерны
Нет (только пресеты)
Да, полный контроль
Синхронизация со звуком
Нет
Да, до 30 сек
Загрузка AHAP-файлов
Нет
Да
Работает на iPad/Mac
Игнорируется молча
Требуется явная проверка
Уважает System Haptics
Автоматически
Автоматически
Типичный сценарий
Кнопки, тумблеры, ошибки форм
Игры, слайдеры «с текстурой», музыкальные приложения
Правило простое: если пресет описывает нужное ощущение, используйте sensoryFeedback. Как только приходит запрос «а сделай такую волну, чтобы палец чувствовал переход через середину слайдера», переходите на Core Haptics. Смешивать оба API в одном экране абсолютно нормально: базовые кнопки на sensoryFeedback, а один нестандартный жест на CHHapticEngine.
Core Haptics: инициализация CHHapticEngine
Core Haptics, это низкоуровневый фреймворк, дающий полный доступ к Taptic Engine. Работать с ним в SwiftUI удобнее через сервисный класс, который создаёт и переиспользует CHHapticEngine. Инициализировать движок дорого (~50 мс на первом запуске), поэтому одно приложение = один движок.
import CoreHaptics
@Observable
final class HapticsService {
private var engine: CHHapticEngine?
init() {
prepareEngine()
}
private func prepareEngine() {
guard CHHapticEngine.capabilitiesForHardware().supportsHaptics else {
return // iPad, Mac, старые iPhone
}
do {
engine = try CHHapticEngine()
engine?.stoppedHandler = { [weak self] reason in
// Приложение ушло в фон, звонок и т.п.
self?.engine = nil
}
engine?.resetHandler = { [weak self] in
try? self?.engine?.start()
}
try engine?.start()
} catch {
engine = nil
}
}
func play(pattern: CHHapticPattern) {
guard let engine else { return }
do {
let player = try engine.makePlayer(with: pattern)
try player.start(atTime: CHHapticTimeImmediate)
} catch {
// Молча деградируем: хаптик не критичен
}
}
}
Ключевой момент: stoppedHandler и resetHandler. Первый вызывается, когда iOS усыпляет движок (входящий звонок, приложение в фоне); второй, когда движок нужно «оживить». Без них вы получите баг «первый раз работает, потом молчит», я сам ловил его в проде на iPhone 12 mini, когда пользователь сворачивал приложение и возвращался через минуту.
Параметры intensity и sharpness на практике
Любой хаптический «удар» в Core Haptics описывается двумя числами: intensity и sharpness. Первое, это амплитуда, буквально «насколько сильно толкнёт». Второе, это то, как ощущается фронт удара: sharpness близкий к 1.0 читается как металлический щелчок пружины, а 0.0 как ватный толчок в подушку. Всё в промежутке, это палитра для UX-инженера.
Ошибка: два transient события с интервалом 90 мс, intensity 0.9, sharpness 0.8. Двойной толчок читается как «нет».
Continuous события: текстуры и волны
Для непрерывного фидбека (например, «трение» при перетаскивании) используется .hapticContinuous. Такое событие звучит от 100 мс до нескольких секунд, а интенсивность и «острота» могут меняться со временем через CHHapticParameterCurve:
Такой паттерн даёт «дыхание»: нарастание и спад. Отлично работает для индикации загрузки, тактильного pull-to-refresh, симуляции трения при перетаскивании тяжёлого объекта.
AHAP-файлы: декларативные паттерны
AHAP (Apple Haptic and Audio Pattern), это JSON-формат для описания хаптических паттернов декларативно. Плюс: паттерн можно править дизайнеру без пересборки приложения; можно A/B-тестировать разные варианты через удалённую доставку файла; и файл читается человеком. Спецификацию Apple опубликовала в Representing Haptic Patterns in AHAP Files.
В iOS 26 playPattern(from:) научился принимать URL из Asset Catalog при использовании нового типа Haptic Data Asset. Это удобнее для локализации хаптик-паттернов под разные варианты интерфейса (например, «тонкий» вариант для Reduce Motion).
Синхронизация хаптика с анимациями SwiftUI
Хороший хаптик всегда «в такт» с анимацией. Правило: тактильный отклик должен попасть в момент, когда пружина достигает пика или объект пересекает значимый край. Для этого удобнее всего запускать хаптик по изменению state, а не в onTapGesture. Тогда он синхронен с началом визуального перехода, а не с моментом отпускания пальца.
Здесь spring response 0.4 определяет длительность визуального «прыжка» примерно в 380 мс, а хаптик прилетает в момент старта анимации: палец сначала чувствует, потом глаз видит завершение. Обратный порядок (сначала анимация, потом хаптик через DispatchQueue.main.asyncAfter) читается как лаг, я ловил это на первом релизе, когда переключил связку и получил жалобу в App Store буквально через день.
Для более сложных сценариев (например, тактильного отклика на пересечении scroll offset), работает связка onChange(of:) с порогом и sensoryFeedback. Подробнее о выборе кривых для сопровождающих анимаций смотрите гайд по анимациям в SwiftUI, там разобраны PhaseAnimator и KeyframeAnimator, с которыми хаптик комбинируется особенно естественно.
Accessibility, Reduce Motion и системные настройки
Пользователь может выключить хаптик четырьмя способами: Settings → Sounds & Haptics → System Haptics, Settings → Accessibility → Motion → Reduce Motion, Settings → Accessibility → Touch → Vibration, а также при беззвучном режиме на некоторых устройствах. Хорошая новость: sensoryFeedback и CHHapticEngine уважают System Haptics автоматически. Если пользователь выключил хаптик глобально, ничего не сработает и никакой битовой ошибки не будет.
Плохая новость: Reduce Motion, это не про хаптик напрямую, но многие пользователи включают его именно чтобы уменьшить сенсорный шум. Мой продуктовый эвристический критерий: если анимация «выключена» при Reduce Motion, соответствующий сопровождающий хаптик тоже должен уйти. Проверяется через Environment:
struct ContextualFeedback: ViewModifier {
@Environment(\.accessibilityReduceMotion) private var reduceMotion
let trigger: Bool
func body(content: Content) -> some View {
content.sensoryFeedback(.impact(weight: .light),
trigger: trigger) { _, _ in
!reduceMotion
}
}
}
Для критичных подтверждений (успешная оплата, ошибка списания) хаптик оставляем всегда, это не украшение, а функциональная часть UI. Подробнее о работе с Accessibility API в SwiftUI мы разбирали в контексте образовательного UX, см. руководство по TipKit в SwiftUI, где accessibility-правила определяют, показывать ли подсказку.
Какие iPhone поддерживают Core Haptics
Core Haptics требует Taptic Engine второго поколения, появившийся в iPhone 8 (сентябрь 2017). Все iPhone начиная с 8-й модели, включая SE 2 и SE 3, поддерживаются. iPhone 7 имеет Taptic Engine, но только для системных ощущений: пользовательские паттерны недоступны. iPad и Mac аппаратно не поддерживают Core Haptics, вызов CHHapticEngine.capabilitiesForHardware().supportsHaptics вернёт false.
Отдельная история, Apple Watch. С watchOS 9+ доступен WKInterfaceDevice.current().play(_:) с пресетами (.click, .success, .failure, .notification, .retry). Полный Core Haptics API на Watch недоступен, только пресеты. Это ограничение аппаратное: Taptic Engine в Watch оптимизирован под короткие импульсы и не воспроизводит continuous-паттерны длиннее ~200 мс.
На всех платформах правильный подход, это тихая деградация. Если устройство не поддерживает хаптик, приложение продолжает работать без него. Никаких ошибок, всплывающих диалогов или условной UI-логики. Пользователь iPad никогда не должен узнать, что «на iPhone здесь ещё и трясётся».
Лучшие практики UX-инженера
За несколько лет работы с хаптиком у меня выкристаллизовалось несколько правил, которые я применяю ко всем проектам. Они не противоречат Apple HIG по хаптику, но добавляют конкретики.
Максимум 3 разных вида хаптика на экране. Больше, и пользователь перестаёт различать смысл каждого. Успех, ошибка, «просто клик», этого хватает.
Не дублируйте системные жесты. iOS уже даёт хаптик на pull-to-refresh, свайп-удаление, свайп-назад. Свой второй поверх системного читается как баг.
Тайминг важнее интенсивности. Хаптик, попадающий в анимационный пик, воспринимается сильнее того же паттерна с задержкой 50 мс. Синхронизируйте по state, а не по gesture callback.
Тестируйте на тихом устройстве в тишине. В шумном офисе с музыкой большинство паттернов сливается в «толчок». Реальные пользователи слушают наушниками, там каждый нюанс слышен.
Хаптик, не замена звука. Если ключевая информация передаётся только звуком, слепые и глухие пользователи её потеряют. Хаптик усиливает, но не заменяет визуальный и звуковой канал.
Держите фидбек до 200 мс. Длинные continuous-паттерны раздражают быстрее любого визуального шума. Исключение: синхронный с реальной задачей фидбек (перетаскивание, прокрутка колеса).
И финальное: не бойтесь убрать хаптик, если тест показал, что он мешает. Отсутствие фидбека, это тоже осознанное дизайнерское решение. Лучше молчаливая кнопка, чем кнопка, которая толкает в палец каждые полсекунды.
Ответы на частые вопросы
Как добавить хаптик-фидбек в SwiftUI одной строкой?
Используйте модификатор .sensoryFeedback(.success, trigger: value) на любой View. Хаптик автоматически сработает при изменении значения value. Доступно с iOS 17, поддерживает пресеты .success, .error, .selection, .impact и другие.
В чём разница между sensoryFeedback и Core Haptics?
sensoryFeedback, это высокоуровневый SwiftUI-модификатор для стандартных пресетов (клик, успех, ошибка). Core Haptics, это низкоуровневый фреймворк, который даёт полный контроль над Taptic Engine: собственные паттерны, синхронизацию со звуком, загрузку AHAP-файлов. Правило: пресета хватает, используйте sensoryFeedback; нужен уникальный паттерн, берите Core Haptics.
Что такое AHAP-файл и как его создать?
AHAP (Apple Haptic and Audio Pattern), это JSON-файл с декларативным описанием хаптического паттерна: список событий, их время, интенсивность и «резкость» (sharpness). Создать можно вручную в текстовом редакторе или через Haptic Composer в Xcode 16 (меню Xcode → Open Developer Tool → Haptic Composer). Загружается через engine.playPattern(from: url).
Какие устройства поддерживают Core Haptics?
Core Haptics работает на iPhone 8 и новее, включая SE 2 и SE 3. iPad и Mac не поддерживаются: вызов CHHapticEngine.capabilitiesForHardware().supportsHaptics вернёт false. На Apple Watch доступны только пресеты через WKInterfaceDevice.play(_:), полный API Core Haptics недоступен.
Как отключить хаптик при включённом Reduce Motion?
Читайте флаг через @Environment(\.accessibilityReduceMotion) и используйте перегрузку sensoryFeedback(_:trigger:condition:), возвращая false из замыкания, когда Reduce Motion включён. Функциональные хаптики (подтверждение платежа, ошибка) оставляйте всегда, они не декоративные.
Что означают параметры intensity и sharpness в Core Haptics?
intensity (0.0–1.0), это амплитуда, «сила» толчка. sharpness (0.0–1.0), это характер фронта: значения ближе к 1.0 читаются как металлический щелчок, ближе к 0.0 как мягкий толчок в подушку. Оба параметра, Float; за пределы диапазона выходить нельзя, Core Haptics их клампит.
Полный разбор ScrollView в SwiftUI и iOS 26: paging и snapping через scrollTargetBehavior, отслеживание позиции через scrollPosition, фазовые анимации scrollTransition и адаптивные карусели. Замена UIScrollView без потери контроля.
Практический гайд по StoreKit 2 в Swift: загрузка продуктов, JWS-верификация транзакций, SubscriptionStoreView и серверная проверка через App Store Server API для iOS 26.
TipKit в SwiftUI: как показать подсказки popover и inline, задать правила и события, донатить активность пользователя, тестировать и не сломать accessibility. Полный гайд для iOS 26 с примерами кода.