创建自定义触感反馈效果

本页将举例说明如何使用不同触感反馈 API 在 Android 应用中创建自定义效果。由于本页上的大部分信息需要充分了解振动致动器的工作原理,因此我们建议您阅读振动致动器入门

本页包含以下示例。

如需查看其他示例,请参阅为事件添加触感反馈,并始终遵循触感反馈设计原则

使用回退机制来处理设备兼容性

实现任何自定义效果时,请考虑以下事项:

  • 实现该效果所需的设备功能
  • 设备无法播放该效果时该怎么做

Android 触感反馈 API 参考文档详细介绍了如何检查是否支持触感反馈中涉及的组件,以便您的应用提供一致的整体体验。

根据您的用例,您可能需要停用自定义效果或根据不同的潜在功能提供备用自定义效果。

针对以下高级设备功能类别进行规划:

  • 如果您使用的是触感反馈基元:设备支持自定义效果所需的基元。(有关基元的详细信息,请参阅下一部分。)

  • 具有振幅控制的设备。

  • 支持基本振动(开启/关闭)的设备,即不支持振幅控制的设备。

如果您的应用的触感反馈效果选择考虑到这些类别,则其触感反馈用户体验应该对于任何单个设备而言都是可预测的。

触感反馈基元的使用

Android 包含几个在振幅和频率上各不相同的触感反馈基元。您可以单独使用一个基元,也可以结合使用多个基元来实现丰富触感反馈效果。

  • 对于两个基元之间的可察觉间隙,使用 50 毫秒或更长的延迟时间,如果可能,还要考虑基元时长
  • 使用比例为 1.4 或更高的缩放比例,以便更好地感知强度差异。
  • 使用缩放比例 0.5、0.7 和 1.0 可创建基元的低强度、中等强度和高强度版本。

创建自定义振动模式

振动模式通常用于吸引注意力的触感反馈,例如通知和铃声。Vibrator 服务可以播放长时间的振动模式,这些模式会随时间改变振动振幅。此类效果被命名为波形。

波形效果很容易感知,但如果在安静的环境中播放,突发的长时间振动可能会使用户感到惊悚。猛增到目标振幅过快也可能产生能听到的蜂鸣噪声。设计波形模式的建议是,平滑振幅转换以创建梯度上升和下降效果。

示例:公开范围渐增模式

波形表示为 VibrationEffect,其中包含以下三个参数:

  1. Timings:每个波形段的时长数组(以毫秒为单位)。
  2. 振幅:第一个参数中指定的每个持续时间所需的振动振幅,用 0 到 255 之间的整数值表示,其中 0 表示振动器“关闭”,255 表示设备的最大振幅。
  3. Repeat index:第一个参数中指定的数组中用于开始重复波形的索引;如果应该仅播放一次该模式,则为 -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);

示例:反抗(以较短的刻度表示)

您可以控制原始振动的振幅,以便将有用的反馈传达给正在进行的操作。可以使用间隔较小的缩放值来创建基元的平滑渐强效果。连续基元之间的延迟时间也可以根据用户互动动态设置。下面的示例展示了由拖动手势控制并使用触感反馈增强的视图动画。

将圆形向下拖动的动画
输入振动波形图

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。这些基元可以更好地协同工作,以创建强度增加然后消失的波形片段。您可以对齐缩放的基元,以防止它们之间的振幅突然跳跃,这对于延长整体效果持续时间也很有效。从观感上看,人们往往会更多地关注上升部分,而不是下降部分,因此利用上升部分比下降部分短,可以将重点转向下降部分。

以下示例展示了此组合用于展开和收起圆形的情况。上升效果可以增强动画过程中的展开感觉。结合上升和下降效果有助于强调动画结束时的合拢。

扩大圆圈的动画
输入振动波形图

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