WWDC Quick Look 💓 By SwiftGGTeam
Practice audio haptic design

Practice audio haptic design

观看原视频

Highlight

Apple 用 HapticRicochet 示例展示了如何把 Core Haptics、音频和动画绑定到同一个交互瞬间,让 iPhone 应用的碰撞、护盾和滚动纹理反馈来源清楚、节奏一致、只在关键动作出现。

核心内容

做一个小游戏时,画面反馈最容易实现。球撞到边界,可以改变速度;用户点一下球,可以播放动画;背景纹理可以换一张图。

问题出在触感和声音。

如果碰撞有声音,却没有触感,球会像贴在屏幕上的图层。如果触感出现得太频繁,用户会觉得吵。如果声音和触感节奏不同,交互会变得松散。

这场 session 用 HapticRicochet 讲这个问题。它来自 HapticBounce 示例。球会根据 iPhone 的方向滚动,撞墙时产生音频和触感。用户点球后,球变大;再点一次,球获得护盾。护盾会在碰撞中损耗,最后球会内爆。

Apple 把重点放在两个动作上。

第一个动作是添加护盾。这个瞬间有 500 毫秒视觉动画,也需要声音和触感一起表达“获得保护”的状态变化。

第二个动作是开启滚动纹理。用户点背景后,背景出现圆点纹理。球滚过纹理时,触感要持续出现,并随球速变化。

Core Haptics(核心触感框架)提供了底层能力。它把反馈拆成四个对象:engine(引擎)、player(播放器)、pattern(模式)、event(事件)。开发者先从 AHAP(Apple Haptic Audio Pattern,苹果触感音频模式)文件创建模式,再创建播放器,最后在交互发生时播放。

这套流程解决的是协作问题。设计师可以在 AHAP 里调整触感和音频。工程师把模式接到真实交互上。两边用同一个文件讨论反馈,减少“更硬一点”或“更厚一点”这类抽象形容词。

详细内容

Core Haptics 的四个对象

04:41

Core Haptics 的 API 围绕四个对象工作:engine、player、pattern、event。

engine 连接 iPhone 的物理执行器。player 控制播放、停止和暂停。pattern 是按时间排列的事件集合。event 是实际的反馈单元,常见类型包括 transient(瞬态)和 continuous(连续)。

session 提到,实践部分会聚焦三件事:加载模式、修改模式、播放模式。

import CoreHaptics

final class HapticController {
    private let engine: CHHapticEngine
    private var player: CHHapticPatternPlayer?

    init() throws {
        engine = try CHHapticEngine()
        try engine.start()
    }

    func loadPattern(named name: String) throws {
        guard let url = Bundle.main.url(forResource: name, withExtension: "ahap") else {
            return
        }

        let pattern = try CHHapticPattern(contentsOf: url)
        player = try engine.makePlayer(with: pattern)
    }

    func play() throws {
        try player?.start(atTime: CHHapticTimeImmediate)
    }
}

关键点:

  • import CoreHaptics 引入触感框架。
  • CHHapticEngine 是播放触感的入口,对应 session 里的 engine。
  • try engine.start() 启动引擎,之后才能播放模式。
  • Bundle.main.url(forResource:withExtension:) 从应用包读取 .ahap 文件。
  • CHHapticPattern(contentsOf:) 从 AHAP 文件创建 pattern。
  • engine.makePlayer(with:) 从 pattern 创建 player。
  • start(atTime: CHHapticTimeImmediate) 立即播放当前模式。

护盾反馈:先加载模式,再在动画时播放

08:05

护盾动作分成两步。

第一步在初始化阶段创建模式。session 的代码从 ShieldTransient 这个 AHAP 文件创建 pattern,再用 engine.makePlayer(with:) 创建 shieldPlayer

第二步在护盾动画开始时播放反馈。shield() 函数调用 startPlayer(shieldPlayer),随后把 shieldAnimation 加到 sphereView.layer 上。

import UIKit
import CoreHaptics

final class ShieldViewController: UIViewController {
    private var engine: CHHapticEngine!
    private var shieldPlayer: CHHapticPatternPlayer?
    private let sphereView = UIView()
    private let shieldAnimation = CABasicAnimation(keyPath: "borderWidth")
    private var isAnimating = false

    override func viewDidLoad() {
        super.viewDidLoad()
        engine = try? CHHapticEngine()
        try? engine.start()
        initializeShieldHaptics()
    }

    func initializeShieldHaptics() {
        guard let pattern = createPatternFromAHAP("ShieldTransient") else {
            return
        }

        shieldPlayer = try? engine.makePlayer(with: pattern)
    }

    func shield() {
        startPlayer(shieldPlayer)

        isAnimating = true
        sphereView.layer.add(shieldAnimation, forKey: "Width")
    }

    private func createPatternFromAHAP(_ name: String) -> CHHapticPattern? {
        guard let url = Bundle.main.url(forResource: name, withExtension: "ahap") else {
            return nil
        }

        return try? CHHapticPattern(contentsOf: url)
    }

    private func startPlayer(_ player: CHHapticPatternPlayer?) {
        try? player?.start(atTime: CHHapticTimeImmediate)
    }
}

关键点:

  • engine = try? CHHapticEngine() 创建触感引擎。
  • try? engine.start() 启动引擎。
  • initializeShieldHaptics() 把护盾触感提前准备好,避免交互发生时才创建。
  • createPatternFromAHAP("ShieldTransient") 对应 session 里的 ShieldTransient 资源。
  • engine.makePlayer(with: pattern) 生成 shieldPlayer
  • shield() 是护盾变形实际发生的位置。
  • startPlayer(shieldPlayer) 先播放触感和音频。
  • sphereView.layer.add(shieldAnimation, forKey: "Width") 播放护盾视觉动画。

修改 AHAP:把喜欢的声音接到合适的触感上

10:11

Apple 展示了两个护盾资源。

ShieldTransient 的声音有“获得保护”的感觉,但触感由三个瞬态事件组成。声音是连续推进的,两者节奏不匹配。

ShieldContinuous 的触感是连续推进的,更适合“护盾生成”。但它自带的音频会衰减,效果也不合适。

最终做法很直接:打开 ShieldContinuous.ahap,找到音频事件,把文件名从 ShieldB.wav 改成 ShieldA.wav。触感保留连续模式,声音换成第一个资源里的声音。

{
  "Event": {
    "Time": 0,
    "EventType": "AudioCustom",
    "EventWaveformPath": "ShieldA.wav",
    "EventParameters": [
      {
        "ParameterID": "AudioVolume",
        "ParameterValue": 1.0
      }
    ]
  }
}

关键点:

  • EventType 使用 AudioCustom,表示这个事件播放自定义音频文件。
  • EventWaveformPath 指向要播放的 .wav 文件。
  • session 中的修改是把 ShieldB.wav 换成 ShieldA.wav
  • AudioVolumeParameterValue 调整音量。
  • 这个改动发生在 AHAP 文件里,不需要改 Swift 播放逻辑。

11:07

资源修改完成后,Swift 代码只需要换文件名。初始化函数从 ShieldTransient 改为 ShieldContinuous

func initializeShieldHaptics() {
    guard let pattern = createPatternFromAHAP("ShieldContinuous") else {
        return
    }

    shieldPlayer = try? engine.makePlayer(with: pattern)
}

关键点:

  • 函数名没有变化,调用点也不需要变化。
  • ShieldContinuous 是修改后的 AHAP 资源。
  • engine.makePlayer(with:) 仍然从 pattern 创建 player。
  • 护盾动画触发时,shield() 继续调用同一个 shieldPlayer

滚动纹理:用高级播放器解决两秒后停止

12:15

滚动纹理的第一版实现有一个问题。背景纹理打开后,球滚动时有触感,但几秒后触感消失。

原因很具体:纹理 AHAP 文件只有 2 秒触感内容。

Apple 的修复方法是把纹理播放器换成 CHHapticAdvancedPatternPlayer。高级播放器提供暂停、恢复、回调等能力。HapticRicochet 只用它的循环播放能力。

import CoreHaptics

final class TextureHaptics {
    private let engine: CHHapticEngine
    private var texturePlayer: CHHapticAdvancedPatternPlayer?

    init(engine: CHHapticEngine) {
        self.engine = engine
    }

    func initializeTextureHaptics() {
        guard let url = Bundle.main.url(forResource: "Texture", withExtension: "ahap"),
              let pattern = try? CHHapticPattern(contentsOf: url) else {
            return
        }

        texturePlayer = try? engine.makeAdvancedPlayer(with: pattern)
        texturePlayer?.loopEnabled = true
    }

    func startTexture() {
        try? texturePlayer?.start(atTime: CHHapticTimeImmediate)
    }
}

关键点:

  • texturePlayer 的类型是 CHHapticAdvancedPatternPlayer
  • CHHapticPattern(contentsOf:) 仍然从同一个纹理 AHAP 文件创建 pattern。
  • engine.makeAdvancedPlayer(with:) 创建高级播放器。
  • texturePlayer?.loopEnabled = true 开启循环。
  • start(atTime:) 的播放入口保持一致。

滚动纹理:触感强度跟随球速更新

12:26

session 里提到,updateTexturePlayer 会在纹理激活时每一帧调用。它根据球的速度更新触感强度。

这里的设计原则是 causality(因果)。用户看到球在纹理背景上滚动,手里感到纹理。球越快,反馈越明显。反馈来源变得清楚。

import CoreHaptics

func updateTexturePlayer(ballSpeed: Float, texturePlayer: CHHapticAdvancedPatternPlayer?) {
    let normalizedSpeed = max(0, min(ballSpeed, 1))
    let intensity = CHHapticDynamicParameter(
        parameterID: .hapticIntensityControl,
        value: normalizedSpeed,
        relativeTime: 0
    )

    try? texturePlayer?.sendParameters([intensity], atTime: CHHapticTimeImmediate)
}

关键点:

  • ballSpeed 表示当前球速,session 说明触感会基于球速更新。
  • max(0, min(ballSpeed, 1)) 把输入限制在 0 到 1。
  • CHHapticDynamicParameter 用来在播放中调整参数。
  • .hapticIntensityControl 调整触感强度。
  • relativeTime: 0 表示参数立即生效。
  • sendParameters(_:atTime:) 把新的强度发送给正在播放的高级播放器。

视觉纹理也要匹配触感密度

14:04

修复循环播放后,还剩一个设计问题。

触感模式很密,2 秒里接近 100 个条目。视觉背景却很粗,屏幕上只有少量圆点。手感和画面密度不一致。

Apple 的处理是把背景图资源换成更细的版本。触感模式不变,视觉纹理变密,球滚过背景时,手感和画面更接近。

import UIKit

final class TextureBackgroundView: UIView {
    private let backgroundImageView = UIImageView()

    func useFineTexture() {
        backgroundImageView.image = UIImage(named: "BackgroundTextureFine")
    }
}

关键点:

  • backgroundImageView 负责显示纹理背景。
  • UIImage(named:) 从资源中读取图片。
  • BackgroundTextureFine 对应 session 中提到的 Fine 版本资源。
  • 这个改动解决的是感官一致性:视觉圆点密度要接近触感事件密度。

核心启发

1. 做一个“物体碰撞”反馈层

  • 做什么:给球、卡片、棋子这类可移动对象加入撞边、落下、吸附的声音和触感。
  • 为什么值得做:session 用球撞墙说明了 causality。反馈要让用户知道哪个动作触发了它。
  • 怎么开始:为每种碰撞准备一个 AHAP 文件,用 CHHapticPattern(contentsOf:) 读取,再用 engine.makePlayer(with:) 创建播放器,在碰撞检测回调里播放。

2. 做一个“状态升级”动画反馈

  • 做什么:在用户完成升级、解锁、装备护盾时,同时播放动画、音频和触感。
  • 为什么值得做:护盾示例证明,状态变化适合用连续触感表达,让用户感到对象进入了新状态。
  • 怎么开始:先做 500 毫秒左右的视觉动画,再用 AHAP 试出一个同样时长的连续触感;如果音频不合适,直接在 AHAP 里替换 AudioCustom 的文件名。

3. 做一个“材质纹理”开关

  • 做什么:让用户切换纸张、金属、网格、粗糙地面等背景材质,拖动物体时感到不同纹理。
  • 为什么值得做:滚动纹理示例把视觉背景和连续触感绑定在一起,用户能理解触感来自屏幕上的纹理。
  • 怎么开始:为每种材质准备一个 2 秒 AHAP,用 CHHapticAdvancedPatternPlayer 播放,并设置 loopEnabled = true

4. 做一个“速度驱动”的触感系统

  • 做什么:让滚动、滑动、拖拽的触感强度跟随速度变化。
  • 为什么值得做:HapticRicochet 每一帧根据球速更新纹理触感,避免固定强度带来的假感。
  • 怎么开始:计算对象速度,把速度归一化到 0 到 1,再用 CHHapticDynamicParameter.hapticIntensityControl 更新正在播放的高级播放器。

5. 做一个 AHAP 调试工作流

  • 做什么:把触感资源当作设计资产管理,和图片、音频一起迭代。
  • 为什么值得做:session 展示了 macOS 12 的 Quick Look Visualizer。选中 .ahap 文件按空格,就能查看触感模式。
  • 怎么开始:把 .ahap.wav.png 放到资源目录。设计阶段用 Quick Look 检查 transient 和 continuous 事件,工程阶段只负责加载和播放。

关联 Session

评论

GitHub Issues · utterances