mcpField
A
axiom-haptics

axiom-haptics

github.com/charleswiltgen设计与 UI1.2k188 次安装今天更新
代码仓库

关于 axiom-haptics

axiom-haptics 是面向 iOS 开发者的触觉与音频反馈实现指南,涵盖 UIFeedbackGenerator、Core Haptics 和 AHAP。内容还包括反馈设计原则、与动画及音频同步,以及真机测试和调试。

主要功能

  1. 01使用 UIFeedbackGenerator 提供基础触觉反馈
  2. 02通过 Core Haptics 创建自定义模式
  3. 03加载 AHAP 触觉与音频模式
  4. 04同步触觉、音频和动画
  5. 05诊断设备限制并测试触觉

适用场景

  1. 01为按钮、选择器和表单结果添加触觉
  2. 02为滑块或持续动作设计反馈
  3. 03制作与动画同步的触觉和音频效果
  4. 04在实体设备上排查触觉问题

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/charleswiltgen/axiom --skill axiom-haptics

安装后在 Claude Code 中输入/axiom-haptics确认已加载。

详细介绍

触觉与音频反馈

iOS 触觉反馈实现综合指南。每款 Apple 设计大奖获奖应用都采用了出色的触觉反馈——相机、地图和天气应用都巧妙运用触觉,打造令人愉悦、响应灵敏的体验。

概述

触觉反馈为用户操作和系统事件提供触觉确认。使用因果性、和谐性与实用性框架进行周密设计时,axiom-haptics 能让界面从实用变得令人愉悦。

本技能涵盖简单触觉反馈(UIFeedbackGenerator)和高级自定义模式(Core Haptics),并提供真实场景示例和音频与触觉同步技巧。

何时使用本技能

  • 为用户交互添加触觉反馈
  • 在 UIFeedbackGenerator 和 Core Haptics 之间进行选择
  • 设计协调统一的音频与触觉体验
  • 使用 AHAP 文件创建自定义触觉模式
  • 将触觉反馈与动画和音频同步
  • 调试触觉问题(模拟器与设备的差异)
  • 优化触觉性能和电池影响

系统要求

  • iOS 10+,适用于 UIFeedbackGenerator
  • iOS 13+,适用于 Core Haptics(CHHapticEngine)
  • iPhone 8+,支持 Core Haptics 硬件
  • 需要实体设备——在 Simulator 中无法感受到触觉反馈

第 1 部分:设计原则(WWDC 2021/10278)

Apple 的音频和触觉设计团队确立了多模态反馈的三项核心原则:

因果性——让用户清楚反馈由什么触发

问题:用户无法判断是什么触发了触觉反馈 解决方案:触觉反馈的时机必须与视觉或交互发生的时刻一致

WWDC 示例:

  • ✅ 球撞上墙壁 → 在碰撞时刻触发触觉反馈
  • ❌ 球撞上墙壁 → 100ms 后才触发触觉反馈(令人困惑)

代码模式:

// ✅ Immediate feedback on touch
@objc func buttonTapped() {
    let generator = UIImpactFeedbackGenerator(style: .medium)
    generator.impactOccurred()  // Fire immediately
    performAction()
}

// ❌ Delayed feedback loses causality
@objc func buttonTapped() {
    performAction()
    DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) {
        let generator = UIImpactFeedbackGenerator(style: .medium)
        generator.impactOccurred()  // Too late!
    }
}
和谐性——各感官协同一致时效果最佳

问题:视觉、音频和触觉反馈彼此不匹配 解决方案:三种感官都应构成协调统一的体验

WWDC 示例:

  • 小球 → 轻柔的触觉反馈 + 高音调声音
  • 大球 → 强烈的触觉反馈 + 低音调声音
  • 护盾变形 → 连续的触觉反馈 + 渐进式音频

关键见解:大型物体在触感上应显得沉重,在声音上应低沉而浑厚,在视觉上应显得厚重。三种感官共同强化同一种体验。

实用性——提供明确价值

问题:因为“能做到”,就在所有地方都使用触觉反馈 解决方案:将触觉反馈留给能为用户带来益处的重要时刻

适合使用触觉反馈的场景:

  • ✅ 确认重要操作(支付完成)
  • ✅ 提醒用户注意关键事件(电量低)
  • ✅ 提供持续反馈(滑动滑块)
  • ✅ 增添愉悦感(应用启动时的点缀效果)

不适合使用触觉反馈的场景:

  • ❌ 每一次轻点(令人不堪重负)
  • ❌ 浏览长列表时(消耗电量)
  • ❌ 用户无法看到的后台事件(令人困惑)
  • ❌ 装饰性动画(没有价值)

第 2 部分:UIFeedbackGenerator(简单触觉反馈)

对于大多数应用,UIFeedbackGenerator 提供 3 种简单的触觉反馈类型,无需自定义模式。

UIImpactFeedbackGenerator

模拟物理碰撞或冲击的触感。

样式(从轻到重排列):

  • .light - 轻微、细腻的点按感
  • .medium - 标准点按感(最常用)
  • .heavy - 强烈、扎实的冲击感
  • .rigid - 坚实、精准的点按感
  • .soft - 柔和、有缓冲的点按感

使用模式:

class MyViewController: UIViewController {
    let impactGenerator = UIImpactFeedbackGenerator(style: .medium)

    override func viewDidLoad() {
        super.viewDidLoad()
        // Prepare reduces latency for next impact
        impactGenerator.prepare()
    }

    @objc func userDidTap() {
        impactGenerator.impactOccurred()
    }
}

强度变化(iOS 13+):

// intensity: 0.0 (lightest) to 1.0 (strongest)
impactGenerator.impactOccurred(intensity: 0.5)

常见使用场景:

  • 按钮点按(.medium)
  • 切换开关(.light)
  • 删除项目(.heavy)
  • 确认选择(.rigid)
UISelectionFeedbackGenerator

用于离散的选项变更(选择器滚轮、分段控件)。

用法:

class PickerViewController: UIViewController {
    let selectionGenerator = UISelectionFeedbackGenerator()

    func pickerView(_ picker: UIPickerView, didSelectRow row: Int,
                    inComponent component: Int) {
        selectionGenerator.selectionChanged()
    }
}

触感类似于:带有卡位的实体滚轮发出的咔哒声

常见使用场景:

  • 选择器滚轮
  • 分段控件
  • 页面指示器
  • 逐步切换界面
UINotificationFeedbackGenerator

系统级成功/警告/错误反馈。

类型:

  • .success - 任务成功完成
  • .warning - 需要注意,但并不严重
  • .error - 发生严重错误

用法:

let notificationGenerator = UINotificationFeedbackGenerator()

func submitForm() {
    // Validate form
    if isValid {
        notificationGenerator.notificationOccurred(.success)
        saveData()
    } else {
        notificationGenerator.notificationOccurred(.error)
        showValidationErrors()
    }
}

最佳实践:根据用户操作结果匹配触觉反馈类型

  • ✅ 支付成功 → .success
  • ✅ 表单验证失败 → .error
  • ✅ 存储空间即将达到上限 → .warning
性能:prepare()

在触觉反馈前调用 prepare() 以降低延迟:

// ✅ Good - prepare before user action
@IBAction func buttonTouchDown(_ sender: UIButton) {
    impactGenerator.prepare()  // User's finger is down
}

@IBAction func buttonTouchUpInside(_ sender: UIButton) {
    impactGenerator.impactOccurred()  // Immediate haptic
}

// ❌ Bad - unprepared haptic may lag
@IBAction func buttonTapped(_ sender: UIButton) {
    let generator = UIImpactFeedbackGenerator()
    generator.impactOccurred()  // May have 10-20ms delay
}

准备时机:调用 prepare() 后,系统会让引擎保持就绪约 1 秒。


第 3 部分:Core Haptics(自定义触觉反馈)

对于需要自定义模式的应用,Core Haptics 可全面控制触觉波形。

四个基本元素
  1. 引擎(CHHapticEngine)- 连接到手机的执行器
  2. 播放器(CHHapticPatternPlayer)- 控制播放
  3. 模式(CHHapticPattern)- 随时间变化的一组事件
  4. 事件(CHHapticEvent)- 定义体验的构建模块
CHHapticEngine 生命周期
import CoreHaptics

class HapticManager {
    var engine: CHHapticEngine?

    func initializeHaptics() {
        // Check device support
        guard CHHapticEngine.capabilitiesForHardware().supportsHaptics else {
            print("Device doesn't support haptics")
            return
        }

        do {
            // Create engine
            engine = try CHHapticEngine()

            // Handle interruptions (calls, Siri, etc.)
            engine?.stoppedHandler = { reason in
                print("Engine stopped: \(reason)")
                self.restartEngine()
            }

            // Handle reset (audio session changes)
            engine?.resetHandler = {
                print("Engine reset")
                self.restartEngine()
            }

            // Start engine
            try engine?.start()

        } catch {
            print("Failed to create haptic engine: \(error)")
        }
    }

    func restartEngine() {
        do {
            try engine?.start()
        } catch {
            print("Failed to restart engine: \(error)")
        }
    }
}

重要:务必设置 stoppedHandler 和 resetHandler,以处理系统中断。

CHHapticEvent 类型
瞬态事件

短促、离散的反馈(如轻敲)。

let intensity = CHHapticEventParameter(
    parameterID: .hapticIntensity,
    value: 1.0  // 0.0 to 1.0
)

let sharpness = CHHapticEventParameter(
    parameterID: .hapticSharpness,
    value: 0.5  // 0.0 (dull) to 1.0 (sharp)
)

let event = CHHapticEvent(
    eventType: .hapticTransient,
    parameters: [intensity, sharpness],
    relativeTime: 0.0  // Seconds from pattern start
)

参数:

  • hapticIntensity:强度(0.0 = 几乎感觉不到,1.0 = 最大)
  • hapticSharpness:特征(0.0 = 沉闷的撞击声,1.0 = 清脆的弹响)
连续事件

持续一段时间的反馈(如振动马达)。

let intensity = CHHapticEventParameter(
    parameterID: .hapticIntensity,
    value: 0.8
)

let sharpness = CHHapticEventParameter(
    parameterID: .hapticSharpness,
    value: 0.3
)

let event = CHHapticEvent(
    eventType: .hapticContinuous,
    parameters: [intensity, sharpness],
    relativeTime: 0.0,
    duration: 2.0  // Seconds
)

适用场景:

  • 物体移动时的滚动质感
  • 马达运行
  • 充电进度
  • 长按反馈
创建和播放模式
func playCustomPattern() {
    // Create events
    let tap1 = CHHapticEvent(
        eventType: .hapticTransient,
        parameters: [
            CHHapticEventParameter(parameterID: .hapticIntensity, value: 0.5),
            CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.5)
        ],
        relativeTime: 0.0
    )

    let tap2 = CHHapticEvent(
        eventType: .hapticTransient,
        parameters: [
            CHHapticEventParameter(parameterID: .hapticIntensity, value: 0.7),
            CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.7)
        ],
        relativeTime: 0.3
    )

    let tap3 = CHHapticEvent(
        eventType: .hapticTransient,
        parameters: [
            CHHapticEventParameter(parameterID: .hapticIntensity, value: 1.0),
            CHHapticEventParameter(parameterID: .hapticSharpness, value: 1.0)
        ],
        relativeTime: 0.6
    )

    do {
        // Create pattern from events
        let pattern = try CHHapticPattern(
            events: [tap1, tap2, tap3],
            parameters: []
        )

        // Create player
        let player = try engine?.makePlayer(with: pattern)

        // Play
        try player?.start(atTime: CHHapticTimeImmediate)

    } catch {
        print("Failed to play pattern: \(error)")
    }
}
CHHapticAdvancedPatternPlayer - 循环播放

对于连续反馈(滚动质感、马达),请使用高级播放器:

func startRollingTexture() {
    let event = CHHapticEvent(
        eventType: .hapticContinuous,
        parameters: [
            CHHapticEventParameter(parameterID: .hapticIntensity, value: 0.4),
            CHHapticEventParameter(parameterID: .hapticSharpness, value: 0.2)
        ],
        relativeTime: 0.0,
        duration: 0.5
    )

    do {
        let pattern = try CHHapticPattern(events: [event], parameters: [])

        // Use advanced player for looping
        let player = try engine?.makeAdvancedPlayer(with: pattern)

        // Enable looping
        try player?.loopEnabled = true

        // Start
        try player?.start(atTime: CHHapticTimeImmediate)

        // Update intensity dynamically based on ball speed
        updateTextureIntensity(player: player)

    } catch {
        print("Failed to start texture: \(error)")
    }
}

func updateTextureIntensity(player: CHHapticAdvancedPatternPlayer?) {
    let newIntensity = calculateIntensityFromBallSpeed()

    let intensityParam = CHHapticDynamicParameter(
        parameterID: .hapticIntensityControl,
        value: newIntensity,
        relativeTime: 0
    )

    try? player?.sendParameters([intensityParam], atTime: CHHapticTimeImmediate)
}

主要区别:CHHapticPatternPlayer 播放一次;CHHapticAdvancedPatternPlayer 支持循环播放和动态参数更新。


第 4 部分:AHAP 文件(Apple Haptic Audio Pattern)

AHAP(Apple Haptic Audio Pattern)文件是结合触觉事件和音频的 JSON 文件。

AHAP 基本结构
{
  "Version": 1.0,
  "Metadata": {
    "Project": "My App",
    "Created": "2024-01-15"
  },
  "Pattern": [
    {
      "Event": {
        "Time": 0.0,
        "EventType": "HapticTransient",
        "EventParameters": [
          {
            "ParameterID": "HapticIntensity",
            "ParameterValue": 1.0
          },
          {
            "ParameterID": "HapticSharpness",
            "ParameterValue": 0.5
          }
        ]
      }
    }
  ]
}
向 AHAP 添加音频
{
  "Version": 1.0,
  "Pattern": [
    {
      "Event": {
        "Time": 0.0,
        "EventType": "AudioCustom",
        "EventParameters": [
          {
            "ParameterID": "AudioVolume",
            "ParameterValue": 0.8
          }
        ],
        "EventWaveformPath": "ShieldA.wav"
      }
    },
    {
      "Event": {
        "Time": 0.0,
        "EventType": "HapticContinuous",
        "EventDuration": 0.5,
        "EventParameters": [
          {
            "ParameterID": "HapticIntensity",
            "ParameterValue": 0.6
          }
        ]
      }
    }
  ]
}
加载 AHAP 文件
func loadAHAPPattern(named name: String) -> CHHapticPattern? {
    guard let url = Bundle.main.url(forResource: name, withExtension: "ahap") else {
        print("AHAP file not found")
        return nil
    }

    do {
        return try CHHapticPattern(contentsOf: url)
    } catch {
        print("Failed to load AHAP: \(error)")
        return nil
    }
}

// Usage
if let pattern = loadAHAPPattern(named: "ShieldTransient") {
    let player = try? engine?.makePlayer(with: pattern)
    try? player?.start(atTime: CHHapticTimeImmediate)
}
设计工作流(WWDC 示例)
  1. 创建视觉动画(例如盾牌变形,500ms)
  2. 设计音频(传达能量增强和坚固感)
  3. 设计触觉反馈(感受变形)
  4. 测试协调性 - 三种感官体验是否相互配合?
  5. 迭代 - 替换 AHAP 资源,直到效果协调一致
  6. 实现 - 更新代码以使用最终资源

迭代示例:盾牌最初使用 3 次瞬态脉冲(触觉反馈)+ 逐渐增强的连续声音(音频)→ 不协调。解决方案:改用连续触觉反馈 + ShieldA.wav 音频 → 形成统一体验。


第 5 部分:音频与触觉反馈同步

匹配动画时序
class ViewController: UIViewController {
    let animationDuration: TimeInterval = 0.5

    func performShieldTransformation() {
        // Start haptic/audio simultaneously with animation
        playShieldPattern()

        UIView.animate(withDuration: animationDuration) {
            self.shieldView.transform = CGAffineTransform(scaleX: 1.2, y: 1.2)
            self.shieldView.alpha = 0.8
        }
    }

    func playShieldPattern() {
        if let pattern = loadAHAPPattern(named: "ShieldContinuous") {
            let player = try? engine?.makePlayer(with: pattern)
            try? player?.start(atTime: CHHapticTimeImmediate)
        }
    }
}

重要:在视觉变化发生的确切时刻触发触觉反馈,而不是提前或延后。

协调音频
import AVFoundation

class AudioHapticCoordinator {
    let audioPlayer: AVAudioPlayer
    let hapticEngine: CHHapticEngine

    func playCoordinatedExperience() {
        // Prepare both systems
        hapticEngine.notifyWhenPlayersFinished { _ in
            return .stopEngine
        }

        // Start at exact same moment
        let startTime = CACurrentMediaTime() + 0.05  // Small delay for sync

        // Start audio
        audioPlayer.play(atTime: startTime)

        // Start haptic
        if let pattern = loadAHAPPattern(named: "CoordinatedPattern") {
            let player = try? hapticEngine.makePlayer(with: pattern)
            try? player?.start(atTime: CHHapticTimeImmediate)
        }
    }
}

第 6 部分:常见模式

按钮轻触
class HapticButton: UIButton {
    let impactGenerator = UIImpactFeedbackGenerator(style: .medium)

    override func touchesBegan(_ touches: Set<UITouch>, with event: UIEvent?) {
        super.touchesBegan(touches, with: event)
        impactGenerator.prepare()
    }

    override func touchesEnded(_ touches: Set<UITouch>, with event: UIEvent?) {
        super.touchesEnded(touches, with: event)
        impactGenerator.impactOccurred()
    }
}
滑块拖动
class HapticSlider: UISlider {
    let selectionGenerator = UISelectionFeedbackGenerator()
    var lastValue: Float = 0

    @objc func valueChanged() {
        let threshold: Float = 0.1

        if abs(value - lastValue) >= threshold {
            selectionGenerator.selectionChanged()
            lastValue = value
        }
    }
}
下拉刷新
class PullToRefreshController: UIViewController {
    let impactGenerator = UIImpactFeedbackGenerator(style: .medium)
    var isRefreshing = false

    func scrollViewDidScroll(_ scrollView: UIScrollView) {
        let threshold: CGFloat = -100
        let offset = scrollView.contentOffset.y

        if offset <= threshold && !isRefreshing {
            impactGenerator.impactOccurred()
            isRefreshing = true
            beginRefresh()
        }
    }
}
成功/错误反馈
func handleServerResponse(_ result: Result<Data, Error>) {
    let notificationGenerator = UINotificationFeedbackGenerator()

    switch result {
    case .success:
        notificationGenerator.notificationOccurred(.success)
        showSuccessMessage()
    case .failure:
        notificationGenerator.notificationOccurred(.error)
        showErrorAlert()
    }
}

第 7 部分:测试与调试

模拟器限制

触觉反馈在模拟器中不起作用。你会看到:

  • 没有触觉反馈
  • 没有警告或错误
  • 代码正常运行

解决方案:务必在实体设备上测试(iPhone 8 或更新机型)。

设备测试清单
  • 在“设置”→“声音与触感”中关闭触感后进行测试
  • 在启用低电量模式时进行测试
  • 在来电期间进行测试(引擎可能会停止)
  • 在后台播放音频时进行测试
  • 使用不同的强度/锐度值进行测试
  • 验证对电池的影响(Instruments 能耗日志)
调试日志
func playHaptic() {
    #if DEBUG
    print("🔔 Playing haptic - Engine running: \(engine?.currentTime ?? -1)")
    #endif

    do {
        let player = try engine?.makePlayer(with: pattern)
        try player?.start(atTime: CHHapticTimeImmediate)

        #if DEBUG
        print("✅ Haptic started successfully")
        #endif
    } catch {
        #if DEBUG
        print("❌ Haptic failed: \(error.localizedDescription)")
        #endif
    }
}

故障排除

引擎无法启动

症状:CHHapticEngine.start() 抛出错误

原因:

  1. 设备不支持 Core Haptics(早于 iPhone 8)
  2. “设置”中已关闭触感
  3. 已启用低电量模式

解决方案:

func safelyStartEngine() {
    guard CHHapticEngine.capabilitiesForHardware().supportsHaptics else {
        print("Device doesn't support haptics")
        return
    }

    do {
        try engine?.start()
    } catch {
        print("Engine start failed: \(error)")
        // Fall back to UIFeedbackGenerator
        useFallbackHaptics()
    }
}
未感受到触觉反馈

症状:代码正常运行,但设备上没有触觉反馈

调试步骤:

  1. 检查“设置” → “声音与触感” → “系统触感”是否已开启
  2. 检查是否已关闭低电量模式
  3. 确认设备为 iPhone 8 或更新机型
  4. 检查强度是否大于 0.3(低于此值可能过于微弱)
  5. 使用 UIFeedbackGenerator 进行测试,以区分 Core Haptics 问题与系统问题
音频与触觉反馈不同步

症状:音频播放正常,但触觉反馈延迟,或反之

原因:

  1. 触觉反馈前未调用 prepare()
  2. 音频和触觉反馈的启动时间不同
  3. 主线程繁忙,阻塞播放

解决方案:

// ✅ Synchronized start
func playCoordinated() {
    impactGenerator.prepare()  // Reduce latency

    // Start both simultaneously
    audioPlayer.play()
    impactGenerator.impactOccurred()
}
AHAP 的音频文件错误

症状:AHAP 模式无法加载或播放

原因:音频文件大于 4.2 MB 或时长超过 23 秒

解决方案:保持音频文件小巧、简短。使用压缩格式(AAC),并将音频裁剪至必要时长。


资源

WWDC:2021-10278、2019-520、2019-223

文档:/corehaptics、/corehaptics/chhapticengine

技能:axiom-swiftui-animation-ref、axiom-ui-testing、axiom-accessibility-diag

评论

支持 Markdown