mcpField
A
axiom-haptics

axiom-haptics

github.com/charleswiltgenデザイン・UI1.2k188 回インストール今日更新
リポジトリ

axiom-haptics について

axiom-hapticsは、触覚と音声フィードバックを実装するiOS開発者向けガイドです。UIFeedbackGenerator、Core Haptics、AHAPに加え、設計原則、同期、実機テストを扱います。

主な機能

  1. 01UIFeedbackGeneratorによる基本的な触覚フィードバック
  2. 02Core Hapticsによるカスタムパターンの作成
  3. 03AHAPの触覚・音声パターンの読み込み
  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 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 つの基本要素
  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 は 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 の例)
  1. 視覚的なアニメーションを作成する(例:盾の変形、500ms)
  2. オーディオをデザインする(エネルギーの増加と堅牢さを伝える)
  3. 触覚フィードバックをデザインする(変形を感じられるようにする)
  4. 調和を確認する - 3 つの感覚すべてが連携していますか?
  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 の 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() がエラーをスローします

原因:

  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

コメント