맞춤 햅틱 효과 만들기

이 페이지에서는 다양한 햅틱 API를 사용하여 Android 애플리케이션에서 맞춤 효과를 만드는 방법의 예를 설명합니다. 이 페이지의 많은 정보는 진동 액추에이터의 작동 방식에 관한 충분한 지식을 기반으로 하므로 진동 액추에이터 기본 지침서를 읽어보는 것이 좋습니다.

이 페이지에는 다음 예가 포함되어 있습니다.

추가 예는 이벤트에 햅틱 반응 추가를 참고하고 항상 햅틱 디자인 원칙을 따르세요.

대체를 사용하여 기기 호환성 처리

맞춤 효과를 구현할 때 다음 사항을 고려하세요.

  • 효과에 필요한 기기 기능
  • 기기에서 효과를 재생할 수 없을 때 해야 할 일

Android 햅틱 API 참조에서는 햅틱과 관련된 구성요소의 지원 여부를 확인하여 앱이 전반적으로 일관된 환경을 제공할 수 있도록 하는 방법을 자세히 설명합니다.

사용 사례에 따라 맞춤 효과를 중지하거나 다양한 잠재적 기능에 따라 대체 맞춤 효과를 제공할 수 있습니다.

다음과 같은 상위 수준의 기기 기능에 대한 계획을 세우세요.

  • 햅틱 프리미티브를 사용하는 경우: 맞춤 효과에 필요한 프리미티브를 지원하는 기기입니다. 프리미티브에 관한 자세한 내용은 다음 섹션을 참고하세요.

  • 진폭 제어 기능이 있는 기기

  • 기본 진동 지원 (켜짐/꺼짐)이 있는 기기, 즉 진폭 제어 기능이 없는 기기

앱의 햅틱 효과 선택이 이러한 카테고리를 고려하는 경우 햅틱 사용자 환경은 모든 개별 기기에 대해 예측 가능한 상태로 유지되어야 합니다.

햅틱 프리미티브 사용

Android에는 진폭과 주파수가 서로 다른 여러 햅틱 프리미티브가 포함되어 있습니다. 하나의 프리미티브를 단독으로 사용하거나 여러 프리미티브를 조합하여 사용하여 풍부한 햅틱 효과를 달성할 수 있습니다.

  • 두 프리미티브 간의 분명한 간격을 위해 50ms 이상의 지연을 사용하고 가능한 경우 원시 기간도 고려합니다.
  • 강도의 차이를 더 잘 인식하도록 1.4 이상의 비율 차이로 다른 척도를 사용합니다.
  • 0.5, 0.7, 1.0의 배율을 사용하여 프리미티브의 낮은 강도, 중간 강도, 고강도 버전을 만듭니다.

맞춤 진동 패턴 만들기

진동 패턴은 알림 및 벨소리와 같은 주의 집중 햅틱에서 자주 사용됩니다. Vibrator 서비스는 시간이 지남에 따라 진동 진폭을 변경하는 긴 진동 패턴을 재생할 수 있습니다. 이러한 효과를 파형이라고 합니다.

파형 효과는 쉽게 감지할 수 있지만 조용한 환경에서 재생하면 갑작스러운 긴 진동으로 사용자를 놀라게 할 수 있습니다. 또한 타겟 진폭까지 너무 빠르게 늘리면 윙윙거리는 소리가 발생할 수도 있습니다. 파형 패턴을 설계할 때는 진폭 전환을 부드럽게 조정하여 증폭 및 하강 효과를 만드는 것이 좋습니다.

샘플: 단계적 확대 패턴

파형은 세 가지 매개변수가 있는 VibrationEffect로 표시됩니다.

  1. 타이밍: 각 파형 세그먼트의 지속 시간 배열(밀리초)입니다.
  2. 진폭: 첫 번째 인수에 지정된 각 기간에 원하는 진동 진폭으로, 0~255 사이의 정수 값으로 표시됩니다. 0은 진동기 'off'를, 255는 기기의 최대 진폭을 나타냅니다.
  3. 반복 색인: 파형 반복을 시작할 첫 번째 인수에 지정된 배열의 색인 또는 패턴을 한 번만 재생해야 하는 경우 -1입니다.

다음은 펄스 사이에 350ms의 일시중지로 두 번 깜빡이는 파형의 예입니다. 첫 번째 펄스는 최대 진폭까지 부드러운 램프이고 두 번째 펄스는 최대 진폭을 유지하기 위한 빠른 램프입니다. 끝에서 정지는 음의 반복 색인 값으로 정의됩니다.

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 // Do not 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; // Do not repeat.

vibrator.vibrate(VibrationEffect.createWaveform(timings, amplitudes, repeatIndex));

샘플: 패턴 반복

파형은 취소할 때까지 반복해서 재생할 수도 있습니다. 반복되는 파형을 만드는 방법은 음수가 아닌 'repeat' 매개변수를 설정하는 것입니다. 반복되는 파형을 재생하면 서비스에서 명시적으로 취소될 때까지 진동이 계속됩니다.

Kotlin

void startVibrating() {
  val timings: LongArray = longArrayOf(50, 50, 100, 50, 50)
  val amplitudes: IntArray = intArrayOf(64, 128, 255, 128, 64)
  val repeat = 1 // Repeat from the second entry, index = 1.
  VibrationEffect repeatingEffect = VibrationEffect.createWaveform(timings, amplitudes, repeat)
  // repeatingEffect can be used in multiple places.

  vibrator.vibrate(repeatingEffect)
}

void stopVibrating() {
  vibrator.cancel()
}

Java

void startVibrating() {
  long[] timings = new long[] { 50, 50, 100, 50, 50 };
  int[] amplitudes = new int[] { 64, 128, 255, 128, 64 };
  int repeat = 1; // Repeat from the second entry, index = 1.
  VibrationEffect repeatingEffect = VibrationEffect.createWaveform(timings, amplitudes, repeat);
  // repeatingEffect can be used in multiple places.

  vibrator.vibrate(repeatingEffect);
}

void stopVibrating() {
  vibrator.cancel();
}

이는 확인을 위해 사용자 작업이 필요한 간헐적인 이벤트에 매우 유용합니다. 이러한 이벤트의 예로는 수신 전화와 트리거된 알람이 있습니다.

샘플: 대체 패턴

진동 진폭 제어는 하드웨어 종속 기능입니다. 이 기능 없이 저사양 기기에서 파형을 재생하면 진폭 배열의 각 양수 항목의 최대 진폭으로 진동합니다. 앱이 이러한 기기를 수용해야 한다면 그러한 상태에서 재생될 때 패턴이 윙윙거리는 효과를 생성하지 않도록 하거나 대신 대체로 재생할 수 있는 더 간단한 켜기/끄기 패턴을 설계하는 것이 좋습니다.

Kotlin

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

Java

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

진동 조합 만들기

이 섹션에서는 이를 더 길고 복잡한 맞춤 효과로 구성하는 방법을 설명하고, 이를 넘어 고급 하드웨어 기능을 사용하여 풍부한 햅틱을 살펴봅니다. 진폭과 주파수가 다양한 효과 조합을 사용하여 더 넓은 주파수 대역폭을 갖는 햅틱 액추에이터가 있는 기기에서 더 복잡한 햅틱 효과를 만들 수 있습니다.

앞부분에서 설명한 맞춤 진동 패턴 만들기 과정에서는 진동 진폭을 조절하여 부드러운 증가 및 감소 효과를 만드는 방법을 설명합니다. 리치 햅틱은 기기 진동기의 더 넓은 주파수 범위를 탐색하여 효과를 더 부드럽게 만들어 이 개념을 개선합니다. 이러한 파형은 특히 크레센도 또는 디미누엔도 효과를 만드는 데 효과적입니다.

이 페이지의 앞부분에서 설명한 컴포지션 프리미티브는 기기 제조업체에서 구현합니다. 명확한 햅틱을 위해 햅틱 원칙에 맞는 선명하고 짧고 쾌적한 진동을 제공합니다. 이러한 기능 및 작동 방식에 관한 자세한 내용은 진동 액추에이터 기본 지침서를 참고하세요.

Android는 지원되지 않는 프리미티브가 포함된 컴포지션의 대체를 제공하지 않습니다. 다음 단계를 수행하는 것이 좋습니다.

  1. 고급 햅틱을 활성화하기 전에 특정 기기에서 사용 중인 모든 프리미티브를 지원하는지 확인하세요.

  2. 프리미티브가 누락된 효과뿐만 아니라 지원되지 않는 일련의 일관된 환경을 사용 중지합니다. 기기의 지원을 확인하는 방법에 관한 자세한 내용은 다음과 같습니다.

VibrationEffect.Composition로 합성된 진동 효과를 만들 수 있습니다. 다음은 서서히 상승하는 효과에 이어 뾰족한 클릭 효과가 이어지는 예입니다.

Kotlin

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

Java

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

컴포지션은 순서대로 재생될 프리미티브를 추가하여 만들어집니다. 또한 각 프리미티브는 확장 가능하므로 각 프리미티브에 의해 생성되는 진동의 진폭을 제어할 수 있습니다. 배율은 0과 1 사이의 값으로 정의되며, 여기서 0은 실제로 사용자가 이 프리미티브를 (거의) 느낄 수 있는 최소 진폭에 매핑됩니다.

동일한 프리미티브의 약한 버전과 강력한 버전을 만들려면 1.4 이상의 비율로 배율을 다르게 하여 강도 차이를 쉽게 인식할 수 있도록 하는 것이 좋습니다. 동일한 프리미티브의 강도 수준을 3개 넘게 만들려고 하지 마세요. 두 가지 강도가 인지적으로 구별되지 않기 때문입니다. 예를 들어 0.5, 0.7, 1.0의 배율을 사용하여 프리미티브의 낮은 강도, 중간 강도, 고강도 버전을 만듭니다.

컴포지션은 연속 프리미티브 사이에 추가되는 지연을 지정할 수도 있습니다. 이 지연은 이전 프리미티브가 종료된 이후 밀리초 단위로 표시됩니다. 일반적으로 두 프리미티브 간의 5~10밀리초 간격은 감지하기에 너무 짧습니다. 두 프리미티브 간에 분명한 간격을 만들려면 약 50ms 이상의 간격을 사용하는 것이 좋습니다. 다음은 지연이 있는 컴포지션의 예입니다.

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

샘플: 저항 (낮은 틱)

기본 진동의 진폭을 제어하여 진행 중인 작업에 유용한 피드백을 전달할 수 있습니다. 근접한 간격 값을 사용하면 프리미티브의 부드러운 크레센도 효과를 만들 수 있습니다. 연속된 프리미티브 간의 지연 시간은 사용자 상호작용에 따라 동적으로 설정될 수도 있습니다. 이는 드래그 동작으로 제어되고 햅틱으로 보강된 뷰 애니메이션의 다음 예에서 확인할 수 있습니다.

원이 아래로 드래그되는 애니메이션
입력 진동 파형 도표

Kotlin

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

  // Only vibrates while the user is dragging
  if (isDragging) {
    LaunchedEffect(Unit) {
      // Continuously run the effect for vibration to occur even when the view
      // is not being drawn, when user stops dragging midway through gesture.
      while (true) {
        // Calculate the interval inversely proportional to the drag offset.
        val vibrationInterval = calculateVibrationInterval(dragOffset)
        // Calculate the scale directly proportional to the drag offset.
        val vibrationScale = calculateVibrationScale(dragOffset)

        delay(vibrationInterval)
        vibrator.vibrate(
          VibrationEffect.startComposition().addPrimitive(
            VibrationEffect.Composition.PRIMITIVE_LOW_TICK,
            vibrationScale
          ).compose()
        )
      }
    }
  }

  Screen() {
    Column(
      Modifier
        .draggable(
          orientation = Orientation.Vertical,
          onDragStarted = {
            isDragging = true
          },
          onDragStopped = {
            isDragging = false
          },
          state = rememberDraggableState { delta ->
            dragOffset += delta
          }
        )
    ) {
      // Build the indicator UI based on how much the user has dragged it.
      ResistIndicator(dragOffset)
    }
  }
}

Java

class DragListener implements View.OnTouchListener {
  // Control variables for the dragging of the indicator.
  private int startY;
  private int vibrationInterval;
  private float vibrationScale;

  @Override
  public boolean onTouch(View view, MotionEvent event) {
    switch (event.getAction()) {
      case MotionEvent.ACTION_DOWN:
        startY = event.getRawY();
        vibrationInterval = calculateVibrationInterval(0);
        vibrationScale = calculateVibrationScale(0);
        startVibration();
        break;
      case MotionEvent.ACTION_MOVE:
        float dragOffset = event.getRawY() - startY;
        // Calculate the interval inversely proportional to the drag offset.
        vibrationInterval = calculateVibrationInterval(dragOffset);
        // Calculate the scale directly proportional to the drag offset.
        vibrationScale = calculateVibrationScale(dragOffset);
        // Build the indicator UI based on how much the user has dragged it.
        updateIndicator(dragOffset);
        break;
      case MotionEvent.ACTION_CANCEL:
      case MotionEvent.ACTION_UP:
        // Only vibrates while the user is dragging
        cancelVibration();
        break;
    }
    return true;
  }

  private void startVibration() {
    vibrator.vibrate(
          VibrationEffect.startComposition()
            .addPrimitive(VibrationEffect.Composition.PRIMITIVE_LOW_TICK, vibrationScale)
            .compose());

    // Continuously run the effect for vibration to occur even when the view
    // is not being drawn, when user stops dragging midway through gesture.
    handler.postDelayed(this::startVibration, vibrationInterval);
  }

  private void cancelVibration() {
    handler.removeCallbacksAndMessages(null);
  }
}

샘플: 확장 (상승 및 하강)

인지되는 진동 강도를 높이기 위한 두 가지 프리미티브, 즉 PRIMITIVE_QUICK_RISEPRIMITIVE_SLOW_RISE가 있습니다. 둘 다 동일한 타겟에 도달하지만 기간은 서로 다릅니다. 축소를 위한 프리미티브는 PRIMITIVE_QUICK_FALL 하나뿐입니다. 이러한 프리미티브는 서로 잘 연동되어 강도가 커졌다가 사라지는 파형 세그먼트를 만듭니다. 조정된 프리미티브를 정렬하여 진폭 사이의 갑작스러운 진폭을 방지할 수 있으며, 이는 전체 효과 지속 시간을 연장하는 데도 효과적입니다. 사람들은 항상 하강 부분보다 상승하는 부분을 더 잘 인지하므로, 상승 부분이 하락 부분보다 짧게 만들어 하강 부분으로 강조를 전환할 수 있습니다.

다음은 원을 펼치고 접는 데 이 컴포지션을 적용한 예입니다. 상승 효과를 사용하면 애니메이션 도중 확장 느낌을 강화할 수 있습니다. 상승 및 하강 효과의 조합은 애니메이션 종료 시 접히는 것을 강조하는 데 도움이 됩니다.

확장 원의 애니메이션
입력 진동 파형 도표

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를 사용하는 것입니다. 이 프리미티브는 두 번 이상 호출될 때 가장 효과적입니다. 여러 개의 스핀을 연결하면 흔들림과 불안정한 효과를 일으킬 수 있으며, 이는 각 프리미티브에 다소 임의적인 배율을 적용하여 더욱 향상될 수 있습니다. 연속적인 회전 프리미티브 간의 간격을 실험할 수도 있습니다. 간격 (사이 0ms) 없이 2번 회전하면 꽉 찬 회전 감각을 느낍니다. 스핀 간 간격을 10~50ms로 늘리면 더 느슨한 회전 감각을 갖게 되며, 동영상 또는 애니메이션의 재생 시간에 일치시키는 데 사용할 수 있습니다.

연속된 스핀이 더 이상 잘 통합되지 않고 개별 효과처럼 느껴지기 때문에 100ms보다 긴 간격은 사용하지 않는 것이 좋습니다.

다음은 아래로 드래그한 다음 놓습니다. 애니메이션은 바운스 변위에 비례하는 다양한 강도로 재생되는 한 쌍의 회전 효과를 사용하여 강화됩니다.

탄성으로 반사되는 애니메이션
입력 진동 파형 도표

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 [-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 [-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은 강하고 잔잔한 효과를 만들 수 있으며, 이 효과는 전반적인 환경을 강화하기 위해 예를 들어 동영상이나 애니메이션에서 영향력의 시각화와 함께 사용할 수 있습니다.

다음은 공이 화면 하단으로 튕길 때마다 재생되는 소리 효과로 개선되는 간단한 볼 드롭 애니메이션의 예입니다.

떨어진 공이 화면 하단으로 튀어나오는 애니메이션
입력 진동 파형 도표

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