axiom-haptics について
axiom-hapticsは、触覚と音声フィードバックを実装するiOS開発者向けガイドです。UIFeedbackGenerator、Core Haptics、AHAPに加え、設計原則、同期、実機テストを扱います。
主な機能
- 01UIFeedbackGeneratorによる基本的な触覚フィードバック
- 02Core Hapticsによるカスタムパターンの作成
- 03AHAPの触覚・音声パターンの読み込み
- 04触覚・音声・アニメーションの同期
- 05触覚のテストとデバイス制限の確認
活用シーン
- 01ボタン・ピッカー・フォーム結果へのフィードバック追加
- 02スライダーや継続動作のフィードバック設計
- 03アニメーションと同期する触覚・音声効果の作成
- 04実機での触覚の問題調査
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ charleswiltgen/ axiom --skill axiom-hapticsインストール後、Claude Code で/axiom-hapticsを入力して読み込まれたか確認します。
詳細
触覚とオーディオのフィードバック
iOS で触覚フィードバックを実装するための包括的なガイドです。Apple Design Award の受賞アプリはどれも優れた触覚フィードバックを活用しています。Camera、Maps、Weather はいずれも触覚を巧みに使い、心地よく反応のよい体験を生み出しています。
概要
触覚フィードバックは、ユーザーの操作やシステムイベントを触覚で確認できるようにします。因果性・調和・有用性のフレームワークに基づいて慎重に設計すると、触覚はインターフェースを機能的なものから心地よいものへと変えます。
このスキルでは、シンプルな触覚(UIFeedbackGenerator)と高度なカスタムパターン(Core Haptics)の両方を、実例やオーディオと触覚を同期させる手法とともに取り上げます。
このスキルを使用する場面
- ユーザー操作に触覚フィードバックを追加するとき
- UIFeedbackGenerator と Core Haptics のどちらを使うか選ぶとき
- 一体感のあるオーディオと触覚の体験を設計するとき
- AHAP ファイルでカスタム触覚パターンを作成するとき
- 触覚をアニメーションやオーディオと同期させるとき
- 触覚の問題をデバッグするとき(シミュレーターと実機の違い)
- 触覚のパフォーマンスとバッテリーへの影響を最適化するとき
システム要件
- UIFeedbackGenerator には iOS 10 以降
- Core Haptics(CHHapticEngine)には iOS 13 以降
- Core Haptics のハードウェアサポートには iPhone 8 以降
- 実機が必要 — シミュレーターでは触覚を感じられません
パート 1:設計原則(WWDC 2021/10278)
Apple のオーディオおよび触覚の設計チームは、マルチモーダルフィードバックの 3 つの基本原則を定めています。
因果性 — フィードバックの原因を明確にする
問題:ユーザーが何によって触覚フィードバックが発生したのか分かりません 解決策:触覚のタイミングを、視覚的な変化や操作の瞬間に合わせる必要があります
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!
}
}
調和 — 感覚は一貫しているときに最も効果を発揮する
問題:視覚、オーディオ、触覚が一致していません 解決策:3 つの感覚すべてで一体感のある体験にする必要があります
WWDC の例:
- 小さなボール → 軽い触覚フィードバック + 高い音
- 大きなボール → 強い触覚フィードバック + 低い音
- シールドの変形 → 継続的な触覚フィードバック + 段階的に変化するオーディオ
重要なポイント:大きな物体は、重く感じられ、低く響く音がし、どっしりと見えるべきです。3 つの感覚すべてが同じ体験を強調します。
有用性 — 明確な価値を提供する
問題:「できるから」という理由だけであらゆる場面に触覚フィードバックを使っています 解決策:ユーザーにとって有益な、重要な瞬間に限って触覚フィードバックを使います
触覚フィードバックを使う場面:
- ✅ 重要な操作を確認するとき(決済の完了)
- ✅ 重大なイベントを知らせるとき(バッテリー残量低下)
- ✅ 継続的なフィードバックを提供するとき(スライダーのスクラブ操作)
- ✅ 楽しさを高めるとき(アプリ起動時の演出)
触覚フィードバックを使わない場面:
- ❌ タップのたびに使う(過剰になる)
- ❌ 長いリストをスクロールするとき(バッテリーを消耗する)
- ❌ ユーザーに見えないバックグラウンドイベント(混乱を招く)
- ❌ 装飾的なアニメーション(価値がない)
パート 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 のタイミング: prepare() の呼び出し後、システムは約 1 秒間、エンジンを準備状態に保ちます。
パート 3:Core Haptics(カスタム触覚フィードバック)
カスタムパターンが必要なアプリでは、Core Haptics により、触覚波形を完全に制御できます。
4 つの基本要素
- エンジン(
CHHapticEngine)- 電話のアクチュエーターへの接続 - プレーヤー(
CHHapticPatternPlayer)- 再生の制御 - パターン(
CHHapticPattern)- 時間経過に伴うイベントの集合 - イベント(
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 は 1 回だけ再生しますが、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 の例)
- 視覚的なアニメーションを作成する(例:盾の変形、500ms)
- オーディオをデザインする(エネルギーの増加と堅牢さを伝える)
- 触覚フィードバックをデザインする(変形を感じられるようにする)
- 調和を確認する - 3 つの感覚すべてが連携していますか?
- 反復する - 一貫性が得られるまで AHAP アセットを入れ替えます
- 実装する - 最終アセットを使用するようにコードを更新します
反復の例: 当初、盾には 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 の Energy Log)
デバッグログ
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() がエラーをスローします
原因:
- デバイスが Core Haptics に対応していません(iPhone 8 未満)
- 「設定」で触覚フィードバックが無効になっています
- 低電力モードが有効になっています
解決策:
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()
}
}
触覚が感じられない
症状:コードは実行されますが、デバイスで触覚が感じられません
デバッグ手順:
- 「設定」→「サウンドと触覚」→「システムの触覚」がオンになっていることを確認します
- 低電力モードがオフになっていることを確認します
- デバイスが iPhone 8 以降であることを確認します
- 強度が 0.3 を超えていることを確認します(これより低い値では弱すぎる場合があります)
- UIFeedbackGenerator でテストし、Core Haptics とシステムのどちらに問題があるかを切り分けます
オーディオと触覚が同期しない
症状:オーディオは再生されますが、触覚が遅れて感じられる、またはその逆です
原因:
- 触覚の前に
prepare()を呼び出していない - オーディオと触覚を異なるタイミングで開始している
- メインスレッドでの負荷の高い処理が再生を妨げている
解決策:
// ✅ 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