SwiftUI Haptics iOS 26: Hướng Dẫn Toàn Diện Sensory Feedback và Core Haptics
Hướng dẫn haptics SwiftUI iOS 26: .sensoryFeedback, CHHapticEngine, AHAP và cách đồng bộ với spring animation, kèm ví dụ code chạy được và ghi chú accessibility.
Trong SwiftUI iOS 26, cách nhanh nhất để thêm haptic feedback là dùng modifier .sensoryFeedback(_:trigger:), chỉ cần một dòng để phát rung khi giá trị trigger thay đổi, không cần tự quản lý UIFeedbackGenerator. Với những trường hợp phức tạp hơn (rung có nhịp, texture, đồng bộ với âm thanh), bạn dùng CHHapticEngine từ framework Core Haptics. Bài viết này đi qua cả hai lớp API, kèm ví dụ chạy được, giá trị spring cụ thể để đồng bộ với animation, và ghi chú về accessibility mà tôi luôn kiểm tra trước khi ship.
.sensoryFeedback(_:trigger:) là API SwiftUI khai báo, hoạt động trên iOS 17+ (bao gồm iOS 26) và tự động tôn trọng cài đặt System Haptics.
Core Haptics cho phép soạn nhịp haptic tùy biến qua CHHapticPattern, CHHapticEvent hoặc file AHAP (JSON), dùng khi bạn cần rung có texture như game, âm nhạc, drag & drop.
Haptics không hoạt động trên iOS Simulator; bắt buộc test trên thiết bị vật lý (iPhone 8 trở lên có Taptic Engine thế hệ 2).
Trên iPad không có Taptic Engine hệ thống, các API haptic sẽ im lặng, kiểm tra CHHapticEngine.capabilitiesForHardware().supportsHaptics trước khi khởi động engine.
Đồng bộ haptic với animation: kích hoạt haptic ở khung hình đỉnh của spring (thường quanh 60% response) để rung khớp cảm giác dừng chứ không phải lúc gesture kết thúc.
Sensory Feedback API là gì và khi nào nên dùng?
SensoryFeedback là kiểu enum trong SwiftUI được Apple giới thiệu ở iOS 17 và được refine tiếp trong iOS 26, đóng vai trò lớp abstraction đứng trên cả UIImpactFeedbackGenerator lẫn CHHapticEngine. Thay vì bạn phải tự khởi tạo generator, gọi prepare(), rồi impactOccurred() đúng thời điểm, bạn khai báo ý định UX (thông báo thành công, chọn item, cảnh báo) và để hệ thống chọn kiểu rung phù hợp với thiết bị và phiên bản iOS.
Trong công việc hằng ngày của tôi, tôi mặc định dùng .sensoryFeedback cho 90% các trigger UX chuẩn: toggle bật/tắt, submit form thành công, xóa item, kéo qua ngưỡng snapping. Chỉ khi designer đưa cho tôi một spec dạng "rung nảy 3 lần giảm dần trong 240ms rồi tap mạnh" tôi mới chuyển sang Core Haptics. Cách phân chia này giữ code UI gọn và cho phép Apple cập nhật đường cong rung khi Taptic Engine thay đổi, điều đã xảy ra khi iPhone 15 Pro giới thiệu haptic tinh hơn.
Cách thêm haptic feedback trong SwiftUI với .sensoryFeedback
Cú pháp cơ bản gắn haptic vào một giá trị observable. Mỗi khi giá trị đó đổi, SwiftUI phát haptic được chỉ định, không cần onChange, không cần state machine.
Ba điểm cần chú ý ở ví dụ này. Thứ nhất, closure điều kiện chỉ trigger haptic khi isLiked đổi từ false sang true, không phát khi bỏ like. Điều này tránh "haptic fatigue", một lỗi tôi thường thấy trong app mới: rung mọi lúc khiến người dùng tắt hẳn haptics trong Settings. Thứ hai, tôi khớp haptic .impact(weight: .medium) với spring animation có response: 0.4; động cơ spring này đạt đỉnh nhanh và haptic medium là "trọng lượng" cảm giác phù hợp, heavy sẽ nặng nề với một icon nhỏ. Thứ ba, intensity: 0.8 thay vì 1.0 vì cường độ tối đa tạo cảm giác quá "công nghiệp" cho một tương tác cảm xúc.
Đối với các trường hợp trigger nhiều điều kiện, dùng phiên bản closure trả về SensoryFeedback?:
.sensoryFeedback(trigger: score) { oldValue, newValue in
if newValue > oldValue { return .increase }
if newValue < oldValue { return .decrease }
return nil
}
Trả về nil nghĩa là "không phát gì lần này". Đây là pattern gọn để phản hồi hướng thay đổi mà không cần viết logic ngoài view. Nếu bạn đang xây UI navigation, hãy tham khảo thêm hướng dẫn SwiftUI NavigationStack iOS 26 để biết cách gắn haptics vào transition khi push/pop.
Sự khác biệt giữa Core Haptics và Sensory Feedback
Sensory Feedback là API cấp cao trong SwiftUI. Core Haptics là framework cấp thấp trong iOS SDK cho phép bạn định nghĩa chính xác timing, cường độ và độ sắc (sharpness) của từng haptic event. Chọn cái nào phụ thuộc vào lượng kiểm soát bạn cần và complexity của rung mong muốn.
Tiêu chí
SensoryFeedback
Core Haptics (CHHapticEngine)
Ngôn ngữ
SwiftUI modifier khai báo
Framework imperative
Số dòng code tối thiểu
1
~15–25 (khởi tạo engine + pattern)
Kiểu rung
11 preset cố định
Không giới hạn, tự soạn
Điều khiển thời gian
Tức thời
Chính xác đến millisecond, có duration
Đồng bộ với audio
Không
Có (audio-haptic sync)
File tài nguyên
Không cần
AHAP (JSON) hoặc dựng runtime
iOS tối thiểu
17.0
13.0
watchOS
10.0
Không hỗ trợ (dùng WKInterfaceDevice)
Quy tắc thực tế tôi áp dụng: nếu haptic bạn cần có thể mô tả bằng một danh từ ("selection", "success", "impact medium"), dùng SensoryFeedback. Nếu bạn phải mô tả bằng câu ("hai tap nhẹ cách nhau 80ms rồi một tap mạnh với texture ma sát trong 300ms"), bạn cần Core Haptics. Trong dự án game hoặc app âm nhạc gần đây tôi làm, khoảng 20% haptics đi qua Core Haptics, phần còn lại đủ dùng SwiftUI.
Tạo custom haptic pattern với CHHapticEngine
Khi bạn cần rung không có sẵn trong SensoryFeedback, dựng pattern qua Core Haptics. Ví dụ dưới đây soạn một "double-tap fade", hai tap ban đầu rồi một continuous haptic yếu dần trong 400ms, phù hợp làm feedback cho một pull-to-refresh mà bạn muốn nghe "phồng ra rồi tan":
import CoreHaptics
@Observable
final class HapticsController {
private var engine: CHHapticEngine?
init() {
guard CHHapticEngine.capabilitiesForHardware().supportsHaptics else { return }
do {
engine = try CHHapticEngine()
engine?.stoppedHandler = { [weak self] _ in
try? self?.engine?.start()
}
engine?.resetHandler = { [weak self] in
try? self?.engine?.start()
}
try engine?.start()
} catch {
print("Haptic engine init failed: \(error)")
}
}
func playDoubleTapFade() {
guard let engine else { return }
let tap1 = CHHapticEvent(eventType: .hapticTransient,
parameters: [
.init(parameterID: .hapticIntensity, value: 0.9),
.init(parameterID: .hapticSharpness, value: 0.7)
],
relativeTime: 0)
let tap2 = CHHapticEvent(eventType: .hapticTransient,
parameters: [
.init(parameterID: .hapticIntensity, value: 0.9),
.init(parameterID: .hapticSharpness, value: 0.7)
],
relativeTime: 0.08) // 80ms sau tap1
let fade = CHHapticEvent(eventType: .hapticContinuous,
parameters: [
.init(parameterID: .hapticIntensity, value: 0.6),
.init(parameterID: .hapticSharpness, value: 0.3)
],
relativeTime: 0.15,
duration: 0.4)
do {
let pattern = try CHHapticPattern(events: [tap1, tap2, fade], parameters: [])
let player = try engine.makePlayer(with: pattern)
try player.start(atTime: CHHapticTimeImmediate)
} catch {
print("Haptic play failed: \(error)")
}
}
}
Bốn chi tiết dễ sai. Một, luôn check supportsHaptics, iPad và các thiết bị cũ sẽ crash hoặc im lặng bất ngờ. Hai, gắn stoppedHandler và resetHandler để tự động restart engine khi hệ điều hành pause nó (ví dụ khi app vào background hoặc có audio interruption). Ba, hapticSharpness điều khiển "độ sắc" chứ không phải cường độ, 0.0 cảm giác như một cú đấm trầm, 1.0 như tiếng ping. Bốn, relativeTime tính từ lúc pattern bắt đầu chứ không phải từ event trước, 0.15s ở fade nghĩa là 150ms sau tap1, không phải 150ms sau tap2.
Tôi thường bọc controller này trong @Environment để mọi view có thể trigger mà không cần khởi tạo lại engine. Cách tiếp cận Observable ở đây tương tự pattern trong UIKit Observable iOS 26, tận dụng macro @Observable để loại bỏ boilerplate ObservableObject.
Dùng file AHAP để thiết kế haptic như thiết kế âm thanh
AHAP (Apple Haptic and Audio Pattern) là file JSON mô tả haptic pattern, cho phép designer chỉnh trực tiếp mà không cần chạm code. Đây là cách tôi làm việc với designer haptic ở dự án hiện tại: họ export AHAP từ Core Haptics reference hoặc tool bên thứ ba, tôi drop vào bundle và play bằng một dòng.
// Bounce.ahap trong bundle
func playAHAP(named name: String) throws {
guard let url = Bundle.main.url(forResource: name, withExtension: "ahap") else {
return
}
try engine?.playPattern(from: url)
}
// Sử dụng
try? haptics.playAHAP(named: "Bounce")
Cấu trúc AHAP tối thiểu trông như sau, mô tả một "bounce" 3 nảy giảm dần, pattern hay dùng khi item được drop vào vị trí sai và nhảy về chỗ cũ:
Thật ra, ưu điểm lớn nhất của AHAP là versionable trong git: bạn thấy diff khi designer đổi timing từ 120ms sang 100ms, thay vì phải review một PR đầy magic number trong Swift. Cross-team review nhanh hơn nhiều.
Đồng bộ haptics với motion và spring animation
Haptic tách rời animation là lỗi UX phổ biến nhất tôi phải fix trong review. Rung phải cảm giác là chuyển động, không phải xảy ra trong lúc chuyển động. Ba nguyên tắc tôi áp dụng, dựa trên đo lường thực tế trên iPhone 15 Pro và iPhone 16 Pro:
1. Đối với spring animation: trigger haptic ở khoảng 60% response time, không phải lúc gọi withAnimation. Với spring response: 0.4, đó là khoảng 240ms sau khi gesture kết thúc, thời điểm view chạm đỉnh overshoot. Rung tại điểm này khớp cảm giác "click" của spring, không phải cảm giác "tôi nhấc tay khỏi màn hình".
Trong iOS 17+ với .sensoryFeedback, độ trễ này được xử lý ngầm khi bạn trigger dựa trên state cuối animation, nhưng nếu bạn tự phát Core Haptics, phải delay thủ công. Đây là dạng chi tiết bạn không thấy trong KeyframeAnimator và PhaseAnimator nhưng cực kỳ ảnh hưởng chất lượng cảm nhận.
2. Đối với drag gesture: haptic khi crossing threshold, không phải khi gesture kết thúc. Nếu user kéo item qua ngưỡng "sẽ xóa", rung ngay tại pixel ngưỡng để họ cảm được biên. Đây là cách app Mail của Apple báo swipe-to-delete đã đủ xa.
@State private var dragOffset: CGFloat = 0
@State private var hasCrossedThreshold = false
.gesture(
DragGesture()
.onChanged { value in
dragOffset = value.translation.width
let crossing = abs(value.translation.width) > 120
if crossing != hasCrossedThreshold {
hasCrossedThreshold = crossing
}
}
)
.sensoryFeedback(.alignment, trigger: hasCrossedThreshold)
3. Đừng chồng haptics. Nếu spring animation kéo dài 400ms và bạn trigger 3 haptics trong khoảng đó, chúng sẽ merge trên Taptic Engine thành một cú rung không rõ. Giới hạn 1 haptic mỗi 100ms trên cùng một view.
Haptics trên watchOS với WKInterfaceDevice
watchOS dùng Taptic Engine khác với iPhone, mạnh hơn tương đối và có API riêng. Trước watchOS 10, bạn chỉ có WKInterfaceDevice.current().play(_:) với 11 preset. Từ watchOS 10 SDK, .sensoryFeedback hoạt động trên SwiftUI Watch app y hệt iOS, đây là một trong những cải thiện API dễ chịu nhất khi làm cross-platform.
Chú ý: watchOS không hỗ trợ CHHapticEngine. Nếu bạn cần custom pattern trên watch, bạn phải gọi WKInterfaceDevice.current().play(_:) nhiều lần với delay giữa các call, không có cách gộp thành một pattern. Đây là lý do haptic library của tôi thường có class abstraction: iOS route qua Core Haptics, watchOS route qua sequence WKHaptic calls.
Accessibility, Reduce Motion và System Haptics
Ba cài đặt hệ thống ảnh hưởng haptics: System Haptics (Settings → Sounds & Haptics), Reduce Motion (Settings → Accessibility → Motion), và Haptic Touch speed. SwiftUI .sensoryFeedback tự động respect cài đặt đầu tiên; hai cài đặt còn lại bạn phải xử lý code.
Cụ thể: khi Reduce Motion bật, người dùng thường cũng muốn giảm haptic (nhiều trường hợp Reduce Motion là dấu hiệu vestibular sensitivity). Tôi kiểm tra bằng accessibilityReduceMotion và giảm intensity xuống 0.4:
@Environment(\.accessibilityReduceMotion) private var reduceMotion
var feedback: SensoryFeedback {
reduceMotion
? .impact(weight: .light, intensity: 0.4)
: .impact(weight: .medium, intensity: 0.9)
}
Với Core Haptics, đọc UIAccessibility.isReduceMotionEnabled tại điểm play và scale hapticIntensity theo. Đừng skip haptic hoàn toàn, nhiều user Reduce Motion vẫn cần haptic để định hướng, đặc biệt là user khiếm thị dùng VoiceOver.
Haptics có chạy trên iOS Simulator không?
Không. iOS Simulator không có Taptic Engine ảo — CHHapticEngine vẫn khởi tạo được nhưng capabilitiesForHardware().supportsHaptics trả về false, và các call .sensoryFeedback im lặng. Đây là điểm gây bực bội lớn nhất khi debug haptics: code có vẻ chạy nhưng không cảm nhận được. Bốn cách tôi work around:
Log tại điểm phát: viết wrapper quanh haptic play để log kèm timestamp. Bạn sẽ thấy chuỗi haptic events trong console và biết logic có chạy hay không.
Test trên thiết bị vật lý sớm: gắn iPhone qua USB và deploy build debug. Xcode 16+ có tab Devices show battery và performance metrics, không có preview haptic được.
Dùng "Reveal Haptics" của một số designer tool để visualize timeline pattern trước khi test trên máy, không thay thế test thật nhưng giảm số lần rebuild.
Test trên iPad để biết fallback path: iPad không có Taptic Engine hệ thống, chạy code haptic của bạn trên iPad giúp phát hiện chỗ nào bạn quên check capability.
Với Live Activities cần haptic alert khi update, bạn phải test trên thiết bị vật lý, Simulator không render Dynamic Island interaction đầy đủ. Xem thêm Live Activities và Dynamic Island iOS 26 để biết cách kết hợp haptic với ActivityKit update.
Câu hỏi thường gặp
Làm sao để thêm haptic feedback trong SwiftUI đơn giản nhất?
Dùng modifier .sensoryFeedback(_:trigger:). Chỉ cần một dòng: .sensoryFeedback(.success, trigger: isSubmitted), SwiftUI sẽ phát haptic mỗi khi isSubmitted đổi giá trị. Không cần tự quản lý UIFeedbackGenerator hay gọi prepare().
Khi nào nên dùng Core Haptics thay vì SensoryFeedback?
Khi bạn cần pattern có nhịp cụ thể (multi-tap, continuous rung với duration), cần đồng bộ audio-haptic, hoặc cần texture (sharpness) khác biệt. Với 11 preset SensoryFeedback là đủ cho phần lớn UX thông thường; game, app âm nhạc, drawing app cần Core Haptics.
Tại sao haptic không hoạt động trên iPad của tôi?
iPad không có Taptic Engine hệ thống, CHHapticEngine.capabilitiesForHardware().supportsHaptics trả về false trên mọi model iPad hiện tại. Code haptic của bạn không crash mà im lặng. Đây là lý do bạn phải luôn check capability trước khi khởi tạo engine.
Làm sao tạo custom haptic pattern nhiều tap?
Dùng CHHapticPattern(events:parameters:) với mảng CHHapticEvent, mỗi event có relativeTime khác nhau (tính từ đầu pattern). Hoặc soạn file .ahap JSON, drop vào bundle rồi play bằng engine.playPattern(from: url). AHAP dễ chia sẻ với designer hơn.
Haptic có tôn trọng cài đặt Reduce Motion không?
SwiftUI .sensoryFeedback tôn trọng cài đặt System Haptics trong Settings, nhưng không tự động scale theo Reduce Motion. Bạn nên đọc @Environment(\.accessibilityReduceMotion) và giảm intensity haptic (không tắt hoàn toàn) khi flag này bật, đặc biệt cho user vestibular sensitivity.
Hướng dẫn triển khai TipKit trong iOS 26 với SwiftUI: từ Tip protocol, TipGroup, rules với Parameter và Event, CloudKit sync đến custom TipViewStyle theo Liquid Glass, kèm bài học thực chiến.
Hướng dẫn xây Live Activities và Dynamic Island trên iOS 26 với ActivityKit và SwiftUI: từ ActivityAttributes, thiết kế bốn state Dynamic Island, cập nhật local và APNs push, tới broadcast channels và interactive App Intents. Kèm code chạy được và bài học thực tế.
Học cách dùng KeyframeAnimator và PhaseAnimator trong SwiftUI iOS 26 để dàn dựng animation nhiều track song song, xử lý Reduce Motion đúng cách và tối ưu hiệu năng render pipeline.