Create custom haptic effects

Stay organized with collections Save and categorize content based on your preferences.

This page covers the examples of how to use different haptics APIs to create custom effects in an Android application. As much of the information on this page relies on a good knowledge of the workings of a vibration actuator, we recommend reading the Vibration actuator primer.

This page includes the following examples.

For additional examples, see Add haptic feedback to events, and always follow haptics design principles.

Use fallbacks to handle device compatibility

When implementing any custom effect, consider the following:

  • Which device capabilities are required for the effect
  • What to do when the device is not capable of playing the effect

The Android haptics API reference provides details on how to check for support for components involved in your haptics, so that your app can provide a consistent overall experience.

Depending on your use case, you might want to disable custom effects or to provide alternative custom effects based on different potential capabilities.

Plan for the following high-level classes of device capability:

  • If you're using haptic primitives: devices supporting those primitives needed by the custom effects. (See the next section for details on primitives.)

  • Devices with amplitude control.

  • Devices with basic vibration support (on/off)—in other words, those lacking amplitude control.

If your app's haptic effect choice accounts for these categories, then its haptic user experience should remain predictable for any individual device.

Usage of haptic primitives

Android includes several haptics primitives that vary in both amplitude and frequency. You may use one primitive alone or multiple primitives in combination to achieve rich haptic effects.

  • Use delays of 50 ms or longer for discernible gaps between two primitives, also taking into account the primitive duration if possible.
  • Use scales that differ by a ratio of 1.4 or more so the difference in intensity is better perceived.
  • Use scales of 0.5, 0.7 and 1.0 to create a low, medium and high intensity version of a primitive.

Create custom vibration patterns

Vibration patterns are often used in attentional haptics, such as notifications and ringtones. The Vibrator service can play long vibration patterns that change the vibration amplitude over time. Such effects are named waveforms.

Waveform effects can be easily perceivable, but sudden long vibrations can startle the user if played in a quiet environment. Ramping to a target amplitude too fast might also produce audible buzzing noises. The recommendation for designing waveform patterns is to smooth the amplitude transitions to create ramp up and down effects.

Sample: Ramp-up pattern

Waveforms are represented as VibrationEffect with three parameters:

  1. Timings: an array of durations, in milliseconds, for each waveform segment.
  2. Amplitudes: the desired vibration amplitude for each duration specified in the first argument, represented by an integer value from 0 to 255, with 0 representing the vibrator "off" and 255 being the device's maximum amplitude.
  3. Repeat index: the index in the array specified in the first argument to start repeating the waveform, or -1 if it should play the pattern only once.

Here is an example waveform that pulses twice with a pause of 350 ms in between pulses. The first pulse is a smooth ramp up to the maximum amplitude, and the second is a quick ramp to hold maximum amplitude. Stopping at the end is defined by the negative repeat index value.

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

Sample: Repeating pattern

Waveforms can also be played repeatedly until cancelled. The way to create a repeating waveform is to set a non-negative ‘repeat’ parameter. When you play a repeating waveform, the vibration continues until it's explicitly cancelled in the service:

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

This is very useful for intermittent events that require user action to acknowledge it. Examples of such events include incoming phone calls and triggered alarms.

Sample: Pattern with fallback

Controlling the amplitude of a vibration is a hardware-dependent capability. Playing a waveform on a low-end device without this capability causes it to vibrate at the maximum amplitude for each positive entry in the amplitude array. If your app needs to accommodate such devices then the recommendation is to make sure that your pattern doesn't generate a buzzing effect when played in that condition, or to design a simpler ON/OFF pattern that can be played as a fallback instead.

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

Create vibration compositions

This section presents ways to compose them into longer and more complex custom effects, and goes beyond that to explore rich haptics using more advanced hardware capabilities. You can use combinations of effects that vary amplitude and frequency to create more complex haptic effects on devices with haptic actuators that have a wider frequency bandwidth.

The process for creating custom vibration patterns, described previously on this page, explains how to control the vibration amplitude to create smooth effects of ramping up and down. Rich haptics improves on this concept by exploring the wider frequency range of the device vibrator to make the effect even smoother. These waveforms are especially effective at creating a crescendo or diminuendo effect.

The composition primitives, described earlier on this page, are implemented by the device manufacturer. They provide a crisp, short and pleasant vibration that aligns with Haptics principles for clear haptics. For more details about these capabilities and how they work, see Vibration actuators primer.

Android doesn't provide fallbacks for compositions with unsupported primitives. We recommend that you perform the following steps:

  1. Before activating your advanced haptics, check that a given device supports all the primitives you're using.

  2. Disable the consistent set of experiences that are unsupported, not just the effects that are missing a primitive. More information on how to check the device’s support is shown as follows.

You can create composed vibration effects with VibrationEffect.Composition. Here is an example of a slowly rising effect followed by a sharp click effect:

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

A composition is created by adding primitives to be played in sequence. Each primitive is also scalable, so you can control the amplitude of the vibration generated by each of them. The scale is defined as a value between 0 and 1, where 0 actually maps to a minimum amplitude at which this primitive can be (barely) felt by the user.

If you’d like to create a weak and strong version of the same primitive, it is recommended that the scales differ by a ratio of 1.4 or more, so the difference in intensity can be easily perceived. Don't try to create more than three intensity levels of the same primitive, because they aren't perceptually distinct. For example, use scales of 0.5, 0.7, and 1.0 to create a low, medium, and high intensity version of a primitive.

The composition can also specify delays to be added in between consecutive primitives. This delay is expressed in milliseconds since the end of the previous primitive. In general, a 5 to 10 ms gap between two primitives is too short to be detectable. Consider using a gap on the order of 50 ms or longer if you want to create a discernible gap between two primitives. Here is an example of a composition with delays:

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

The following APIs can be used to verify the device support for specific primitives:

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

It's also possible to check multiple primitives and then decide which ones to compose based on the device support level:

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

Sample: Resist (with low ticks)

You can control the amplitude of the primitive vibration to convey useful feedback to an action in progress. Closely-spaced scale values can be used to create a smooth crescendo effect of a primitive. The delay between consecutive primitives can also be dynamically set based on the user interaction. This is illustrated in the following example of a view animation controlled by a drag gesture and augmented with haptics.

Animation of a circle being dragged down
Plot of input vibration waveform

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

Sample: Expand (with rise and fall)

There are two primitives for ramping up the perceived vibration intensity: the PRIMITIVE_QUICK_RISE and PRIMITIVE_SLOW_RISE. They both reach the same target, but with different durations. There is only one primitive for ramping down, the PRIMITIVE_QUICK_FALL. These primitives work better together to create a waveform segment that grows in intensity and then dies off. You can align scaled primitives to prevent sudden jumps in amplitude between them, which also works well for extending the overall effect duration. Perceptually, people always notice the rising portion more than the falling portion, so making the rising portion shorter than the falling can be used to shift the emphasis towards the falling portion.

Here is an example of an application of this composition for expanding and collapsing a circle. The rise effect can enhance the feeling of expansion during the animation. The combination of rise and fall effects helps emphasize the collapsing at the end of the animation.

Animation of an expanding circle
Plot of input vibration waveform

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

Sample: Wobble (with spins)

One of the key haptics principles is to delight users. A fun way to introduce a pleasant unexpected vibration effect is to use the PRIMITIVE_SPIN. This primitive is most effective when it is called more than once. Multiple spins concatenated can create a wobbling and unstable effect, which can be further enhanced by applying a somewhat random scaling on each primitive. You can also experiment with the gap between successive spin primitives. Two spins without any gap (0 ms in between) creates a tight spinning sensation. Increasing the inter-spin gap from 10 to 50 ms leads to a looser spinning sensation, and can be used to match the duration of a video or animation.

We don't recommended using a gap that is longer than 100 ms, as the successive spins no longer integrate well and begin to feel like individual effects.

Here is an example of a elastic shape that bounces back after being dragged down and then released. The animation is enhanced with a pair of spin effects, played with varying intensities that are proportional to the bounce displacement.

Animation of an elastic shape bouncing
Plot of input vibration waveform

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

Sample: Bounce (with thuds)

Another advanced application of vibration effects is to simulate physical interactions. The PRIMITIVE_THUD can create a strong and reverberating effect, which can be paired with the visualization of an impact, in a video or animation for example, to augment the overall experience.

Here is an example of a simple ball drop animation enhanced with a thud effect played each time the ball bounces off the bottom of the screen:

Animation of a dropped ball bouncing off the bottom of the screen
Plot of input vibration waveform

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