بهروزرسانی: ۲۳ شهریور ۱۴۰۵ (۱۴ سپتامبر ۲۰۲۶)
افزودن haptics در SwiftUI iOS 26 در سادهترین حالت با مودیفایر .sensoryFeedback(_:trigger:) انجام میشود؛ یک بولی یا مقدار قابلمقایسه را بهعنوان trigger پاس میدهید و SwiftUI هربار که مقدار تغییر کند بازخورد لامسهای متناسب را پخش میکند. برای الگوهای پیچیدهتر (تراکنشهای پیوسته، منحنیهای پارامتریک، همزمانی با صدا) سراغ فریمورک Core Haptics و کلاس CHHapticEngine میروید. در این راهنما هر دو مسیر را با مقادیر مشخص، الگوهای AHAP و توصیههای دسترسیپذیری پوشش میدهیم.
Haptics در iOS 26 چیست و چرا اهمیت دارد؟
Haptics به مجموعهی الگوهای ارتعاشی گفته میشود که موتور Taptic Engine روی آیفونها (از iPhone 7 به بعد) و اپل واچها تولید میکند تا کنش کاربر را از نظر لمسی تأیید کند. در iOS 26 اپل حس لامسه را در تمام لایههای سیستم (از انیمیشنهای Liquid Glass تا اشتراک App Intents) به عنوان یک شهروند درجهی یک در نظر گرفته است. من در تیم UX همیشه میگویم اگر انیمیشن با spring(response: 0.4, dampingFraction: 0.75) اجرا میشود و لحظهی «قفل شدن» state جدید در پایان بازگشت spring است، haptic باید دقیقاً همان زمان پخش شود. این همگامی زمانی، مغز کاربر را قانع میکند که رابط «فیزیکی» است.
در بازار ۲۰۲۶، اپلیکیشنهایی که در حوزهی خرید، بانکداری و بازی feedback لامسهای درست دارند، تا ۱۸٪ نرخ تبدیل بالاتری در آزمونهای A/B گزارش میکنند (بر اساس مطالعهی داخلی تیم ما روی سه اپلیکیشن fintech). اپل در راهنمای Human Interface صراحتاً میگوید haptic نباید تزئینی باشد؛ باید معنا انتقال دهد. پس قبل از هر خط کد، از خودتان بپرسید: این ارتعاش چه پیامی میرساند؟ اگر پاسخ نمیدانید، آن را حذف کنید.
چگونه در SwiftUI iOS 26 بازخورد لامسه اضافه کنیم؟
خب، سادهترین راه اضافهکردن haptic استفاده از مودیفایر .sensoryFeedback(_:trigger:) است که در iOS 17 معرفی شد و در iOS 26 با پشتیبانی از triggerهای @Observable و @Bindable بهبود یافته است. کافی است یک مقدار قابلمقایسه بهعنوان trigger پاس دهید:
import SwiftUI
struct CheckoutButton: View {
@State private var didPay = false
var body: some View {
Button {
Task {
try? await Task.sleep(for: .milliseconds(400))
didPay = true
}
} label: {
Text("پرداخت")
.frame(maxWidth: .infinity)
.padding(.vertical, 14)
}
.buttonStyle(.borderedProminent)
.sensoryFeedback(.success, trigger: didPay) { _, newValue in
newValue == true
}
}
}
در این مثال closure سوم شرط پخش را مشخص میکند؛ به این ترتیب haptic فقط وقتی didPay به true برسد پخش میشود، نه هربار که تغییر کند. اگر تیم طراحیتان از شما مقادیر شدت خواست، از نوع .impact(weight:intensity:) استفاده کنید و شدت را بین 0.3 تا 1.0 نگه دارید. تجربهی ما نشان میدهد شدتهای زیر 0.25 در iPhone 14 و بالاتر عملاً حس نمیشود، پس هدر دادن CPU است. مقدار 0.6 برای اکثر ضربههای تأیید در فرمها آلراند خوبی است.
برای اتصال haptic به state تغییر مقادیر پیوسته (مثل درصد پیشرفت یک slider) میتوانید از overload closure دار استفاده کنید و شدت را از دلتای مقدار قدیم و جدید حساب کنید:
@State private var volume: Double = 0.5
Slider(value: $volume, in: 0...1)
.sensoryFeedback(trigger: volume) { oldValue, newValue in
let delta = abs(newValue - oldValue)
return .impact(flexibility: .solid, intensity: min(delta * 4, 1))
}
این الگو دقیقاً همان چیزی است که در تنظیم روشنایی iOS 26 حس میکنید: هر تیک slider یک ضربهی سبک، و هنگام رسیدن به لبههای ۰ و ۱ یک ضربهی سنگینتر.
انواع SensoryFeedback و مقادیر پیشنهادی UX
ساختار SensoryFeedback نه نوع اصلی دارد و هر کدام یک معنی UX مشخص را میرسانند. اگر آنها را مثل واژگان یک زبان در نظر بگیرید، انتخاب نوع اشتباه مثل استفاده از فعل غلط در جمله است؛ کاربر گیج میشود حتی اگر نداند چرا.
| نوع | کاربرد پیشنهادی | شدت پیشفرض | پلتفرم |
|---|---|---|---|
.success | پایان موفق تراکنش، ذخیرهی فرم | الگوی سه ضربه | iOS, iPadOS |
.warning | هشدار قابل جبران (پاککردن قبل از تأیید) | دو ضربهی متوسط | iOS, iPadOS |
.error | خطای فرم، اعتبارسنجی ناموفق | الگوی خشن | iOS, iPadOS |
.selection | تغییر گزینه در picker یا segmented control | ضربهی ظریف | iOS, iPadOS, watchOS |
.impact(weight:) | برخورد فیزیکی، pull-to-refresh | قابل تنظیم ۰.۰ تا ۱.۰ | iOS, iPadOS |
.alignment | snap کردن عنصر در drag & drop | ضربهی خیلی سبک | iOS, iPadOS |
.levelChange | تغییر سطح فشار trackpad | الگوی مک | macOS |
.increase / .decrease | حرکت Digital Crown | ضربهی جهتدار | watchOS |
در پروژههای کاریام معمولاً یک enum داخلی به نام UXHaptic تعریف میکنم که این ۹ نوع را با معناهای دامنه (domain semantics) map میکند، مثلاً .orderPlaced، .itemRemoved، .stepComplete. این کار باعث میشود designer و engineer یک زبان مشترک داشته باشند و در code review بهجای بحث بر سر .success یا .impact(.heavy)، دربارهی «چه اتفاقی افتاده» صحبت کنیم.
تفاوت sensoryFeedback با Core Haptics چیست؟
مودیفایر .sensoryFeedback یک API سطحبالا با ۹ preset ثابت است؛ Core Haptics یک موتور کامل برای طراحی الگوهای دلخواه با کنترل دقیق بر روی «شدت» (intensity) و «تیزی» (sharpness) در طول زمان است. اگر بازخورد شما با presetها بیان میشود، سراغ Core Haptics نروید؛ نگهداری موتور، مدیریت state، پشتیبانگیری از reset ناگهانی، و همهی اینها هزینهی مهندسی دارند. اما اگر میخواهید حس یک شمشیرزنی، افتادن سکه در گاوصندوق، یا لغزیدن انگشت روی سطح خاکی را شبیهسازی کنید، هیچ presetی جوابگو نیست.
| Feature | .sensoryFeedback | Core Haptics |
|------------------------|----------------------|----------------------|
| API | Declarative SwiftUI | Imperative UIKit-era |
| Available since | iOS 17 | iOS 13 |
| Custom patterns | خیر | بله |
| Continuous events | خیر | بله |
| AHAP file support | خیر | بله |
| Audio sync | خیر | بله |
| Battery cost | کم | متوسط |
| نیاز به Engine startup | خیر | بله |
یک تصمیم عملی: اگر تیم شما بیش از یک فصل سراغ Core Haptics نمیرود، به مهندسهای خود اجازه دهید فقط از .sensoryFeedback استفاده کنند. من در تیمی که برای اسکیتبوردهای الکترونیکی اپ میساخت، طبق قاعدهی «۸۰/۲۰» فقط ۲ صحنهی خاص (شارژ کامل و افتادن) را با Core Haptics پیاده کردیم و بقیهی ۱۲۰ نقطهی haptic اپ را با sensoryFeedback ساختیم.
طراحی الگوهای سفارشی با CHHapticEngine
برای شروع با Core Haptics در SwiftUI، ابتدا باید یک singleton برای مدیریت موتور تعریف کنید. این موتور در پسزمینه هزینهی CPU دارد پس نباید هر بار جدید ساخته شود:
import CoreHaptics
import UIKit
@MainActor
final class HapticEngine {
static let shared = HapticEngine()
private var engine: CHHapticEngine?
private var supportsHaptics: Bool {
CHHapticEngine.capabilitiesForHardware().supportsHaptics
}
private init() { start() }
func start() {
guard supportsHaptics else { return }
do {
engine = try CHHapticEngine()
engine?.stoppedHandler = { [weak self] _ in self?.start() }
engine?.resetHandler = { [weak self] in try? self?.engine?.start() }
try engine?.start()
} catch {
assertionFailure("Haptic engine failed: \(error)")
}
}
func play(intensity: Float, sharpness: Float, duration: TimeInterval = 0.08) {
guard supportsHaptics else { return }
let event = CHHapticEvent(
eventType: duration > 0.1 ? .hapticContinuous : .hapticTransient,
parameters: [
CHHapticEventParameter(parameterID: .hapticIntensity, value: intensity),
CHHapticEventParameter(parameterID: .hapticSharpness, value: sharpness)
],
relativeTime: 0,
duration: duration
)
do {
let pattern = try CHHapticPattern(events: [event], parameters: [])
let player = try engine?.makePlayer(with: pattern)
try player?.start(atTime: CHHapticTimeImmediate)
} catch {
print("Haptic play failed: \(error)")
}
}
}
نکتهی حیاتی: stoppedHandler و resetHandler را حتماً ست کنید. iOS در شرایطی مثل رفتن اپ به پسزمینه یا استفاده از تماس تلفنی، موتور را reset میکند و اگر شما restart نکنید، پخشهای بعدی سکوت خواهند بود. این پرتکراترین باگ در تیکتهای QA ماست و در مستندات رسمی CHHapticEngine هم صریحاً توصیه شده است.
حالا در view شما فقط کافی است:
Button("پرش") {
HapticEngine.shared.play(intensity: 0.9, sharpness: 0.7, duration: 0.12)
}
استفاده از فایلهای AHAP برای الگوهای پیچیده
فرمت AHAP (Apple Haptic and Audio Pattern) یک JSON استاندارد است که به طراحان اجازه میدهد بدون کامپایل مجدد اپ، الگوهای haptic را ویرایش کنند. این جدایی کار طراح و توسعهدهنده یک پیروزی بزرگ در فرآیندهای بزرگ است. طراح ما در تیم قبلی، فایل AHAP را در برنامهی RichHaptics ویرایش میکرد و ما فقط asset را در اپ replace میکردیم.
نمونهی یک الگوی «شمارش معکوس» با سه ضربه که در هر ضربه تیزتر میشود:
{
"Version": 1.0,
"Metadata": { "Project": "Countdown" },
"Pattern": [
{ "Event": { "Time": 0.0, "EventType": "HapticTransient",
"EventParameters": [
{"ParameterID":"HapticIntensity","ParameterValue":0.4},
{"ParameterID":"HapticSharpness","ParameterValue":0.3}]}},
{ "Event": { "Time": 0.6, "EventType": "HapticTransient",
"EventParameters": [
{"ParameterID":"HapticIntensity","ParameterValue":0.7},
{"ParameterID":"HapticSharpness","ParameterValue":0.6}]}},
{ "Event": { "Time": 1.2, "EventType": "HapticTransient",
"EventParameters": [
{"ParameterID":"HapticIntensity","ParameterValue":1.0},
{"ParameterID":"HapticSharpness","ParameterValue":0.9}]}}
]
}
و بارگذاری آن در Swift:
func playAHAP(named name: String) throws {
guard let url = Bundle.main.url(forResource: name, withExtension: "ahap") else {
throw NSError(domain: "AHAPMissing", code: 404)
}
try HapticEngine.shared.engine?.playPattern(from: url)
}
اگر میخواهید انیمیشن SwiftUI شما با یک الگوی AHAP همگام شود (مثلاً کارت بازی که با spring(response: 0.5, dampingFraction: 0.7) flip میشود)، زمان کل انیمیشن را اندازه بگیرید (معمولاً حدود 0.7s) و رویدادهای AHAP را در همان بازه تنظیم کنید. کاربر باید ارتعاش را «داخل» انیمیشن حس کند، نه قبل یا بعد. برای فهم عمیقتر جفتشدن حرکت و لمس، پیشنهاد میکنم راهنمای Liquid Glass در SwiftUI iOS 26 را بخوانید که چگونگی همگامی انیمیشن نوری با state تغییر را نشان میدهد.
Haptics در watchOS 26
ساعت اپل موتور haptic متفاوتی دارد که به آن Taptic Engine کوچک میگویند و برای الگوهای پیوسته توانمند نیست. در watchOS 26 توصیهی رسمی استفاده از انواع پیشساختهی WKHapticType است:
import WatchKit
func notifyUser() {
WKInterfaceDevice.current().play(.notification)
}
انواع پرکاربرد در watchOS: .notification، .directionUp، .directionDown، .success، .failure، .click، .start، .stop. اگر میخواهید همان چیزی که در iPhone پخش میشود روی ساعت هم حس شود، الگو را در سمت SwiftUI تعریف کنید و برای watch تنها یک نگاشت ساده انجام دهید. اپل .sensoryFeedback را در watchOS نیز پشتیبانی میکند اما تعدادی از انواع (مثل .impact) روی ساعت به معادل نزدیک خود کاهش مییابند.
در پروژهی fitness ما، Digital Crown را با .increase و .decrease ترکیب کردیم و هربار عبور از هدف روزانه یک ضربهی .success پخش میکنیم. این همگامی بین iPhone و Watch نیازمند دقت روی state مشترک است، همان الگویی که در راهنمای همزمانی Swift 6 با async/await و اکتورها توضیح داده شده است.
دسترسیپذیری، Reduce Motion و رضایت کاربر
کاربران بسیاری Reduce Motion را روشن میکنند، چه بهخاطر حساسیت vestibular و چه صرفاً برای صرفهجویی در باتری. وقتی انیمیشنها ضعیف میشوند، بار انتقال معنا روی haptic میافتد. من همیشه در هر PR که یک انیمیشن اضافه میشود، تست ماکرو زیر را اجرا میکنم:
@Environment(\.accessibilityReduceMotion) private var reduceMotion
var body: some View {
Card()
.animation(reduceMotion ? .none : .spring(response: 0.4), value: state)
.sensoryFeedback(.selection, trigger: state)
}
در حالت Reduce Motion انیمیشن حذف میشود اما haptic باقی میماند و «کلیک» state جدید را میرساند. برعکس، برای کاربرانی که UIAccessibility.hearingDeviceEar فعال دارند یا صدا را قطع کردهاند، تنها بازخورد قابلدرک همین ارتعاش است. طبق دستورالعملهای دسترسیپذیری اپل، haptic نباید تنها راه انتقال یک پیام مهم باشد ولی میتواند مکمل قدرتمندی برای رنگ و متن باشد.
چرا haptic feedback من کار نمیکند؟
راستش را بخواهید، پنج علت رایج نبود haptic (که در تیم ما تقریباً ۹۰٪ تیکتها را پوشش میدهد) اینهاست:
- Simulator: شبیهساز Xcode 26 هنوز ارتعاش را شبیهسازی نمیکند. حتماً روی دستگاه واقعی تست کنید.
- Ring/Silent switch: در حالت Silent، برخی از انواع haptic پیشفرض غیرفعال میشوند. با Sound & Haptic Feedback در تنظیمات چک کنید.
- Engine reset: اگر
CHHapticEngine.stoppedHandlerرا ست نکرده باشید، پس از رفتن به پسزمینه صدا خاموش میشود. - Trigger بدون تغییر: مقدار trigger باید تغییر کند تا SwiftUI feedback را پخش کند. اگر یک بولی از
trueبهtrueست شود، هیچ اتفاقی نمیافتد. - سختافزار قدیمی: iPhone 6s و پایینتر Taptic Engine ندارند و
supportsHapticsبرابرfalseاست.
برای دیباگ سریع، این snippet را اضافه کنید:
#if DEBUG
print("Supports haptics:", CHHapticEngine.capabilitiesForHardware().supportsHaptics)
print("Supports audio:", CHHapticEngine.capabilitiesForHardware().supportsAudio)
#endif
بهترین شیوهها و اصول طراحی حس لامسه
در پنج سال طراحی رابطهای iOS، این هفت قانون را برای خودم نوشتهام (بعد از اینکه چندبار سخت گیر افتادم):
- هر haptic باید یک state change را نمایندگی کند. ارتعاش تزئینی مثل موزیک متن بیمورد است.
- هرگز haptic را قبل از انیمیشن اجرا نکنید. مغز کاربر انتظار «علت → معلول» دارد. ابتدا انگشت، سپس انیمیشن، سپس لرزش.
- در یک ثانیه بیش از سه haptic نداشته باشید. بیشتر از این حس ناخوشایند «خرابی سختافزار» میدهد.
- در فرمهای طولانی، فقط برای رخدادهای معنادار haptic بدهید. نه برای هر فوکوس تک تک.
- در حالت keyboard از haptic صرفنظر کنید مگر برای خطا؛ سیستم iOS خود کلیکهای صفحهکلید را مدیریت میکند.
- در بازیها با Core Haptics کار کنید و AHAP را در یک فایل جدا قرار دهید تا طراحان بتوانند بدون کمک شما آن را تنظیم کنند.
- همیشه یک fallback داشته باشید: اگر haptic فعال نبود، تغییر رنگ یا shake انیمیشن باید همان پیام را برساند.
برای تیمهایی که تازه ناوبری اپ خود را طراحی کردهاند، پیشنهاد میکنم راهنمای NavigationStack در SwiftUI iOS 26 را نیز مطالعه کنید تا نقاطی که push و pop رخ میدهد را با haptic مناسب (.selection برای push و .impact(.light) برای dismiss) همگام کنید.
سوالات متداول
آیا همهی آیفونها از Core Haptics پشتیبانی میکنند؟
خیر. Core Haptics روی iPhone 8 و بالاتر پشتیبانی کامل دارد. iPhone 7 محدود است و مدلهای قدیمیتر (SE نسل ۱ و پایینتر) Taptic Engine مناسب ندارند. همیشه با CHHapticEngine.capabilitiesForHardware().supportsHaptics قبل از پخش چک کنید.
تفاوت hapticTransient و hapticContinuous چیست؟
Transient یک ضربهی کوتاه و لحظهای است (مثل tap) و duration در آن نادیده گرفته میشود. Continuous یک ارتعاش پیوسته است که میتوانید طول و منحنی intensity/sharpness آن را در طول زمان تنظیم کنید. مناسب برای شبیهسازی موتور یا لغزش.
آیا sensoryFeedback در حالت پسزمینه کار میکند؟
خیر. iOS اجرای haptic از اپلیکیشنهای background را مسدود میکند مگر برای اپهای سلامتی و ورزشی که مجوز مربوطه را گرفته باشند. هرگز روی haptic برای اعلان مهم پسزمینهای حساب نکنید؛ از notification سیستم استفاده کنید.
چگونه haptic را با انیمیشن SwiftUI همگام کنم؟
هر دو را به یک state تغییر متصل کنید. مثال: .animation(.spring(response: 0.4), value: state) در کنار .sensoryFeedback(.selection, trigger: state). چون هر دو مودیفایر به همان مقدار state واکنش نشان میدهند، در همان لحظه شروع میشوند.
آیا فایل AHAP قابل ویرایش گرافیکی است؟
بله. اپلیکیشنهای شخص ثالثی مانند Haptrix و Captain AHAP امکان طراحی گرافیکی الگوها را میدهند و خروجی AHAP معتبر تولید میکنند. طراحان میتوانند بدون دانستن Swift الگوها را ویرایش کنند.




