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. Deeply motivated by challenges and tends to be excited by breaking conventional ways of thinking and doing. He builds fun and creative apps.