Haptics в SwiftUI 2026: Пълно ръководство за SensoryFeedback, Core Haptics и CHHapticEngine

Пълно ръководство за хаптична обратна връзка в SwiftUI: кога да ползвате декларативния SensoryFeedback и кога Core Haptics с CHHapticEngine и AHAP патерни, с реални стойности от продукция.

Haptics в SwiftUI 2026: Пълно ръководство
On this page
  1. SensoryFeedback срещу Core Haptics: коя да изберете?
  2. Как да добавя haptic feedback в SwiftUI?
  3. Core Haptics: персонализирани патерни с CHHapticEngine
  4. AHAP файлове: декларативно описание на патерна
  5. Хаптики за конкретни UX сценарии
  6. Хаптики на Apple Watch с WatchKit
  7. Хаптики и достъпност: не забравяйте Reduce Motion
  8. Тестване на хаптики: симулатор срещу устройство
  9. Често срещани грешки при работа с haptics

Актуализирано: 13 септември 2026 г.

За да добавите хаптична обратна връзка в SwiftUI, използвайте модификатора .sensoryFeedback(_:trigger:). Работи от iOS 17 нагоре и покрива около 90% от типичните UX случаи с един ред код. За персонализирани патерни (например текстура на плат, вибрация с нарастващ интензитет или синхронизация със звук) преминете към Core Haptics и CHHapticEngine. В това ръководство давам конкретни стойности за intensity, sharpness и response, които съм извадил от продукционни SwiftUI приложения през 2026 г.

SensoryFeedback срещу Core Haptics: коя да изберете?

Първото решение, което вземам, когато дизайнер ме моли за „леко потупване при потвърждение“, е дали ще стигна със SensoryFeedback, или ще ми трябва CHHapticEngine. Практически правилото ми е просто: ако мога да опиша ефекта с една от седемте семантични думи на Apple (success, warning, error, selection, impact, increase, decrease), използвам SensoryFeedback. Ако имам нужда от прогресивен интензитет, синхронизация с аудио, ритмичен патерн или ефект, който да опише текстура (например преминаване през тик-марки на плъзгач с усещане за „зъбци“), скачам на Core Haptics.

ХарактеристикаSensoryFeedback (SwiftUI)Core Haptics (CHHapticEngine)
Минимална версияiOS 17 / watchOS 10 / tvOS 17iOS 13 (iPhone 8+)
Стил на APIДекларативен модификаторИмперативен engine + събития
Семантични предефинирани13 case-а (.success, .selection, .impact...)Няма, вие описвате всичко
Персонализирани патерниНеДа (transient + continuous)
Синхронизация с аудиоНеДа (audio + haptic pattern player)
Ред код за „success“1 ред~30 реда
Тестване в симулатораСамо визуален индикаторСамо визуален индикатор
Разход на батерияПренебрежимЗабележим при дълги continuous

За контекст: в последното приложение, което пуснах в продукция, около 40 от 45 хаптик задействания се справиха със SensoryFeedback. Останалите 5 (текстурата при плъзгане в custom карта, ефектът при разгъване на pull-quote, и синхронизацията с преходни звуци) изискваха Core Haptics с AHAP файлове.

Как да добавя haptic feedback в SwiftUI?

Най-краткият път е модификаторът .sensoryFeedback(_:trigger:). Той се задейства, когато стойността на trigger се промени, така избягвате императивно „извикай това сега“ и оставяте SwiftUI да свърже събитието с промяна на състоянието.

import SwiftUI

struct FavoriteButton: View {
    @State private var isFavorited = false

    var body: some View {
        Button {
            isFavorited.toggle()
        } label: {
            Image(systemName: isFavorited ? "heart.fill" : "heart")
                .foregroundStyle(isFavorited ? .pink : .secondary)
        }
        // Задейства се при всяко превключване на isFavorited.
        .sensoryFeedback(.impact(weight: .light, intensity: 0.7),
                         trigger: isFavorited)
    }
}

Ако искате хаптиката да се задейства само при определен преход (например „от false към true“), използвайте overload-а с closure:

.sensoryFeedback(trigger: isFavorited) { oldValue, newValue in
    // Тик само при добавяне в любими, не при премахване.
    guard !oldValue, newValue else { return nil }
    return .success
}

Всички варианти на SensoryFeedback

В iOS 26 наборът от семантични случаи е:

  • .success, .warning, .error: за потвърждения и грешки.
  • .selection: за плъзгачи, picker-и и промяна на избора.
  • .increase, .decrease: за stepper-и и промяна на числена стойност.
  • .start, .stop: за начало и край на дълго действие.
  • .alignment, .levelChange: за snap към решетка или „прекрачване на праг“.
  • .pathComplete: за приключване на многостъпков поток.
  • .impact(weight: .light | .medium | .heavy, intensity: 0...1): общото „тик“.
  • .impact(flexibility: .soft | .solid | .rigid, intensity: 0...1): когато характерът на удара е по-важен от „теглото“.

Условно задействане с trigger closures

Ако едно и също view покрива няколко състояния, вместо да добавяте по един модификатор за всеки случай, върнете различни feedback стойности от closure-а:

enum SubmissionState { case idle, submitting, succeeded, failed }

@State private var state: SubmissionState = .idle

.sensoryFeedback(trigger: state) { _, newState in
    switch newState {
    case .succeeded: return .success
    case .failed:    return .error
    case .submitting: return .impact(weight: .light, intensity: 0.4)
    case .idle:      return nil
    }
}

Връщането на nil е ключово. Така казвате на SwiftUI, че тази промяна не заслужава хаптика, вместо да задействате празен pulse.

Core Haptics: персонализирани патерни с CHHapticEngine

Когато семантичните случаи не са достатъчни (например ви трябва плъзгач, който „тика“ при преминаване през всеки 10% с постепенно нарастващ интензитет), преминете към CHHapticEngine. Той работи с два типа събития: transient (кратък удар, под 100 ms) и continuous (постоянна вибрация с продължителност до 30 секунди).

import CoreHaptics

final class HapticsController {
    private var engine: CHHapticEngine?

    init?() {
        // 1. Проверете за хардуерна поддръжка (iPhone 8+, не работи на iPad).
        guard CHHapticEngine.capabilitiesForHardware().supportsHaptics else {
            return nil
        }
        do {
            engine = try CHHapticEngine()
            // 2. Автоматично рестартиране, ако системата спре engine-а.
            engine?.resetHandler = { [weak self] in
                try? self?.engine?.start()
            }
            engine?.stoppedHandler = { reason in
                print("Engine stopped: \(reason.rawValue)")
            }
            try engine?.start()
        } catch {
            return nil
        }
    }

    func playTick(intensity: Float, sharpness: Float) {
        guard let engine else { return }
        let event = CHHapticEvent(
            eventType: .hapticTransient,
            parameters: [
                CHHapticEventParameter(parameterID: .hapticIntensity, value: intensity),
                CHHapticEventParameter(parameterID: .hapticSharpness, value: sharpness)
            ],
            relativeTime: 0
        )
        do {
            let pattern = try CHHapticPattern(events: [event], parameters: [])
            let player = try engine.makePlayer(with: pattern)
            try player.start(atTime: CHHapticTimeImmediate)
        } catch {
            print("Failed to play tick: \(error)")
        }
    }
}

Двете оси, които променят характера на удара, са:

  • Intensity (0.0 – 1.0): колко силен е ефектът. Под 0.3 е едвам доловим; над 0.85 започва да звучи натрапчиво.
  • Sharpness (0.0 – 1.0): 0.0 е мек, тъп „бум“; 1.0 е остро, метално „тик“. За премахване на елемент използвам 0.8; за „мек“ drag drop използвам 0.15.

Continuous събития и параметрични криви

За да опишете нарастващ мотор (например „зареждане на действие“ при задържане), използвайте hapticContinuous плюс CHHapticParameterCurve, който променя интензитета във времето:

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

let ramp = CHHapticParameterCurve(
    parameterID: .hapticIntensityControl,
    controlPoints: [
        .init(relativeTime: 0.0, value: 0.3),
        .init(relativeTime: 1.5, value: 1.0)
    ],
    relativeTime: 0
)

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

AHAP файлове: декларативно описание на патерна

Ако предпочитате да опишете патерна веднъж и да го преизползвате (или още по-добре, дизайнерът ви да го редактира без Swift), използвайте формат AHAP (Apple Haptic and Audio Pattern). Това е обикновен JSON, който заредете от bundle-а:

{
  "Version": 1.0,
  "Pattern": [
    { "Event": {
        "Time": 0.0,
        "EventType": "HapticTransient",
        "EventParameters": [
          { "ParameterID": "HapticIntensity", "ParameterValue": 0.9 },
          { "ParameterID": "HapticSharpness", "ParameterValue": 0.4 }
        ]
    }},
    { "Event": {
        "Time": 0.12,
        "EventType": "HapticTransient",
        "EventParameters": [
          { "ParameterID": "HapticIntensity", "ParameterValue": 0.6 },
          { "ParameterID": "HapticSharpness", "ParameterValue": 0.7 }
        ]
    }}
  ]
}
guard let url = Bundle.main.url(forResource: "PurchaseConfirmed", withExtension: "ahap") else { return }
try engine?.playPattern(from: url)

За детайлите на формата вижте официалната AHAP документация на Apple. Там са описани всички поддържани полета, включително аудио събитията, които могат да се синхронизират с хаптиките.

Хаптики за конкретни UX сценарии

Ето рецептите, които използвам най-често. Числата не са произволни. Тествал съм ги на iPhone 15 Pro и iPhone 17 с реални потребители, следейки как реагират след 30 повторения.

Форми и валидация

При успешно изпращане на форма: .success. При грешка на поле: .error, но само веднъж. Ако валидирате на всеки keystroke, ще подлудите потребителя. Ключът е да задействате хаптиката на trigger: submitAttempted, а не на trigger: isValid.

Плъзгачи и селектори

За snap-ване към стъпка използвайте .selection. За плъзгач без стъпки, но с психологически „центрирана“ стойност (например при изравняване с 0), използвайте .alignment. За детайли как да интегрирате това с плавни анимации, вижте нашето ръководство за SwiftUI анимации с PhaseAnimator и KeyframeAnimator. Хаптиките и spring анимациите се проектират заедно, не поотделно.

Pull-to-refresh и swipe actions

Стандартният refreshable модификатор в SwiftUI вече играе .impact(weight: .medium) автоматично при достигане на прага. Не го дублирайте. При swipe-to-delete добавете .impact(flexibility: .rigid, intensity: 0.8) точно в момента, в който бутонът се разкрие напълно.

Успешни и неуспешни действия при плащане

За плащания винаги избирам .success, дори когато е изкушаващо да измисля „custom победен акорд“. Потребителите са тренирани от Apple Pay да разпознават този патерн, и той им казва „парите ти са в безопасност“ по-бързо, отколкото визуалния tick.

Хаптики на Apple Watch с WatchKit

Apple Watch използва отделен, много по-ограничен API. Няма Core Haptics с патерни, само предефинирани случаи през WKInterfaceDevice:

import WatchKit

WKInterfaceDevice.current().play(.success)
WKInterfaceDevice.current().play(.notification)
WKInterfaceDevice.current().play(.retry)
WKInterfaceDevice.current().play(.directionUp)
WKInterfaceDevice.current().play(.directionDown)

От watchOS 10 нагоре обаче SensoryFeedback в SwiftUI също работи и се превежда автоматично към съответните WatchKit случаи. Практически това означава, че ако споделяте SwiftUI код между iOS и watchOS target, същите .sensoryFeedback модификатори продължават да работят. Поредната причина да предпочетете декларативния подход.

Хаптики и достъпност: не забравяйте Reduce Motion

Хаптиките попадат в същата категория, в която попадат и анимациите. За някои потребители те са дразнител или дори тригер за гадене. iOS централно уважава настройката „Настройки → Достъпност → Тактилна обратна връзка“, но добър UX инженер прави повече: следи UIAccessibility.isReduceMotionEnabled и предоставя in-app превключвател.

@Environment(\.accessibilityReduceMotion) private var reduceMotion
@AppStorage("hapticsEnabled") private var hapticsEnabled = true

.sensoryFeedback(trigger: state) { _, newState in
    guard hapticsEnabled, !reduceMotion else { return nil }
    return newState == .succeeded ? .success : nil
}

Ако искате пълно ръководство по темата, включително как да интегрирате VoiceOver и Dynamic Type около същите състояния, прочетете нашето ръководство за SwiftUI Accessibility. Достъпността и хаптиките са част от един и същ дизайн-разговор, никога не ги проектирайте изолирано.

Тестване на хаптики: симулатор срещу устройство

Xcode симулаторът от версия 15.4 нагоре показва визуален индикатор за задействана хаптика (малка кръгла иконка в горния десен ъгъл на прозореца), но не пренася реални вибрации. Единственият надежден начин да тествате е на устройство, и по-точно на същото устройство, на което ще инсталира потребителят, защото Taptic Engine-ите на iPhone SE, iPhone 15 Pro и iPhone 17 Pro Max са физически различни и произвеждат различно „чувство“ за същите параметри.

Моят workflow: първо ги пиша с числа „по слух“, после ги проверявам на три референтни устройства: iPhone SE (най-слабият Taptic Engine), iPhone 15 (среден) и iPhone 17 Pro (най-точен). Ако едно и също intensity=0.7 звучи „лениво“ на SE и „агресивно“ на Pro, слизам на 0.55 и печеля устойчивост между устройствата. За препоръки по интеграция със системните жестове погледнете и SwiftUI ръководството за iOS 26. Част от новите Liquid Glass ефекти носят и подразбиращи се хаптики, които не трябва да дублирате.

Често срещани грешки при работа с haptics

След преглед на десетки code review-та, ето грешките, които виждам да се повтарят:

  1. Извикване на хаптики от background thread. И SensoryFeedback, и CHHapticEngine.makePlayer трябва да се използват от главната опашка, иначе engine-ът тихо ще игнорира събитието.
  2. Един CHHapticEngine за целия app life-cycle. Не създавайте нов engine за всяка хаптика, защото струва около 15 ms инициализация. Дръжте един singleton, стартиран при launch.
  3. Задействане на хаптики по onAppear. Потребителят не разбира защо телефонът вибрира при отваряне на екран. Ако наистина искате приветствие, ползвайте task {} с delay 0.3 s.
  4. Хаптики без визуален feedback. Ако бутонът не показва анимация, но телефонът вибрира, потребителят се обърква. Правило: хаптика без анимация е бъг.
  5. Дублиране на системни хаптики. refreshable, DatePicker и Slider вече свирят собствени хаптики, не добавяйте свои отгоре.
  6. Игнориране на CHHapticEngine.capabilitiesForHardware(). На iPad и на iPhone 7 и по-стари Core Haptics хвърля грешка при инициализация. Винаги проверявайте.

За най-актуалните препоръки на Apple по „кога да задействате хаптика“ вижте Playing Haptics в Human Interface Guidelines. Задължително четиво преди дизайн ревю.

Често задавани въпроси

Работят ли хаптиките на всички iPhone модели?

Не. Core Haptics с CHHapticEngine изисква Taptic Engine от второ поколение, тоест iPhone 8 или по-нов. iPad-ите нямат Taptic Engine и CHHapticEngine.capabilitiesForHardware().supportsHaptics винаги връща false. Простите UIKit-стил хаптики през UINotificationFeedbackGenerator работят и на по-стари устройства, но с ограничен репертоар.

Каква е разликата между SensoryFeedback и UIImpactFeedbackGenerator?

SensoryFeedback е декларативен SwiftUI модификатор от iOS 17, който се задейства при промяна на trigger. UIImpactFeedbackGenerator е императивен UIKit клас, който извиквате ръчно с .impactOccurred(). За нови SwiftUI проекти винаги предпочитайте SensoryFeedback, защото той се държи по-предвидимо със state management-а и работи и на watchOS.

Как да тествам haptics в Xcode симулатора?

Симулаторът не пренася истински вибрации. Той само показва малък визуален индикатор, когато се задейства хаптика. За реално тестване използвайте физическо устройство и, ако е възможно, три различни модела (например iPhone SE, iPhone 15 и iPhone 17 Pro), за да проверите как параметрите се превеждат на различни Taptic Engine-и.

Как да създам персонализирани хаптик патерни?

Използвайте Core Haptics с CHHapticEngine. Или конструирайте патерна програмно с масив от CHHapticEvent обекти (transient и continuous), или го опишете в AHAP JSON файл и го заредете с engine.playPattern(from: url). AHAP е предпочитан, защото дизайнерът ви може да го редактира без Swift знания.

Защо моята хаптика не се задейства при първо стартиране на приложението?

Най-често защото CHHapticEngine не е стартиран още, или защото е бил спрян при преминаване във фон и не е рестартиран. Имплементирайте engine.resetHandler и engine.stoppedHandler, които го рестартират автоматично, и извиквайте try engine.start() при scenePhase == .active.