创建自定义触感反馈效果

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

本页包含以下示例:

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

使用后备机制处理设备兼容性问题

实现自定义触感反馈效果时,设备兼容性和回退行为取决于您选择的 API Surface:

  • VibrationEffect.Builder(推荐):从 Android 16 (26Q4) 开始,使用 VibrationEffect.Builder 创建的效果包含自动框架级回退。如果设备本身不支持请求的 Preset 或基本 Envelope,框架会在播放时自动将其转换为适当的替代格式,并尽力确保播放效果。在播放由 VibrationEffect.Builder 组成的效果之前,您无需手动检查每个基元的设备功能。
    • 例外情况:使用 WaveformEnvelopeBuilder 创建的高级波形包络不支持自动回退,因为它们依赖于特定的硬件频率映射 (FOAM)。如果不支持,则无法播放。
  • VibrationEffect.Composition:使用 startComposition() API 创建的合成内容没有自动后备。如果一个组合中包含哪怕一个不受支持的原语,整个振动效果都无法播放。您必须使用 vibrator.arePrimitivesSupported() 手动检查功能。
  • 具有振幅控制功能的波形:在缺少振幅控制功能的设备上,非零振幅会向上舍入到 100%。检查 vibrator.hasAmplitudeControl(),并根据需要回退到明确设计的开/关模式。

触感基元的使用

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

  • 使用 50 毫秒或更长的延迟时间,以便在两个基元之间留出可辨别的间隙,同时尽可能考虑基元时长。
  • 使用比例相差 1.4 倍或以上的比例,以便更好地感知强度差异。
  • 使用 0.5、0.7 和 1.0 的缩放比例,创建低、中、高强度的基元。

创建自定义振动模式

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

波形效果通常是可以感知的,但如果在安静的环境中播放,突发的长时间振动可能会使用户受到惊吓。过快地斜升到目标振幅也可能会产生能听到的蜂鸣噪声。设计波形模式,以平滑振幅转换,从而创建渐强和渐弱效果。

振动模式示例

以下部分提供了几种振动模式示例:

渐增模式

波形表示为 VibrationEffect,具有三个参数:

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

以下是一个示例波形,它会振动两次,两次振动之间有 350 毫秒的停顿。第一个脉冲是平稳上升到最大振幅,第二个脉冲是快速上升到最大振幅并保持。负重复索引值定义了在末尾停止。

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

重复模式

波形也可以重复播放,直到取消。创建重复波形的方法是设置非负 repeat 参数。当您播放重复波形时,振动会持续进行,直到在服务中明确取消:

fun 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.
    val repeatingEffect: VibrationEffect = VibrationEffect.createWaveform(
        timings, amplitudes, repeat
    )
    // repeatingEffect can be used in multiple places.

    vibrator.vibrate(repeatingEffect)
}

fun stopVibrating() {
    vibrator.cancel()
}

这对于需要用户执行操作来确认的间歇性事件非常有用。此类事件的示例包括来电和触发的闹钟。

具有后备功能的模式

控制振动幅度是一项依赖于硬件的功能。在不具备此功能的低端设备上播放波形会导致设备在振幅数组中每个正条目的最大振幅下振动。如果您的应用需要适应此类设备,请使用在这些条件下播放时不会产生振动效果的模式,或者设计一个更简单的开/关模式,以便在无法播放原始模式时作为后备模式播放。

if (vibrator.hasAmplitudeControl()) {
    vibrator.vibrate(
        VibrationEffect.createWaveform(
            smoothTimings, amplitudes, smoothRepeatIdx
        )
    )
} else {
    vibrator.vibrate(
        VibrationEffect.createWaveform(
            onOffTimings, onOffRepeatIdx
        )
    )
}

包含VibrationEffect.Builder的时间轴锚定合成

从 Android 16 (26Q4) 开始,VibrationEffect.Builder 是用于创建复杂振动效果和组合的首选 API。它可让您通过 startTimeMillis 沿绝对时间轴对离散触感元素进行排序,从而构建富有表现力的触感。

VibrationEffect.Builder 支持组合多种振动类型:

使用 VibrationEffect.Builder 构建的振动在其元素(包括预设、基本包络和组合振动效果)中具有内置的框架级自动回退功能,可确保在不同设备上提供一致的用户体验,而无需手动进行功能检查。(使用 WaveformEnvelopeBuilder 创建的高级波形包络需要硬件支持,并且不支持自动回退。)

使用预设进行撰写

使用 VibrationEffect.Preset 向组合中添加常见的预定义短触感脉冲(例如 PRESET_CLICK、PRESET_TICK 或 PRESET_LOW_TICK)。预设由设备制造商实现,可提供清脆、短暂且令人愉悦的振动,符合触感反馈原则,可实现清晰的触感反馈。如需详细了解这些功能及其工作原理,请参阅振动执行器入门。

预设会取代 VibrationEffect.Composition API 中的短基元。 对于较长或持续的触感反馈(例如渐强和渐弱),请改用包络波形 (PWLE)。

每个预设都可以分配一个介于 0.0f 和 1.0f 之间的可选缩放比例,并放置在从合成开始的显式开始时间(以毫秒为单位)。

Kotlin

val clickPreset = VibrationEffect.Preset.create(
    VibrationEffect.Preset.PRESET_CLICK, /* scale= */ 0.8f
)
val tickPreset = VibrationEffect.Preset.create(
    VibrationEffect.Preset.PRESET_TICK, /* scale= */ 0.5f
)

val effect = VibrationEffect.Builder()
    .addPreset(/* startTimeMillis= */ 0L, clickPreset)
    .addPreset(/* startTimeMillis= */ 100L, tickPreset)
    .build()

vibrator.vibrate(effect)

Java

VibrationEffect.Preset clickPreset = VibrationEffect.Preset.create(
    VibrationEffect.Preset.PRESET_CLICK, /* scale= */ 0.8f
);
VibrationEffect.Preset tickPreset = VibrationEffect.Preset.create(
    VibrationEffect.Preset.PRESET_TICK, /* scale= */ 0.5f
);

VibrationEffect effect = new VibrationEffect.Builder()
    .addPreset(/* startTimeMillis= */ 0L, clickPreset)
    .addPreset(/* startTimeMillis= */ 100L, tickPreset)
    .build();

vibrator.vibrate(effect);

使用包络和预设进行创作

您可以将 VibrationEffect.Envelope 实例(使用 BasicEnvelopeBuilder 或 WaveformEnvelopeBuilder 创建)与预设无缝结合,打造丰富的多段触感模式。

以下示例会先播放平滑的渐强和渐弱包络,然后播放尖锐的点击预设:

Kotlin

val basicEnvelope = VibrationEffect.Envelope.create(
    VibrationEffect.BasicEnvelopeBuilder()
        .setInitialSharpness(0.0f)
        .addControlPoint(1.0f, 1.0f, 300L)
        .addControlPoint(0.0f, 0.5f, 100L)
)
val clickPreset = VibrationEffect.Preset.create(
    VibrationEffect.Preset.PRESET_CLICK, 0.9f
)

val mixedEffect = VibrationEffect.Builder()
    .addEnvelope(/* startTimeMillis= */ 0L, basicEnvelope)
    .addPreset(/* startTimeMillis= */ 450L, clickPreset)
    .build()

vibrator.vibrate(mixedEffect)

Java

VibrationEffect.Envelope basicEnvelope = VibrationEffect.Envelope.create(
    new VibrationEffect.BasicEnvelopeBuilder()
        .setInitialSharpness(0.0f)
        .addControlPoint(1.0f, 1.0f, 300L)
        .addControlPoint(0.0f, 0.5f, 100L)
);
VibrationEffect.Preset clickPreset = VibrationEffect.Preset.create(
    VibrationEffect.Preset.PRESET_CLICK, 0.9f
);

VibrationEffect mixedEffect = new VibrationEffect.Builder()
    .addEnvelope(/* startTimeMillis= */ 0L, basicEnvelope)
    .addPreset(/* startTimeMillis= */ 450L, clickPreset)
    .build();

vibrator.vibrate(mixedEffect);

重复使用和移动现有活动

如需重复使用或串联现有 VibrationEffect(包括 VibrationEffect.Composition),请使用 getEvents() 检索其 VibrationEffect.Event 对象列表,并使用 addEvents(startTimeShiftMillis, events) 附加这些对象(或将效果直接传递给 VibrationEffect.Builder(effect) 构造函数)。通过这种方式导入 VibrationEffect.Composition 实例时,框架会自动将其基元转换为预设,从而实现运行时回退支持。

Kotlin

val existingEffect = VibrationEffect.Builder()
    .addPreset(
        0L,
        VibrationEffect.Preset.create(VibrationEffect.Preset.PRESET_CLICK)
    )
    .addPreset(
        80L,
        VibrationEffect.Preset.create(VibrationEffect.Preset.PRESET_TICK)
    )
    .build()

// Shift and append the existing events 200ms into the new composition.
val combinedEffect = VibrationEffect.Builder()
    .addEvents(/* startTimeShiftMillis= */ 200L, existingEffect.events)
    .build()

vibrator.vibrate(combinedEffect)

Java

VibrationEffect existingEffect = new VibrationEffect.Builder()
    .addPreset(
        0L,
        VibrationEffect.Preset.create(VibrationEffect.Preset.PRESET_CLICK)
    )
    .addPreset(
        80L,
        VibrationEffect.Preset.create(VibrationEffect.Preset.PRESET_TICK)
    )
    .build();

// Shift and append the existing events 200ms into the new composition.
VibrationEffect combinedEffect = new VibrationEffect.Builder()
    .addEvents(/* startTimeShiftMillis= */ 200L, existingEffect.getEvents())
    .build();

vibrator.vibrate(combinedEffect);

创建重复性合成

使用 setRepeatingEffect(startTimeMillis, repeatingEffect, durationMillis) 向组合添加重复模式:

Kotlin

val repeatingPattern = VibrationEffect.Builder()
    .addPreset(
        0L,
        VibrationEffect.Preset.create(
            VibrationEffect.Preset.PRESET_CLICK, 1.0f
        )
    )
    .addPreset(
        150L,
        VibrationEffect.Preset.create(
            VibrationEffect.Preset.PRESET_LOW_TICK, 0.6f
        )
    )
    .build()

val repeatingEffect = VibrationEffect.Builder()
    .setRepeatingEffect(
        /* startTimeMillis= */ 0L,
        /* effect= */ repeatingPattern,
        /* durationMillis= */ 300L
    )
    .build()

vibrator.vibrate(repeatingEffect)

Java

VibrationEffect repeatingPattern = new VibrationEffect.Builder()
    .addPreset(
        0L,
        VibrationEffect.Preset.create(
            VibrationEffect.Preset.PRESET_CLICK, 1.0f
        )
    )
    .addPreset(
        150L,
        VibrationEffect.Preset.create(
            VibrationEffect.Preset.PRESET_LOW_TICK, 0.6f
        )
    )
    .build();

VibrationEffect repeatingEffect = new VibrationEffect.Builder()
    .setRepeatingEffect(
        /* startTimeMillis= */ 0L,
        /* effect= */ repeatingPattern,
        /* durationMillis= */ 300L
    )
    .build();

vibrator.vibrate(repeatingEffect);

时间安排、验证和偏差管理

使用 VibrationEffect.Builder 设计合成时,请注意以下时间安排和验证规则:

  • 严格递增的开始时间:添加到构建器的每个元素都必须具有严格大于或等于前一个元素的开始时间的 startTimeMillis。
  • 构建时验证:构建器会在 build() 时间使用已知元素时长(或预设的最短时长 1 毫秒)执行尽力验证。如果检测到不可能的重叠,则会抛出 IllegalArgumentException。
  • 播放顺序移位:如果下一个元素的开始时间到来时,上一个振动元素仍在实际播放,框架会自动将下一个元素移至最早的可用时间段。这样可以确保事件不会重叠,也不会丢失任何振动,但如果事件安排得太近,可能会导致轻微的时间偏差。为尽可能减少漂移,请在连续的触感反馈事件之间留出足够的时间(例如 50 毫秒或更长时间)。

创建振动基元组合

本部分介绍了如何使用 VibrationEffect.Composition 组合振动。本页前面介绍的组合基元由设备制造商实现。它们提供了一种清脆、短暂且令人愉悦的振动,符合清晰触感反馈的触感反馈原则。如需详细了解这些功能及其工作原理,请参阅振动执行器入门。

与 VibrationEffect.Builder 不同,VibrationEffect.Composition API 没有针对不受支持的原语的自动回退。因此:

  1. 在启用高级触感反馈之前,请检查给定设备是否支持您使用的所有基元。

  2. 停用不受支持的一组一致体验,而不仅仅是缺少基元的特效。

  • Kotlin: val fallbackEffect = VibrationEffect.Builder(compositionEffect).build() (或 .addEvents(0L, compositionEffect.events))
  • Java: VibrationEffect fallbackEffect = new VibrationEffect.Builder(compositionEffect).build(); (或 .addEvents(0L, compositionEffect.getEvents()))

使用 VibrationEffect.Builder 构建时,框架会将组合基元转换为预设,并在用户的设备不支持任何基元时自动提供运行时回退。

组合振动效果

您可以使用 VibrationEffect.Composition 创建组合振动效果。以下是一个缓慢上升效果后接一个急促点击效果的示例:

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 毫秒或更长的间隙。以下是具有延迟时间的组合示例:

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

检查支持哪些原语

以下 API 可用于验证设备是否支持特定基元:

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.
}

您还可以检查多个基元,然后根据设备支持级别决定要组合哪些基元:

val primitives: IntArray = intArrayOf(
    VibrationEffect.Composition.PRIMITIVE_LOW_TICK,
    VibrationEffect.Composition.PRIMITIVE_TICK,
    VibrationEffect.Composition.PRIMITIVE_CLICK
)
val supported: BooleanArray = vibrator.arePrimitivesSupported(*primitives)

振动组合示例

以下部分提供了几个振动组合示例,这些示例摘自 GitHub 上的触感反馈示例应用。

抵抗(低刻度)

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

动画:一个圆圈被向下拖动。
输入振动波形图。

图 1. 此波形表示设备上振动的输出加速度。

@Composable
fun ResistScreen() {
    // Control variables for the dragging of the indicator.
    var isDragging by remember { mutableStateOf(false) }
    var dragOffset by remember { mutableFloatStateOf(0f) }

    // Only vibrates while the user is dragging
    LaunchedEffect(isDragging) {
        // Continuously run the effect for vibration to occur even when the view
        // is not being drawn, when user stops dragging midway through gesture.
        while (isDragging) {
            // 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()
            )
        }
    }

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

展开(含升降)

有两个基元可用于提高感知振动强度:PRIMITIVE_QUICK_RISE 和 PRIMITIVE_SLOW_RISE。它们都达到相同的目标,但持续时间不同。只有一个用于降低音量的基元,即 PRIMITIVE_QUICK_FALL。这些基元可以更好地协同工作,从而创建强度不断增加然后逐渐减弱的波形片段。您可以对齐缩放后的基元,以防止它们之间的振幅突然跳跃,这也有助于延长整体效果持续时间。 从感知上来说,人们总是更关注上升部分,而不是下降部分,因此,让上升部分比下降部分短,可用于将重点转移到下降部分。

以下是此组合的应用示例,用于展开和收起圆圈。上升效果可以增强动画期间的扩张感。上升和下降效果的组合有助于强调动画结束时的折叠。

动画:一个不断扩大的圆圈。
输入振动波形图。

图 2.此波形表示设备上振动的输出加速度。

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)

    Column(
        Modifier
            .clickable(
                onClick = {
                    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)
    }
}

摇摆(带旋转)

触感反馈原则的关键之一是让用户感到愉悦。如需引入令人愉悦的意外振动效果,一种有趣的方法是使用 PRIMITIVE_SPIN。此基元在多次调用时最有效。串联多个旋转可以产生摇晃和不稳定的效果,通过对每个图元应用某种随机缩放,可以进一步增强这种效果。您还可以尝试调整连续旋转基元之间的间隔。两次旋转之间没有任何间隙(间隔 0 毫秒)会产生紧密的旋转感。将旋转间隔从 10 毫秒增加到 50 毫秒会产生更松散的旋转感,可用于匹配视频或动画的时长。

请勿使用超过 100 毫秒的间隙,因为连续旋转不再能很好地集成,并且开始感觉像单独的效果。

以下是一个弹性形状示例,该形状在被向下拖动然后释放后会弹回。动画通过一对旋转效果进行增强,播放时强度会根据弹跳位移而变化。

动画:一个弹性形状在弹跳
输入振动波形图

图 3. 此波形表示设备上振动的输出加速度。

@Composable
fun WobbleScreen() {
    // Control variables for the dragging and animating state of the elastic.
    var dragDistance by remember { mutableFloatStateOf(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
        ),
        // The spring ends on its target. An end value of 0f means that the
        // shape is back at rest, so the wobble is over.
        finishedListener = { endValue -> if (endValue == 0f) isWobbling = false },
    )

    LaunchedEffect(isWobbling) {
        while (isWobbling) {
            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 = {
                    // Wobble only if the shape is far enough from rest to vibrate.
                    // It then springs back to 0f, and the finishedListener ends
                    // the wobble.
                    val displacement = dragDistanceAnimated / MAX_DRAG_DISTANCE
                    isWobbling = displacement > SPIN_MIN_DISPLACEMENT
                    dragDistance = 0f
                },
                orientation = Orientation.Vertical,
                state = rememberDraggableState { delta ->
                    isWobbling = false
                    dragDistance += delta
                }
            )
    ) {
        // Draw the wobbling shape using the animated spring-like value. The
        // lambda defers the read to the draw phase, so the animation doesn't
        // recompose this screen on every frame.
        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)
}

弹跳(伴有砰砰声)

振动效果的另一个高级应用是模拟物理互动。PRIMITIVE_THUD可以产生强烈的回响效果,可与视频或动画等中的冲击视觉效果搭配使用,以增强整体体验。

以下示例展示了球掉落动画,其中增强了每次球从屏幕底部弹起时播放的砰砰声效果:

动画:一个球掉落并从屏幕底部弹起。
输入振动波形图。

图 4. 此波形表示设备上振动的输出加速度。

enum class BallPosition {
    Start,
    End
}

@Composable
fun BounceScreen() {
    // Control variable for the state of the ball.
    var ballPosition by remember { mutableStateOf(BallPosition.Start) }
    // Plain holder for the bounce count. It's read only in the effect and in
    // the click handler, never in composition, so it needs no snapshot state.
    val bounceCount = remember { intArrayOf(0) }

    // Animation for the bouncing ball.
    val transitionData = updateTransitionData(ballPosition)
    val collisionData = updateCollisionData(transitionData)

    // Ball is about to contact floor. The effect runs only when the collision
    // state changes, so it vibrates once per collision. Nothing here suspends,
    // so a keyed SideEffect does the job without launching a coroutine.
    SideEffect(collisionData.collisionWithFloor) {
        if (collisionData.collisionWithFloor) {
            val vibrationScale = 0.7.pow(bounceCount[0]++).toFloat()
            vibrator.vibrate(
                VibrationEffect.startComposition().addPrimitive(
                    VibrationEffect.Composition.PRIMITIVE_THUD,
                    vibrationScale
                ).compose()
            )
        }
    }

    Box(
        Modifier
            .fillMaxSize()
            .clickable {
                if (transitionData.isAtStart) {
                    ballPosition = BallPosition.End
                } else {
                    ballPosition = BallPosition.Start
                    bounceCount[0] = 0
                }
            },
    ) {
        // Build the ball UI based on the current state.
        BouncingBall(transitionData)
    }
}

带包络的振动波形

创建自定义振动模式的过程可让您控制振动幅度,从而创建平滑的渐强和渐弱效果。本部分介绍了如何使用波形包络创建动态触感效果,以便精确控制振动幅度和频率随时间的变化。这样,您就可以打造更丰富、更细致的触感体验。

从 Android 16(API 级别 36)开始,系统提供以下 API,以便通过定义一系列控制点来创建振动波形包络:

Android 不提供包络效果的后备。如果您需要此支持,请完成以下步骤:

  1. 使用 Vibrator.areEnvelopeEffectsSupported() 检查给定设备是否支持包络效果。
  2. 停用不受支持的一致体验,或使用自定义振动模式或组合作为后备替代方案。

如需创建更基本的包络效果,请将 BasicEnvelopeBuilder 与以下参数搭配使用:

  • \( [0, 1] \)范围内的强度值,表示振动的感知强度。例如, \( 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 \) 会产生在给定频率下可实现的最大输出加速度的一半。
  • 频率值,以赫兹为单位。

  • 时长值,表示从上一个控制点过渡到新控制点所需的时间(以毫秒为单位)。

以下代码展示了一个示例波形,该波形定义了 400 毫秒的振动效果。它首先在 50 毫秒内以恒定的 60 Hz 频率将振幅从关闭状态提升到最大。然后,在接下来的 100 毫秒内,频率提升到 120 Hz,并保持该水平 200 毫秒。最后,在最后的 50 毫秒内,振幅降低到 \( 0 \),频率恢复到 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() {
    // The spring's height above the floor: 1f is the drop height, 0f is the floor.
    val springHeight = remember { Animatable(1f) }
    var isAnimating by remember { mutableStateOf(true) }

    LaunchedEffect(isAnimating) {
        if (!isAnimating) return@LaunchedEffect
        springHeight.snapTo(1f)

        // Each bounce reaches a fraction of the previous height.
        var bounceHeight = 1f
        while (bounceHeight > MIN_BOUNCE_HEIGHT) {
            // Fall to the floor, speeding up like a falling object. A fall from
            // a greater height takes longer.
            val fallDurationMs = (DROP_DURATION_MS * sqrt(bounceHeight)).toInt()
            springHeight.animateTo(0f, tween(fallDurationMs, easing = EaseInQuad))

            // The spring hits the floor: play a "boing" envelope effect that fades
            // out. A higher bounce gives a stronger and sharper vibration.
            vibrator.vibrate(
                VibrationEffect.BasicEnvelopeBuilder()
                    // Starting from zero sharpness here, will simulate a smoother
                    // "boing" effect.
                    .setInitialSharpness(0f)
                    // Add a control point to reach the target intensity and
                    // sharpness very quickly.
                    .addControlPoint(bounceHeight, bounceHeight * MAX_SHARPNESS, 20L)
                    // Add a control point to fade out the vibration intensity while
                    // maintaining sharpness.
                    .addControlPoint(0f, bounceHeight * MAX_SHARPNESS, fallDurationMs.toLong())
                    .build()
            )

            // Bounce back up to a lower height, slowing down like a thrown object.
            // When the next bounce is too small, the spring stays on the floor.
            bounceHeight *= BOUNCE_DAMPING
            if (bounceHeight > MIN_BOUNCE_HEIGHT) {
                val riseDurationMs = (DROP_DURATION_MS * sqrt(bounceHeight)).toInt()
                springHeight.animateTo(bounceHeight, tween(riseDurationMs, easing = EaseOutQuad))
            }
        }
        isAnimating = false
    }

    Box(
        modifier = Modifier
            .fillMaxSize()
            .clickable(enabled = !isAnimating) { isAnimating = true }
            .drawBehind {
                // Read the height in the draw phase, and scale it to the distance
                // between the top of the box and the floor.
                val floorTop = size.height - FLOOR_HEIGHT.toPx()
                val dropDistance = floorTop - SPRING_HEIGHT.toPx()
                drawSpring(topY = dropDistance * (1f - springHeight.value))
                drawFloor(top = floorTop)
            }
    ) {
        if (!isAnimating) {
            Text("Tap to restart", Modifier.align(Alignment.Center))
        }
    }
}

火箭发射

前面的示例展示了如何使用基本包络 API 模拟弹簧回弹反应。WaveformEnvelopeBuilder 可实现对设备整个频率范围的精确控制,从而实现高度自定义的触感反馈效果。通过将此功能与 FOAM 数据相结合,您可以根据特定的频率功能定制振动。

以下示例演示了使用动态振动模式模拟火箭发射。该效果从支持的最小频率加速度输出 0.1 G 开始,一直到谐振频率,始终保持 10% 的振幅输入。这样,即使驱动幅度相同,效果也会以相当强的输出开始,并增加感知强度和清晰度。达到共振后,效果频率会降回最小值,这会被感知为强度和清晰度下降。这会产生一种先有阻力后释放的感觉,模拟发射到太空的过程。

基本包络 API 无法实现此效果,因为它会抽象化处理有关其共振频率和输出加速度曲线的设备特定信息。提高锐度可能会使等效频率超出共振频率,从而可能导致意外的加速度下降。

动画:一艘火箭飞船从屏幕底部起飞。
输入振动波形图。

图 6. 模拟火箭发射的振动的输出加速度波形图。

@Composable
fun RocketLaunchAnimation() {
    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)
                )
            )
            animation.snapTo(0f)
            isLaunched = false
        }
    }

    Canvas(
        modifier = Modifier
            .fillMaxSize()
            .clickable {
                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.toLong(), 0.7f)
                    isLaunched = true
                }
            }
            .background(colorResource(R.color.background))
    ) {
        // Read the animation in the draw phase, and scale it to the canvas height.
        drawRocket(size.height * animation.value)
    }
}

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 - rampUpDurationMs - minDurationMs

    vibrator.vibrate(
        VibrationEffect.WaveformEnvelopeBuilder()
            // Quickly reach the target 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()
    )
}

LavaBeats

如火箭发射示例中所示,WaveformEnvelopeBuilder API 支持控制振动的振幅和频率片段,从而可以设计许多复杂的触感反馈效果。此类设计的另一个示例是模拟更抽象的身体感觉,例如“活力”。

这可以通过使用特定振幅和频率的振动片段来表示典型心电图 (ECG) 信号的生物标志物来实现。LavaBeats 是一个示例,其中 ECG 记录的两个特征片段表示为由时间延迟分隔的两个脉冲。第一个特征脉冲是 QRS 复合波,表现为高振幅和短持续时间的尖峰。第二个脉冲是 T 波,其振幅较低、持续时间较长且形状较平滑(见图 7)。

使用 WaveformEnvelopeBuilder 构建这两个脉冲的各种重复,这些脉冲由固定的第一到第二脉冲延迟分隔。第一个脉冲可以是线性调频信号,在短时间内从低频开始,到高频结束。第二个脉冲可以表示为低频正弦波的一个周期。我们可以将这两个脉冲组合成一个节拍,并按照典型的每分钟节拍数 (bpm) 速率,多次重复该组合,并在其间设置延迟。结果是产生类似于心跳的触感反馈效果。

您可以在 GitHub 上的触感反馈示例应用中试用 LavaBeats,并感受伴随熔岩灯可视化效果的触感效果,该可视化效果会以与触感效果相同的节奏跳动。您还可以通过修改两个脉冲的振幅、频率、时长和延迟来更改效果设置,以创建不同的跳动感觉。

熔岩灯动画,伴随触感心跳模式。
ECG 片段及其特征波形的图

图 7. 包含 QRS 复合波和 T 波的心电图记录片段

private fun createEnvelopeEffect(
    beatParameters: List<BeatParameter>
): VibrationEffect =
    VibrationEffect.WaveformEnvelopeBuilder()
        .apply {
            repeat(beatParameters.getNumBeats()) {
                // First pulse chirp
                addControlPoint(
                    beatParameters.getFirstPulseAmplitude(),
                    beatParameters.getFirstPulseStartFreq(),
                    ENVELOPE_RAMP_DURATION_MILLIS,
                )
                addControlPoint(
                    beatParameters.getFirstPulseAmplitude(),
                    beatParameters.getFirstPulseEndFreq(),
                    beatParameters.getFirstPulseDurationMillis().toLong(),
                )
                addControlPoint(
                    0f,
                    beatParameters.getFirstPulseEndFreq(),
                    ENVELOPE_RAMP_DURATION_MILLIS,
                )

                // Delay between first and second pulse
                addControlPoint(
                    0f,
                    beatParameters.getFirstPulseEndFreq(),
                    beatParameters.getFirstToSecondPulseDelayMillis().toLong(),
                )

                // Second pulse
                addControlPoint(
                    beatParameters.getSecondPulseAmplitude(),
                    beatParameters.getSecondPulseFreq(),
                    ENVELOPE_RAMP_DURATION_MILLIS,
                )
                addControlPoint(
                    beatParameters.getSecondPulseAmplitude(),
                    beatParameters.getSecondPulseFreq(),
                    (1_000 / (2f * beatParameters.getSecondPulseFreq())).toLong(),
                )
                addControlPoint(
                    0f,
                    beatParameters.getSecondPulseFreq(),
                    ENVELOPE_RAMP_DURATION_MILLIS,
                )
                addControlPoint(
                    0f,
                    beatParameters.getSecondPulseFreq(),
                    beatParameters.getBeatDelayMillis().toLong(),
                )
            }
        }
        .build()

/** A parameter of a haptic beat effect that represents an ECG signal parameter */
data class BeatParameter(
    val description: String = "",
    val value: Float = 0f,
    val range: ClosedFloatingPointRange<Float> = 0f..1f,
    val steps: Int = 0,
    val isFrequencyType: Boolean = false,
)