建立自訂觸覺技術效果

本頁面將說明如何使用不同的觸覺 API,在 Android 應用程式中建立自訂效果。由於本頁面上的大部分資訊都需要對震動致動器的運作方式有充分瞭解,因此建議您參閱震動致動器入門

本頁面包含以下範例。

如需其他範例,請參閱「為事件新增觸覺回饋」,並隨時遵循觸覺回饋設計原則

使用備用方案處理裝置相容性

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

  • 特效所需的裝置功能
  • 裝置無法播放效果時該怎麼辦

Android haptics API 參考資料詳細說明如何檢查 haptics 相關元件的支援情形,讓應用程式能夠提供一致的整體體驗。

視用途而定,您可能需要停用自訂特效,或根據不同的潛在功能提供其他自訂特效。

請為下列裝置功能的高階類別進行規劃:

  • 如果您使用觸覺原始功能:支援自訂效果所需原始功能的裝置。(詳情請參閱下一節的「基本元素」)。

  • 支援振幅控制的裝置。

  • 支援基本震動 (開啟/關閉) 的裝置,也就是缺乏振幅控制的裝置。

如果應用程式的觸覺回饋效果選項適用於這些類別,則其觸覺回饋使用者體驗應可在任何個別裝置上保持一致。

使用觸覺回饋原語

Android 提供多種觸覺回饋原始元素,其振幅和頻率各有不同。您可以單獨使用一個原始物件,也可以結合多個原始物件,以獲得豐富的觸覺效果。

  • 請使用 50 毫秒或更長的延遲時間,以便在兩個基本圖形之間產生可辨識的間隔,並盡可能考量基本圖形的時間長度
  • 請使用差異比率為 1.4 以上比例的刻度,以便更清楚看出強度差異。
  • 使用 0.5、0.7 和 1.0 的比例,建立基本圖形的低、中和高強度版本。

建立自訂震動模式

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

波形效果很容易辨識,但如果在安靜的環境中播放,突然出現的長時間震動可能會嚇到使用者。若要以過快的速度達到目標振幅,也可能會產生可聽見的嗡嗡聲。建議您在設計波形圖案時,將振幅轉換平滑化,以便產生漸增和漸減效果。

範例:增加曝光量模式

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

  1. Timings:每個波形區段的時間長度陣列,以毫秒為單位。
  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 // 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));

範例:重複圖案

您也可以重複播放波形,直到取消為止。如要建立重複的波形,請設定非負的「重複」參數。播放重複的波形時,震動會持續進行,直到服務中明確取消為止:

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);

範例:抗拒 (使用低速計時器)

您可以控制原始震動的振幅,為進行中的動作傳達實用的回饋。您可以使用密集的縮放值,為基本圖形建立平滑的漸強效果。您也可以根據使用者互動,動態設定連續原始圖形之間的延遲時間。如以下範例所示,此為由拖曳手勢控制的檢視畫面動畫,並搭配觸覺回饋。

拖曳圓圈的動畫
輸入震動波形的圖表

圖 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 [-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)
  }
}

範例:彈跳 (含 thud)

振動效果的另一個進階用途是模擬物理互動。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;
          }
        }
      });
  }
}