Twórz własne efekty haptyczne

Na tej stronie znajdziesz przykłady użycia różnych interfejsów API haptyki do tworzenia w aplikacji na Androida niestandardowych efektów wykraczających poza standardowe kształty drgań.

Na tej stronie znajdziesz te przykłady:

Więcej przykładów znajdziesz w artykule Dodawanie do zdarzeń informacji o wibracjach. Zawsze postępuj zgodnie z zasadami projektowania wibracji.

Używanie funkcji rezerwowych do obsługi zgodności urządzeń

Podczas wdrażania dowolnego efektu niestandardowego weź pod uwagę te kwestie:

  • Jakie funkcje urządzenia są wymagane do uzyskania efektu
  • Co zrobić, gdy urządzenie nie może odtworzyć efektu

dokumentacji interfejsu Android Haptics API znajdziesz szczegółowe informacje o tym, jak sprawdzić obsługę komponentów związanych z haptyką, aby aplikacja zapewniała spójne wrażenia.

W zależności od przypadku użycia możesz wyłączyć efekty niestandardowe lub udostępnić alternatywne efekty niestandardowe na podstawie różnych potencjalnych możliwości.

Zaplanuj następujące ogólne klasy możliwości urządzenia:

  • Jeśli używasz podstawowych wzorców wibracji: urządzenia obsługujące te wzorce potrzebne do efektów niestandardowych. (Szczegółowe informacje o typach prostych znajdziesz w następnej sekcji).

  • Urządzenia z regulacją amplitudy.

  • Urządzenia z podstawową obsługą wibracji (włączanie i wyłączanie) – innymi słowy te, które nie mają możliwości sterowania amplitudą.

Jeśli wybór efektu haptycznego w aplikacji uwzględnia te kategorie, wrażenia użytkownika związane z haptyką powinny być przewidywalne na każdym urządzeniu.

Używanie podstawowych elementów haptycznych

Android zawiera kilka podstawowych efektów dotykowych, które różnią się amplitudą i częstotliwością. Możesz użyć jednego elementu pierwotnego lub kilku elementów pierwotnych w połączeniu, aby uzyskać bogate efekty haptyczne.

  • Używaj opóźnień wynoszących co najmniej 50 ms, aby uzyskać zauważalne przerwy między 2 elementami podstawowymi. W miarę możliwości uwzględniaj też czas trwania elementu podstawowego.
  • Używaj skal, które różnią się od siebie o współczynnik 1,4 lub więcej, aby różnica w intensywności była lepiej widoczna.
  • Użyj skal 0,5, 0,7 i 1,0, aby utworzyć wersję elementu o niskiej, średniej i wysokiej intensywności.

Tworzenie niestandardowych wzorców wibracji

Wzorce wibracji są często używane w przypadku reakcji haptycznych związanych z uwagą, takich jak powiadomienia i dzwonki. Usługa Vibrator może odtwarzać długie wzorce wibracji, które zmieniają amplitudę wibracji w czasie. Takie efekty nazywamy kształtami fali.

Efekty wibracji są zwykle zauważalne, ale nagłe długie wibracje mogą zaskoczyć użytkownika, jeśli są odtwarzane w cichym otoczeniu. Zbyt szybkie osiąganie docelowej amplitudy może też powodować słyszalne brzęczenie. Projektuj wzory przebiegu, aby wygładzić przejścia amplitudy i uzyskać efekt narastania i opadania.

Przykłady wzorów wibracji

W sekcjach poniżej znajdziesz kilka przykładów wzorców wibracji:

Wzór zwiększania wyświetlania

Kształty fal są reprezentowane jako VibrationEffect z 3 parametrami:

  1. Timings: tablica czasów trwania (w milisekundach) poszczególnych segmentów fali.
  2. Amplitudy: żądana amplituda wibracji dla każdego czasu trwania określonego w pierwszym argumencie, reprezentowana przez liczbę całkowitą z zakresu od 0 do 255, gdzie 0 oznacza „wyłączony” wibrator, a 255 – maksymalną amplitudę urządzenia.
  3. Indeks powtórzenia: indeks w tablicy określonej w pierwszym argumencie, od którego ma się rozpocząć powtarzanie kształtu fali, lub -1, jeśli wzorzec ma być odtwarzany tylko raz.

Oto przykład przebiegu fali, który pulsuje 2 razy z przerwą 350 ms między impulsami. Pierwszy impuls to płynny wzrost do maksymalnej amplitudy, a drugi to szybki wzrost do maksymalnej amplitudy. Zatrzymanie na końcu jest określone przez ujemną wartość indeksu powtórzeń.

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

Powtarzający się wzór

Kształty fal można też odtwarzać wielokrotnie, dopóki nie zostaną anulowane. Aby utworzyć powtarzający się kształt fali, ustaw nieujemny parametr repeat. Gdy odtwarzasz powtarzający się kształt fali, wibracje trwają, dopóki nie zostaną wyraźnie anulowane w usłudze:

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

Jest to bardzo przydatne w przypadku zdarzeń przerywanych, które wymagają potwierdzenia przez użytkownika. Przykłady takich zdarzeń to połączenia przychodzące i włączone alarmy.

Wzorzec z wersją zastępczą

Kontrolowanie amplitudy wibracji jest funkcją zależną od sprzętu. Odtwarzanie fali na urządzeniu z niższej półki bez tej funkcji powoduje wibracje urządzenia z maksymalną amplitudą dla każdego dodatniego wpisu w tablicy amplitud. Jeśli Twoja aplikacja musi obsługiwać takie urządzenia, użyj wzorca, który nie generuje efektu brzęczenia, gdy jest odtwarzany w takich warunkach, lub zaprojektuj prostszy wzorzec włączania/wyłączania, który można odtwarzać jako zastępczy.

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

Tworzenie kompozycji wibracji

W tej sekcji znajdziesz sposoby na łączenie wibracji w dłuższe i bardziej złożone efekty niestandardowe. Oprócz tego dowiesz się, jak wykorzystać zaawansowane możliwości sprzętowe do tworzenia bogatych efektów dotykowych. Możesz używać kombinacji efektów o różnej amplitudzie i częstotliwości, aby tworzyć bardziej złożone efekty haptyczne na urządzeniach z siłownikami haptycznymi o szerszym paśmie częstotliwości.

Proces tworzenia niestandardowych wzorców wibracji opisany wcześniej na tej stronie wyjaśnia, jak kontrolować amplitudę wibracji, aby uzyskać płynne efekty narastania i zanikania. Zaawansowane wibracje to rozwinięcie tej koncepcji. Wykorzystują one szerszy zakres częstotliwości wibratora urządzenia, aby efekt był jeszcze płynniejszy. Te kształty fal są szczególnie skuteczne w tworzeniu efektu crescendo lub diminuendo.

Podstawowe elementy kompozycji opisane wcześniej na tej stronie są implementowane przez producenta urządzenia. Zapewniają one wyraźne, krótkie i przyjemne wibracje, które są zgodne z zasadami haptyki. Więcej informacji o tych możliwościach i sposobie ich działania znajdziesz w tym artykule.

Android nie zapewnia alternatywnych rozwiązań w przypadku kompozycji z nieobsługiwanymi elementami podstawowymi. Dlatego wykonaj te czynności:

  1. Zanim aktywujesz zaawansowane wibracje, sprawdź, czy dane urządzenie obsługuje wszystkie używane przez Ciebie elementy podstawowe.

  2. Wyłącz spójny zestaw nieobsługiwanych funkcji, a nie tylko efekty, w których brakuje elementu podstawowego.

Więcej informacji o tym, jak sprawdzić, czy urządzenie jest obsługiwane, znajdziesz w kolejnych sekcjach.

Tworzenie złożonych efektów wibracji

Możesz tworzyć złożone efekty wibracji za pomocą funkcji VibrationEffect.Composition. Oto przykład efektu powolnego wzrostu, a następnie efektu ostrego kliknięcia:

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

Kompozycja powstaje przez dodanie elementów pierwotnych, które mają być odtwarzane w sekwencji. Każdy z nich jest też skalowalny, więc możesz kontrolować amplitudę wibracji generowanych przez poszczególne efekty. Skala jest zdefiniowana jako wartość z zakresu od 0 do 1, przy czym 0 odpowiada minimalnej amplitudzie, przy której użytkownik może (ledwo) odczuwać to działanie.

Tworzenie wariantów w elementach pierwotnych wibracji

Jeśli chcesz utworzyć słabą i silną wersję tego samego elementu, utwórz współczynniki siły wynoszące co najmniej 1,4, aby różnica w intensywności była łatwo dostrzegalna. Nie próbuj tworzyć więcej niż 3 poziomów intensywności tego samego elementu podstawowego, ponieważ nie są one postrzegane jako odrębne. Użyj na przykład skal 0,5, 0,7 i 1,0, aby utworzyć wersje o niskiej, średniej i wysokiej intensywności.

Dodawanie przerw między podstawowymi elementami wibracji

Kompozycja może też określać opóźnienia, które mają być dodawane między kolejnymi elementami podstawowymi. Opóźnienie jest wyrażone w milisekundach od końca poprzedniego elementu. Ogólnie rzecz biorąc, przerwa między dwoma elementami pierwotnymi wynosząca od 5 do 10 ms jest zbyt krótka, aby można ją było wykryć. Jeśli chcesz utworzyć zauważalną przerwę między 2 elementami, użyj przerwy o długości około 50 ms lub dłuższej. Oto przykład kompozycji z opóźnieniami:

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

Sprawdzanie, które elementy są obsługiwane

Do weryfikacji obsługi konkretnych elementów podstawowych przez urządzenie można użyć tych interfejsów 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.
}

Możesz też sprawdzić kilka elementów podstawowych, a potem zdecydować, które z nich chcesz połączyć, na podstawie poziomu obsługi urządzenia:

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

Przykłady kompozycji wibracji

W sekcjach poniżej znajdziesz kilka przykładów kompozycji wibracji pochodzących z przykładowej aplikacji haptycznej na GitHubie.

Odporność (z niską liczbą kleszczy)

Możesz kontrolować amplitudę wibracji podstawowych, aby przekazywać przydatne informacje zwrotne o trwającym działaniu. Wartości skali umieszczone blisko siebie mogą służyć do tworzenia płynnego efektu crescendo elementu podstawowego. Opóźnienie między kolejnymi elementami może być też dynamicznie ustawiane na podstawie interakcji użytkownika. Ilustruje to poniższy przykład animacji widoku sterowanej gestem przeciągnięcia i wzbogaconej o haptykę.

Animacja przedstawiająca przeciąganie kółka w dół.
Wykres wejściowego przebiegu drgań.

Rysunek 1. Ta fala reprezentuje przyspieszenie wyjściowe wibracji na urządzeniu.

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

Rozwijanie (z narastaniem i opadaniem)

Istnieją 2 elementy podstawowe do zwiększania odczuwalnej intensywności wibracji: PRIMITIVE_QUICK_RISEPRIMITIVE_SLOW_RISE. Oba rodzaje docierają do tego samego celu, ale w różnym czasie. Istnieje tylko jeden element pierwotny do zmniejszania wartości, PRIMITIVE_QUICK_FALL. Te elementy działają lepiej razem, tworząc segment fali, który rośnie w intensywności, a następnie zanika. Możesz wyrównać skalowane elementy, aby zapobiec nagłym skokom amplitudy między nimi, co sprawdza się również w przypadku wydłużania ogólnego czasu trwania efektu. Ludzie zawsze bardziej zwracają uwagę na część rosnącą niż na część malejącą, więc skrócenie części rosnącej w stosunku do części malejącej może przesunąć nacisk na część malejącą.

Oto przykład zastosowania tej kompozycji do rozwijania i zwijania okręgu. Efekt wznoszenia może wzmocnić wrażenie ekspansji podczas animacji. Połączenie efektów wznoszenia i opadania pomaga podkreślić zwijanie na końcu animacji.

Animacja przedstawiająca powiększające się kółko.
Wykres wejściowego przebiegu drgań.

Ilustracja 2. Ten przebieg fali przedstawia przyspieszenie wyjściowe wibracji na urządzeniu.

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

Wahadło (z obrotami)

Jedną z głównych zasad dotyczących haptyki jest sprawianie użytkownikom przyjemności. Zabawnym sposobem na wprowadzenie przyjemnego, nieoczekiwanego efektu wibracji jest użycie PRIMITIVE_SPIN. Ten element jest najbardziej skuteczny, gdy jest wywoływany więcej niż raz. Połączenie wielu obrotów może wywołać efekt drgania i niestabilności, który można dodatkowo wzmocnić, stosując nieco losowe skalowanie każdego elementu. Możesz też eksperymentować z odstępem między kolejnymi elementami obrotu. Dwa obroty bez przerwy (0 ms między nimi) wywołują silne wrażenie wirowania. Zwiększenie odstępu między obrotami z 10 ms do 50 ms powoduje mniejsze wrażenie wirowania i może być używane do dopasowania czasu trwania filmu lub animacji.

Nie używaj przerw dłuższych niż 100 ms, ponieważ kolejne obroty nie będą już dobrze się łączyć i zaczną przypominać oddzielne efekty.

Oto przykład elastycznego kształtu, który po przeciągnięciu w dół i zwolnieniu wraca na swoje miejsce. Animacja jest wzbogacona o 2 efekty obrotu odtwarzane z różną intensywnością proporcjonalną do odległości odbicia.

Animacja przedstawiająca elastyczny kształt odbijający się
Wykres przebiegu drgań wejściowych

Rysunek 3. Ta fala reprezentuje przyspieszenie wyjściowe wibracji na urządzeniu.

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

Odbijanie (z odgłosami uderzeń)

Innym zaawansowanym zastosowaniem efektów wibracji jest symulowanie interakcji fizycznych. PRIMITIVE_THUD może wywołać silny i rozległy efekt, który można połączyć z wizualizacją uderzenia, np. w filmie lub animacji, aby wzmocnić ogólne wrażenia.

Oto przykład animacji spadającej piłki z efektem uderzenia odtwarzanym za każdym razem, gdy piłka odbija się od dolnej krawędzi ekranu:

Animacja przedstawiająca spadającą piłkę odbijającą się od dołu ekranu.
Wykres wejściowego przebiegu drgań.

Rysunek 4. Ta fala reprezentuje przyspieszenie wyjściowe wibracji na urządzeniu.

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

Kształt fali wibracji z obwiedniami

Proces tworzenia niestandardowych wzorców wibracji umożliwia kontrolowanie amplitudy wibracji w celu uzyskania płynnych efektów narastania i zanikania. W tej sekcji wyjaśniamy, jak tworzyć dynamiczne efekty haptyczne za pomocą obwiedni kształtu fali, które umożliwiają precyzyjne sterowanie amplitudą i częstotliwością wibracji w czasie. Dzięki temu możesz tworzyć bogatsze i bardziej zniuansowane wrażenia haptyczne.

Od Androida 16 (poziom API 36) system udostępnia te interfejsy API do tworzenia obwiedni przebiegu drgań przez zdefiniowanie sekwencji punktów kontrolnych:

  • BasicEnvelopeBuilder: przystępne podejście do tworzenia efektów haptycznych niezależnych od sprzętu.
  • WaveformEnvelopeBuilder: bardziej zaawansowane podejście do tworzenia efektów haptycznych; wymaga znajomości sprzętu haptycznego.

Android nie zapewnia alternatywnych rozwiązań dla efektów koperty. Jeśli potrzebujesz takiej pomocy, wykonaj te czynności:

  1. Sprawdź, czy dane urządzenie obsługuje efekty koperty, za pomocą funkcji Vibrator.areEnvelopeEffectsSupported().
  2. Wyłącz spójny zestaw funkcji, które nie są obsługiwane, lub użyj niestandardowych wzorców wibracji albo kompozycji jako alternatywnych rozwiązań.

Aby utworzyć więcej podstawowych efektów obwiedni, użyj funkcji BasicEnvelopeBuilder z tymi parametrami:

  • Wartość intensywności w zakresie \( [0, 1] \), która reprezentuje odczuwalną siłę wibracji. Na przykład wartość \( 0.5 \) jest postrzegana jako połowa maksymalnej intensywności globalnej, jaką może osiągnąć urządzenie.
  • Wartość ostrości w zakresie \( [0, 1] \), która reprezentuje wyrazistość wibracji. Niższe wartości oznaczają łagodniejsze wibracje, a wyższe – bardziej intensywne.

  • Wartość czasu trwania, która reprezentuje czas (w milisekundach) potrzebny na przejście od ostatniego punktu kontrolnego, czyli pary intensywności i ostrości, do nowego.

.

Oto przykład przebiegu, w którym intensywność wibracji rośnie od niskiej do wysokiej w ciągu 500 ms, a następnie maleje do\( 0 \) (wyłączone) w ciągu 100 ms.

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

Jeśli masz bardziej zaawansowaną wiedzę na temat haptyki, możesz zdefiniować efekty obwiedni za pomocą WaveformEnvelopeBuilder. Podczas korzystania z tego obiektu możesz uzyskać dostęp do mapowania częstotliwości na przyspieszenie wyjściowe (FOAM) za pomocą VibratorFrequencyProfile.

  • Wartość amplitudy w zakresie \( [0, 1] \), która reprezentuje osiągalną siłę wibracji przy danej częstotliwości, określoną przez FOAM urządzenia. Na przykład wartość \( 0.5 \) generuje połowę maksymalnego przyspieszenia wyjściowego, które można osiągnąć przy danej częstotliwości.
  • Wartość częstotliwości podana w hercach.

  • Wartość duration, która reprezentuje czas w milisekundach potrzebny na przejście od ostatniego punktu kontrolnego do nowego.

.

Poniższy kod przedstawia przykładowy kształt fali, który definiuje 400-milisekundowy efekt wibracji. Zaczyna się od 50-milisekundowego wzrostu amplitudy od wyłączonej do pełnej przy stałej częstotliwości 60 Hz. Następnie w ciągu kolejnych 100 ms częstotliwość wzrasta do 120 Hz i utrzymuje się na tym poziomie przez 200 ms. Na koniec w ciągu ostatnich 50 ms amplituda spada do \( 0 \), a częstotliwość wraca do 60 Hz:

vibrator.vibrate(VibrationEffect.WaveformEnvelopeBuilder()
    .addControlPoint(1.0f, 60f, 50)
    .addControlPoint(1.0f, 120f, 100)
    .addControlPoint(1.0f, 120f, 200)
    .addControlPoint(0.0f, 60f, 50)
    .build()
)

W sekcjach poniżej znajdziesz kilka przykładów przebiegów drgań z obwiedniami.

Sprężyna odbijająca

W poprzednim przykładzie użyto znaku PRIMITIVE_THUD, aby zasymulować interakcje związane z fizycznym odbiciem. Podstawowy interfejs API koperty zapewnia znacznie większą kontrolę, umożliwiając precyzyjne dostosowanie intensywności i ostrości wibracji. Dzięki temu wibracje lepiej odzwierciedlają animowane zdarzenia.

Oto przykład spadającej sprężyny z animacją wzbogaconą o podstawowy efekt obwiedni odtwarzany za każdym razem, gdy sprężyna odbija się od dołu ekranu:

Animacja przedstawiająca sprężynę, która po upuszczeniu odbija się od dołu ekranu.
Wykres wejściowej fali drgań.

Rysunek 5. Wykres fali przyspieszenia wyjściowego dla wibracji, która symuluje odbijającą się sprężynę.

@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 desired 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")
    }
  }
}

Start rakiety

W poprzednim przykładzie pokazaliśmy, jak za pomocą podstawowego interfejsu API koperty symulować reakcję sprężystą. WaveformEnvelopeBuilder umożliwia precyzyjne sterowanie pełnym zakresem częstotliwości urządzenia, co pozwala na uzyskanie wysoce spersonalizowanych efektów haptycznych. Łącząc te dane z danymi FOAM, możesz dostosować wibracje do określonych możliwości częstotliwości.

Oto przykład symulacji startu rakiety z użyciem dynamicznego wzorca wibracji. Efekt przechodzi od minimalnej obsługiwanej częstotliwości wyjściowej przyspieszenia, 0,1 G, do częstotliwości rezonansowej, zawsze przy zachowaniu 10% amplitudy wejściowej. Dzięki temu efekt zaczyna się od dość silnego sygnału wyjściowego i zwiększa postrzeganą intensywność i ostrość, mimo że amplituda sterująca jest taka sama. Po osiągnięciu rezonansu częstotliwość efektu spada z powrotem do minimum, co jest odbierane jako zmniejszenie intensywności i ostrości. Daje to wrażenie początkowego oporu, a następnie zwolnienia, co przypomina start w kosmos.

Nie jest to możliwe w przypadku podstawowego interfejsu API koperty, ponieważ ukrywa on informacje o częstotliwości rezonansowej i krzywej przyspieszenia wyjściowego urządzenia. Zwiększenie ostrości może spowodować przesunięcie równoważnej częstotliwości poza rezonans, co może spowodować niezamierzony spadek przyspieszenia.

Animacja przedstawiająca rakietę startującą z dołu ekranu.
Wykres wejściowej fali drgań.

Rysunek 6. Wykres przebiegu przyspieszenia wyjściowego dla wibracji, która symuluje start rakiety.

@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 desired 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()
  )
}