Skip to content

Animation

Every animation here is a port of the corresponding res/anim resource from 1.x, with the same duration, the same interpolator, and the same pivot. The three AOSP interpolators involved (accelerate/decelerate, bounce, and overshoot) are reimplemented as Compose Easing functions, so the motion is identical on Android, iOS, Desktop, and Web.

Enter and exit

Balloon example Balloon example Balloon example Balloon example

setBalloonAnimation(BalloonAnimation.ELASTIC)
Value Enter Exit
NONE instant instant
FADE alpha 0 to 1 over 200ms, linear reverse
ELASTIC scale 0.5 to 1 over 250ms, bounce scale to 0 over 250ms
OVERSHOOT scale 0.5 to 1 over 250ms, overshoot scale to 0 over 250ms
CIRCULAR circular reveal, 500ms by default scale to 0 over 200ms

Every scale pivots on the balloon's center, which is what the original animation resources did regardless of where the arrow sits.

setCircularDuration(700L)  // only affects CIRCULAR

CIRCULAR clears focusability

setBalloonAnimation(BalloonAnimation.CIRCULAR) also sets focusable to false, which is what 1.x did so the reveal can play without the popup stealing input. Call setFocusable(true) afterwards if you want it back.

Highlight animations

Balloon example Balloon example Balloon example Balloon example

A looping animation that plays for as long as the balloon is showing, to draw the eye.

setBalloonHighlightAnimation(BalloonHighlightAnimation.HEARTBEAT)
setBalloonHighlightAnimation(BalloonHighlightAnimation.SHAKE, startDelayMillis = 300L)
Value Motion
NONE nothing, the default
HEARTBEAT pulses between 100% and 90% scale over 800ms
SHAKE slides 13% of its own size away from the anchor and back over 650ms
BREATH fades between 75% and 100% alpha over 800ms
ROTATE spins in 3D, configured separately

HEARTBEAT pivots on the arrow edge, so the arrow stays pinned to the anchor while the rest of the balloon pulses toward it. When the arrow is hidden it pivots on the center instead. SHAKE slides away from the arrow, so the tooltip tugs against the anchor it points at.

Until startDelayMillis elapses the balloon is drawn untransformed, exactly as in 1.x.

ROTATE

setBalloonHighlightAnimation(BalloonHighlightAnimation.ROTATE)
setBalloonRotationAnimation(
    BalloonRotateAnimation(
        direction = BalloonRotateDirection.RIGHT,
        turns = 1,
        loops = BalloonRotateAnimation.INFINITE,
        speedMillis = 2500,
        degreeX = 0,
        degreeZ = 0,
    ),
)

The balloon turns 360 * turns * direction degrees around Y, plus optional fixed sweeps around X and Z, over speedMillis, repeating loops times about its center. INFINITE repeats forever.

Two differences from 1.x worth knowing. The perspective is density relative here, where android.graphics.Camera sat at a fixed 576px whatever the screen, so the same rotation reads slightly flatter on a dense display and consistent across devices. And 1.x left balloonRotateAnimation null by default, which meant ROTATE did nothing until you configured it; here the default is a real BalloonRotateAnimation().

Overlay animation

setBalloonOverlayAnimation(BalloonOverlayAnimation.FADE)  // default
setBalloonOverlayAnimation(BalloonOverlayAnimation.NONE)

See Overlay.

Disabling animations in tests

Set BalloonAnimation.NONE so a screenshot or a semantics assertion runs against a settled frame:

val style = DefaultBalloonStyle.derive { setBalloonAnimation(BalloonAnimation.NONE) }

HEARTBEAT, SHAKE, BREATH, and a ROTATE with loops = INFINITE never finish, so the Compose test clock never goes idle and waitForIdle would hang. Drive the clock by hand for those. A ROTATE with a finite loops completes on its own and needs none of this.

mainClock.autoAdvance = false
runOnUiThread { balloonState.showAlignBottom() }
mainClock.advanceTimeBy(16L)
onNodeWithTag("body").assertIsDisplayed()