本頁面將舉例說明如何使用各種觸覺 API 在 Android 應用程式中建立自訂效果。本頁所有資訊都仰賴對振動致動器的良好知識,建議您閱讀震動致動器入門。
本頁會舉例說明。
如需其他範例,請參閱「為事件新增觸覺回饋」一文並一律遵循觸覺技術設計原則。
使用備用方案處理裝置相容性
導入自訂效果時,請考量以下幾點:
- 效果需要哪些裝置功能
- 裝置無法播放效果時的處理方式
Android 觸覺技術 API 參考資料詳細說明如何檢查觸覺技術相關元件的支援,以便應用程式提供一致的整體體驗。
視您的用途而定,您可能會想停用自訂效果,或根據不同的潛在功能提供其他自訂效果。
請規劃下列高階裝置功能類別:
如果您使用的是觸覺基本功能:裝置可支援自訂效果所需的基本功能。(如要進一步瞭解原始物件,請參閱下一節)。
提供音量控制的裝置。
支援「基本」震動功能的裝置 (開啟/關閉),也就是缺少振幅控制項的裝置。
如果應用程式的觸覺效果選擇屬於這些類別,則任何個別裝置都應可預測其觸覺使用者體驗。
使用觸覺基元
Android 包含多種觸覺技術和頻率各異的觸覺「原始」。您可以使用單一原始物件,也可以合併使用多個原始物件,以創造豐富的觸覺效果。
- 使用延遲時間達 50 毫秒或更長時間,確保兩個原始時間之間的可明顯差距,並且盡可能考慮原始時間長度。
- 使用與 1.4 以上的比例差異的縮放比例,即可更清楚看出強度差異。
使用 0.5、0.7 和 1.0 的尺度為原始版本建立低、中和高強度版本。
建立自訂震動模式
震動模式通常用於注意力觸覺,例如通知和鈴聲。Vibrator
服務可播放長時間的震動模式,會隨著時間改變震動幅度。這類效果稱為波形。
波形效果可很容易察覺,但如果在安靜的環境中玩遊戲,長時間震動可能會啟動使用者。提高目標振幅也可能產生音訊嗡嗡聲。設計波形模式時,建議您順暢轉換振幅轉場,以產生增減效果。
範例:增加曝光模式
波形以 VibrationEffect
表示,包含三個參數:
- 時間:每個波形片段的持續時間陣列,以毫秒為單位。
- 放大幅度:第一個引數指定每段時間所需的震動幅度,以 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 // Do not repeat. vibrator.vibrate(VibrationEffect.createWaveform(timings, amplitudes, repeatIndex))
Java
long[] timings = new long[] { 50, 50, 50, 50, 50, 100, 350, 25, 25, 25, 25, 200 }; int[] amplitudes = new int[] { 33, 51, 75, 113, 170, 255, 0, 38, 62, 100, 160, 255 }; int repeatIndex = -1; // Do not repeat. vibrator.vibrate(VibrationEffect.createWaveform(timings, amplitudes, repeatIndex));
範例:重複模式
也可以重複播放揮手,直到取消為止。建立重複波形的方法為設定非負數的「repeat」參數。播放重複的波形時,震動會持續到服務中明確取消。
Kotlin
void startVibrating() { val timings: LongArray = longArrayOf(50, 50, 100, 50, 50) val amplitudes: IntArray = intArrayOf(64, 128, 255, 128, 64) val repeat = 1 // Repeat from the second entry, index = 1. VibrationEffect repeatingEffect = VibrationEffect.createWaveform(timings, amplitudes, repeat) // repeatingEffect can be used in multiple places. vibrator.vibrate(repeatingEffect) } void stopVibrating() { vibrator.cancel() }
Java
void startVibrating() { long[] timings = new long[] { 50, 50, 100, 50, 50 }; int[] amplitudes = new int[] { 64, 128, 255, 128, 64 }; int repeat = 1; // Repeat from the second entry, index = 1. VibrationEffect repeatingEffect = VibrationEffect.createWaveform(timings, amplitudes, repeat); // repeatingEffect can be used in multiple places. vibrator.vibrate(repeatingEffect); } void stopVibrating() { vibrator.cancel(); }
對於需要使用者採取行動才能確認的間歇事件,這項功能非常實用。這類事件的範例包括來電和觸發的鬧鐘。
範例:包含備用的模式
控制震動的振幅是與硬體相依的功能。如果沒有這項功能,在低階裝置上播放波形,會導致振幅陣列中每個正值的最大振動值。如果應用程式需要配合這類裝置,建議您確保的模式不會在該條件下播放時產生震動效果,或是設計更精簡的開啟/關閉模式,做為備用模式。
Kotlin
if (vibrator.hasAmplitudeControl()) { vibrator.vibrate(VibrationEffect.createWaveform(smoothTimings, amplitudes, smoothRepeatIdx)) } else { vibrator.vibrate(VibrationEffect.createWaveform(onOffTimings, onOffRepeatIdx)) }
Java
if (vibrator.hasAmplitudeControl()) { vibrator.vibrate(VibrationEffect.createWaveform(smoothTimings, amplitudes, smoothRepeatIdx)); } else { vibrator.vibrate(VibrationEffect.createWaveform(onOffTimings, onOffRepeatIdx)); }
建立震動組合
本節將說明如何將這些內容組合成更長、更複雜的自訂效果,然後再透過更進階的硬體功能探索豐富的觸覺技術。您可以使用不同振幅和頻率的效果組合,為具有較寬頻頻寬的裝置產生更複雜的觸覺效果。
如本頁所述,建立自訂震動模式的程序說明瞭如何控制震動振幅,進而製造拉平和向下的流暢效果。豐富的觸覺回饋可改善裝置的震動器,讓效果更順暢。這些波形在引發漸強或漸弱效果時特別有效。
本頁前述的組合「原始」是由裝置製造商實作。能根據觸覺技術原則提供清晰、短而亮的震動,適合用於清晰觸覺回饋的原則。如要進一步瞭解這些功能及其運作方式,請參閱震動致動器入門。
如果組合使用不支援的原始物件,則 Android 不會提供備用選項。建議您採取下列步驟:
啟用進階觸覺回饋之前,請先檢查特定裝置是否支援您要使用的所有基元。
停用一系列不支援的一致體驗,而不只是缺少基本的效果。請參閱下文,進一步瞭解如何查看裝置支援。
你可以使用 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);
樣本:反射 (低刻度)
您可以控制原始震動的強度,以針對執行中的動作傳達實用的回饋。適度的縮放值可用來建立原始的流暢漸變效果。您也可以根據使用者互動,動態設定連續基元之間的延遲時間。以下範例顯示由拖曳手勢控制的檢視畫面動畫,並透過觸覺技術擴增。
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
這個原始數量可降低。這些基本功能可更有效率地一起建立增強強度的波形線段,之後再逐漸消失。您可以對齊縮放過的基元,以避免彼此的振幅突然跳出,這也很適合用來延長整體效果持續時間。可想而知,人們總是注意到竄升部分大於掉落的部分,因此,若是將竄升部分比掉落的部分更短,就可用來將強調效果移向掉落的部分。
以下範例顯示此組合用於展開及收合圓形的範例。上升效果可以提升動畫播放期間的展開感受。升起和跌倒效果組合有助於強調動畫結束時的收合效果。
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; } }
示例:Wobble (含旋轉)
讓使用者滿意的其中一項主要觸覺原則。如果想創造令人愉悅的意外震動效果,使用 PRIMITIVE_SPIN
是很有趣的方法。如果呼叫多次,這個原始物件最有效。多個連續旋轉可能會產生膨脹與不穩定的效果,並對每個原始檔案套用些微的隨機縮放方式,進一步增強效果。您也可以實驗連續轉動的原始物件之間的差異。兩個旋轉時沒有間隔 (介於 0 毫秒之間),讓轉動的轉動感會更加緊。將跨邊旋轉差距從 10 至 50 毫秒增加,會導致轉動的轉動感變大,並且可以用來搭配影片或動畫的時間長度。
我們不建議使用超過 100 毫秒的間隔,因為後續的轉動不再完美整合,開始感覺就像個人效果。
下面是向下拖曳後鬆開的彈性形狀範例。透過一組旋轉效果強化動畫效果,播放時強度與跳動位移成正比。
Kotlin
@Composable fun WobbleScreen() { // Control variables for the dragging and animating state of the elastic. var dragDistance by remember { mutableStateOf(0f) } var isWobbling by remember { mutableStateOf(false) } // Use drag distance to create an animated float value behaving like a spring. val dragDistanceAnimated by animateFloatAsState( targetValue = if (dragDistance > 0f) dragDistance else 0f, animationSpec = spring( dampingRatio = Spring.DampingRatioHighBouncy, stiffness = Spring.StiffnessMedium ), ) if (isWobbling) { LaunchedEffect(Unit) { while (true) { val displacement = dragDistanceAnimated / MAX_DRAG_DISTANCE // Use some sort of minimum displacement so the final few frames // of animation don't generate a vibration. if (displacement > SPIN_MIN_DISPLACEMENT) { vibrator.vibrate( VibrationEffect.startComposition().addPrimitive( VibrationEffect.Composition.PRIMITIVE_SPIN, nextSpinScale(displacement) ).addPrimitive( VibrationEffect.Composition.PRIMITIVE_SPIN, nextSpinScale(displacement) ).compose() ) } // Delay the next check for a sufficient duration until the current // composition finishes. Note that you can use // Vibrator.getPrimitiveDurations API to calculcate the delay. delay(VIBRATION_DURATION) } } } Box( Modifier .fillMaxSize() .draggable( onDragStopped = { isWobbling = true dragDistance = 0f }, orientation = Orientation.Vertical, state = rememberDraggableState { delta -> isWobbling = false dragDistance += delta } ) ) { // Draw the wobbling shape using the animated spring-like value. WobbleShape(dragDistanceAnimated) } } // Calculate a random scale for each spin to vary the full effect. fun nextSpinScale(displacement: Float): Float { // Generate a random offset in [-0.1,+0.1] to be added to the vibration // scale so the spin effects have slightly different values. val randomOffset: Float = Random.Default.nextFloat() * 0.2f - 0.1f return (displacement + randomOffset).absoluteValue.coerceIn(0f, 1f) }
Java
class AnimationListener implements DynamicAnimation.OnAnimationUpdateListener { private final Random vibrationRandom = new Random(seed); private final long lastVibrationUptime; @Override public void onAnimationUpdate(DynamicAnimation animation, float value, float velocity) { // Delay the next check for a sufficient duration until the current // composition finishes. Note that you can use // Vibrator.getPrimitiveDurations API to calculcate the delay. if (SystemClock.uptimeMillis() - lastVibrationUptime < VIBRATION_DURATION) { return; } float displacement = calculateRelativeDisplacement(value); // Use some sort of minimum displacement so the final few frames // of animation don't generate a vibration. if (displacement < SPIN_MIN_DISPLACEMENT) { return; } lastVibrationUptime = SystemClock.uptimeMillis(); vibrator.vibrate( VibrationEffect.startComposition() .addPrimitive(VibrationEffect.Composition.PRIMITIVE_SPIN, nextSpinScale(displacement)) .addPrimitive(VibrationEffect.Composition.PRIMITIVE_SPIN, nextSpinScale(displacement)) .compose()); } // Calculate a random scale for each spin to vary the full effect. float nextSpinScale(float displacement) { // Generate a random offset in [-0.1,+0.1] to be added to the vibration // scale so the spin effects have slightly different values. float randomOffset = vibrationRandom.nextFloat() * 0.2f - 0.1f return MathUtils.clamp(displacement + randomOffset, 0f, 1f) } }
範例:跳出 (有困難)
另一個進階震動效果是模擬實際互動。PRIMITIVE_THUD
可建立強而有力的逆轉效果,無論是影片或動畫中的影響力視覺化圖表,都能強化整體體驗。
以下範例顯示,每次球從螢幕底部彈跳時,都會播放經過柔和效果提升的簡易球掉落動畫:
Kotlin
enum class BallPosition { Start, End } @Composable fun BounceScreen() { // Control variable for the state of the ball. var ballPosition by remember { mutableStateOf(BallPosition.Start) } var bounceCount by remember { mutableStateOf(0) } // Animation for the bouncing ball. var transitionData = updateTransitionData(ballPosition) val collisionData = updateCollisionData(transitionData) // Ball is about to contact floor, only vibrating once per collision. var hasVibratedForBallContact by remember { mutableStateOf(false) } if (collisionData.collisionWithFloor) { if (!hasVibratedForBallContact) { val vibrationScale = 0.7.pow(bounceCount++).toFloat() vibrator.vibrate( VibrationEffect.startComposition().addPrimitive( VibrationEffect.Composition.PRIMITIVE_THUD, vibrationScale ).compose() ) hasVibratedForBallContact = true } } else { // Reset for next contact with floor. hasVibratedForBallContact = false } Screen() { Box( Modifier .fillMaxSize() .clickable { if (transitionData.isAtStart) { ballPosition = BallPosition.End } else { ballPosition = BallPosition.Start bounceCount = 0 } }, ) { // Build the ball UI based on the current state. BouncingBall(transitionData) } } }
Java
class ClickListener implements View.OnClickListener { @Override public void onClick(View view) { view.animate() .translationY(targetY) .setDuration(3000) .setInterpolator(new BounceInterpolator()) .setUpdateListener(new AnimatorUpdateListener() { boolean hasVibratedForBallContact = false; int bounceCount = 0; @Override public void onAnimationUpdate(ValueAnimator animator) { boolean valueBeyondThreshold = (float) animator.getAnimatedValue() > 0.98; if (valueBeyondThreshold) { if (!hasVibratedForBallContact) { float vibrationScale = (float) Math.pow(0.7, bounceCount++); vibrator.vibrate( VibrationEffect.startComposition() .addPrimitive(VibrationEffect.Composition.PRIMITIVE_THUD, vibrationScale) .compose()); hasVibratedForBallContact = true; } } else { // Reset for next contact with floor. hasVibratedForBallContact = false; } } }); } }