Оновлено: 13 серпня 2026
Хаптика у SwiftUI для iOS 26 будується на двох рівнях. Перший, декларативний модифікатор .sensoryFeedback, покриває стандартні відгуки (success, error, impact, selection). Другий, фреймворк Core Haptics, дає власні патерни через CHHapticEngine та AHAP-файли. У 99% екранів вистачає першого: одного рядка модифікатора, прив'язаного до значення-тригера. Другий потрібен, коли ви оркеструєте послідовність із десятків подій, синхронізуєте вібрацію зі звуком або малюєте власну «фактуру» під бренд. Цей посібник показує обидва підходи з реальними значеннями (intensity, sharpness, spring response), які я підбирала на фізичних пристроях, а не на симуляторі.
Швидкий старт: перший тактильний відгук за один рядок
Найшвидший шлях додати хаптику в SwiftUI-застосунок для iOS 26 такий: прикріпити модифікатор .sensoryFeedback до будь-якого View і передати йому тригер. Тригер, це будь-яке Equatable-значення у вашому state; кожного разу, коли воно змінюється, SwiftUI відтворює вказаний тип відгуку. Ніякого prepare(), ніякого утримання посилання на генератор. Всі оптимізації робить система.
import SwiftUI
struct LikeButton: View {
@State private var likes = 0
var body: some View {
Button {
likes += 1
} label: {
Label("\(likes)", systemImage: "heart.fill")
.font(.title2.weight(.semibold))
.contentTransition(.numericText(value: Double(likes)))
}
.sensoryFeedback(.impact(weight: .light, intensity: 0.7), trigger: likes)
.animation(.spring(response: 0.32, dampingFraction: 0.7), value: likes)
}
}
У цьому шаблоні я навмисно поставила intensity: 0.7 замість дефолтної 1.0. За моїми спостереженнями на iPhone 15 Pro та iPhone 17, повна інтенсивність для дрібної дії типу «лайк» відчувається агресивно й швидко втомлює. Значення 0.6–0.75 звучить «правильно» для інкрементних змін. Пружину я тримаю на response: 0.32, щоб візуальний і тактильний відгук збіглися у часі: більший response відірве кадри від вібрації, і хаптика читатиметься як окрема подія, а не як частина руху.
Модифікатор sensoryFeedback: усі 11 типів у iOS 26
У iOS 17 Apple додала базовий набір, а до iOS 26 SensoryFeedback розрісся до 11 семантичних типів. Кожен тип має конкретне UX-призначення. Не використовуйте, наприклад, .success для звичайного натискання кнопки, бо система адаптує його інтенсивність під очікування користувача «щось важливе завершено».
| Тип | Коли використовувати | Відчуття |
|---|---|---|
.success | Успішне завершення дії, підтвердження | Легкий «двоступінчастий» патерн |
.warning | Дія вимагає уваги, але не помилка | Різкий короткий стук |
.error | Помилка, недоступна дія | Три короткі удари |
.selection | Прокрутка сегментів, зміна значення в пікері | Ледь помітний клік |
.impact(flexibility:intensity:) | Взаємодія з фізичним об'єктом (тумблер, слайдер) | Регульований удар |
.impact(weight:intensity:) | Той самий impact, але описаний через масу | Легкий/середній/важкий |
.increase / .decrease | Крокові зміни (наприклад, +/− крок) | Спрямований клік |
.start / .stop | Початок/кінець довгої дії (запис, drag) | Різкий короткий пік |
.alignment | Прив'язка при перетягуванні до сітки | Тонкий «магнітний» щиглик |
.pathComplete | Замикання жестового шляху (unlock, підпис) | Легкий вихід «наверх» |
.levelChange | Перехід між дискретними станами (сторінка) | Двофазний перехід |
Типи .alignment, .pathComplete і .levelChange, це саме те, що часто плутають з .selection. Різниця у семантиці: .selection спрацьовує, коли значення змінилося, а .alignment, коли жест потрапив у snap-точку. Якщо ви робите щось на кшталт ScrollTargetBehavior з магнітом до країв елементів, .alignment звучить природніше. У моєму досвіді системи навігації, побудовані на типобезпечному NavigationStack і NavigationPath, часто виграють від .levelChange при переходах між екранами: це працює як тактильна «сходинка» ієрархії.
@State private var selected: Category?
Picker("Категорія", selection: $selected) { /* ... */ }
.pickerStyle(.segmented)
.sensoryFeedback(.selection, trigger: selected)
// А для snap-у у drag-жесті:
.gesture(
DragGesture()
.onChanged { value in
let snapped = snapToGrid(value.translation)
if snapped != lastSnap {
lastSnap = snapped
snapCounter += 1
}
}
)
.sensoryFeedback(.alignment, trigger: snapCounter)
Умовний тригер: коли відгук має спрацювати
Найчастіша помилка, з якою я стикаюся на код-рев'ю: розробник вішає .sensoryFeedback на тригер, який змінюється значно частіше, ніж треба. Наприклад, @State з кількістю прокручених пікселів у скролі, і телефон гуде безперервно, доки палець рухається. SwiftUI дає два способи контролю: варіант із condition-замиканням і варіант із значенням замикання.
// 1) Умовне спрацювання: тільки коли перейшли з nil у не-nil.
.sensoryFeedback(.selection, trigger: store.selection) { oldValue, newValue in
oldValue == nil && newValue != nil
}
// 2) Динамічний вибір типу відгуку залежно від дельти.
.sensoryFeedback(trigger: dragOffset) { oldValue, newValue in
let delta = abs(newValue - oldValue)
guard delta >= 40 else { return nil }
return .impact(flexibility: .solid, intensity: min(1.0, delta / 200))
}
Другий варіант, це те, що дає нам «пропорційну» хаптику. Я використовую його для повзунків налаштувань і жестів прокрутки з інерцією: користувач відчуває, як «натяжка» посилюється разом з амплітудою жесту. Порогове значення delta >= 40 не універсальне; для великоформатних елементів я підіймаю поріг до 60, для точних (стилус) опускаю до 20.
Impact із flexibility, weight та intensity
Тип .impact має два варіанти опису «фізики» удару, і вибір впливає на те, як тактильний відгук читається користувачем. Параметр flexibility моделює матеріал об'єкта (soft, solid, rigid), а weight моделює його масу (light, medium, heavy). Комбінувати обидва не можна: це два окремі ініціалізатори.
// «М'який» удар, гумовий м'яч по столу.
.sensoryFeedback(.impact(flexibility: .soft, intensity: 0.65), trigger: bounce)
// «Твердий» удар, кубик льоду.
.sensoryFeedback(.impact(flexibility: .rigid, intensity: 0.85), trigger: snap)
// Через масу, важкий тумблер вимикача.
.sensoryFeedback(.impact(weight: .heavy, intensity: 0.9), trigger: toggleState)
Мої підбори з UX-тестування: для перемикачів (toggles) я беру .impact(weight: .medium, intensity: 0.85), для кнопок довільного вибору кольору беру .impact(flexibility: .soft, intensity: 0.55), а для snap-у до країв контейнера підходить .impact(flexibility: .rigid, intensity: 0.75). Загальне правило: rigid з високим intensity читаються як «клацнуло», soft із низьким intensity, як «пружинить». Не варто ставити intensity: 1.0 у циклічних діях. Це найшвидший спосіб втомити долоню й отримати ярлик «додаток з надлишковою вібрацією» від UX-аудиту.
Коли переходити на Core Haptics замість sensoryFeedback
.sensoryFeedback, це шпаклівка для 90% екранів. Але коли потрібна послідовність із десятків подій, або динамічне модулювання інтенсивності протягом секунд, або синхронізація з аудіо, час вмикати Core Haptics і CHHapticEngine. Це нижчерівневий API, що дає прямий доступ до Taptic Engine з мілісекундною точністю.
Три класичні сценарії, які я реалізовувала саме через Core Haptics:
- Ігровий feedback для колізій і поштовхів у реальному часі, SpriteKit-подібна логіка.
- Тактильна «партитура» для навчального застосунку: коли користувач має відчути ритмічний патерн, наприклад азбуку Морзе.
- Синхронізована з аудіо-запуском вібрація, наприклад підсилення бас-удару у медіа-плеєрі.
import CoreHaptics
@MainActor
final class HapticEngineHost {
private var engine: CHHapticEngine?
func start() throws {
guard CHHapticEngine.capabilitiesForHardware().supportsHaptics else { return }
let engine = try CHHapticEngine()
engine.playsHapticsOnly = true // Ми не програємо звук через цей движок.
engine.isAutoShutdownEnabled = true
engine.resetHandler = { [weak self] in
try? self?.engine?.start()
}
engine.stoppedHandler = { reason in
print("Haptic engine stopped: \(reason.rawValue)")
}
try engine.start()
self.engine = engine
}
func playSharpTap(intensity: Float = 0.8, sharpness: Float = 0.7) throws {
guard let engine else { return }
let event = CHHapticEvent(
eventType: .hapticTransient,
parameters: [
CHHapticEventParameter(parameterID: .hapticIntensity, value: intensity),
CHHapticEventParameter(parameterID: .hapticSharpness, value: sharpness)
],
relativeTime: 0
)
let pattern = try CHHapticPattern(events: [event], parameters: [])
let player = try engine.makePlayer(with: pattern)
try player.start(atTime: CHHapticTimeImmediate)
}
}
Дві дрібні деталі, які легко пропустити. Перше, playsHapticsOnly = true заощаджує ресурси, коли ви не змішуєте аудіо. Друге, isAutoShutdownEnabled = true вивантажує движок після кількох секунд простою, і система знову підніме його при виклику start(). У resetHandler обов'язково перезапустіть движок, інакше після системного переривання (наприклад, вхідний дзвінок) ваша хаптика замовкне до кінця сесії. Оскільки в Swift 6.2 із Approachable Concurrency за замовчуванням усе на MainActor, помічайте цей клас @MainActor. Core Haptics потокобезпечний, але тримати життєвий цикл у одному контексті простіше.
AHAP-файли: власні тактильні композиції
AHAP (Apple Haptic and Audio Pattern), це JSON-формат, що описує послідовність хаптичних подій, аудіо-подій і кривих параметрів. Написати серйозний патерн вручну важко (файли на сотні рядків, це норма), тому індустрія використовує візуальні редактори типу Captain AHAP. Але базову структуру треба знати, щоб хоча б редагувати і вантажити в проєкт.
{
"Version": 1.0,
"Metadata": { "Project": "SwiftCrafted Demo", "Created": "2026-08-13" },
"Pattern": [
{
"Event": {
"Time": 0.0,
"EventType": "HapticTransient",
"EventParameters": [
{ "ParameterID": "HapticIntensity", "ParameterValue": 0.9 },
{ "ParameterID": "HapticSharpness", "ParameterValue": 0.8 }
]
}
},
{
"Event": {
"Time": 0.15,
"EventType": "HapticContinuous",
"EventDuration": 0.6,
"EventParameters": [
{ "ParameterID": "HapticIntensity", "ParameterValue": 0.7 },
{ "ParameterID": "HapticSharpness", "ParameterValue": 0.3 }
]
}
},
{
"ParameterCurve": {
"ParameterID": "HapticIntensityControl",
"Time": 0.15,
"ParameterCurveControlPoints": [
{ "Time": 0.0, "ParameterValue": 1.0 },
{ "Time": 0.6, "ParameterValue": 0.0 }
]
}
}
]
}
Цей патерн, це «удар + затухаючий гул» тривалістю близько 750 мс. Транзієнт на Time: 0 дає початковий поштовх, безперервна подія з EventDuration: 0.6 формує тіло, а ParameterCurve згасає інтенсивність від 1.0 до 0.0 лінійно. Пам'ятайте важливий нюанс: крива для HapticIntensity є мультиплікативною (як у аудіо), а для HapticSharpness — адитивною. Тобто, якщо базова sharpness події вже 1.0, крива HapticSharpnessControl нічого не додасть. Це часте джерело плутанини. Я одного разу дві години не могла зрозуміти, чому «динамічна різкість» не працює, доки не опустила базове значення до 0.3.
func play(patternNamed name: String) throws {
guard let engine,
let url = Bundle.main.url(forResource: name, withExtension: "ahap") else { return }
try engine.playPattern(from: url)
}
Хаптика на watchOS та інших платформах Apple
У кросплатформних SwiftUI-проєктах модифікатор .sensoryFeedback, це ваш друг. На watchOS UIKit недоступний як публічний API, тож UINotificationFeedbackGenerator просто не існує. Раніше доводилося писати обгортку над WKInterfaceDevice.current().play(_:); сьогодні той самий SwiftUI-код працює і на iPhone, і на Apple Watch.
#if os(watchOS)
import WatchKit
#endif
struct AlertRow: View {
@State private var acknowledged = false
var body: some View {
Button("Підтвердити") { acknowledged = true }
.sensoryFeedback(.success, trigger: acknowledged)
}
}
Для tvOS хаптика фізично не працює на телевізорі, але на Siri Remote (2-го покоління і новіше) можна відчути тактильний відгук у деяких сценаріях. iPadOS підтримує Core Haptics лише на iPad із відповідним «залізом» (наприклад, Magic Keyboard trackpad має власну хаптику через NSHapticFeedbackManager у macOS). Тому перед відтворенням завжди перевіряйте CHHapticEngine.capabilitiesForHardware().supportsHaptics. На пристроях без Taptic Engine ваш код мовчки нічого не робитиме, і це нормально. Але робити його залежним від наявності хаптики (наприклад, не показувати кнопку «підтвердити»), категорично ні: тактильний відгук має бути додатковим каналом, а не єдиним.
UIFeedbackGenerator: резервний варіант для UIKit і iOS 16
Якщо ваша ціль, це iOS 16 або нижче, або ви працюєте в UIKit, залишається старий добрий UIFeedbackGenerator. Він живий і не депрекований, просто заміщений декларативним підходом у SwiftUI. Три підкласи, які ви точно бачили:
UINotificationFeedbackGenerator, три системні події:.success,.warning,.error.UIImpactFeedbackGenerator, легкий/середній/важкий/soft/rigid удар.UISelectionFeedbackGenerator, клік прокрутки колеса вибору.
import UIKit
final class LegacyHaptics {
private let impact = UIImpactFeedbackGenerator(style: .medium)
private let notify = UINotificationFeedbackGenerator()
func armImpact() { impact.prepare() } // Викликати за ~500 мс до події.
func tap() { impact.impactOccurred(intensity: 0.8) }
func success() { notify.notificationOccurred(.success) }
}
Ключова відмінність від .sensoryFeedback: тут вам треба явно кликати prepare() перед подією, інакше перша вібрація може прийти з затримкою до 200 мс і читатиметься як «поламано». SwiftUI-модифікатор робить це під капотом. Тому міграція UIKit-екранів на SwiftUI одразу прибирає цілий клас «плаваючих» лагів хаптики.
Життєвий цикл CHHapticEngine: reset, stopped, restart
Це найбільш недооцінений розділ будь-якого туторіалу з Core Haptics, і водночас найчастіша причина «хаптика працювала, а потім раптом ні». CHHapticEngine може бути зупинений системою з кількох причин: аудіосесія перервалася (дзвінок), застосунок пішов у бекграунд, спрацював watchdog після довгого простою. Ваша відповідальність, правильно перезапустити.
engine.stoppedHandler = { reason in
// Причини: audioSessionInterrupt, applicationSuspended, idleTimeout,
// systemError, notifyWhenFinished, engineDestroyed, gameControllerDisconnect.
switch reason {
case .audioSessionInterrupt, .applicationSuspended:
// Не перезапускаємо тут, почекаємо на подію активації сцени.
break
default:
// На інших причинах логуємо для діагностики.
print("Haptic engine stopped: \(reason.rawValue)")
}
}
engine.resetHandler = { [weak engine] in
// Викликається після серверного reset, треба перезапустити ручками.
do { try engine?.start() } catch { print("Restart failed: \(error)") }
}
// І в самому View, перезапуск при поверненні у foreground.
.onChange(of: scenePhase) { _, phase in
if phase == .active { try? engine.start() }
}
На iOS 26 з'явилася зручна дрібниця. Якщо ви користуєтесь @Observable-моделлю (Observation-фреймворк) для стану сцени, підписка через .onChange(of: scenePhase) викликає перезапуск лише один раз при переході, а не на кожен рендер. Це знімає стару проблему, коли розробники ловили race між willEnterForeground і першою тактильною подією.
Доступність, батарея та UX-правила, які я застосовую
Хаптика, це додатковий канал зв'язку з користувачем, і UX-етика тут така сама сувора, як для звуку. Мої особисті правила, вироблені після кількох публічних релізів:
- Поважайте
Reduce Motion. ЧитайтеUIAccessibility.isReduceMotionEnabledі в цьому режимі відключайте всю декоративну хаптику (наприклад, «пружинистий» відгук при прокрутці). Залишайте лише семантичну (success/error/warning). - Не дублюйте семантику. Якщо ви показуєте помилку
.errorхаптикою, не додавайте одразу.warning: користувач не розрізнить, а Taptic Engine ще й з'їсть 20 мс на переходи. - Тримайте частоту нижче 5 подій/сек. Все, що частіше, це континуальний патерн через Core Haptics, а не серія транзієнтів.
- Тестуйте на найновішому iPhone і на iPhone SE. Taptic Engine у Pro-моделях значно потужніший; те, що на 17 Pro відчувається як «елегантний клік», на SE (3-го покоління) може взагалі не зареєструватися.
- Батарея. Continuous-подія на 3 секунди з intensity 1.0 з'їдає приблизно стільки ж, скільки середній анімаційний кадр SwiftUI за той самий час. Це не безкоштовно. Довгі гули лишайте для рідкісних подій.
- Локалізація культури. Хаптика читається однаково в усіх мовах, але очікування різні. У Японії, наприклад, тактильна відповідь на кожну кнопку сприймається як норма, у Європі часто як «нав'язливо».
Для навігаційних сценаріїв, наприклад з адаптивною навігацією через TabView і sidebarAdaptable, я зазвичай додаю .selection на зміну активного табу, але тільки якщо перехід супроводжується явним жестом користувача, а не програмно (з deep link). Це та деталь, за яку користувачі не подякують явно, але без якої додаток відчувається «дешевше». Апеляція до офіційних Human Interface Guidelines щодо хаптики і офіційної документації для .sensoryFeedback, це найкоротший шлях розв'язати суперечку з дизайнерами про доцільність.
Часті питання
Що таке sensoryFeedback у SwiftUI?
.sensoryFeedback, це декларативний модифікатор View, доданий в iOS 17 і розширений до 11 типів у iOS 26. Він відтворює вбудовані тактильні (та в деяких випадках аудіо) відгуки при зміні прив'язаного значення-тригера, замінюючи потребу вручну керувати генераторами UIFeedbackGenerator у більшості SwiftUI-сценаріїв.
У чому різниця між sensoryFeedback і UIFeedbackGenerator?
.sensoryFeedback, це SwiftUI-модифікатор, декларативний і крос-платформний (працює на iOS та watchOS без змін). UIFeedbackGenerator, це імперативний UIKit API, який вимагає ручного виклику prepare() і не доступний на watchOS. Для нових SwiftUI-проєктів обирайте .sensoryFeedback; UIKit-варіант тримайте для міграцій і iOS 16 та старіших.
Коли потрібно використовувати Core Haptics замість sensoryFeedback?
Core Haptics потрібен для власних патернів, послідовностей із десятків подій, динамічного модулювання інтенсивності через ParameterCurve, а також для синхронізації хаптики з аудіо. Для звичайних UI-подій (кнопка, тумблер, зміна вибору) .sensoryFeedback дає той самий результат меншою кров'ю.
Чому не працює хаптика в моєму SwiftUI-додатку?
Три найчастіші причини: (1) ви тестуєте на симуляторі, він взагалі не має Taptic Engine; (2) тригер не змінюється (якщо ви передаєте константу або значення, яке SwiftUI вважає незмінним, модифікатор не спрацює); (3) для Core Haptics, движок зупинений системою і не був перезапущений у resetHandler чи при поверненні у foreground.
Чи працює sensoryFeedback на watchOS?
Так, починаючи з watchOS 10 модифікатор .sensoryFeedback працює на Apple Watch і замінює старий WKInterfaceDevice.play(_:). Це особливо цінно тим, що UIKit недоступний на watchOS як публічний API, тож UIFeedbackGenerator там компілюватися не буде. SwiftUI-модифікатор єдиний офіційний шлях.
Як зробити власний хаптичний патерн у Swift?
Створіть екземпляр CHHapticEngine, побудуйте CHHapticPattern з масиву CHHapticEvent (транзієнтних або континуальних), налаштуйте параметри hapticIntensity та hapticSharpness, отримайте плеєр через engine.makePlayer(with:) і викличте start(atTime:). Для складних патернів експортуйте JSON у форматі AHAP і завантажте через engine.playPattern(from: url).




