Хаптика у SwiftUI для iOS 26: sensoryFeedback, Core Haptics та власні AHAP-патерни
Практичний посібник з хаптики у SwiftUI для iOS 26: модифікатор .sensoryFeedback з 11 типами, умовні тригери, Core Haptics та власні AHAP-патерни з реальними значеннями intensity і sharpness.
Хаптика у SwiftUI для iOS 26 будується на двох рівнях. Перший, декларативний модифікатор .sensoryFeedback, покриває стандартні відгуки (success, error, impact, selection). Другий, фреймворк Core Haptics, дає власні патерни через CHHapticEngine та AHAP-файли. У 99% екранів вистачає першого: одного рядка модифікатора, прив'язаного до значення-тригера. Другий потрібен, коли ви оркеструєте послідовність із десятків подій, синхронізуєте вібрацію зі звуком або малюєте власну «фактуру» під бренд. Цей посібник показує обидва підходи з реальними значеннями (intensity, sharpness, spring response), які я підбирала на фізичних пристроях, а не на симуляторі.
.sensoryFeedback(_:trigger:) — офіційний спосіб додати хаптику в SwiftUI з iOS 17; у iOS 26 підтримуються 11 типів відгуку, включно з .pathComplete, .alignment та .levelChange.
Тригер спрацьовує лише при зміні значення, тож не забувайте про Equatable і про варіант із замиканням condition, коли треба фільтрувати переходи.
Core Haptics і CHHapticEngine потрібні для власних патернів, синхронізованих зі звуком; AHAP-файл описується як JSON із подіями типу HapticTransient або HapticContinuous.
Ключові параметри події: HapticIntensity (мультиплікативна крива) і HapticSharpness (адитивна крива). Я тримаю intensity в межах 0.6–0.9, а sharpness близько 0.3 для «м'яких» і 0.7 для «різких» подій.
На watchOS UIFeedbackGenerator недоступний, тож .sensoryFeedback це єдиний крос-платформний шлях для SwiftUI-застосунків.
Перед відтворенням завжди перевіряйте CHHapticEngine.capabilitiesForHardware().supportsHaptics і обробляйте resetHandler/stoppedHandler, інакше движок замовкне після дзвінка чи фонового переходу.
Швидкий старт: перший тактильний відгук за один рядок
Найшвидший шлях додати хаптику в SwiftUI-застосунок для iOS 26 такий: прикріпити модифікатор .sensoryFeedback до будь-якого View і передати йому тригер. Тригер, це будь-яке Equatable-значення у вашому state; кожного разу, коли воно змінюється, SwiftUI відтворює вказаний тип відгуку. Ніякого prepare(), ніякого утримання посилання на генератор. Всі оптимізації робить система.
У цьому шаблоні я навмисно поставила 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). Комбінувати обидва не можна: це два окремі ініціалізатори.
Мої підбори з 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. Але базову структуру треба знати, щоб хоча б редагувати і вантажити в проєкт.
Цей патерн, це «удар + затухаючий гул» тривалістю близько 750 мс. Транзієнт на Time: 0 дає початковий поштовх, безперервна подія з EventDuration: 0.6 формує тіло, а ParameterCurve згасає інтенсивність від 1.0 до 0.0 лінійно. Пам'ятайте важливий нюанс: крива для HapticIntensity є мультиплікативною (як у аудіо), а для HapticSharpness — адитивною. Тобто, якщо базова sharpness події вже 1.0, крива HapticSharpnessControl нічого не додасть. Це часте джерело плутанини. Я одного разу дві години не могла зрозуміти, чому «динамічна різкість» не працює, доки не опустила базове значення до 0.3.
У кросплатформних SwiftUI-проєктах модифікатор .sensoryFeedback, це ваш друг. На watchOS UIKit недоступний як публічний API, тож UINotificationFeedbackGenerator просто не існує. Раніше доводилося писати обгортку над WKInterfaceDevice.current().play(_:); сьогодні той самий SwiftUI-код працює і на iPhone, і на Apple Watch.
Для 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.
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 за той самий час. Це не безкоштовно. Довгі гули лишайте для рідкісних подій.
Локалізація культури. Хаптика читається однаково в усіх мовах, але очікування різні. У Японії, наприклад, тактильна відповідь на кожну кнопку сприймається як норма, у Європі часто як «нав'язливо».
.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).
Розберемось, як макрос @Observable у Swift замінює ObservableObject у SwiftUI: гранулярне стеження, @Bindable, @ObservationIgnored, withObservationTracking та новинки iOS 26.
Практичний посібник з нового TabView у SwiftUI для iOS 26: новий тип Tab, стиль sidebarAdaptable, TabSection, роль пошуку, кастомізація й адаптація до iPad, Mac Catalyst і visionOS з прикладами коду.
NavigationStack у SwiftUI для iOS 26: типобезпечна маршрутизація через navigationDestination, глибокі посилання з onOpenURL, збереження шляху через SceneStorage і робочі приклади коду Swift 6.2.