创建自定义触感反馈效果

本页介绍了一些示例,展示了如何使用不同的触感反馈 API 在 Android 应用中创建除标准振动波形之外的自定义效果。

本页包含以下示例:

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

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

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

  • 该效果需要哪些设备功能
  • 当设备无法播放效果时该怎么办

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 或更高的版本,以便用户能够轻松感知强度差异。请勿尝试为同一基元创建超过 3 个强度级别,因为它们在感知上没有明显区别。例如,使用 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;
            }
            }
        });
    }
}