Хаптика у SwiftUI для iOS 26: sensoryFeedback, Core Haptics та власні AHAP-патерни

Практичний посібник з хаптики у SwiftUI для iOS 26: модифікатор .sensoryFeedback з 11 типами, умовні тригери, Core Haptics та власні AHAP-патерни з реальними значеннями intensity і sharpness.

SwiftUI хаптика iOS 26: посібник (2026)

Оновлено: 13 серпня 2026

Хаптика у 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(), ніякого утримання посилання на генератор. Всі оптимізації робить система.

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:

  1. Ігровий feedback для колізій і поштовхів у реальному часі, SpriteKit-подібна логіка.
  2. Тактильна «партитура» для навчального застосунку: коли користувач має відчути ритмічний патерн, наприклад азбуку Морзе.
  3. Синхронізована з аудіо-запуском вібрація, наприклад підсилення бас-удару у медіа-плеєрі.
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-етика тут така сама сувора, як для звуку. Мої особисті правила, вироблені після кількох публічних релізів:

  1. Поважайте Reduce Motion. Читайте UIAccessibility.isReduceMotionEnabled і в цьому режимі відключайте всю декоративну хаптику (наприклад, «пружинистий» відгук при прокрутці). Залишайте лише семантичну (success/error/warning).
  2. Не дублюйте семантику. Якщо ви показуєте помилку .error хаптикою, не додавайте одразу .warning: користувач не розрізнить, а Taptic Engine ще й з'їсть 20 мс на переходи.
  3. Тримайте частоту нижче 5 подій/сек. Все, що частіше, це континуальний патерн через Core Haptics, а не серія транзієнтів.
  4. Тестуйте на найновішому iPhone і на iPhone SE. Taptic Engine у Pro-моделях значно потужніший; те, що на 17 Pro відчувається як «елегантний клік», на SE (3-го покоління) може взагалі не зареєструватися.
  5. Батарея. Continuous-подія на 3 секунди з intensity 1.0 з'їдає приблизно стільки ж, скільки середній анімаційний кадр SwiftUI за той самий час. Це не безкоштовно. Довгі гули лишайте для рідкісних подій.
  6. Локалізація культури. Хаптика читається однаково в усіх мовах, але очікування різні. У Японії, наприклад, тактильна відповідь на кожну кнопку сприймається як норма, у Європі часто як «нав'язливо».

Для навігаційних сценаріїв, наприклад з адаптивною навігацією через 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).

Diana Kowalski
Про Автора Diana Kowalski

Mobile UX engineer translating design intent into pixel-perfect SwiftUI. Has strong opinions about haptics.