このページでは、さまざまな ハプティクス API を使用して、Android アプリで標準のバイブレーション ウェーブフォームを超えるカスタム エフェクトを作成する方法の例について説明します。
このページでは、次の例を説明します。
- カスタム バイブレーション パターン
- 増加パターン: スムーズに開始されるパターン。
- 繰り返しパターン: 終わりのないパターン。
- フォールバックありのパターン: フォールバックのデモ。
- バイブレーションの構成
- エンベロープ付きのバイブレーション波形
- バウンドするスプリング: 基本的なエンベロープ効果を使用して、バネのように跳ねる効果を作成します。
- ロケットの打ち上げ: 波形エンベロープ効果を使用したロケットの打ち上げ効果。
その他の例については、イベントに触覚フィードバックを追加するをご覧ください。また、常に触覚設計の原則に従ってください。
デバイスの互換性を処理するためにフォールバックを使用する
カスタム エフェクトを実装する際は、次の点を考慮してください。
- エフェクトに必要なデバイスの機能
- デバイスでエフェクトを再生できない場合の対応
Android ハプティクス API リファレンスでは、アプリで全体的なエクスペリエンスを一貫して提供できるように、ハプティクスに関連するコンポーネントのサポートを確認する方法について詳しく説明しています。
ユースケースに応じて、カスタム エフェクトを無効にしたり、さまざまな潜在的な機能に基づいて代替のカスタム エフェクトを提供したりできます。
デバイス機能の次の大まかなクラスを計画します。
ハプティクスのプリミティブを使用している場合: カスタム エフェクトに必要なプリミティブをサポートしているデバイス。(プリミティブの詳細については、次のセクションをご覧ください)。
振幅制御機能付きのデバイス。
バイブレーションの基本的なサポート(オン/オフ)が可能なデバイス(振幅の調整が不可能なデバイス)。
アプリのハプティクス エフェクトの選択でこれらのカテゴリを考慮すると、個々のデバイスでハプティクス ユーザー エクスペリエンスが予測可能になります。
ハプティクス プリミティブの使用
Android には、振幅と周波数の両方が異なる複数のハプティクス プリミティブが含まれています。1 つのプリミティブを単独で使用することも、複数のプリミティブを組み合わせて使用することもできます。これにより、豊かなハプティクス効果を実現できます。
- 2 つのプリミティブの間に認識できるギャップがある場合は、50 ミリ秒以上の遅延を使用します。可能であれば、プリミティブの持続時間も考慮してください。
- 強度の違いを認識しやすいように、比率が 1.4 以上のスケールを使用します。
スケールを 0.5、0.7、1.0 にして、プリミティブの低、中、高の強度バージョンを作成します。
カスタムのバイブレーション パターンを作成する
バイブレーション パターンは、通知や着信音などの注意喚起ハプティクスでよく使用されます。Vibrator
サービスは、時間の経過とともに振動振幅が変化する長い振動パターンを再生できます。このような効果を波形と呼びます。
通常、波形エフェクトは認識できますが、静かな環境で再生すると、突然のロング バイブレーションでユーザーが驚く可能性があります。ターゲット振幅に急速に増加すると、耳障りなブーンというノイズが発生することもあります。振幅の遷移をスムーズにして、増加と減少の効果を作成するように波形パターンを設計します。
バイブレーション パターンの例
以降のセクションでは、バイブレーション パターンの例をいくつか示します。
増加パターン
波形は、3 つのパラメータを持つ VibrationEffect
として表されます。
- タイミング: 各波形セグメントの再生時間(ミリ秒単位)の配列。
- 振幅: 最初の引数で指定された各時間の所望の振幅。0 ~ 255 の整数値で表されます。0 はバイブレーターの「オフ状態」、255 はデバイスの最大振幅を表します。
- 繰り返しインデックス: 波形の繰り返しを開始する最初の引数で指定された配列のインデックス。パターンを 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 // 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 では、サポートされていないプリミティブを含むコンポジションのフォールバックは提供されていません。そのため、次の手順を実施します。
高度なハプティクスを有効にする前に、使用しているプリミティブをデバイスがすべてサポートしていることを確認してください。
プリミティブがないエフェクトだけでなく、サポートされていない一貫したエクスペリエンス セットを無効にします。
デバイスのサポートを確認する方法の詳細については、次のセクションをご覧ください。
合成バイブレーション エフェクトを作成する
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);
バイブレーションの構成例
以降のセクションでは、GitHub のハプティクス サンプルアプリから取得したバイブレーションの構成例をいくつか示します。
抵抗(低いティック数)
プリミティブ バイブレーションの振幅を制御して、進行中のアクションに有用なフィードバックを伝達できます。スケール値を狭くすると、プリミティブの滑らかなクレッシェンド効果を作成できます。連続するプリミティブ間の遅延は、ユーザー操作に基づいて動的に設定することもできます。次の例は、ドラッグ ジェスチャーで制御され、ハプティクスで拡張されたビュー アニメーションを示しています。

図 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_RISE
と PRIMITIVE_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 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 にはエンベロープ エフェクトの代替手段がありません。このサポートが必要な場合は、次の手順を行います。
Vibrator.areEnvelopeEffectsSupported()
を使用して、特定のデバイスがエンベロープ エフェクトをサポートしているかどうかを確認します。- サポートされていない一貫したエクスペリエンスを無効にするか、フォールバックとしてカスタム バイブレーション パターンまたはコンポーズを使用します。
より基本的なエンベロープ エフェクトを作成するには、次のパラメータで BasicEnvelopeBuilder
を使用します。
- \( [0, 1] \)の範囲内の強度値。振動の知覚される強度を表します。たとえば、値 \( 0.5 \)は、デバイスで達成可能なグローバル最大強度の半分として認識されます。
振動の鮮明さを表す \( [0, 1] \)の範囲内のシャープネス値。値が小さいほど振動はスムーズになり、値が大きいほど振動はシャープになります。
時間の値。最後のコントロール ポイント(明るさとシャープネスのペア)から新しいコントロール ポイントへの遷移にかかる時間をミリ秒単位で表します。
以下に、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)にアクセスできます。
- \( [0, 1] \)の範囲内の振幅値。デバイスの FOAM によって決定される、特定の周波数で達成可能な振動強度を表します。たとえば、値が \( 0.5 \) の場合、指定された周波数で達成可能な最大出力加速度の半分が生成されます。
ヘルツ単位の周波数値。
duration 値。最後のコントロール ポイントから新しいコントロール ポイントへの遷移にかかる時間をミリ秒単位で表します。
次のコードは、400 ms の振動効果を定義する波形の例を示しています。最初は、60 Hz でオフから最大まで 50 ミリ秒かけて振幅を増加させます。次に、次の 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 では、振動の強さとシャープさを正確に調整できるため、より細かい制御が可能です。これにより、アニメーション イベントに沿ったより正確な触覚フィードバックが可能になります。
以下は、ばねが画面の下部から跳ね返るたびに基本的なエンベロープ効果が再生され、アニメーションが強化された自由落下するばねの例です。
@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 desired 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 では、共振周波数と出力加速度曲線に関するデバイス固有の情報が抽象化されるため、この効果は実現できません。シャープさを上げると、等価周波数が共振を超え、意図しない加速度の低下が発生する可能性があります。
@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 desired 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()
)
}