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

このページでは、さまざまなハプティクス API を使用して Android アプリでカスタム エフェクトを作成する方法の例を紹介します。このページの情報の多くは、バイブレーション アクチュエータの仕組みを十分に理解していることを前提としています。そのため、バイブレーション アクチュエータ入門を読むことをおすすめします。

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

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

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

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

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

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

ユースケースに応じて、カスタム エフェクトを無効にすることや、さまざまな潜在的機能に基づいて代替のカスタム エフェクトを提供できます。

次のようなデバイス機能の概要クラスについて計画します。

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

  • 振幅制御が可能なデバイス

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

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

触覚プリミティブの使用

Android には、振幅と周波数の両方で変化する触覚プリミティブがいくつか含まれています。1 つのプリミティブを単独で使用するか、複数のプリミティブを組み合わせて、リッチな触覚効果を実現できます。

  • 2 つのプリミティブ間の認識可能なギャップには 50 ms 以上の遅延を使用します。可能であればプリミティブ期間も考慮します。
  • 比率の差が 1.4 以上であれば、強さの違いがわかりやすくなります。
  • 0.5、0.7、1.0 のスケールを使用して、プリミティブの低、中、高の強度バージョンを作成します。

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

バイブレーションパターンは、通知や着信音など、注意喚起のハプティクスによく使用されます。Vibrator サービスは、長いバイブレーション パターンを再生し、時間の経過とともにバイブレーションの振幅を変化させることができます。このようなエフェクトは波形と名付けられます。

波形効果は簡単に感じられますが、静かな環境でプレイすると、突然の長いバイブレーションでユーザーを驚かせる可能性があります。また、目標振幅への急速な遷移が速すぎると、可聴ノイズが発生する可能性もあります。波形パターンの設計では、振幅の遷移を滑らかにして、ランプアップ効果とランプダウン効果を生み出すことをおすすめします。

サンプル: 立ち上げパターン

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

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

次に、パルス間に 350 ms の休止時間で 2 回パルスする波形の例を示します。1 つ目のパルスは最大振幅までの滑らかなランプで、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 ms のギャップは短すぎるため、検出できません。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);

例: レジスト(低い目盛り)

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

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

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 のみです。これらのプリミティブは連携して効果を発揮し、強度が増して消える波形セグメントを作成します。スケーリングされたプリミティブを調整して、それらの間の振幅の急激な跳ねを防ぐことができます。これは、エフェクト期間全体を延長する場合にも適しています。知覚的には、立ち下がり部分よりも上昇部分のほうが常に認識されるため、上昇部分を下降部分よりも短くすることで、強調を低下部分に近づけることができます。

以下は、このコンポジションを円の展開と折りたたみに適用した例です。立ち上がり効果により、アニメーション中の膨張感を高めることができます。立ち上がりと立ち下がりのエフェクトを組み合わせると、アニメーションの最後に折りたたみが強調されます。

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

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

サンプル: 揺れ(回転あり)

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

連続するスピンがうまく統合されなくなり、個別の効果のように感じられるようになるため、100 ms を超えるギャップの使用はおすすめしません。

以下は、ドラッグして離すと元に戻る弾性形状の例です。アニメーションは 2 つのスピン エフェクトで強化され、バウンス変位に比例してさまざまな強度で再生されます。

弾性体が弾むアニメーション
入力振動波形のプロット

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

例: バウンス(音あり)

バイブレーション効果のもう 1 つの高度な応用は、物理的な操作のシミュレーションです。PRIMITIVE_THUD は強い反響効果を生み出すことができます。この効果を動画やアニメーションなどでインパクトの可視化と組み合わせることで、全体的なエクスペリエンスを強化できます。

以下は、ボールが画面下部から跳ね返るたびにサウンド効果が生まれる、シンプルなボールドロップ アニメーションの例です。

ドロップしたボールが画面を下から跳ねてくるアニメーション
入力振動波形のプロット

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