VibrationEffect.Builder を使用したタイムライン アンカー付きのコンポジション、カスタム バイブレーション パターン、高度な波形エンベロープなど、Android でカスタム触覚効果を作成する方法に関する例とガイダンスを提供します。 keywords_public: > Android、ハプティクス、カスタム効果、バイブレーション、ハプティクス API、バイブレーション パターン、VibrationEffect.Builder、コンポジション、ハプティクス プリミティブ、波形エンベロープ、UI
このページでは、さまざまなハプティクス API を使用して、Android アプリで標準のバイブレーション波形を超えるカスタム効果を作成する方法の例を紹介します。
このページでは、次の例について説明します。
VibrationEffect.Builderを使用したタイムライン アンカー付きコンポジション- プリセットで作成: 事前定義された触覚を連続して再生します。
- エンベロープとプリセットで作曲する: タイムラインに沿ってエンベロープとプリセットを組み合わせます。
- イベントの再利用とシフト: 既存のコンポジション イベントをシフトして再利用します。
- 繰り返しコンポジション: タイムライン エフェクトを繰り返して作成します。
- カスタム バイブレーション パターン
- ランプアップ パターン: スムーズに開始するパターン。
- 繰り返しパターン: 終わりがないパターン。
- フォールバック パターン: フォールバックのデモ。
- バイブレーション プリミティブの構成
- エンベロープ付きのバイブレーション波形
- バウンスするスプリング: 基本的なエンベロープ効果を使用した、バウンスするスプリング効果。
- ロケットの打ち上げ: 波形エンベロープ効果を使用したロケットの打ち上げエフェクト。
その他の例については、イベントに触覚フィードバックを追加するをご覧ください。また、常にハプティクス設計の原則に従ってください。
フォールバックを使用してデバイスの互換性を処理する
カスタムの触覚効果を実装する場合、デバイスの互換性とフォールバック動作は、選択する API サーフェスによって異なります。
VibrationEffect.Builder(推奨): Android 16(26Q4)以降では、VibrationEffect.Builderを使用して作成されたエフェクトに自動フレームワーク レベルのフォールバックが含まれます。デバイスがリクエストされたPresetまたは基本Envelopeをネイティブでサポートしていない場合、フレームワークは再生時にベスト エフォートで適切な代替に自動的に変換します。VibrationEffect.Builderで構成されたエフェクトを再生する前に、プリミティブごとのデバイス機能を個別に確認する必要はありません。- 例外:
WaveformEnvelopeBuilderで作成された高度な波形エンベロープは、特定のハードウェア周波数マッピング(FOAM)に依存するため、自動フォールバックをサポートしていません。サポートされていない場合は再生されません。
- 例外:
VibrationEffect.Composition:startComposition()API で作成されたコンポジションには、自動フォールバックがありません。サポートされていないプリミティブが 1 つでも含まれていると、バイブレーション全体が再生されません。vibrator.arePrimitivesSupported()を使用して、手動で機能を確認する必要があります。- 振幅制御のある波形: 振幅制御のないデバイスでは、ゼロ以外の振幅は 100% に切り上げられます。
vibrator.hasAmplitudeControl()を確認し、必要に応じて明示的に設計されたオン/オフ パターンにフォールバックします。
ハプティクス プリミティブの使用
Android には、振幅と周波数の両方が異なる複数のハプティクス プリミティブが含まれています。1 つのプリミティブを単独で使用することも、複数のプリミティブを組み合わせて使用することもできます。
- 2 つのプリミティブ間の認識可能なギャップには 50 ミリ秒以上の遅延を使用します。可能な場合は、プリミティブの継続時間も考慮します。
- 強度の違いがより認識されるように、1.4 以上の比率で異なるスケールを使用します。
0.5、0.7、1.0 のスケールを使用して、プリミティブの低、中、高の強度のバージョンを作成します。
カスタムのバイブレーション パターンを作成する
バイブレーション パターンは、通知や着信音などの注意喚起のハプティクスでよく使用されます。Vibrator サービスは、振動の振幅が時間とともに変化する長いバイブレーション パターンを再生できます。このような効果を波形と呼びます。
通常、波形効果は認識できますが、静かな環境で再生されると、突然の長いバイブレーションはユーザーを驚かせてしまう可能性があります。目標振幅へのランプアップが速すぎると、可聴のブーンというノイズが発生することもあります。振幅の移行をスムーズにして、ランプアップとランプダウンの効果を生み出す波形パターンを設計します。
バイブレーション パターンの例
以降のセクションでは、バイブレーション パターンの例をいくつか紹介します。
ランプアップ パターン
波形は 3 つのパラメータを持つ 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));
}
VibrationEffect.Builder を使用したタイムライン アンカー付きコンポジション
Android 16(26Q4)以降、複雑なバイブレーション効果と構成を作成するには、VibrationEffect.Builder が推奨される API です。startTimeMillis を使用して絶対タイムラインに沿って個別の触覚要素をシーケンスすることで、表現力豊かな触覚感覚を構築できます。
VibrationEffect.Builder は、複数のバイブレーション タイプを組み合わせることをサポートしています。
- プリセット: クリックやチックなどの事前定義された触覚パルス(
VibrationEffect.Preset)。 - エンベロープ: ハードウェアに依存しない基本エンベロープ(
BasicEnvelopeBuilder)や高度な周波数変調エンベロープ(WaveformEnvelopeBuilder)など、動的な連続波形。 - 既存の VibrationEffect: プリミティブな構成(
VibrationEffect.Composition)、ステップ波形(VibrationEffect.createWaveform)、事前定義された効果(VibrationEffect.createPredefined)、ワンショット(VibrationEffect.createOneShot)をaddEvents()またはコンストラクタのコピーを使用してインポートします。 - 繰り返しシーケンス:
setRepeatingEffect()で構成された連続パターン。
VibrationEffect.Builder で構築されたバイブレーションには、要素(プリセット、基本エンベロープ、バイブレーション効果の組み合わせなど)全体にわたるフレームワーク レベルの自動フォールバックが組み込まれており、手動で機能チェックを行うことなく、さまざまなデバイスで一貫したユーザー エクスペリエンスを確保できます。(WaveformEnvelopeBuilder で作成された高度な波形エンベロープにはハードウェア サポートが必要で、自動フォールバック サポートはありません)。
プリセットを使用して作成する
VibrationEffect.Preset を使用して、一般的な事前定義済みの短い触覚パルス(PRESET_CLICK、PRESET_TICK、PRESET_LOW_TICK など)をコンポジションに追加します。プリセットは、デバイス メーカーが実装し、クリア ハプティクスのハプティクス原則に沿った、鮮明で短く心地よいバイブレーションを提供します。これらの機能とその仕組みの詳細については、バイブレーション アクチュエータの入門をご覧ください。
プリセットは、VibrationEffect.Composition API の短いプリミティブに代わるものです。ランプアップやランプダウンなど、長い時間または連続した触覚フィードバックの場合は、代わりにエンベロープ波形(PWLE)を使用します。
各プリセットには、0.0f~1.0f の範囲でオプションのスケールを割り当てることができ、コンポジションの開始からの明示的な開始時間(ミリ秒単位)に配置されます。
Kotlin
val clickPreset = VibrationEffect.Preset.create(
VibrationEffect.Preset.PRESET_CLICK, /* scale= */ 0.8f
)
val tickPreset = VibrationEffect.Preset.create(
VibrationEffect.Preset.PRESET_TICK, /* scale= */ 0.5f
)
val effect = VibrationEffect.Builder()
.addPreset(/* startTimeMillis= */ 0L, clickPreset)
.addPreset(/* startTimeMillis= */ 100L, tickPreset)
.build()
vibrator.vibrate(effect)
Java
VibrationEffect.Preset clickPreset = VibrationEffect.Preset.create(
VibrationEffect.Preset.PRESET_CLICK, /* scale= */ 0.8f
);
VibrationEffect.Preset tickPreset = VibrationEffect.Preset.create(
VibrationEffect.Preset.PRESET_TICK, /* scale= */ 0.5f
);
VibrationEffect effect = new VibrationEffect.Builder()
.addPreset(/* startTimeMillis= */ 0L, clickPreset)
.addPreset(/* startTimeMillis= */ 100L, tickPreset)
.build();
vibrator.vibrate(effect);
エンベロープとプリセットで作曲する
VibrationEffect.Envelope インスタンス(BasicEnvelopeBuilder または WaveformEnvelopeBuilder を使用して作成)をプリセットとシームレスに組み合わせて、リッチなマルチセグメント ハプティクス パターンを作成できます。
スムーズなランプアップとフェードアウトのエンベロープを再生し、その後にシャープなクリック プリセットを再生する例を次に示します。
Kotlin
val basicEnvelope = VibrationEffect.Envelope.create(
VibrationEffect.BasicEnvelopeBuilder()
.setInitialSharpness(0.0f)
.addControlPoint(1.0f, 1.0f, 300L)
.addControlPoint(0.0f, 0.5f, 100L)
)
val clickPreset = VibrationEffect.Preset.create(
VibrationEffect.Preset.PRESET_CLICK, 0.9f
)
val mixedEffect = VibrationEffect.Builder()
.addEnvelope(/* startTimeMillis= */ 0L, basicEnvelope)
.addPreset(/* startTimeMillis= */ 450L, clickPreset)
.build()
vibrator.vibrate(mixedEffect)
Java
VibrationEffect.Envelope basicEnvelope = VibrationEffect.Envelope.create(
new VibrationEffect.BasicEnvelopeBuilder()
.setInitialSharpness(0.0f)
.addControlPoint(1.0f, 1.0f, 300L)
.addControlPoint(0.0f, 0.5f, 100L)
);
VibrationEffect.Preset clickPreset = VibrationEffect.Preset.create(
VibrationEffect.Preset.PRESET_CLICK, 0.9f
);
VibrationEffect mixedEffect = new VibrationEffect.Builder()
.addEnvelope(/* startTimeMillis= */ 0L, basicEnvelope)
.addPreset(/* startTimeMillis= */ 450L, clickPreset)
.build();
vibrator.vibrate(mixedEffect);
既存のイベントを再利用してシフトする
既存の VibrationEffect(VibrationEffect.Composition を含む)を再利用または連結するには、getEvents() を使用して VibrationEffect.Event オブジェクトのリストを取得し、addEvents(startTimeShiftMillis, events) を使用してオフセットで追加します(または、効果を VibrationEffect.Builder(effect) コンストラクタに直接渡します)。このように VibrationEffect.Composition インスタンスをインポートすると、フレームワークは自動的にプリミティブをプリセットに変換し、ランタイム フォールバックのサポートを有効にします。
Kotlin
val existingEffect = VibrationEffect.Builder()
.addPreset(
0L,
VibrationEffect.Preset.create(VibrationEffect.Preset.PRESET_CLICK)
)
.addPreset(
80L,
VibrationEffect.Preset.create(VibrationEffect.Preset.PRESET_TICK)
)
.build()
// Shift and append the existing events 200ms into the new composition.
val combinedEffect = VibrationEffect.Builder()
.addEvents(/* startTimeShiftMillis= */ 200L, existingEffect.events)
.build()
vibrator.vibrate(combinedEffect)
Java
VibrationEffect existingEffect = new VibrationEffect.Builder()
.addPreset(
0L,
VibrationEffect.Preset.create(VibrationEffect.Preset.PRESET_CLICK)
)
.addPreset(
80L,
VibrationEffect.Preset.create(VibrationEffect.Preset.PRESET_TICK)
)
.build();
// Shift and append the existing events 200ms into the new composition.
VibrationEffect combinedEffect = new VibrationEffect.Builder()
.addEvents(/* startTimeShiftMillis= */ 200L, existingEffect.getEvents())
.build();
vibrator.vibrate(combinedEffect);
繰り返しコンポジションを作成する
setRepeatingEffect(startTimeMillis, repeatingEffect, durationMillis) を使用して、コンポジションに繰り返しパターンを追加します。
Kotlin
val repeatingPattern = VibrationEffect.Builder()
.addPreset(
0L,
VibrationEffect.Preset.create(
VibrationEffect.Preset.PRESET_CLICK, 1.0f
)
)
.addPreset(
150L,
VibrationEffect.Preset.create(
VibrationEffect.Preset.PRESET_LOW_TICK, 0.6f
)
)
.build()
val repeatingEffect = VibrationEffect.Builder()
.setRepeatingEffect(
/* startTimeMillis= */ 0L,
/* effect= */ repeatingPattern,
/* durationMillis= */ 300L
)
.build()
vibrator.vibrate(repeatingEffect)
Java
VibrationEffect repeatingPattern = new VibrationEffect.Builder()
.addPreset(
0L,
VibrationEffect.Preset.create(
VibrationEffect.Preset.PRESET_CLICK, 1.0f
)
)
.addPreset(
150L,
VibrationEffect.Preset.create(
VibrationEffect.Preset.PRESET_LOW_TICK, 0.6f
)
)
.build();
VibrationEffect repeatingEffect = new VibrationEffect.Builder()
.setRepeatingEffect(
/* startTimeMillis= */ 0L,
/* effect= */ repeatingPattern,
/* durationMillis= */ 300L
)
.build();
vibrator.vibrate(repeatingEffect);
タイミング、検証、ドリフト管理
VibrationEffect.Builder を使用してコンポジションを設計する場合は、次のタイミングと検証ルールに留意してください。
- 開始時刻の厳密な増加: ビルダーに追加されるすべての要素は、前の要素の開始時刻よりも厳密に大きいか等しい
startTimeMillisを持つ必要があります。 - ビルド時の検証: ビルダーは、既知の要素の継続時間(プリセットの場合は 1 ミリ秒の最小値)を使用して、
build()時にベスト エフォート型の検証を行います。不可能な重複が検出されると、IllegalArgumentExceptionがスローされます。 - 再生のシーケンシャル シフト: 次の要素の開始時刻に前のバイブレーション要素がまだ物理的に再生されている場合、フレームワークは次の要素を最も早い利用可能なタイムスロットに自動的にシフトします。これにより、イベントが重複したり、バイブレーションがドロップしたりすることはありませんが、イベントが近すぎる間隔でスケジュールされている場合は、わずかなタイミングのずれが生じる可能性があります。ドリフトを最小限に抑えるため、連続する触覚イベントの間に十分な時間(50 ミリ秒以上など)を確保します。
バイブレーション プリミティブのコンポジションを作成する
このセクションでは、VibrationEffect.Composition を使用してバイブレーションを構成する方法について説明します。このページの冒頭で説明した合成のプリミティブは、デバイス メーカーによって実装されます。クリア ハプティクスのハプティクス原則に沿った、短く心地よい振動を提供します。これらの機能とその仕組みについて詳しくは、バイブレーション アクチュエータの入門をご覧ください。
VibrationEffect.Builder とは異なり、VibrationEffect.Composition API にはサポートされていないプリミティブの自動フォールバックはありません。次のようになります。
高度なハプティクスを有効にする前に、使用しているすべてのプリミティブが特定のデバイスでサポートされていることを確認してください。
プリミティブが欠落しているエフェクトだけでなく、サポートされていない一貫したエクスペリエンスのセットを無効にします。
- Kotlin:
val fallbackEffect = VibrationEffect.Builder(compositionEffect).build()(または.addEvents(0L, compositionEffect.events)) - Java:
VibrationEffect fallbackEffect =new VibrationEffect.Builder(compositionEffect).build();(または.addEvents(0L, compositionEffect.getEvents()))
VibrationEffect.Builder で構築すると、フレームワークはコンポジション プリミティブをプリセットに変換し、ユーザーのデバイスでサポートされていないプリミティブがある場合は、ランタイム フォールバックを自動的に提供します。
合成されたバイブレーション効果
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 のみです。これらのプリミティブを組み合わせることで、強度が徐々に増して消えていく波形セグメントを作成できます。スケーリングされたプリミティブを配置することで、振幅の急激な変化を防ぐことができます。これは、全体的なエフェクトの持続時間を延長するのにも有効です。人間は、上昇部分よりも下降部分を認識しやすいため、上昇部分を下降部分よりも短くすることで、下降部分に重点を置くことができます。
円の拡大と縮小にこのコンポジションを適用した例を次に示します。ライズ効果は、アニメーション中の拡大感を高めることができます。上昇効果と下降効果を組み合わせることで、アニメーションの最後に折りたたまれることを強調できます。
図 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 を使用してエンベロープ効果を定義できます。このオブジェクトを使用すると、VibratorFrequencyProfile を介して周波数と出力加速度のマッピング(FOAM)にアクセスできます。
- デバイスの FOAM によって決定される、特定の周波数で達成可能な振動の強さを表す、 \( [0, 1] \)の範囲の振幅値。たとえば、値が \( 0.5 \) の場合、指定された周波数で達成可能な最大出力加速度の半分が生成されます。
ヘルツ単位で指定された周波数の値。
期間値。最後の制御点から新しい制御点に移行するのにかかる時間をミリ秒単位で表します。
次のコードは、400 ミリ秒のバイブレーション効果を定義する波形の例を示しています。まず、50 ミリ秒の振幅ランプで、オフからフルまで一定の 60 Hz で上昇します。次に、周波数が次の 100 ミリ秒で 120 Hz まで上昇し、そのレベルで 200 ミリ秒間維持されます。最後に、振幅が \( 0 \)まで下降し、周波数が最後の 50 ミリ秒で 60 Hz に戻ります。
vibrator.vibrate(VibrationEffect.WaveformEnvelopeBuilder()
.addControlPoint(1.0f, 60f, 50)
.addControlPoint(1.0f, 120f, 100)
.addControlPoint(1.0f, 120f, 200)
.addControlPoint(0.0f, 60f, 50)
.build()
)
以降のセクションでは、エンベロープを含むバイブレーション波形の例をいくつか示します。
跳ねるスプリング
前のサンプルでは、PRIMITIVE_THUD を使用して物理的なバウンス インタラクションをシミュレートしています。基本的なエンベロープ API は、振動の強さとシャープさを正確に調整できる、より細かい制御を提供します。これにより、アニメーション イベントをより正確に追跡する触覚フィードバックが得られます。
画面の下部にバネが跳ね返るたびに、基本的なエンベロープ効果でアニメーションが強化される自由落下のバネの例を次に示します。
図 5. 跳ねるバネをシミュレートする振動の出力加速度波形グラフ。
@Composable
fun BouncingSpringAnimation() {
var springX by remember { mutableStateOf(SPRING_WIDTH) }
var springY by remember { mutableStateOf(SPRING_HEIGHT) }
var velocityX by remember { mutableFloatStateOf(INITIAL_VELOCITY) }
var velocityY by remember { mutableFloatStateOf(INITIAL_VELOCITY) }
var sharpness by remember { mutableFloatStateOf(INITIAL_SHARPNESS) }
var intensity by remember { mutableFloatStateOf(INITIAL_INTENSITY) }
var multiplier by remember { mutableFloatStateOf(INITIAL_MULTIPLIER) }
var bottomBounceCount by remember { mutableIntStateOf(0) }
var animationStartTime by remember { mutableLongStateOf(0L) }
var isAnimating by remember { mutableStateOf(false) }
val (screenHeight, screenWidth) = getScreenDimensions(context)
LaunchedEffect(isAnimating) {
animationStartTime = System.currentTimeMillis()
isAnimating = true
while (isAnimating) {
velocityY += GRAVITY
springX += velocityX.dp
springY += velocityY.dp
// Handle bottom collision
if (springY > screenHeight - FLOOR_HEIGHT - SPRING_HEIGHT / 2) {
// Set the spring's y-position to the bottom bounce point, to keep it
// above the floor.
springY = screenHeight - FLOOR_HEIGHT - SPRING_HEIGHT / 2
// Reverse the vertical velocity and apply damping to simulate a bounce.
velocityY *= -BOUNCE_DAMPING
bottomBounceCount++
// Calculate the fade-out duration of the vibration based on the
// vertical velocity.
val fadeOutDuration =
((abs(velocityY) / GRAVITY) * FRAME_DELAY_MS).toLong()
// Create a "boing" envelope vibration effect that fades out.
vibrator.vibrate(
VibrationEffect.BasicEnvelopeBuilder()
// Starting from zero sharpness here, will simulate a smoother
// "boing" effect.
.setInitialSharpness(0f)
// Add a control point to reach the target intensity and
// sharpness very quickly.
.addControlPoint(intensity, sharpness, 20L)
// Add a control point to fade out the vibration intensity while
// maintaining sharpness.
.addControlPoint(0f, sharpness, fadeOutDuration)
.build()
)
// Decrease the intensity and sharpness of the vibration for subsequent
// bounces, and reduce the multiplier to create a fading effect.
intensity *= multiplier
sharpness *= multiplier
multiplier -= 0.1f
}
if (springX > screenWidth - SPRING_WIDTH / 2) {
// Prevent the spring from moving beyond the right edge of the screen.
springX = screenWidth - SPRING_WIDTH / 2
}
// Check for 3 bottom bounces and then slow down.
if (bottomBounceCount >= MAX_BOTTOM_BOUNCE &&
System.currentTimeMillis() - animationStartTime > 1000) {
velocityX *= 0.9f
velocityY *= 0.9f
}
delay(FRAME_DELAY_MS) // Control animation speed.
// Determine if the animation should continue based on the spring's
// position and velocity.
isAnimating = (springY < screenHeight + SPRING_HEIGHT ||
springX < screenWidth + SPRING_WIDTH)
&& (velocityX >= 0.1f || velocityY >= 0.1f)
}
}
Box(
modifier = Modifier
.fillMaxSize()
.noRippleClickable {
if (!isAnimating) {
resetAnimation()
}
}
.width(screenWidth)
.height(screenHeight)
) {
DrawSpring(mutableStateOf(springX), mutableStateOf(springY))
DrawFloor()
if (!isAnimating) {
DrawText("Tap to restart")
}
}
}
ロケットの打ち上げ
前のサンプルでは、基本的なエンベロープ API を使用して、弾むバネの反応をシミュレートする方法を示しました。WaveformEnvelopeBuilder は、デバイスの全周波数範囲を正確に制御し、高度にカスタマイズされたハプティクス効果を実現します。これを FOAM データと組み合わせることで、特定の周波数機能に合わせて振動を調整できます。
動的振動パターンを使用したロケット発射シミュレーションの例を次に示します。効果は、サポートされている最小周波数加速度出力の 0.1 G から共振周波数まで変化し、常に 10% の振幅入力を維持します。これにより、駆動振幅が同じでも、効果が適度に強い出力で始まり、知覚される強度とシャープネスが増加します。共振に達すると、エフェクト周波数は最小値まで戻り、強度とシャープネスが低下したように感じられます。これにより、宇宙への打ち上げを模倣した、最初の抵抗の後に解放される感覚が生まれます。
この効果は、共振周波数と出力加速度曲線に関するデバイス固有の情報を抽象化する基本的なエンベロープ API では実現できません。シャープネスを上げると、等価周波数が共振を超え、意図しない加速度の低下を引き起こす可能性があります。
図 6. ロケットの打ち上げをシミュレートする振動の出力加速度波形グラフ。
@Composable
fun RocketLaunchAnimation() {
val context = LocalContext.current
val screenHeight = remember { mutableFloatStateOf(0f) }
var rocketPositionY by remember { mutableFloatStateOf(0f) }
var isLaunched by remember { mutableStateOf(false) }
val animation = remember { Animatable(0f) }
val animationDuration = 3000
LaunchedEffect(isLaunched) {
if (isLaunched) {
animation.animateTo(
1.2f, // Overshoot so that the rocket goes off the screen.
animationSpec = tween(
durationMillis = animationDuration,
// Applies an easing curve with a slow start and rapid acceleration
// towards the end.
easing = CubicBezierEasing(1f, 0f, 0.75f, 1f)
)
) {
rocketPositionY = screenHeight.floatValue * value
}
animation.snapTo(0f)
rocketPositionY = 0f;
isLaunched = false;
}
}
Box(
modifier = Modifier
.fillMaxSize()
.noRippleClickable {
if (!isLaunched) {
// Play vibration with same duration as the animation, using 70% of
// the time for the rise of the vibration, to match the easing curve
// defined previously.
playVibration(vibrator, animationDuration, 0.7f)
isLaunched = true
}
}
.background(Color(context.getColor(R.color.background)))
.onSizeChanged { screenHeight.floatValue = it.height.toFloat() }
) {
drawRocket(rocketPositionY)
}
}
private fun playVibration(
vibrator: Vibrator,
totalDurationMs: Long,
riseBias: Float,
minOutputAccelerationGs: Float = 0.1f,
) {
require(riseBias in 0f..1f) { "Rise bias must be between 0 and 1." }
if (!vibrator.areEnvelopeEffectsSupported()) {
return
}
val resonantFrequency = vibrator.resonantFrequency
if (resonantFrequency.isNaN()) {
// Device doesn't have or expose a resonant frequency.
return
}
val startFrequency = vibrator.frequencyProfile?.getFrequencyRange(minOutputAccelerationGs)?.lower ?: return
if (startFrequency >= resonantFrequency) {
// Vibrator can't generate the minimum required output at lower frequencies.
return
}
val minDurationMs = vibrator.envelopeEffectInfo.minControlPointDurationMillis
val rampUpDurationMs = (riseBias * totalDurationMs).toLong() - minDurationMs
val rampDownDurationMs = totalDurationMs - rampUpDuration - minDurationMs
vibrator.vibrate(
VibrationEffect.WaveformEnvelopeBuilder()
// Quickly reach the target output at the start frequency
.addControlPoint(0.1f, startFrequency, minDurationMs)
.addControlPoint(0.1f, resonantFrequency, rampUpDurationMs)
.addControlPoint(0.1f, startFrequency, rampDownDurationMs)
// Controlled ramp down to zero to avoid ringing after the vibration.
.addControlPoint(0.0f, startFrequency, minDurationMs)
.build()
)
}
LavaBeats
ロケット発射の例のように、WaveformEnvelopeBuilder API は、バイブレーションの振幅と周波数のセグメントを制御できるようにすることで、多くの複雑な触覚効果を設計できるようにします。このような設計の別の例として、「活気」などのより抽象的な身体感覚のエミュレーションがあります。
これは、特定の振幅と周波数の振動セグメントで典型的な心電図(ECG)信号のバイオマーカーを表すことで実現できます。LavaBeats は、心電図記録の 2 つの特徴的なセグメントが、時間遅延で区切られた 2 つのパルスとして表される例です。最初の特徴的なパルスは QRS 複合波です。これは、振幅が大きく持続時間が短い鋭いピークとして表示されます。2 つ目のパルスは T 波で、振幅が小さく、持続時間が長く、形状が滑らかです(図 7 を参照)。
WaveformEnvelopeBuilder を使用して、固定された 1 つ目のパルスから 2 つ目のパルスの遅延で区切られた、これら 2 つのパルスのさまざまな繰り返しを構築します。最初のパルスは、短時間で低周波数から高周波数までスイープするチャープ信号にできます。2 つ目のパルスは、低周波の正弦波の 1 つの周期として表すことができます。2 つのパルスを 1 つのビートに合成し、一般的な 1 分あたりのビート数(bpm)に従って、遅延を挟みながら合成を数回繰り返すことができます。結果として、心臓の鼓動に似た触覚効果が得られます。
GitHub のハプティクス サンプルアプリで LavaBeats を試すと、ハプティクス効果と同じリズムで点滅する溶岩ランプの視覚効果とともに、その効果を体感できます。また、2 つのパルスの振幅、周波数、持続時間、遅延を変更して、さまざまなビート感を生み出すように効果の設定を変更することもできます。
図 7. QRS 複合波と T 波を含む心電図記録のセグメント
@RequiresApi(Build.VERSION_CODES.BAKLAVA)
private fun createEnvelopeEffect(
beatParameters: List<BeatParameter>
):VibrationEffect =
VibrationEffect.WaveformEnvelopeBuilder()
.apply {
repeat(beatParameters.getNumBeats()) {
// First pulse chirp
addControlPoint(
beatParameters.getFirstPulseAmplitude(),
beatParameters.getFirstPulseStartFreq(),
ENVELOPE_RAMP_DURATION_MILLIS,
)
addControlPoint(
beatParameters.getFirstPulseAmplitude(),
beatParameters.getFirstPulseEndFreq(),
beatParameters.getFirstPulseDurationMillis().toLong(),
)
addControlPoint(
0f,
beatParameters.getFirstPulseEndFreq(),
ENVELOPE_RAMP_DURATION_MILLIS,
)
// Delay between first and second pulse
addControlPoint(
0f,
beatParameters.getFirstPulseEndFreq(),
beatParameters.getFirstToSecondPulseDelayMillis().toLong(),
)
// Second pulse
addControlPoint(
beatParameters.getSecondPulseAmplitude(),
beatParameters.getSecondPulseFreq(),
ENVELOPE_RAMP_DURATION_MILLIS,
)
addControlPoint(
beatParameters.getSecondPulseAmplitude(),
beatParameters.getSecondPulseFreq(),
(1_000 / (2f * beatParameters.getSecondPulseFreq())).toLong(),
)
addControlPoint(
0f,
beatParameters.getSecondPulseFreq(),
ENVELOPE_RAMP_DURATION_MILLIS,
)
addControlPoint(
0f,
beatParameters.getSecondPulseFreq(),
beatParameters.getBeatDelayMillis().toLong(),
)
}
}
.build()
/** A parameter of a haptic beat effect that represents an ECG signal parameter */
@Stable
data class BeatParameter(
val description: String = "",
val value: Float = 0f,
val range: ClosedFloatingPointRange<Float> = 0f..1f,
val steps: Int = 0,
val isFrequencyType: Boolean = false,
)