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

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

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

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

Как использовать резервные варианты для обеспечения совместимости с устройствами

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

  • VibrationEffect.Builder (рекомендуется). В Android 16 (IV квартал 2026 г.) эффекты, созданные с помощью VibrationEffect.Builder, включают автоматический переход на уровень фреймворка. Если устройство не поддерживает запрошенный Preset или базовый Envelope, фреймворк автоматически преобразует его в подходящий альтернативный вариант во время воспроизведения. Вам не нужно вручную проверять возможности устройства для каждого примитива, прежде чем воспроизводить эффекты, созданные с помощью VibrationEffect.Builder.
    • Исключение. Расширенные огибающие формы волны, созданные с помощью WaveformEnvelopeBuilder, не поддерживают автоматический переход на резервный вариант, поскольку зависят от определенных частотных сопоставлений оборудования (FOAM). Если формат не поддерживается, видео не будет воспроизводиться.
  • VibrationEffect.Composition: композиции, созданные с помощью API startComposition(), не имеют автоматических резервных вариантов. Если в композиции есть хотя бы один неподдерживаемый примитив, вибрация не будет воспроизводиться. Проверять возможности нужно вручную с помощью метода 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 мс между импульсами. Первый импульс – это плавное нарастание до максимальной амплитуды, а второй – быстрое нарастание с последующим удержанием максимальной амплитуды. Остановка в конце определяется отрицательным значением индекса повтора.

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

Это очень полезно для периодических событий, требующих подтверждения от пользователя. Примеры таких событий: входящие звонки и срабатывание будильника.

Графический ключ с резервным PIN-кодом

Управление амплитудой вибрации – это зависимая от оборудования функция. Если воспроизвести звуковую волну на устройстве низкого класса без этой функции, устройство будет вибрировать с максимальной амплитудой для каждого положительного значения в массиве амплитуд. Если ваше приложение должно работать на таких устройствах, используйте шаблон, который не вызывает вибрацию при воспроизведении в таких условиях, или разработайте более простой шаблон включения/выключения, который можно будет использовать в качестве резервного.

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

Композиции, привязанные к хронологии, с пользователем 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). Предустановки реализуются производителем устройства, чтобы обеспечить четкую, короткую и приятную вибрацию, соответствующую принципам тактильной обратной связи. Подробнее о том, как работают эти функции, можно узнать в руководстве по вибромоторам.

Наборы настроек заменяют короткие примитивы из API VibrationEffect.Composition. Для более продолжительных или непрерывных тактильных ощущений, например нарастающих и убывающих, используйте огибающие формы волны (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. Компоненты примитивов, описанные выше на этой странице, реализуются производителем устройства. Они обеспечивают четкую, короткую и приятную вибрацию, соответствующую принципам тактильной обратной связи. Подробнее об этих возможностях и принципах их работы см. в разделе Vibration actuators primer.

В отличие от VibrationEffect.Builder, API VibrationEffect.Composition не имеет автоматических резервных механизмов для неподдерживаемых примитивов. Поэтому:

  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. Вот пример медленно нарастающего эффекта, за которым следует резкий щелчок:

Kotlin

vibrator.vibrate(
    VibrationEffect.startComposition().addPrimitive(
    VibrationEffect.Composition.PRIMITIVE_SLOW_RISE
    ).addPrimitive(
    VibrationEffect.Composition.PRIMITIVE_CLICK
    ).compose()
)

Java

vibrator.vibrate(
    VibrationEffect.startComposition()
        .addPrimitive(VibrationEffect.Composition.PRIMITIVE_SLOW_RISE)
        .addPrimitive(VibrationEffect.Composition.PRIMITIVE_CLICK)
        .compose());

Композиция создается путем добавления примитивов, которые будут воспроизводиться последовательно. Каждый примитив можно масштабировать, чтобы управлять амплитудой вибрации, которую он создает. Шкала определяется как значение от 0 до 1, где 0 соответствует минимальной амплитуде, при которой пользователь может (едва) почувствовать примитив.

Создание вариантов в примитивах вибрации

Если вы хотите создать слабую и сильную версию одного и того же примитива, создайте соотношения силы 1,4 или более, чтобы разница в интенсивности была легко заметна. Не пытайтесь создать более трех уровней интенсивности одного и того же примитива, поскольку они не будут восприниматься как разные. Например, чтобы создать версии примитива с низкой, средней и высокой интенсивностью, используйте масштабы 0,5, 0,7 и 1,0.

Как добавить промежутки между примитивами вибрации

В композиции также можно указать задержки между последовательными примитивами. Задержка указывается в миллисекундах с момента окончания предыдущего примитива. Как правило, разница в 5–10 мс между двумя примитивами слишком мала, чтобы ее можно было заметить. Если вы хотите, чтобы между двумя примитивами был заметный промежуток, используйте задержку порядка 50 мс или больше. Вот пример композиции с задержками:

Kotlin

val delayMs = 100
vibrator.vibrate(
    VibrationEffect.startComposition().addPrimitive(
    VibrationEffect.Composition.PRIMITIVE_SPIN, 0.8f
    ).addPrimitive(
    VibrationEffect.Composition.PRIMITIVE_SPIN, 0.6f
    ).addPrimitive(
    VibrationEffect.Composition.PRIMITIVE_THUD, 1.0f, delayMs
    ).compose()
)

Java

int delayMs = 100;
vibrator.vibrate(
    VibrationEffect.startComposition()
        .addPrimitive(VibrationEffect.Composition.PRIMITIVE_SPIN, 0.8f)
        .addPrimitive(VibrationEffect.Composition.PRIMITIVE_SPIN, 0.6f)
        .addPrimitive(
            VibrationEffect.Composition.PRIMITIVE_THUD, 1.0f, delayMs)
        .compose());

Какие примитивы поддерживаются

Чтобы проверить, поддерживает ли устройство определенные примитивы, можно использовать следующие API:

Kotlin

val primitive = VibrationEffect.Composition.PRIMITIVE_LOW_TICK

if (vibrator.areAllPrimitivesSupported(primitive)) {
  vibrator.vibrate(VibrationEffect.startComposition()
        .addPrimitive(primitive).compose())
} else {
  // Play a predefined effect or custom pattern as a fallback.
}

Java

int primitive = VibrationEffect.Composition.PRIMITIVE_LOW_TICK;

if (vibrator.areAllPrimitivesSupported(primitive)) {
  vibrator.vibrate(VibrationEffect.startComposition()
        .addPrimitive(primitive).compose());
} else {
  // Play a predefined effect or custom pattern as a fallback.
}

Также можно проверить несколько примитивов и решить, какие из них следует использовать, исходя из уровня поддержки устройства:

Kotlin

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

Java

int[] primitives = new int[] {
VibrationEffect.Composition.PRIMITIVE_LOW_TICK,
VibrationEffect.Composition.PRIMITIVE_TICK,
VibrationEffect.Composition.PRIMITIVE_CLICK
};
boolean[] supported = vibrator.arePrimitivesSupported(effects);

Примеры вибрации

В разделах ниже приведены примеры вибрационных композиций, взятые из примера приложения для работы с тактильной обратной связью на 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_RISE и PRIMITIVE_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;
            }
            }
        });
    }
}

Форма вибрации с огибающими

Создавая собственные режимы вибрации, вы можете контролировать амплитуду вибрации, чтобы создавать плавные эффекты нарастания и спада. В этом разделе рассказывается, как создавать динамические тактильные эффекты с помощью огибающих формы волны, которые позволяют точно контролировать амплитуду и частоту вибрации с течением времени. Это позволяет создавать более насыщенные и разнообразные тактильные ощущения.

В Android 16 (уровень API 36) и более поздних версиях системы доступны следующие API для создания огибающей формы волны вибрации путем определения последовательности контрольных точек:

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

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

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

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

  • Значение intensity в диапазоне \( [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] \), которое представляет собой достижимую силу вибрации на заданной частоте, определяемую пеной устройства. Например, значение \( 0.5 \) создает половину максимального выходного ускорения, которое может быть достигнуто при заданной частоте.
  • Значение частоты, указанное в герцах.

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

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

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() {
  var springX by remember { mutableStateOf(SPRING_WIDTH) }
  var springY by remember { mutableStateOf(SPRING_HEIGHT) }
  var velocityX by remember { mutableFloatStateOf(INITIAL_VELOCITY) }
  var velocityY by remember { mutableFloatStateOf(INITIAL_VELOCITY) }
  var sharpness by remember { mutableFloatStateOf(INITIAL_SHARPNESS) }
  var intensity by remember { mutableFloatStateOf(INITIAL_INTENSITY) }
  var multiplier by remember { mutableFloatStateOf(INITIAL_MULTIPLIER) }
  var bottomBounceCount by remember { mutableIntStateOf(0) }
  var animationStartTime by remember { mutableLongStateOf(0L) }
  var isAnimating by remember { mutableStateOf(false) }

  val (screenHeight, screenWidth) = getScreenDimensions(context)

  LaunchedEffect(isAnimating) {
    animationStartTime = System.currentTimeMillis()
    isAnimating = true

    while (isAnimating) {
      velocityY += GRAVITY
      springX += velocityX.dp
      springY += velocityY.dp

      // Handle bottom collision
      if (springY > screenHeight - FLOOR_HEIGHT - SPRING_HEIGHT / 2) {
        // Set the spring's y-position to the bottom bounce point, to keep it
        // above the floor.
        springY = screenHeight - FLOOR_HEIGHT - SPRING_HEIGHT / 2

        // Reverse the vertical velocity and apply damping to simulate a bounce.
        velocityY *= -BOUNCE_DAMPING
        bottomBounceCount++

        // Calculate the fade-out duration of the vibration based on the
        // vertical velocity.
        val fadeOutDuration =
            ((abs(velocityY) / GRAVITY) * FRAME_DELAY_MS).toLong()

        // Create a "boing" envelope vibration effect that fades out.
        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(intensity, sharpness, 20L)

                // Add a control point to fade out the vibration intensity while
                // maintaining sharpness.
                .addControlPoint(0f, sharpness, fadeOutDuration)
                .build()
        )

        // Decrease the intensity and sharpness of the vibration for subsequent
        // bounces, and reduce the multiplier to create a fading effect.
        intensity *= multiplier
        sharpness *= multiplier
        multiplier -= 0.1f
      }

      if (springX > screenWidth - SPRING_WIDTH / 2) {
        // Prevent the spring from moving beyond the right edge of the screen.
        springX = screenWidth - SPRING_WIDTH / 2
      }

      // Check for 3 bottom bounces and then slow down.
      if (bottomBounceCount >= MAX_BOTTOM_BOUNCE &&
            System.currentTimeMillis() - animationStartTime > 1000) {
        velocityX *= 0.9f
        velocityY *= 0.9f
      }

      delay(FRAME_DELAY_MS) // Control animation speed.

      // Determine if the animation should continue based on the spring's
      // position and velocity.
      isAnimating = (springY < screenHeight + SPRING_HEIGHT ||
            springX < screenWidth + SPRING_WIDTH)
        && (velocityX >= 0.1f || velocityY >= 0.1f)
    }
  }

  Box(
    modifier = Modifier
      .fillMaxSize()
      .noRippleClickable {
        if (!isAnimating) {
          resetAnimation()
        }
      }
      .width(screenWidth)
      .height(screenHeight)
  ) {
    DrawSpring(mutableStateOf(springX), mutableStateOf(springY))
    DrawFloor()
    if (!isAnimating) {
      DrawText("Tap to restart")
    }
  }
}

Запуск ракеты

В предыдущем примере показано, как использовать базовый API огибающей для имитации реакции пружины. WaveformEnvelopeBuilder позволяет точно управлять полным диапазоном частот устройства, что дает возможность создавать высококачественные тактильные эффекты. Сочетая эти данные с данными FOAM, вы можете настроить вибрацию в соответствии с определенными частотными возможностями.

Вот пример, в котором показана симуляция запуска ракеты с использованием динамической вибрации. Эффект переходит от минимальной поддерживаемой частоты ускорения (0,1 G) к резонансной частоте, всегда поддерживая входную амплитуду 10 %. Это позволяет эффекту начинаться с достаточно сильного выходного сигнала и увеличивать воспринимаемую интенсивность и четкость, даже если амплитуда остается прежней. При достижении резонанса частота эффекта снижается до минимального значения, что воспринимается как снижение интенсивности и четкости. Это создает ощущение первоначального сопротивления, за которым следует освобождение, имитирующее запуск в космос.

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

Анимация ракеты, взлетающей с нижнего края экрана.
График входного сигнала вибрации.

Рисунок 6. График ускорения для вибрации, имитирующей запуск ракеты.

@Composable
fun RocketLaunchAnimation() {
  val context = LocalContext.current
  val screenHeight = remember { mutableFloatStateOf(0f) }
  var rocketPositionY by remember { mutableFloatStateOf(0f) }
  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)
        )
      ) {
        rocketPositionY = screenHeight.floatValue * value
      }
      animation.snapTo(0f)
      rocketPositionY = 0f;
      isLaunched = false;
    }
  }

  Box(
    modifier = Modifier
      .fillMaxSize()
      .noRippleClickable {
        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, 0.7f)
          isLaunched = true
        }
      }
      .background(Color(context.getColor(R.color.background)))
      .onSizeChanged { screenHeight.floatValue = it.height.toFloat() }
  ) {
    drawRocket(rocketPositionY)
  }
}

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 - rampUpDuration - 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, который выглядит как резкий пик высокой амплитуды и короткой продолжительности. Второй импульс – это волна T, которая имеет меньшую амплитуду, большую продолжительность и более плавную форму (см. рис. 7).

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

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

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

Рисунок 7. Фрагмент записи ЭКГ с комплексом QRS и зубцом T

@RequiresApi(Build.VERSION_CODES.BAKLAVA)
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 */
@Stable
data class BeatParameter(
    val description: String = "",
    val value: Float = 0f,
    val range: ClosedFloatingPointRange<Float> = 0f..1f,
    val steps: Int = 0,
    val isFrequencyType: Boolean = false,
)