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

このページでは、さまざまなハプティクス 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 です。

パルス間に 350 ms の休止時間がある 2 回のパルスの波形の例を次に示します。最初のパルスは最大振幅までスムーズに増加し、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 // Do not 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; // Do not 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 ms 以上のギャップを使用することを検討してください。遅延のあるコンポジションの例を次に示します。

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);

例: 抵抗(低いティック数)

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

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

図 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 ms)と、回転がきつく感じられます。スピン間隔を 10 ms から 50 ms に増やすと、スピン感が緩くなります。動画やアニメーションの長さに合うように使用できます。

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 [-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 [-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;
          }
        }
      });
  }
}