Как создавать собственные тактильные эффекты

На этой странице приведены примеры использования различных API тактильных эффектов для создания пользовательских эффектов, помимо стандартных вибраций, в приложении для Android.

На этой странице приведены следующие примеры:

Дополнительные примеры можно найти в статье Как добавить виброотклик для событий. Всегда следуйте принципам разработки виброотклика.

Используйте резервные объявления, чтобы обеспечить совместимость с устройствами

При реализации пользовательских тактильных эффектов совместимость устройств и резервное поведение зависят от выбранной поверхности API:

  • VibrationEffect.Builder (рекомендуется). Начиная с Android 16 (IV квартал 2026 г.) эффекты, созданные с помощью 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. Amplitudes. Амплитуда вибрации для каждого значения продолжительности, указанного в первом аргументе. Представляется целым числом от 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.Preset), например щелчки и тики.
  • Огибающие. Динамические непрерывные формы сигнала, включая базовые огибающие, не зависящие от аппаратного обеспечения (BasicEnvelopeBuilder), и расширенные огибающие с частотной модуляцией (WaveformEnvelopeBuilder).
  • Существующие VibrationEffects. Примитивные композиции (VibrationEffect.Composition), ступенчатые формы сигнала (VibrationEffect.createWaveform), предопределенные эффекты (VibrationEffect.createPredefined) и однократные эффекты (VibrationEffect.createOneShot), импортированные с помощью addEvents() или копирования конструктора.
  • Повторяющиеся последовательности. Непрерывные шаблоны, настроенные с помощью setRepeatingEffect().

Вибрации, созданные с помощью 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), получите список его объектов VibrationEffect.Event с помощью getEvents() и добавьте их со смещением, используя 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, позволяющие создать огибающую временной диаграммы сигнала вибрации, задав последовательность контрольных точек:

  • BasicEnvelopeBuilder: доступный подход к созданию тактильных эффектов, не зависящих от аппаратного обеспечения.
  • WaveformEnvelopeBuilder. Более продвинутый подход к созданию тактильных эффектов. Требует знакомства с аппаратным обеспечением для тактильной обратной связи.

В Android нет резервных вариантов для эффектов огибающей. Если вам нужна такая поддержка, выполните следующие действия:

  1. Проверьте, поддерживает ли устройство эффекты огибающей, с помощью метода Vibrator.areEnvelopeEffectsSupported().
  2. Отключите неподдерживаемые функции или используйте вместо них собственные шаблоны вибрации или композиции.

Чтобы создать более простые эффекты огибающей, используйте BasicEnvelopeBuilder со следующими параметрами:

  • Значение интенсивности в диапазоне \( [0, 1] \), которое представляет ощущаемую силу вибрации. Например, значение \( 0.5 \)воспринимается как половина глобальной максимальной интенсивности, которая может быть достигнута устройством.
  • Значение резкости в диапазоне \( [0, 1] \), которое определяет четкость вибрации. Чем ниже значение, тем плавнее вибрация, а чем выше – тем резче.

  • Значение duration, которое представляет собой время в миллисекундах, необходимое для перехода от последней контрольной точки (то есть пары интенсивности и резкости) к новой.

Ниже приведен пример временной диаграммы сигнала, которая увеличивает интенсивность от низкого до высокого уровня, максимальную силу вибрации в течение 500 мс, а затем снижает ее до\( 0 \) (выкл.) в течение 100 мс.

vibrator.vibrate(
    VibrationEffect.BasicEnvelopeBuilder()
        .setInitialSharpness(0.0f)
        .addControlPoint(1.0f, 1.0f, 500)
        .addControlPoint(0.0f, 1.0f, 100)
        .build()
)

Если вы хорошо разбираетесь в тактильных эффектах, то можете задать эффекты огибающей с помощью WaveformEnvelopeBuilder. При использовании этого объекта вы можете получить доступ к сопоставлению частоты и выходного ускорения (FOAM) через VibratorFrequencyProfile.

  • Значение амплитуды в диапазоне \( [0, 1] \), которое представляет достижимую силу вибрации на заданной частоте, определяемую FOAM устройства. Например, значение \( 0.5 \) генерирует половину максимального выходного ускорения, которое может быть достигнуто на заданной частоте.
  • Значение частоты, указанное в герцах.

  • Значение duration, которое представляет собой время в миллисекундах, необходимое для перехода от последней контрольной точки к новой.

В примере кода ниже показана временная диаграмма сигнала, которая определяет эффект вибрации длительностью 400 мс. Он начинается с 50-миллисекундного нарастания амплитуды от нуля до максимума при постоянной частоте 60 Гц. Затем частота увеличивается до 120 Гц в течение следующих 100 мс и остается на этом уровне в течение 200 мс. Наконец, амплитуда снижается до \( 0 \), а частота возвращается к 60 Гц в течение последних 50 мс:

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

Как и в примере с запуском ракеты, API WaveformEnvelopeBuilder позволяет создавать сложные тактильные эффекты, управляя амплитудой и частотой вибрации. Другой пример такого дизайна – эмуляция более абстрактных физических ощущений, таких как "живость".

Для этого биомаркеры типичного сигнала электрокардиограммы (ЭКГ) сопоставляются с сегментами вибрации определенных амплитуд и частот. На рисунке LavaBeats показан пример, в котором два характерных сегмента записи ЭКГ представлены в виде двух импульсов, разделенных временной задержкой. Первый характерный импульс – это комплекс QRS, который выглядит как острый пик высокой амплитуды и короткой продолжительности. Второй импульс — это зубец Т, который имеет меньшую амплитуду, большую продолжительность и более плавную форму (см. рисунок 7).

Используйте WaveformEnvelopeBuilder, чтобы создать различные повторения этих двух импульсов, разделенных фиксированной задержкой между первым и вторым импульсами. Первый импульс может быть сигналом с линейной частотной модуляцией, который начинается с низкой частоты и заканчивается более высокой частотой в течение короткого периода времени. Второй импульс можно представить как один период низкочастотной синусоиды. Мы можем объединить два импульса в один удар и повторить его несколько раз с задержкой между ними, следуя типичной частоте ударов в минуту (bpm). В результате получается тактильный эффект, напоминающий биение сердца.

Вы можете попробовать LavaBeats в нашем примере приложения с тактильными эффектами на GitHub и почувствовать эффект, сопровождаемый визуализацией лавовой лампы, которая бьется в том же ритме, что и тактильный эффект. Вы также можете изменить настройки эффекта, чтобы создать разные ощущения биения, изменив амплитуду, частоту, продолжительность и задержку двух импульсов.

Анимация лавовой лампы, которая пульсирует в такт сердцебиению.
График сегмента ЭКГ и его характерные формы сигналов

Рисунок 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,
)