Хаптик-фидбек в SwiftUI: sensoryFeedback, Core Haptics и AHAP-паттерны в iOS 26

Разбираем хаптик-фидбек в SwiftUI: когда хватает .sensoryFeedback, а когда пора идти в Core Haptics. Параметры intensity и sharpness, декларативные AHAP-паттерны, синхронизация с анимациями и правила accessibility для iOS 26.

Хаптик в SwiftUI: sensoryFeedback iOS 26

Обновлено: 10 августа 2026

Хаптик-фидбек в 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

Параметр.sensoryFeedbackCore Haptics
Минимальная версияiOS 17iOS 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-инженера.

func makeTap(intensity: Float, sharpness: Float) throws -> CHHapticPattern {
    let event = CHHapticEvent(
        eventType: .hapticTransient,
        parameters: [
            CHHapticEventParameter(parameterID: .hapticIntensity, value: intensity),
            CHHapticEventParameter(parameterID: .hapticSharpness, value: sharpness)
        ],
        relativeTime: 0
    )
    return try CHHapticPattern(events: [event], parameters: [])
}

Мои пресеты, выведенные экспериментально на iPhone 15 Pro и проверенные на 12 mini (разные Taptic Engine, разные ощущения):

  • Клик кнопки: intensity 0.6, sharpness 0.5, узнаваемый «тап», не режущий.
  • Металлический щелчок (переключатель): intensity 0.8, sharpness 1.0, отчётливый край.
  • Мягкий отскок (drag release): intensity 0.4, sharpness 0.1, «подушка».
  • Ошибка: два transient события с интервалом 90 мс, intensity 0.9, sharpness 0.8. Двойной толчок читается как «нет».

Continuous события: текстуры и волны

Для непрерывного фидбека (например, «трение» при перетаскивании) используется .hapticContinuous. Такое событие звучит от 100 мс до нескольких секунд, а интенсивность и «острота» могут меняться со временем через CHHapticParameterCurve:

let continuous = CHHapticEvent(
    eventType: .hapticContinuous,
    parameters: [
        CHHapticEventParameter(parameterID: .hapticIntensity, value: 0.5),
        CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.3)
    ],
    relativeTime: 0,
    duration: 1.2
)

let curve = CHHapticParameterCurve(
    parameterID: .hapticIntensityControl,
    controlPoints: [
        .init(relativeTime: 0.0, value: 0.0),
        .init(relativeTime: 0.6, value: 1.0),
        .init(relativeTime: 1.2, value: 0.0)
    ],
    relativeTime: 0
)

let pattern = try CHHapticPattern(events: [continuous], parameterCurves: [curve])

Такой паттерн даёт «дыхание»: нарастание и спад. Отлично работает для индикации загрузки, тактильного pull-to-refresh, симуляции трения при перетаскивании тяжёлого объекта.

AHAP-файлы: декларативные паттерны

AHAP (Apple Haptic and Audio Pattern), это JSON-формат для описания хаптических паттернов декларативно. Плюс: паттерн можно править дизайнеру без пересборки приложения; можно A/B-тестировать разные варианты через удалённую доставку файла; и файл читается человеком. Спецификацию Apple опубликовала в Representing Haptic Patterns in AHAP Files.

{
  "Version": 1.0,
  "Metadata": { "Project": "Heartbeat" },
  "Pattern": [
    {
      "Event": {
        "Time": 0.0,
        "EventType": "HapticTransient",
        "EventParameters": [
          { "ParameterID": "HapticIntensity", "ParameterValue": 0.9 },
          { "ParameterID": "HapticSharpness", "ParameterValue": 0.4 }
        ]
      }
    },
    {
      "Event": {
        "Time": 0.28,
        "EventType": "HapticTransient",
        "EventParameters": [
          { "ParameterID": "HapticIntensity", "ParameterValue": 0.6 },
          { "ParameterID": "HapticSharpness", "ParameterValue": 0.3 }
        ]
      }
    }
  ]
}

Файл кладём в бандл и подгружаем одной строкой:

func playHeartbeat() throws {
    guard let url = Bundle.main.url(forResource: "Heartbeat", withExtension: "ahap") else {
        return
    }
    try engine?.playPattern(from: url)
}

В iOS 26 playPattern(from:) научился принимать URL из Asset Catalog при использовании нового типа Haptic Data Asset. Это удобнее для локализации хаптик-паттернов под разные варианты интерфейса (например, «тонкий» вариант для Reduce Motion).

Синхронизация хаптика с анимациями SwiftUI

Хороший хаптик всегда «в такт» с анимацией. Правило: тактильный отклик должен попасть в момент, когда пружина достигает пика или объект пересекает значимый край. Для этого удобнее всего запускать хаптик по изменению state, а не в onTapGesture. Тогда он синхронен с началом визуального перехода, а не с моментом отпускания пальца.

struct ToggleFavorite: View {
    @State private var isFavorite = false

    var body: some View {
        Image(systemName: isFavorite ? "star.fill" : "star")
            .scaleEffect(isFavorite ? 1.15 : 1.0)
            .animation(.spring(response: 0.4, dampingFraction: 0.7),
                       value: isFavorite)
            .onTapGesture { isFavorite.toggle() }
            .sensoryFeedback(.impact(weight: .light, intensity: 0.7),
                             trigger: isFavorite)
    }
}

Здесь 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 по хаптику, но добавляют конкретики.

  1. Максимум 3 разных вида хаптика на экране. Больше, и пользователь перестаёт различать смысл каждого. Успех, ошибка, «просто клик», этого хватает.
  2. Не дублируйте системные жесты. iOS уже даёт хаптик на pull-to-refresh, свайп-удаление, свайп-назад. Свой второй поверх системного читается как баг.
  3. Тайминг важнее интенсивности. Хаптик, попадающий в анимационный пик, воспринимается сильнее того же паттерна с задержкой 50 мс. Синхронизируйте по state, а не по gesture callback.
  4. Тестируйте на тихом устройстве в тишине. В шумном офисе с музыкой большинство паттернов сливается в «толчок». Реальные пользователи слушают наушниками, там каждый нюанс слышен.
  5. Хаптик, не замена звука. Если ключевая информация передаётся только звуком, слепые и глухие пользователи её потеряют. Хаптик усиливает, но не заменяет визуальный и звуковой канал.
  6. Держите фидбек до 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 их клампит.

Diana Kowalski
Об авторе Diana Kowalski

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