本頁面將介紹如何使用不同的觸覺 API 建立自訂效果,讓 Android 應用程式不只提供標準的震動波形。
本頁面包含以下範例:
如需其他範例,請參閱「為事件新增觸覺回饋」,並隨時遵循觸覺回饋設計原則。
使用備用方案處理裝置相容性
實作任何自訂效果時,請考量以下事項:
- 特效所需的裝置功能
- 裝置無法播放效果時該怎麼辦
Android haptics API 參考資料詳細說明如何檢查 haptics 相關元件的支援情形,讓應用程式能夠提供一致的整體體驗。
視用途而定,您可能需要停用自訂特效,或根據不同的潛在功能提供其他自訂特效。
請為下列裝置功能的高階類別進行規劃:
如果您使用觸覺原始功能:裝置支援自訂效果所需的原始功能。(詳情請參閱下一節的「基本元素」)。
支援振幅控制的裝置。
支援基本震動功能 (開啟/關閉) 的裝置,也就是缺乏振幅控制功能的裝置。
如果應用程式的觸覺回饋效果選項適用於這些類別,則其觸覺回饋使用者體驗應可在任何個別裝置上維持一致。
使用觸覺回饋原語
Android 提供多種觸覺回饋原始元素,其振幅和頻率各有不同。您可以單獨使用一個原始物件,也可以結合多個原始物件,以獲得豐富的觸覺效果。
- 請使用 50 毫秒或更長的延遲時間,以便在兩個圖元之間產生可辨識的間隔,並盡可能考量圖元持續時間。
- 請使用差異比率為 1.4 以上比例的刻度,以便更清楚看出強度差異。
使用 0.5、0.7 和 1.0 的比例,建立基本元素的低、中和高強度版本。
建立自訂震動模式
震動模式通常用於注意力觸覺回饋,例如通知和鈴聲。Vibrator
服務可播放長時間的震動模式,隨時間變化震動幅度。這類效果稱為波形。
波形效果通常是可感知的,但如果在安靜的環境中播放,突然出現的長時間震動可能會嚇到使用者。過快地提升至目標振幅也可能會產生可聽見的嗡嗡聲。設計波形圖案,以便平滑處理振幅轉換,創造漸增和漸減效果。
震動模式範例
以下各節將提供幾種震動模式範例:
增加曝光量模式
波形會以 VibrationEffect
表示,並包含三個參數:
- Timings:每個波形區段的時間長度陣列,以毫秒為單位。
- 振幅:第一個引數中指定的每個時間長度所需的振動振幅,以 0 到 255 的整數值表示,其中 0 代表振動器「關閉狀態」,255 則是裝置的最大振幅。
- 重複索引:第一個引數中指定陣列的索引,用於開始重複波形圖,如果只播放一次圖案,則為 -1。
以下是脈衝兩次的波形示例,脈衝之間的間隔為 350 毫秒。第一個脈衝是平滑地逐漸增加至最大振幅,第二個脈衝則是快速逐漸增加至最大振幅。停止在結尾的行為是由負重複索引值定義。
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 以上的強度,這樣一來,您就能輕易分辨強度差異。請勿嘗試為相同的圖元建立超過三個強度層級,因為這些層級在感知上並無差異。例如,使用 0.5、0.7 和 1.0 的比例,建立基本元素的低、中和高強度版本。
在震動原語之間加入間隔
組合也能指定要新增的連續原始元素間延遲時間。這項延遲時間會以毫秒為單位,自上一個基本元素結束後開始計算。一般來說,兩個原始元素之間的 5 到 10 毫秒差距太短,無法偵測到。如要讓兩個基本圖形之間有明顯的間隔,請使用 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
。兩者都會觸及相同的目標,但時間長度不同。只有一個基本類型可用於降速,即 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
。這個原始元素最適合多次呼叫。將多個旋轉連接起來,可能會產生搖晃和不穩定的效果,您可以對每個基本圖形套用隨機縮放比例,進一步強化效果。您也可以嘗試調整連續旋轉基本元素之間的間距。兩次旋轉之間沒有任何間隔 (0 毫秒),會造成緊密旋轉的感受。將旋轉間隔從 10 毫秒增加至 50 毫秒,可產生較鬆散的旋轉效果,並可用於配合影片或動畫的時間長度。
請勿使用超過 100 毫秒的間隔,因為連續旋轉的效果會變得無法整合,並開始讓人感覺像是個別特效。
以下是彈性形狀的範例,當您將其向下拖曳並放開時,它會彈回。動畫會搭配一組旋轉效果,以不同的強度播放,強度與彈跳位移成正比。

圖 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)
}
}
彈跳 (含 thud)
振動效果的另一個進階用途是模擬物理互動。PRIMITIVE_THUD
可產生強烈的迴盪效果,並搭配視覺效果 (例如影片或動畫中的撞擊聲),提升整體體驗。
以下是球落下動畫的範例,每次球彈出螢幕底部時,都會播放 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] \)的銳利度值,代表震動效果的清晰度。值越低,震動越平滑;值越高,震動感越強烈。
duration 值,代表從最後一個控制點 (即強度和清晰度組合) 轉換至新控制點所需的時間 (以毫秒為單位)。
以下是示例波形,會在 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 毫秒震動效果的示例波形。這項測試會先以 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 提供更精細的控制選項,可讓您精確調整震動強度和銳利度。這可讓觸覺回饋更準確地追蹤動畫事件。
以下是自由落體彈簧的示例,每次彈簧彈到畫面底部時,動畫都會加上基本包絡效果:
@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()
)
}