このページでは、さまざまな ハプティクス API を 使用して、Android アプリの標準的な バイブレーション波形 以外のカスタム効果を作成する方法の例について説明します。
このページには、次の例を記載しています。
- カスタム バイブレーション パターン
- ランプアップ パターン: スムーズに始まるパターン。
- 繰り返しパターン: 終わりがないパターン。
- フォールバック付きパターン: フォールバックのデモ。
- バイブレーションの構成
- エンベロープ付きバイブレーション波形
その他の例については、イベントに触覚フィードバックを追加するをご覧ください。また、 ハプティクスの設計原則に必ず従ってください。
フォールバックを使用してデバイスの互換性を処理する
カスタム効果を実装する場合は、次の点を考慮してください。
- 効果に必要なデバイスの機能
- デバイスが効果を再生できない場合の対処方法
Android ハプティクス API リファレンスには、ハプティクスに関連するコンポーネントの サポートを確認する方法が記載されています。これにより、アプリは 一貫した全体的なエクスペリエンスを提供できます。
ユースケースによっては、カスタム効果を無効にしたり、さまざまな潜在的な機能に基づいて代替のカスタム効果を提供したりすることが必要になる場合があります。
デバイス機能の次のハイレベル クラスを計画します。
ハプティクス プリミティブを使用している場合: カスタム効果に必要なプリミティブ をサポートするデバイス。(プリミティブの詳細については、次のセクションをご覧ください)。
振幅制御を備えたデバイス。
基本的なバイブレーション サポート(オン/オフ)を備えたデバイス。つまり、振幅制御がないデバイス。
アプリのハプティクス効果の選択でこれらのカテゴリが考慮されている場合、ハプティクスのユーザー エクスペリエンスは個々のデバイスで予測可能になります。
ハプティクス プリミティブの使用
Android には、振幅と周波数の両方が異なるハプティクス プリミティブがいくつか含まれています。 1 つのプリミティブを単独で使用することも、複数のプリミティブを組み合わせて使用して、リッチなハプティクス効果を実現することもできます。
- 2 つのプリミティブ間の識別可能なギャップには 50 ミリ秒以上の遅延を使用します。 可能であれば、プリミティブの再生時間も考慮してください。
- 強度の違いがより認識されるように、1.4 以上の比率で異なるスケールを使用します。
0.5、0.7、1.0 のスケールを使用して、プリミティブの低、中、高強度のバージョンを作成します。
カスタム バイブレーション パターンを作成する
バイブレーション パターンは、通知や着信音など、注意を促すハプティクスでよく使用されます。Vibrator サービスは、バイブレーションの振幅を時間とともに変化させる長いバイブレーション パターン
を再生できます。このような効果は波形と呼ばれます。
波形効果は通常認識できますが、静かな環境で再生すると、突然の長いバイブレーションでユーザーが驚く可能性があります。ターゲットの振幅に急激に上昇すると、ブーンという音が聞こえることがあります。波形パターンを設計して振幅の遷移をスムーズにし、ランプアップ効果とランプダウン効果を作成します。
バイブレーション パターンの例
以降のセクションでは、バイブレーション パターンの例をいくつか示します。
ランプアップ パターン
波形は、VibrationEffect として表されます。
- タイミング: 各波形セグメントの再生時間(ミリ秒単位)の配列。
- 振幅: 最初の引数で指定された各再生時間の必要なバイブレーションの振幅。0 ~ 255 の整数値で表されます。0 はバイブレータの「オフ状態」、255 はデバイスの最大振幅を表します。
- 繰り返しインデックス: 波形の繰り返しを開始する最初の引数で指定された配列のインデックス。パターンを 1 回だけ再生する場合は -1。
次に、パルス間に 350 ミリ秒のポーズを挟んで 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 ミリ秒以上のギャップを使用します。次に、遅延を含む構成の例を示します。
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の 1 つだけです。これらのプリミティブを組み合わせると、強度が増加してから消える波形セグメントを作成できます。スケーリングされたプリミティブを揃えて、振幅の急激な変化を防ぐことができます。これは、全体的な効果の再生時間を延長する場合にも有効です。認識上、人は下降部分よりも上昇部分を常に認識するため、上昇部分を下降部分よりも短くすることで、下降部分に重点を置くことができます。
次に、円を拡大縮小するためのこの構成の適用例を示します。上昇効果により、アニメーション中の拡大感を高めることができます。上昇効果と下降効果を組み合わせることで、アニメーションの最後に縮小を強調できます。
図 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;
}
}
揺れ(スピン付き)
ハプティクスの重要な原則の一つは、ユーザーを喜ばせることです。心地よい予期しないバイブレーション効果を導入する楽しい方法の一つに、
を使用する方法があります
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 では、エンベロープ効果のフォールバックは提供されません。このサポートが必要な場合は、次の手順を完了します。
- 特定のデバイスがエンベロープ効果をサポートしていることを確認します
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 を使用してエンベロープ効果を定義できます。このオブジェクトを使用すると、
周波数から出力加速度へのマッピング(FOAM)を介して
VibratorFrequencyProfileにアクセスできます。
- 範囲内の振幅値 \( [0, 1] \)。デバイスの FOAM によって決定される、特定の周波数で実現可能なバイブレーション強度を表します。たとえば、 \( 0.5 \) の値は、特定の周波数で実現可能な最大 出力加速度の半分を生成します。
周波数 の値(ヘルツ単位)。
再生時間 の値。最後のコントロール ポイントから新しいコントロール ポイントに移行するまでの時間(ミリ秒単位)を表します。
次のコードは、400 ミリ秒のバイブレーション効果を定義する波形の例を示しています。一定の 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 を使用すると、バイブレーションの強度と鮮明度を正確に調整できます。これにより、アニメーション イベントに正確に追従する触覚フィードバックが得られます。
次に、ばねが画面の下部から跳ね返るたびに基本的なエンベロープ効果が再生される、自由落下のばねの例を示します。
図 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 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 では実現できません。鮮明度を上げると、相当する周波数が共振を超え、意図しない加速度の低下を引き起こす可能性があります。
図 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 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()
)
}