建立自訂觸覺技術效果

本頁提供範例,說明如何在 Android 應用程式中使用不同的觸覺 API,建立標準震動波形以外的自訂效果。

本頁面包含下列範例:

如需其他範例,請參閱「為活動新增觸覺回饋」,並一律遵循觸覺設計原則

使用備援來處理裝置相容性問題

實作任何自訂效果時,請考量下列事項:

  • 特效所需的裝置功能
  • 裝置無法播放效果時的處理方式

Android 觸覺回饋 API 參考資料詳細說明如何檢查觸覺回饋相關元件的支援情形,確保應用程式提供一致的整體體驗。

視用途而定,您可能會想停用自訂效果,或根據不同的潛在功能提供替代的自訂效果。

規劃下列高階裝置功能類別:

  • 如果您使用觸覺基本功能:支援自訂效果所需基本功能的裝置。(如要瞭解基本型別的詳細資料,請參閱下一節)。

  • 支援振幅控制的裝置。

  • 支援基本震動功能 (開啟/關閉) 的裝置,也就是無法控制震動幅度的裝置。

如果應用程式的觸覺效果選擇考量了這些類別,那麼任何裝置的觸覺使用者體驗都應保持可預測性。

使用觸覺原始型別

Android 包含數種觸覺基本單元,這些單元的震幅和頻率各不相同。您可以單獨使用一個基本觸覺效果,也可以組合使用多個基本觸覺效果,打造豐富的觸覺效果。

  • 在兩個圖元之間使用 50 毫秒以上的延遲,以產生可辨識的間隔,並盡可能考量圖元持續時間
  • 使用比例相差 1.4 以上的音階,這樣就能更清楚地感受到強度差異。
  • 使用 0.5、0.7 和 1.0 的比例,建立低、中和高強度版本的圖元。

建立自訂震動模式

震動模式通常用於注意力觸覺回饋,例如通知和鈴聲。Vibrator 服務可以播放長時間的震動模式,並隨時間改變震動幅度。這類效果稱為波形。

波形效果通常可以察覺,但如果在安靜的環境中播放,突然的長震動可能會嚇到使用者。如果目標振幅的升幅過快,也可能會產生可聽見的嗡嗡聲。設計波形模式,平滑振幅轉換,營造升降效果。

震動模式範例

以下各節提供幾種震動模式的範例:

增加曝光量模式

波形會以 VibrationEffect 表示,並有三個參數:

  1. 時間:每個波形區段的時間長度陣列 (以毫秒為單位)。
  2. 震幅:第一個引數中指定各個時間長度的所需震動幅度,以 0 到 255 的整數值表示,其中 0 代表震動器「關閉狀態」,255 則是裝置的最大震幅。
  3. 重複索引:在第一個引數中指定的陣列索引,從該處開始重複波形,如果模式只應播放一次,則為 -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 不會為含有不支援基本體的組合提供備援。因此,請執行下列步驟:

  1. 啟用進階觸覺回饋前,請先確認特定裝置支援您使用的所有基本元素。

  2. 停用不支援的一致體驗組合,而不只是缺少基本體的特效。

如要進一步瞭解如何檢查裝置是否支援,請參閱下列章節。

建立合成震動效果

您可以使用 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_RISEPRIMITIVE_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)
    }
}

彈跳 (發出砰砰聲)

震動效果的另一項進階應用是模擬實體互動。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,可透過定義一系列控制點來建立震動波形封包:

Android 不提供信封效果的回溯功能。如需這項支援服務,請完成下列步驟:

  1. 使用 Vibrator.areEnvelopeEffectsSupported() 檢查特定裝置是否支援信封效果。
  2. 停用不支援的一致體驗組合,或使用自訂震動模式組合做為備用替代方案。

如要建立更多基本封套效果,請使用 BasicEnvelopeBuilder 和下列參數:

  • 範圍 \( [0, 1] \)內的 intensity 值,代表震動的感知強度。舉例來說, \( 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 毫秒震動效果的波形。首先,振幅會以 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()
  )
}