カスタムの触覚効果を作成する

このページでは、さまざまなハプティクス API を使用して、Android アプリで標準のバイブレーション波形を超えるカスタム効果を作成する方法の例を紹介します。

このページでは、次の例について説明します。

その他の例については、イベントに触覚フィードバックを追加するをご覧ください。また、常にハプティクス設計の原則に従ってください。

フォールバックを使用してデバイスの互換性を処理する

カスタム効果を実装する場合は、次の点を考慮してください。

  • エフェクトに必要なデバイス機能
  • デバイスがエフェクトを再生できない場合の対応

Android ハプティクス API リファレンスには、ハプティクスに関わるコンポーネントのサポートを確認する方法の詳細が記載されています。これにより、アプリで一貫した全体的なエクスペリエンスを提供できます。

ユースケースによっては、カスタム効果を無効にしたり、さまざまな潜在的な機能に基づいて代替のカスタム効果を提供したりすることが必要になる場合があります。

デバイス機能の次の大まかなクラスを計画します。

  • ハプティクス プリミティブを使用している場合: カスタム効果に必要なプリミティブをサポートするデバイス。(プリミティブの詳細については、次のセクションをご覧ください)。

  • 振幅制御を備えたデバイス。

  • 基本的なバイブレーション サポート(オン/オフ)を備えたデバイス - つまり、振幅制御がないデバイス。

アプリの触覚効果の選択でこれらのカテゴリが考慮されている場合、触覚のユーザー エクスペリエンスは個々のデバイスで予測可能な状態を維持します。

触覚プリミティブの使用

Android には、振幅と周波数の両方が異なる複数のハプティクス プリミティブが含まれています。1 つのプリミティブを単独で使用することも、複数のプリミティブを組み合わせて使用することもできます。

  • 2 つのプリミティブ間の認識可能なギャップには 50 ミリ秒以上の遅延を使用します。可能な場合は、プリミティブの継続時間も考慮します。
  • 強度の違いがよりよく認識されるように、1.4 以上の比率で異なるスケールを使用します。
  • 0.5、0.7、1.0 のスケールを使用して、プリミティブの低、中、高の強度のバージョンを作成します。

カスタムのバイブレーション パターンを作成する

バイブレーション パターンは、通知や着信音などの注意喚起のハプティクスによく使用されます。Vibrator サービスは、振動の振幅が時間とともに変化する長いバイブレーション パターンを再生できます。このような効果を波形と呼びます。

通常、波形効果は認識できますが、静かな環境で再生されると、突然の長いバイブレーションでユーザーが驚くことがあります。目標振幅へのランプアップが速すぎると、ブーンという雑音が発生することもあります。振幅の移行をスムーズにして、ランプアップとランプダウンの効果を生み出す波形パターンを設計します。

バイブレーション パターンの例

以降のセクションでは、バイブレーション パターンの例をいくつか紹介します。

拡大パターン

波形は、3 つのパラメータを持つ VibrationEffect として表されます。

  1. タイミング: 各波形セグメントの継続時間(ミリ秒単位)の配列。
  2. 振幅: 最初の引数で指定された各期間の振動振幅。0 ~ 255 の整数値で表されます。0 はバイブレータの「オフ状態」を表し、255 はデバイスの最大振幅を表します。
  3. 繰り返しインデックス: 波形の繰り返しを開始する、最初の引数で指定された配列のインデックス。パターンを 1 回だけ再生する場合は -1。

以下は、2 回パルスし、パルス間に 350 ミリ秒のポーズがある波形の例です。最初のパルスは最大振幅までスムーズにランプアップし、2 番目のパルスは最大振幅を維持するためにすばやくランプアップします。終了時に停止することは、負の繰り返しインデックス値で定義されます。

Kotlin

val timings: LongArray = longArrayOf(
    50, 50, 50, 50, 50, 100, 350, 25, 25, 25, 25, 200)
val amplitudes: IntArray = intArrayOf(
    33, 51, 75, 113, 170, 255, 0, 38, 62, 100, 160, 255)
val repeatIndex = -1 // Don't repeat.

vibrator.vibrate(VibrationEffect.createWaveform(
    timings, amplitudes, repeatIndex))

Java

long[] timings = new long[] {
    50, 50, 50, 50, 50, 100, 350, 25, 25, 25, 25, 200 };
int[] amplitudes = new int[] {
    33, 51, 75, 113, 170, 255, 0, 38, 62, 100, 160, 255 };
int repeatIndex = -1; // Don't repeat.

vibrator.vibrate(VibrationEffect.createWaveform(
    timings, amplitudes, repeatIndex));

繰り返しパターン

波形は、キャンセルされるまで繰り返し再生することもできます。繰り返し波形を作成するには、非負の repeat パラメータを設定します。繰り返し波形を再生する場合、サービスで明示的にキャンセルされるまでバイブレーションが継続します。

Kotlin

void startVibrating() {
val timings: LongArray = longArrayOf(50, 50, 100, 50, 50)
val amplitudes: IntArray = intArrayOf(64, 128, 255, 128, 64)
val repeat = 1 // Repeat from the second entry, index = 1.
VibrationEffect repeatingEffect = VibrationEffect.createWaveform(
    timings, amplitudes, repeat)
// repeatingEffect can be used in multiple places.

vibrator.vibrate(repeatingEffect)
}

void stopVibrating() {
vibrator.cancel()
}

Java

void startVibrating() {
long[] timings = new long[] { 50, 50, 100, 50, 50 };
int[] amplitudes = new int[] { 64, 128, 255, 128, 64 };
int repeat = 1; // Repeat from the second entry, index = 1.
VibrationEffect repeatingEffect = VibrationEffect.createWaveform(
    timings, amplitudes, repeat);
// repeatingEffect can be used in multiple places.

vibrator.vibrate(repeatingEffect);
}

void stopVibrating() {
vibrator.cancel();
}

これは、ユーザーの操作を必要とする断続的なイベントを認識するのに非常に便利です。このようなイベントの例としては、着信やアラームのトリガーなどがあります。

フォールバックを含むパターン

バイブレーションの振幅の制御は、ハードウェアに依存する機能です。この機能がないローエンド デバイスで波形を再生すると、振幅配列の正のエントリごとにデバイスが最大振幅で振動します。アプリでそのようなデバイスに対応する必要がある場合は、その条件で再生してもブーンという効果が発生しないパターンを使用するか、代わりにフォールバックとして再生できるシンプルなオン/オフ パターンを設計します。

Kotlin

if (vibrator.hasAmplitudeControl()) {
  vibrator.vibrate(VibrationEffect.createWaveform(
    smoothTimings, amplitudes, smoothRepeatIdx))
} else {
  vibrator.vibrate(VibrationEffect.createWaveform(
    onOffTimings, onOffRepeatIdx))
}

Java

if (vibrator.hasAmplitudeControl()) {
  vibrator.vibrate(VibrationEffect.createWaveform(
    smoothTimings, amplitudes, smoothRepeatIdx));
} else {
  vibrator.vibrate(VibrationEffect.createWaveform(
    onOffTimings, onOffRepeatIdx));
}

バイブレーション コンポジションを作成する

このセクションでは、バイブレーションを組み合わせてより長く複雑なカスタム効果を作成する方法を紹介します。さらに、より高度なハードウェア機能を使用してリッチなハプティクスを体験する方法も説明します。振幅と周波数を変化させる効果を組み合わせることで、周波数帯域幅が広いハプティクス アクチュエータを備えたデバイスで、より複雑なハプティクス効果を作成できます。

このページの冒頭で説明したカスタム バイブレーション パターンを作成するプロセスでは、バイブレーションの振幅を制御して、スムーズなランプアップとランプダウンの効果を作成する方法について説明しています。リッチ ハプティクスは、デバイスのバイブレータの周波数範囲を広げて、効果をさらにスムーズにすることで、このコンセプトを改善しています。これらの波形は、クレッシェンドやディミヌエンドの効果を生み出すのに特に効果的です。

このページの冒頭で説明したコンポジションのプリミティブは、デバイス メーカーによって実装されます。クリア ハプティクスのハプティクス原則に沿った、鮮明で短く、心地よいバイブレーションを提供します。これらの機能とその仕組みについて詳しくは、バイブレーション アクチュエータの入門をご覧ください。

Android では、サポートされていないプリミティブを含むコンポジションのフォールバックは提供されません。そのため、次の手順を行います。

  1. 高度なハプティクスを有効にする前に、使用するすべてのプリミティブがデバイスでサポートされていることを確認してください。

  2. プリミティブが欠落しているエフェクトだけでなく、サポートされていない一貫性のあるエクスペリエンスのセットを無効にします。

デバイスのサポートを確認する方法の詳細については、以下のセクションをご覧ください。

合成バイブレーション効果を作成する

VibrationEffect.Composition を使用して、合成バイブレーション効果を作成できます。ゆっくりと上昇するエフェクトの後に急激なクリック効果が続く例を次に示します。

Kotlin

vibrator.vibrate(
    VibrationEffect.startComposition().addPrimitive(
    VibrationEffect.Composition.PRIMITIVE_SLOW_RISE
    ).addPrimitive(
    VibrationEffect.Composition.PRIMITIVE_CLICK
    ).compose()
)

Java

vibrator.vibrate(
    VibrationEffect.startComposition()
        .addPrimitive(VibrationEffect.Composition.PRIMITIVE_SLOW_RISE)
        .addPrimitive(VibrationEffect.Composition.PRIMITIVE_CLICK)
        .compose());

コンポジションは、順番に再生するプリミティブを追加して作成されます。各プリミティブはスケーラブルでもあるため、それぞれが生成するバイブレーションの振幅を制御できます。スケールは 0 ~ 1 の値として定義されます。0 は、このプリミティブをユーザーが(かろうじて)感じられる最小振幅にマッピングされます。

バイブレーション プリミティブでバリエーションを作成する

同じプリミティブの弱いバージョンと強いバージョンを作成する場合は、強度比を 1.4 以上にして、強度の違いを認識できるようにします。同じプリミティブの強度レベルを 3 つ以上作成しないでください。知覚的に区別できません。たとえば、スケール 0.5、0.7、1.0 を使用して、プリミティブの低強度、中強度、高強度のバージョンを作成します。

バイブレーション プリミティブ間にギャップを追加

コンポジションでは、連続するプリミティブの間に挿入する遅延を指定することもできます。この遅延は、前のプリミティブの終了からのミリ秒単位で表されます。一般に、2 つのプリミティブ間のギャップが 5 ~ 10 ミリ秒の場合、短すぎて検出できません。2 つのプリミティブ間に認識可能なギャップを作成する場合は、50 ミリ秒以上のギャップを使用します。遅延を含むコンポジションの例を次に示します。

Kotlin

val delayMs = 100
vibrator.vibrate(
    VibrationEffect.startComposition().addPrimitive(
    VibrationEffect.Composition.PRIMITIVE_SPIN, 0.8f
    ).addPrimitive(
    VibrationEffect.Composition.PRIMITIVE_SPIN, 0.6f
    ).addPrimitive(
    VibrationEffect.Composition.PRIMITIVE_THUD, 1.0f, delayMs
    ).compose()
)

Java

int delayMs = 100;
vibrator.vibrate(
    VibrationEffect.startComposition()
        .addPrimitive(VibrationEffect.Composition.PRIMITIVE_SPIN, 0.8f)
        .addPrimitive(VibrationEffect.Composition.PRIMITIVE_SPIN, 0.6f)
        .addPrimitive(
            VibrationEffect.Composition.PRIMITIVE_THUD, 1.0f, delayMs)
        .compose());

サポートされているプリミティブを確認する

次の API を使用して、特定のプリミティブに対するデバイスのサポートを検証できます。

Kotlin

val primitive = VibrationEffect.Composition.PRIMITIVE_LOW_TICK

if (vibrator.areAllPrimitivesSupported(primitive)) {
  vibrator.vibrate(VibrationEffect.startComposition()
        .addPrimitive(primitive).compose())
} else {
  // Play a predefined effect or custom pattern as a fallback.
}

Java

int primitive = VibrationEffect.Composition.PRIMITIVE_LOW_TICK;

if (vibrator.areAllPrimitivesSupported(primitive)) {
  vibrator.vibrate(VibrationEffect.startComposition()
        .addPrimitive(primitive).compose());
} else {
  // Play a predefined effect or custom pattern as a fallback.
}

複数のプリミティブをチェックし、デバイスのサポートレベルに基づいてコンポーズするプリミティブを決定することもできます。

Kotlin

val effects: IntArray = intArrayOf(
VibrationEffect.Composition.PRIMITIVE_LOW_TICK,
VibrationEffect.Composition.PRIMITIVE_TICK,
VibrationEffect.Composition.PRIMITIVE_CLICK
)
val supported: BooleanArray = vibrator.arePrimitivesSupported(primitives)

Java

int[] primitives = new int[] {
VibrationEffect.Composition.PRIMITIVE_LOW_TICK,
VibrationEffect.Composition.PRIMITIVE_TICK,
VibrationEffect.Composition.PRIMITIVE_CLICK
};
boolean[] supported = vibrator.arePrimitivesSupported(effects);

バイブレーションの構成例

以降のセクションでは、GitHub の haptics サンプルアプリから抜粋した、バイブレーション コンポジションの例をいくつか紹介します。

抵抗(低ティック)

プリミティブ バイブレーションの振幅を制御して、進行中のアクションに役立つフィードバックを伝えることができます。スケール値を密に配置すると、プリミティブの滑らかなクレッシェンド効果を作成できます。連続するプリミティブ間の遅延も、ユーザー インタラクションに基づいて動的に設定できます。次の例は、ドラッグ操作で制御され、ハプティクスで拡張されたビュー アニメーションを示しています。

円が下にドラッグされるアニメーション。
入力振動波形のプロット。

図 1. この波形は、デバイスのバイブレーションの出力加速度を表します。

Kotlin

@Composable
fun ResistScreen() {
    // Control variables for the dragging of the indicator.
    var isDragging by remember { mutableStateOf(false) }
    var dragOffset by remember { mutableStateOf(0f) }

    // Only vibrates while the user is dragging
    if (isDragging) {
        LaunchedEffect(Unit) {
        // Continuously run the effect for vibration to occur even when the view
        // is not being drawn, when user stops dragging midway through gesture.
        while (true) {
            // Calculate the interval inversely proportional to the drag offset.
            val vibrationInterval = calculateVibrationInterval(dragOffset)
            // Calculate the scale directly proportional to the drag offset.
            val vibrationScale = calculateVibrationScale(dragOffset)

            delay(vibrationInterval)
            vibrator.vibrate(
            VibrationEffect.startComposition().addPrimitive(
                VibrationEffect.Composition.PRIMITIVE_LOW_TICK,
                vibrationScale
            ).compose()
            )
        }
        }
    }

    Screen() {
        Column(
        Modifier
            .draggable(
            orientation = Orientation.Vertical,
            onDragStarted = {
                isDragging = true
            },
            onDragStopped = {
                isDragging = false
            },
            state = rememberDraggableState { delta ->
                dragOffset += delta
            }
            )
        ) {
        // Build the indicator UI based on how much the user has dragged it.
        ResistIndicator(dragOffset)
        }
    }
}

Java

class DragListener implements View.OnTouchListener {
    // Control variables for the dragging of the indicator.
    private int startY;
    private int vibrationInterval;
    private float vibrationScale;

    @Override
    public boolean onTouch(View view, MotionEvent event) {
        switch (event.getAction()) {
        case MotionEvent.ACTION_DOWN:
            startY = event.getRawY();
            vibrationInterval = calculateVibrationInterval(0);
            vibrationScale = calculateVibrationScale(0);
            startVibration();
            break;
        case MotionEvent.ACTION_MOVE:
            float dragOffset = event.getRawY() - startY;
            // Calculate the interval inversely proportional to the drag offset.
            vibrationInterval = calculateVibrationInterval(dragOffset);
            // Calculate the scale directly proportional to the drag offset.
            vibrationScale = calculateVibrationScale(dragOffset);
            // Build the indicator UI based on how much the user has dragged it.
            updateIndicator(dragOffset);
            break;
        case MotionEvent.ACTION_CANCEL:
        case MotionEvent.ACTION_UP:
            // Only vibrates while the user is dragging
            cancelVibration();
            break;
        }
        return true;
    }

    private void startVibration() {
        vibrator.vibrate(
            VibrationEffect.startComposition()
                .addPrimitive(VibrationEffect.Composition.PRIMITIVE_LOW_TICK,
                        vibrationScale)
                .compose());

        // Continuously run the effect for vibration to occur even when the view
        // is not being drawn, when user stops dragging midway through gesture.
        handler.postDelayed(this::startVibration, vibrationInterval);
    }

    private void cancelVibration() {
        handler.removeCallbacksAndMessages(null);
    }
}

拡大(上昇と下降あり)

知覚されるバイブレーションの強度を上げるためのプリミティブは、PRIMITIVE_QUICK_RISEPRIMITIVE_SLOW_RISE の 2 つです。どちらも同じターゲットに到達しますが、期間が異なります。ランプダウン用のプリミティブは PRIMITIVE_QUICK_FALL のみです。これらのプリミティブを組み合わせることで、強度が徐々に増して消えていく波形セグメントを作成できます。スケーリングされたプリミティブを配置することで、プリミティブ間の振幅の急激な変化を防ぐことができます。これは、全体的なエフェクトの持続時間を延長するのにも有効です。人は、上昇部分よりも下降部分を認識しやすいため、上昇部分を下降部分よりも短くすることで、下降部分に重点を置くことができます。

円を拡大 / 縮小するこのコンポジションの適用例を次に示します。ライズ効果は、アニメーション中の拡大感を高めることができます。上昇効果と下降効果を組み合わせることで、アニメーションの最後に折りたたまれることを強調できます。

円が拡大するアニメーション。
入力振動波形のプロット。

図 2. この波形は、デバイスのバイブレーションの出力加速度を表しています。

Kotlin

enum class ExpandShapeState {
    Collapsed,
    Expanded
}

@Composable
fun ExpandScreen() {
    // Control variable for the state of the indicator.
    var currentState by remember { mutableStateOf(ExpandShapeState.Collapsed) }

    // Animation between expanded and collapsed states.
    val transitionData = updateTransitionData(currentState)

    Screen() {
        Column(
        Modifier
            .clickable(
            {
                if (currentState == ExpandShapeState.Collapsed) {
                currentState = ExpandShapeState.Expanded
                vibrator.vibrate(
                    VibrationEffect.startComposition().addPrimitive(
                    VibrationEffect.Composition.PRIMITIVE_SLOW_RISE,
                    0.3f
                    ).addPrimitive(
                    VibrationEffect.Composition.PRIMITIVE_QUICK_FALL,
                    0.3f
                    ).compose()
                )
                } else {
                currentState = ExpandShapeState.Collapsed
                vibrator.vibrate(
                    VibrationEffect.startComposition().addPrimitive(
                    VibrationEffect.Composition.PRIMITIVE_SLOW_RISE
                    ).compose()
                )
            }
            )
        ) {
        // Build the indicator UI based on the current state.
        ExpandIndicator(transitionData)
        }
    }
}

Java

class ClickListener implements View.OnClickListener {
    private final Animation expandAnimation;
    private final Animation collapseAnimation;
    private boolean isExpanded;

    ClickListener(Context context) {
        expandAnimation = AnimationUtils.loadAnimation(context, R.anim.expand);
        expandAnimation.setAnimationListener(new Animation.AnimationListener() {

        @Override
        public void onAnimationStart(Animation animation) {
            vibrator.vibrate(
            VibrationEffect.startComposition()
                .addPrimitive(
                    VibrationEffect.Composition.PRIMITIVE_SLOW_RISE, 0.3f)
                .addPrimitive(
                    VibrationEffect.Composition.PRIMITIVE_QUICK_FALL, 0.3f)
                .compose());
        }
        });

        collapseAnimation = AnimationUtils
                .loadAnimation(context, R.anim.collapse);
        collapseAnimation.setAnimationListener(new Animation.AnimationListener() {

            @Override
            public void onAnimationStart(Animation animation) {
                vibrator.vibrate(
                VibrationEffect.startComposition()
                    .addPrimitive(
                        VibrationEffect.Composition.PRIMITIVE_SLOW_RISE)
                    .compose());
            }
        });
    }

    @Override
    public void onClick(View view) {
        view.startAnimation(isExpanded ? collapseAnimation : expandAnimation);
        isExpanded = !isExpanded;
    }
}

ゆらゆら(回転あり)

ハプティクスに関する重要な原則の 1 つは、ユーザーを喜ばせることです。PRIMITIVE_SPIN を使用すると、予期しない心地よいバイブレーション効果を楽しく導入できます。このプリミティブは、複数回呼び出された場合に最も効果的です。複数のスピンを連結すると、ぐらぐらして不安定なエフェクトが生まれます。このエフェクトは、各プリミティブにややランダムなスケーリングを適用することでさらに強化できます。連続するスピン プリミティブ間のギャップを試すこともできます。2 回の回転の間にギャップがない(0 ミリ秒)と、きつい回転感覚が生じます。スピン間のギャップを 10 ミリ秒から 50 ミリ秒に増やすと、スピンの感覚が緩くなり、動画やアニメーションの長さに合わせることができます。

100 ミリ秒を超えるギャップは使用しないでください。連続するスピンがうまく統合されなくなり、個別の効果のように感じられるようになります。

下にドラッグして離すと跳ね返る弾性シェイプの例を次に示します。アニメーションは、バウンスの変位に比例するさまざまな強度で再生される 2 つの回転効果で強化されます。

弾力のあるシェイプが跳ねるアニメーション
入力振動波形のプロット

図 3. この波形は、デバイスのバイブレーションの出力加速度を表します。

Kotlin

@Composable
fun WobbleScreen() {
    // Control variables for the dragging and animating state of the elastic.
    var dragDistance by remember { mutableStateOf(0f) }
    var isWobbling by remember { mutableStateOf(false) }

    // Use drag distance to create an animated float value behaving like a spring.
    val dragDistanceAnimated by animateFloatAsState(
        targetValue = if (dragDistance > 0f) dragDistance else 0f,
        animationSpec = spring(
            dampingRatio = Spring.DampingRatioHighBouncy,
            stiffness = Spring.StiffnessMedium
        ),
    )

    if (isWobbling) {
        LaunchedEffect(Unit) {
            while (true) {
                val displacement = dragDistanceAnimated / MAX_DRAG_DISTANCE
                // Use some sort of minimum displacement so the final few frames
                // of animation don't generate a vibration.
                if (displacement > SPIN_MIN_DISPLACEMENT) {
                    vibrator.vibrate(
                        VibrationEffect.startComposition().addPrimitive(
                            VibrationEffect.Composition.PRIMITIVE_SPIN,
                            nextSpinScale(displacement)
                        ).addPrimitive(
                        VibrationEffect.Composition.PRIMITIVE_SPIN,
                        nextSpinScale(displacement)
                        ).compose()
                    )
                }
                // Delay the next check for a sufficient duration until the
                // current composition finishes. Note that you can use
                // Vibrator.getPrimitiveDurations API to calculcate the delay.
                delay(VIBRATION_DURATION)
            }
        }
    }

    Box(
        Modifier
            .fillMaxSize()
            .draggable(
                onDragStopped = {
                    isWobbling = true
                    dragDistance = 0f
                },
                orientation = Orientation.Vertical,
                state = rememberDraggableState { delta ->
                    isWobbling = false
                    dragDistance += delta
                }
            )
    ) {
        // Draw the wobbling shape using the animated spring-like value.
        WobbleShape(dragDistanceAnimated)
    }
}

// Calculate a random scale for each spin to vary the full effect.
fun nextSpinScale(displacement: Float): Float {
    // Generate a random offset in the range [-0.1, +0.1] to be added to the
    // vibration scale so the spin effects have slightly different values.
    val randomOffset: Float = Random.Default.nextFloat() * 0.2f - 0.1f
    return (displacement + randomOffset).absoluteValue.coerceIn(0f, 1f)
}

Java

class AnimationListener implements DynamicAnimation.OnAnimationUpdateListener {
    private final Random vibrationRandom = new Random(seed);
    private final long lastVibrationUptime;

    @Override
    public void onAnimationUpdate(
        DynamicAnimation animation, float value, float velocity) {
        // Delay the next check for a sufficient duration until the current
        // composition finishes. Note that you can use
        // Vibrator.getPrimitiveDurations API to calculcate the delay.
        if (SystemClock.uptimeMillis() - lastVibrationUptime < VIBRATION_DURATION) {
            return;
        }

        float displacement = calculateRelativeDisplacement(value);

        // Use some sort of minimum displacement so the final few frames
        // of animation don't generate a vibration.
        if (displacement < SPIN_MIN_DISPLACEMENT) {
            return;
        }

        lastVibrationUptime = SystemClock.uptimeMillis();
        vibrator.vibrate(
        VibrationEffect.startComposition()
            .addPrimitive(VibrationEffect.Composition.PRIMITIVE_SPIN,
            nextSpinScale(displacement))
            .addPrimitive(VibrationEffect.Composition.PRIMITIVE_SPIN,
            nextSpinScale(displacement))
            .compose());
    }

    // Calculate a random scale for each spin to vary the full effect.
    float nextSpinScale(float displacement) {
        // Generate a random offset in the range [-0.1,+0.1] to be added to
        // the vibration scale so the spin effects have slightly different
        // values.
        float randomOffset = vibrationRandom.nextFloat() * 0.2f - 0.1f
        return MathUtils.clamp(displacement + randomOffset, 0f, 1f)
    }
}

バウンス(ドスンという音あり)

バイブレーション効果のもう一つの高度な応用は、物理的なインタラクションをシミュレートすることです。PRIMITIVE_THUD は、強力で反響するエフェクトを生み出すことができます。たとえば、動画やアニメーションで衝撃の視覚化と組み合わせることで、全体的な体験を向上させることができます。

ボールが画面の下部で跳ね返るたびに、ドスンという効果音が再生されるボール落下アニメーションの例を次に示します。

落としたボールが画面の下部で跳ね返るアニメーション。
入力振動波形のプロット。

図 4. この波形は、デバイスのバイブレーションの出力加速度を表します。

Kotlin

enum class BallPosition {
    Start,
    End
}

@Composable
fun BounceScreen() {
    // Control variable for the state of the ball.
    var ballPosition by remember { mutableStateOf(BallPosition.Start) }
    var bounceCount by remember { mutableStateOf(0) }

    // Animation for the bouncing ball.
    var transitionData = updateTransitionData(ballPosition)
    val collisionData = updateCollisionData(transitionData)

    // Ball is about to contact floor, only vibrating once per collision.
    var hasVibratedForBallContact by remember { mutableStateOf(false) }
    if (collisionData.collisionWithFloor) {
        if (!hasVibratedForBallContact) {
        val vibrationScale = 0.7.pow(bounceCount++).toFloat()
        vibrator.vibrate(
            VibrationEffect.startComposition().addPrimitive(
            VibrationEffect.Composition.PRIMITIVE_THUD,
            vibrationScale
            ).compose()
        )
        hasVibratedForBallContact = true
        }
    } else {
        // Reset for next contact with floor.
        hasVibratedForBallContact = false
    }

    Screen() {
        Box(
        Modifier
            .fillMaxSize()
            .clickable {
            if (transitionData.isAtStart) {
                ballPosition = BallPosition.End
            } else {
                ballPosition = BallPosition.Start
                bounceCount = 0
            }
            },
        ) {
        // Build the ball UI based on the current state.
        BouncingBall(transitionData)
        }
    }
}

Java

class ClickListener implements View.OnClickListener {
    @Override
    public void onClick(View view) {
        view.animate()
        .translationY(targetY)
        .setDuration(3000)
        .setInterpolator(new BounceInterpolator())
        .setUpdateListener(new AnimatorUpdateListener() {

            boolean hasVibratedForBallContact = false;
            int bounceCount = 0;

            @Override
            public void onAnimationUpdate(ValueAnimator animator) {
            boolean valueBeyondThreshold = (float) animator.getAnimatedValue() > 0.98;
            if (valueBeyondThreshold) {
                if (!hasVibratedForBallContact) {
                float vibrationScale = (float) Math.pow(0.7, bounceCount++);
                vibrator.vibrate(
                    VibrationEffect.startComposition()
                    .addPrimitive(
                        VibrationEffect.Composition.PRIMITIVE_THUD,
                        vibrationScale)
                    .compose());
                hasVibratedForBallContact = true;
                }
            } else {
                // Reset for next contact with floor.
                hasVibratedForBallContact = false;
            }
            }
        });
    }
}

エンベロープ付きのバイブレーション波形

カスタム バイブレーション パターンを作成するプロセスでは、バイブレーションの振幅を制御して、スムーズなランプアップ効果とランプダウン効果を作成できます。このセクションでは、波形エンベロープを使用して動的なハプティクス効果を作成する方法について説明します。波形エンベロープを使用すると、バイブレーションの振幅と周波数を時間経過とともに正確に制御できます。これにより、よりリッチでニュアンスのある触覚エクスペリエンスを作成できます。

Android 16(API レベル 36)以降、システムは、一連のコントロール ポイントを定義してバイブレーション波形エンベロープを作成するための次の API を提供します。

  • BasicEnvelopeBuilder: ハードウェアに依存しない触覚効果を作成するためのアクセス可能なアプローチ。
  • WaveformEnvelopeBuilder: ハプティクス効果を作成するためのより高度なアプローチ。ハプティクス ハードウェアに精通している必要があります。

Android では、エンベロープ効果のフォールバックは提供されません。このサポートが必要な場合は、次の手順を行います。

  1. Vibrator.areEnvelopeEffectsSupported() を使用して、特定のデバイスがエンベロープ効果をサポートしているかどうかを確認します。
  2. サポートされていない一貫したエクスペリエンス セットを無効にするか、カスタム バイブレーション パターンまたはコンポジションをフォールバックの代替手段として使用します。

より基本的なエンベロープ効果を作成するには、次のパラメータを指定して BasicEnvelopeBuilder を使用します。

  • \( [0, 1] \)の範囲の強度の値。振動の知覚強度を表します。たとえば、値 \( 0.5 \)は、デバイスで達成可能なグローバル最大強度の半分と認識されます。
  • \( [0, 1] \)の範囲の鮮明度の値。振動の鮮明度を表します。値が小さいほど、振動は滑らかになり、値が大きいほど、振動は鋭くなります。

  • duration 値。最後のコントロール ポイント(強度とシャープネスのペア)から新しいコントロール ポイントに移行するのにかかる時間をミリ秒単位で表します。

次の波形の例では、500 ミリ秒かけて低音から高音、最大強度のバイブレーションまで強度を上げ、100 ミリ秒かけて\( 0 \) (オフ)まで強度を下げています。

vibrator.vibrate(VibrationEffect.BasicEnvelopeBuilder()
    .setInitialSharpness(0.0f)
    .addControlPoint(1.0f, 1.0f, 500)
    .addControlPoint(0.0f, 1.0f, 100)
    .build()
)

ハプティクスに関する高度な知識がある場合は、WaveformEnvelopeBuilder を使用してエンベロープ効果を定義できます。このオブジェクトを使用すると、VibratorFrequencyProfile を介して周波数と出力のアクセラレーションのマッピング(FOAM)にアクセスできます。

  • デバイスの FOAM によって決定される、特定の周波数で達成可能な振動の強さを表す、 \( [0, 1] \)の範囲の振幅値。たとえば、値が \( 0.5 \) の場合、指定された周波数で達成可能な最大出力加速度の半分が生成されます。
  • 周波数の値(ヘルツ単位)。

  • duration 値。最後のコントロール ポイントから新しいコントロール ポイントに移行するのにかかる時間をミリ秒単位で表します。

次のコードは、400 ミリ秒のバイブレーション効果を定義する波形の例を示しています。まず、50 ミリ秒の振幅ランプで、オフからフルまで 60 Hz の一定の周波数で上昇します。次に、周波数は次の 100 ミリ秒で 120 Hz まで上昇し、200 ミリ秒間そのレベルを維持します。最後に、振幅は \( 0 \)まで下降し、周波数は最後の 50 ミリ秒で 60 Hz に戻ります。

vibrator.vibrate(VibrationEffect.WaveformEnvelopeBuilder()
    .addControlPoint(1.0f, 60f, 50)
    .addControlPoint(1.0f, 120f, 100)
    .addControlPoint(1.0f, 120f, 200)
    .addControlPoint(0.0f, 60f, 50)
    .build()
)

以降のセクションでは、エンベロープを含むバイブレーション波形の例をいくつか示します。

跳ねるバネ

前のサンプルでは、PRIMITIVE_THUD を使用して物理的なバウンスのインタラクションをシミュレートしています。基本的なエンベロープ API は、振動の強さとシャープさを正確に調整できる、より細かい制御を提供します。これにより、アニメーション イベントをより正確に追跡する触覚フィードバックが得られます。

次の例は、自由落下のばねのアニメーションを、ばねが画面の下部に跳ね返るたびに再生される基本的なエンベロープ効果で強化したものです。

画面の下部で跳ね返るばねのアニメーション。
入力振動波形のプロット。

図 5. 跳ねるバネをシミュレートする振動の出力加速度波形グラフ。

@Composable
fun BouncingSpringAnimation() {
  var springX by remember { mutableStateOf(SPRING_WIDTH) }
  var springY by remember { mutableStateOf(SPRING_HEIGHT) }
  var velocityX by remember { mutableFloatStateOf(INITIAL_VELOCITY) }
  var velocityY by remember { mutableFloatStateOf(INITIAL_VELOCITY) }
  var sharpness by remember { mutableFloatStateOf(INITIAL_SHARPNESS) }
  var intensity by remember { mutableFloatStateOf(INITIAL_INTENSITY) }
  var multiplier by remember { mutableFloatStateOf(INITIAL_MULTIPLIER) }
  var bottomBounceCount by remember { mutableIntStateOf(0) }
  var animationStartTime by remember { mutableLongStateOf(0L) }
  var isAnimating by remember { mutableStateOf(false) }

  val (screenHeight, screenWidth) = getScreenDimensions(context)

  LaunchedEffect(isAnimating) {
    animationStartTime = System.currentTimeMillis()
    isAnimating = true

    while (isAnimating) {
      velocityY += GRAVITY
      springX += velocityX.dp
      springY += velocityY.dp

      // Handle bottom collision
      if (springY > screenHeight - FLOOR_HEIGHT - SPRING_HEIGHT / 2) {
        // Set the spring's y-position to the bottom bounce point, to keep it
        // above the floor.
        springY = screenHeight - FLOOR_HEIGHT - SPRING_HEIGHT / 2

        // Reverse the vertical velocity and apply damping to simulate a bounce.
        velocityY *= -BOUNCE_DAMPING
        bottomBounceCount++

        // Calculate the fade-out duration of the vibration based on the
        // vertical velocity.
        val fadeOutDuration =
            ((abs(velocityY) / GRAVITY) * FRAME_DELAY_MS).toLong()

        // Create a "boing" envelope vibration effect that fades out.
        vibrator.vibrate(
            VibrationEffect.BasicEnvelopeBuilder()
                // Starting from zero sharpness here, will simulate a smoother
                // "boing" effect.
                .setInitialSharpness(0f)

                // Add a control point to reach the target intensity and
                // sharpness very quickly.
                .addControlPoint(intensity, sharpness, 20L)

                // Add a control point to fade out the vibration intensity while
                // maintaining sharpness.
                .addControlPoint(0f, sharpness, fadeOutDuration)
                .build()
        )

        // Decrease the intensity and sharpness of the vibration for subsequent
        // bounces, and reduce the multiplier to create a fading effect.
        intensity *= multiplier
        sharpness *= multiplier
        multiplier -= 0.1f
      }

      if (springX > screenWidth - SPRING_WIDTH / 2) {
        // Prevent the spring from moving beyond the right edge of the screen.
        springX = screenWidth - SPRING_WIDTH / 2
      }

      // Check for 3 bottom bounces and then slow down.
      if (bottomBounceCount >= MAX_BOTTOM_BOUNCE &&
            System.currentTimeMillis() - animationStartTime > 1000) {
        velocityX *= 0.9f
        velocityY *= 0.9f
      }

      delay(FRAME_DELAY_MS) // Control animation speed.

      // Determine if the animation should continue based on the spring's
      // position and velocity.
      isAnimating = (springY < screenHeight + SPRING_HEIGHT ||
            springX < screenWidth + SPRING_WIDTH)
        && (velocityX >= 0.1f || velocityY >= 0.1f)
    }
  }

  Box(
    modifier = Modifier
      .fillMaxSize()
      .noRippleClickable {
        if (!isAnimating) {
          resetAnimation()
        }
      }
      .width(screenWidth)
      .height(screenHeight)
  ) {
    DrawSpring(mutableStateOf(springX), mutableStateOf(springY))
    DrawFloor()
    if (!isAnimating) {
      DrawText("Tap to restart")
    }
  }
}

ロケットの打ち上げ

前のサンプルでは、基本的なエンベロープ API を使用して、弾むバネの反応をシミュレートする方法を示しました。WaveformEnvelopeBuilder は、デバイスの全周波数帯域を正確に制御し、高度にカスタマイズされたハプティクス効果を実現します。これを FOAM データと組み合わせることで、特定の周波数機能に合わせてバイブレーションを調整できます。

動的振動パターンを使用したロケット発射シミュレーションの例を次に示します。効果は、サポートされている最小周波数加速度出力の 0.1 G から共振周波数まで変化し、常に 10% の振幅入力が維持されます。これにより、駆動振幅が同じでも、効果が適度に強い出力で始まり、知覚される強度とシャープネスが増加します。共振に達すると、エフェクトの周波数は最小値に戻り、強度とシャープネスが低下したように感じられます。これにより、宇宙への打ち上げを模倣した、最初の抵抗感の後に解放感が得られます。

この効果は、共振周波数と出力加速度曲線に関するデバイス固有の情報を抽象化する基本的なエンベロープ API では実現できません。シャープネスを上げると、等価周波数が共振を超え、意図しない加速度の低下を引き起こす可能性があります。

画面の下からロケットが飛び立つアニメーション。
入力振動波形のプロット。

図 6. ロケットの打ち上げをシミュレートする振動の出力加速度波形グラフ。

@Composable
fun RocketLaunchAnimation() {
  val context = LocalContext.current
  val screenHeight = remember { mutableFloatStateOf(0f) }
  var rocketPositionY by remember { mutableFloatStateOf(0f) }
  var isLaunched by remember { mutableStateOf(false) }
  val animation = remember { Animatable(0f) }

  val animationDuration = 3000
  LaunchedEffect(isLaunched) {
    if (isLaunched) {
      animation.animateTo(
        1.2f, // Overshoot so that the rocket goes off the screen.
        animationSpec = tween(
          durationMillis = animationDuration,
          // Applies an easing curve with a slow start and rapid acceleration
          // towards the end.
          easing = CubicBezierEasing(1f, 0f, 0.75f, 1f)
        )
      ) {
        rocketPositionY = screenHeight.floatValue * value
      }
      animation.snapTo(0f)
      rocketPositionY = 0f;
      isLaunched = false;
    }
  }

  Box(
    modifier = Modifier
      .fillMaxSize()
      .noRippleClickable {
        if (!isLaunched) {
          // Play vibration with same duration as the animation, using 70% of
          // the time for the rise of the vibration, to match the easing curve
          // defined previously.
          playVibration(vibrator, animationDuration, 0.7f)
          isLaunched = true
        }
      }
      .background(Color(context.getColor(R.color.background)))
      .onSizeChanged { screenHeight.floatValue = it.height.toFloat() }
  ) {
    drawRocket(rocketPositionY)
  }
}

private fun playVibration(
  vibrator: Vibrator,
  totalDurationMs: Long,
  riseBias: Float,
  minOutputAccelerationGs: Float = 0.1f,
) {
  require(riseBias in 0f..1f) { "Rise bias must be between 0 and 1." }

  if (!vibrator.areEnvelopeEffectsSupported()) {
    return
  }

  val resonantFrequency = vibrator.resonantFrequency
  if (resonantFrequency.isNaN()) {
    // Device doesn't have or expose a resonant frequency.
    return
  }

  val startFrequency = vibrator.frequencyProfile?.getFrequencyRange(minOutputAccelerationGs)?.lower ?: return

  if (startFrequency >= resonantFrequency) {
    // Vibrator can't generate the minimum required output at lower frequencies.
    return
  }

  val minDurationMs = vibrator.envelopeEffectInfo.minControlPointDurationMillis
  val rampUpDurationMs = (riseBias * totalDurationMs).toLong() - minDurationMs
  val rampDownDurationMs = totalDurationMs - rampUpDuration - minDurationMs

  vibrator.vibrate(
    VibrationEffect.WaveformEnvelopeBuilder()
      // Quickly reach the target output at the start frequency
      .addControlPoint(0.1f, startFrequency, minDurationMs)
      .addControlPoint(0.1f, resonantFrequency, rampUpDurationMs)
      .addControlPoint(0.1f, startFrequency, rampDownDurationMs)

      // Controlled ramp down to zero to avoid ringing after the vibration.
      .addControlPoint(0.0f, startFrequency, minDurationMs)
      .build()
  )
}

LavaBeats

ロケット発射の例のように、WaveformEnvelopeBuilder API では、バイブレーションの振幅と周波数のセグメントを制御できるため、複雑な触覚効果を多数設計できます。このような設計の別の例として、「活気」などのより抽象的な身体感覚のエミュレーションがあります。

これは、特定の振幅と周波数の振動セグメントで典型的な心電図(ECG)信号のバイオマーカーを表すことで実現できます。LavaBeats は、心電図記録の 2 つの特徴的なセグメントが、時間遅延で区切られた 2 つのパルスとして表される例です。最初の特徴的なパルスは QRS 複合波です。これは、振幅が大きく持続時間が短い鋭いピークとして表示されます。2 つ目のパルスは T 波で、振幅が小さく、持続時間が長く、形状が滑らかです(図 7 を参照)。

WaveformEnvelopeBuilder を使用して、固定された 1 番目から 2 番目のパルス遅延で区切られた 2 つのパルスのさまざまな繰り返しを構築します。最初のパルスは、短時間で低周波数から高周波数まで変化するチャープ信号にすることができます。2 番目のパルスは、低周波正弦波の 1 つの期間として表すことができます。2 つのパルスを 1 つのビートに構成し、一般的な 1 分あたりのビート数(bpm)に従って、構成を数回繰り返します。結果として、心臓の鼓動に似た触覚効果が得られます。

GitHub のハプティクス サンプルアプリLavaBeats を試すと、ハプティクス効果と同じリズムで脈打つ溶岩ランプの視覚化とともに、その効果を実感できます。エフェクトの設定を変更して、2 つのパルスの振幅、周波数、持続時間、遅延を変更することで、さまざまなビート感を作成することもできます。

ハプティクスによる心臓の鼓動パターンで脈打つ溶岩ランプのアニメーション。
心電図セグメントとその特徴的な波形のプロット

図 7. QRS 複合波と T 波を含む心電図記録のセグメント

@RequiresApi(Build.VERSION_CODES.BAKLAVA)
private fun createEnvelopeEffect(
beatParameters: List<BeatParameter>
):VibrationEffect =
    VibrationEffect.WaveformEnvelopeBuilder()
        .apply {
            repeat(beatParameters.getNumBeats()) {
                // First pulse chirp
                addControlPoint(
                    beatParameters.getFirstPulseAmplitude(),
                    beatParameters.getFirstPulseStartFreq(),
                    ENVELOPE_RAMP_DURATION_MILLIS,
                )
                addControlPoint(
                    beatParameters.getFirstPulseAmplitude(),
                    beatParameters.getFirstPulseEndFreq(),
                    beatParameters.getFirstPulseDurationMillis().toLong(),
                )
                addControlPoint(
                    0f,
                    beatParameters.getFirstPulseEndFreq(),
                    ENVELOPE_RAMP_DURATION_MILLIS,
                )

                // Delay between first and second pulse
                addControlPoint(
                    0f,
                    beatParameters.getFirstPulseEndFreq(),
                    beatParameters.getFirstToSecondPulseDelayMillis().toLong(),
                )

                // Second pulse
                addControlPoint(
                    beatParameters.getSecondPulseAmplitude(),
                    beatParameters.getSecondPulseFreq(),
                    ENVELOPE_RAMP_DURATION_MILLIS,
                )
                addControlPoint(
                    beatParameters.getSecondPulseAmplitude(),
                    beatParameters.getSecondPulseFreq(),
                    (1_000 / (2f * beatParameters.getSecondPulseFreq())).toLong(),
                )
                addControlPoint(
                    0f,
                    beatParameters.getSecondPulseFreq(),
                    ENVELOPE_RAMP_DURATION_MILLIS,
                )
                addControlPoint(
                    0f,
                    beatParameters.getSecondPulseFreq(),
                    beatParameters.getBeatDelayMillis().toLong(),
                )
            }
        }
        .build()

/** A parameter of a haptic beat effect that represents an ECG signal parameter */
@Stable
data class BeatParameter(
    val description: String = "",
    val value: Float = 0f,
    val range: ClosedFloatingPointRange<Float> = 0f..1f,
    val steps: Int = 0,
    val isFrequencyType: Boolean = false,
)