Animations in Jetpack Compose

Android Taipei Dec. 2023: Jetpack Compose 動畫實務

John Lu
6 min readDec 26, 2023

--

Outlines

  1. Principles of Animation
  2. Built-in Animation APIs
  3. Choosing an API

1) Principles of Animation

  • What property am I animating?
  • When should this property change?
  • How should this property animate?

What to animate

Most animations are made up of changing different properties or values of a composable.

  • Scale
  • Translation
  • Rotation
  • Alpha
  • Color

Break down the animation

Analyse which properties of what composables are changing over time.

When to animate

  • On State Change
  • On Launch
  • Infinitely
  • With Gesture

On State Change

  • Store and manage the value change over time for you.
  • Typically one-shot state changes.
  • animateColorAsState()
  • animateDpAsState()
  • animateFloatAsState()
  • animateSizeAsState()
  • animateRectAsState()

animate*AsState()

var clicked by remember { mutableStateOf(false) }
val pxToMove = with(LocalDensity.current) {
100.dp.toPx().roundToInt()
}
val offset by animateIntOffsetAsState(
targetValue = if (clicked) {
IntOffset(pxToMove, pxToMove)
} else {
IntOffset.Zero
},
label = "offset"
)

Box(Modifier.fillMaxSize()) {
Box(
modifier = Modifier
.offset {
offset
}
.background(Color.Blue)
.size(100.dp)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null
) {
clicked = !clicked
}
)
}

Transition

  • Coordinated state updates that’ll happen at the same time, based on a state.
  • Not independent of each other — happen at the same time.
  • SeekableTransition — allows for seeking between states.
var targetState by remember { mutableStateOf(false) }
val transition = updateTransition(targetState, label = "transition")

val pxToMove = with(LocalDensity.current) {
100.dp.toPx().roundToInt()
}

val scale by transition.animateFloat (label = "scale") { state ->
when (state) {
false -> 1f
true -> 3f
}
}
val rotate by transition.animateFloat(label = "rotate") { state ->
when (state) {
false -> 0f
true -> 45f
}
}
val offset by transition.animateIntOffset(label = "offset") { state ->
when (state) {
false -> IntOffset.Zero
true -> IntOffset(pxToMove, pxToMove)
}
}

Box(Modifier.fillMaxSize()) {
Box(
modifier = Modifier
.graphicsLayer {
scaleX = scale
scaleY = scale
translationX = offset.x.toFloat()
translationY = offset.y.toFloat()
rotationZ = rotate
}
.background(Color.Blue)
.size(100.dp)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null
) {
targetState = !targetState
}
)
}

On Launch

// Start out gray and animate to green/red based on `open`
val color = remember { Animatable(Color.Gray) }
var clicked by remember { mutableStateOf(false) }
LaunchedEffect(clicked) {
color.animateTo(if (clicked) Color.Green else Color.Red)
}

Box(Modifier.fillMaxSize()) {
Box(
modifier = Modifier
.background(color.value)
.size(100.dp)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null
) {
clicked = !clicked
}
)
}

Infinitely

val infiniteTransition = rememberInfiniteTransition(label = "InfiniteTransition")
val scale by infiniteTransition.animateFloat(
initialValue = 1f,
targetValue = 8f,
animationSpec = infiniteRepeatable(tween(1000), RepeatMode.Reverse),
label = "FloatAnimation"
)
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(
text = "Android TPE",
modifier = Modifier
.graphicsLayer {
scaleX = scale
scaleY = scale
transformOrigin = TransformOrigin.Center
},
style = LocalTextStyle.current.copy(
textMotion = TextMotion.Animated
)
)
}

With Gesture

  • Interruptions of ongoing animation when touch begins.
  • Continue animation after gesture is finished.
    — Either decaying or snapping to a certain position.
    — Needs to feel like a natural stop.

Drag gesture handling

fun Modifier.animateDraggable(
translation: Animatable<Float, AnimationVector1D>,
draggableState: DraggableState,
decay: DecayAnimationSpec<Float>,
screenWidth: () -> Float,
onUpdateBoxState: (BoxState) -> Unit,
) = this
.graphicsLayer {
translationX = translation.value
val scale = lerp(1f, 0.8f, translation.value / screenWidth())
scaleX = if (translationX > 0) scale else 1 / scale
scaleY = if (translationX > 0) scale else 1 / scale
}
.draggable(
state = draggableState,
orientation = Orientation.Horizontal,
onDragStopped = { velocity ->
val targetX = decay.calculateTargetValue(
translation.value,
velocity
)
launch {
val boundaryX =
if (targetX > screenWidth() * 0.5) screenWidth()
else if (targetX < screenWidth() * -0.5) -screenWidth()
else 0f
val canReachBoundaryWithTarget = (targetX > boundaryX && boundaryX == screenWidth())
|| (targetX < boundaryX && boundaryX == -screenWidth())
if (canReachBoundaryWithTarget) {
translation.animateDecay(
initialVelocity = velocity,
animationSpec = decay
)
} else {
translation.animateTo(boundaryX, initialVelocity = velocity)
}
onUpdateBoxState(if (boundaryX == screenWidth()) BoxState.Open else BoxState.Closed)
}
}
)

How to animate

Animation Specs

  • Each animation is controlled in its execution by an AnimationSpec.
  • Stores info about the type (Int, Float) and configuration.
  • tween()
  • spring() // Default for all Compose animations
  • snap()
  • keyframes()
  • repeatable()

tween()

Animating between two values. Useful when you want to set a duration in milliseconds for how long your animation should take.

var clicked by remember { mutableStateOf(false) }
val xOffset = remember {
Animatable(0f)
}

LaunchedEffect(clicked) {
xOffset.animateTo(
targetValue = if (clicked) 200f else 0f,
animationSpec = tween(
durationMillis = 300,
easing = LinearEasing
)
)
}

Box(Modifier.fillMaxSize()) {
Box(
modifier = Modifier
.graphicsLayer {
translationX = xOffset.value
}
.background(Color.Blue)
.size(100.dp)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null
) {
clicked = !clicked
}
)
}

Easing

  • Rate of chang of your animation over time.
  • Easing is an interface that takes a fraction [0, 1] and produces another value.
@Stable
fun interface Easing {
fun transform(fraction: Float): Float
}

LinearEasing

Graph showing how Linear Easing works over time, and applying the animation to different animations such as translation, rotation, scaling, alpha and color.
  • f(n) = n
  • Doesn’t accelerate or decelerate, constant speed.
val xOffset = remember {
Animatable(0f)
}

LaunchedEffect(Unit) {
xOffset.animateTo(
targetValue = 200f,
animationSpec = tween(
durationMillis = 300,
easing = LinearEasing
)
)
}
  • Start: xOffset = 0
  • Midpoint: xOffset = 100f
  • End: XOffset = 200f

CubicBezierEasing

EaseInOut with its 4 points mapped
  • Follows a bezier curve.
  • Provide it with two control points (p1, p2).
val EaseInOut: Easing = CubicBezierEasing(0.42f, 0.0f, 0.58f, 1.0f)

spring()

Animate with physics-based properties. It is more natural than tween — as it calculates the velocity based on the stiffness and damping ratio.

tween vs spring

Spring animations: Configuration

  • dampingRatio: how bouncy the spring should be, relative to its mass.
  • stiffness: how fast the spring should move towards end.

2) Built-in Animation APIs

AnimatedVisibility

var visible by remember {
mutableStateOf(true)
}

AnimatedVisibility(visible) {
// Composable
}
Animating the appearance and disappearance of an item in a column

animateContentSize()

var expanded by remember {
mutableStateOf(false)
}

Box(
modifier = Modifier
.background(Color.Blue)
.animateContentSize()
.height(if (expanded) 400.dp else 200.dp)
.fillMaxWidth()
) {

}
Composable smoothly animating between a small and a larger size

AnimatedContent

Animate changes between different Composables.

  • Customize X order of animation.
  • Customize transition between the different states.
@Composable
fun SearchContext(
tabSelected: KimojiScreen
) {
AnimatedContent(
targetState = tabSelected,
label = "AnimatedContent"
) { targetState ->
when (targetState) {
KimojiScreen.Add -> {}
KimojiScreen.Gallery -> {}
KimojiScreen.Diary -> {}
}
}
}

Enter & Exit Transitions

  • Customise how an element enters the screen, and exits.
  • slideIntoContainer, slideOutOfContainer: slides the incoming/outgoing content based on the direction.
@Composable
fun SearchContext(
tabSelected: KimojiScreen
) {
AnimatedContent(
targetState = tabSelected,
label = "AnimatedContent",
transitionSpec = {
slideIntoContainer(
towards = AnimatedContentScope.SlideDirection.Up,
animationSpec = tween(durationMillis = 300, easing = EaseIn),
) with slideOutOfContainer(
towards = AnimatedContentScope.SlideDirection.Down,
animationSpec = tween(durationMillis = 300, easing = EaseOut),
)
}
) { targetState ->
when (targetState) {
KimojiScreen.Add -> {}
KimojiScreen.Gallery -> {}
KimojiScreen.Diary -> {}
}
}
}

3) Choosing an API

Most animations can be made with Animatableor animate() function.

Decision tree describing how to choose the appropriate animation API

Summary

  • Analyse designs with three principles: what, when and how
  • Gesture driven animations are typically made up of dragging on screen and flinging after the fact.
  • Use built-in components to make animations easier.
  • Use decision tree diagram to help pick an API.

References

Practical magic with animations in Jetpack Compose

--

--

John Lu
John Lu

Written by John Lu

AI Engineer. Innovative and results-driven. Adept at deploying cutting-edge models with high-performance apps. He transforms challenges into practical solutions

No responses yet