Animating Inside and Outside the Box with Jetpack Compose

Nirbhay Pherwani on 2023-12-13

Animating Inside and Outside the Box with Jetpack Compose

Building Creative Animations with Compose in Android

This image was created with the assistance of DALL·E 3

Introduction

Animations have the power to make user interfaces feel alive and engaging. In Android, with Jetpack Compose, this power is at your fingertips, offering advanced tools to craft truly dynamic UIs. In this article, we’ll go beyond the basics and explore the deeper aspects of animations within Jetpack Compose.

We’ll cover a range of techniques, from creating fluid, physics-based motions that add a touch of realism, to complex choreographed sequences that bring a narrative quality to your interfaces. Whether you’re fine-tuning your skills or just curious about what’s possible, this journey will provide practical insights into making your apps not only function smoothly but also delight users at every interaction.

Let’s dive in and discover how these animations can transform your approach to UI design, making it more intuitive, responsive, and enjoyable for users.

Section 1 — Custom Animation Handlers in Jetpack Compose

Game Character Movement

Embracing Dynamic Interactivity with Custom Animations

In this section, we explore the use of advanced custom animation handlers in Jetpack Compose to create dynamic and interactive UI elements. Our focus is on a real-world example that demonstrates how user interaction can influence an animation in a meaningful way.

Example — Interactive Game Character Movement

We’ll illustrate this concept with an example where a game character (represented by a face icon) follows a path determined by a user-draggable control point.

@Composable
fun GameCharacterMovement() {
    val startPosition = Offset(100f, 100f)
    val endPosition = Offset(250f, 400f)
    val controlPoint = remember { mutableStateOf(Offset(200f, 300f)) }
    val position = remember { Animatable(startPosition, Offset.VectorConverter) }

    LaunchedEffect(controlPoint.value) {
        position.animateTo(
            targetValue = endPosition,
            animationSpec = keyframes {
                durationMillis = 5000
                controlPoint.value at 2500 // midway point controlled by the draggable control point
            }
        )
    }

    val onControlPointChange: (offset: Offset) -> Unit = {
        controlPoint.value = it
    }

    Box(modifier = Modifier.fillMaxSize()) {

        Icon(
            Icons.Filled.Face, contentDescription = "Localized description", modifier = Modifier
                .size(50.dp)
                .offset(x = position.value.x.dp, y = position.value.y.dp)
        )

        DraggableControlPoint(controlPoint.value, onControlPointChange)
    }
}

Explanation

Explanation

  1. Interactive Educational Apps: In an educational app, animations can be used to make learning more engaging. For instance, dragging a planet along its orbit in an astronomy app to see different constellations.
  2. Interactive Storytelling and Games: In digital storytelling or gaming apps, allowing users to influence the story or game environment through draggable elements can create a more immersive experience.

Synchronizing Multiple Elements for Harmonious Effects

In this section, we delve into the art of choreographing complex animations in Jetpack Compose. We focus on creating synchronized animations where multiple elements interact seamlessly, enhancing the overall user experience.

A) Chain Reaction Animations — The Domino Effect

Domino Effect

Creating a domino effect in UI can be achieved by setting up a series of animations where the completion of one triggers the start of the next.

@Composable
fun DominoEffect() {
    val animatedValues = List(6) { remember { Animatable(0f) } }

    LaunchedEffect(Unit) {
        animatedValues.forEachIndexed { index, animate ->
            animate.animateTo(
                targetValue = 1f,
                animationSpec = tween(durationMillis = 1000, delayMillis = index * 100)
            )
        }
    }

    Box (modifier = Modifier.fillMaxSize()){
      animatedValues.forEachIndexed { index, value ->
        Box(
            modifier = Modifier
                .size(50.dp)
                .offset(x = ((index+1) * 50).dp, y = ((index+1) * 30).dp)
                .background(getRandomColor(index).copy(alpha = value.value))
        )
      }
    }
}

fun getRandomColor(seed: Int): Color {
    val random = Random(seed = seed).nextInt(256)
    return Color(random, random, random)
}

Explanation

In this timeline, each element will fade in and move into position as the user scrolls through the timeline. We’ll use LazyColumn for the scrollable list and Animatable for the animation.

@Composable
fun InteractiveTimeline(timelineItems: List<String>) {
    val scrollState = rememberLazyListState()

    LazyColumn(state = scrollState) {
        itemsIndexed(timelineItems) { index, item ->
            val animatableAlpha = remember { Animatable(0f) }
            val isVisible = remember {
                derivedStateOf {
                    scrollState.firstVisibleItemIndex <= index
                }
            }

            LaunchedEffect(isVisible.value) {
                if (isVisible.value) {
                    animatableAlpha.animateTo(
                        1f, animationSpec = tween(durationMillis = 1000)
                    )

                }
            }

            TimelineItem(
                text = item,
                alpha = animatableAlpha.value,
            )
        }
    }
}

@Composable
fun TimelineItem(text: String, alpha: Float) {
    Row(
        modifier = Modifier
            .fillMaxWidth()
            .background(Color.DarkGray.copy(alpha = alpha))
            .padding(16.dp),
        verticalAlignment = Alignment.CenterVertically
    ) {
        Text(
            text = text,
            color = Color.White,
            modifier = Modifier.fillMaxWidth(),
            textAlign = TextAlign.Center,
            fontSize = 18.sp,
            fontWeight = FontWeight.SemiBold
        )
    }
}

Explanation

This interactive timeline is ideal for applications where you want to present a series of events or steps in a visually engaging way. The animation enhances user engagement by drawing attention to the items as they come into view.

Such animations are not only captivating but can also be used to guide user attention through a sequence of events or actions in your app.

Section 3 — Physics-based Animations for Realism in Jetpack Compose

Elastic Drag Animation

Leveraging Physics to Enhance UI Dynamics

In this section, we explore how to integrate physics principles into animations with Jetpack Compose, adding a layer of realism and interactivity to the UI. We’ll focus on an elastic drag interaction example.

Elastic Effect on Drag

This example illustrates an elastic drag interaction on an icon. When dragged vertically, the icon stretches and bounces back with an elastic effect, simulating the behavior of a spring or rubber band.

@Composable
fun ElasticDraggableBox() {
    var animatableOffset by remember { mutableStateOf(Animatable(0f)) }

    Box(modifier = Modifier.fillMaxSize().background(Color(0xFFFFA732)), contentAlignment = Alignment.Center) {
        Box(
            modifier = Modifier
                .offset(y = animatableOffset.value.dp)
                .draggable(
                    orientation = Orientation.Vertical,
                    state = rememberDraggableState { delta ->
                        animatableOffset = Animatable(animatableOffset.value + delta)
                    },
                    onDragStopped = {
                        animatableOffset.animateTo(0f, animationSpec = spring())
                    }
                )
                .size(350.dp),
            contentAlignment = Alignment.Center
        ) {
            Icon(
                Icons.Filled.Favorite,
                contentDescription = "heart",
                modifier = Modifier.size(animatableOffset.value.dp + 150.dp),
                tint = Color.Red
            )
        }
    }
}

Explanation

Enhancing User Experience with Responsive Gestures

In this section, we explore how Jetpack Compose can be used to create animations that are controlled by user gestures. We’ll focus on two examples — a multi-touch transformable image and a gesture-controlled audio waveform.

A) Multi Touch Transformable Image

In this example, we’ll create an image view that users can interact with using multi-touch gestures like pinch, zoom, and rotate.

Multi-touch Transformable Image
@Composable
fun TransformableImage(imageId: Int = R.drawable.android) {
    var scale by remember { mutableStateOf(1f) }
    var rotation by remember { mutableStateOf(0f) }
    var offset by remember { mutableStateOf(Offset.Zero) }

    Box(modifier = Modifier.fillMaxSize().background(Color.DarkGray), contentAlignment = Alignment.Center) {
        Image(
            painter = painterResource(id = imageId),
            contentDescription = "Transformable image",
            contentScale = ContentScale.Crop,
            modifier = Modifier
                .size(300.dp)
                .graphicsLayer(
                    scaleX = scale,
                    scaleY = scale,
                    rotationZ = rotation,
                    translationX = offset.x,
                    translationY = offset.y
                )
                .pointerInput(Unit) {
                    detectTransformGestures { _, pan, zoom, rotate ->
                        scale *= zoom
                        rotation += rotate
                        offset += pan
                    }
                }
        )
    }
}

Explanation

Here’s a waveform visualization that changes its appearance based on user gestures, such as swipes and pinches, to control aspects like amplitude and frequency.

Gesture Controlled Waveform
@Composable
fun GestureControlledWaveform() {
    var amplitude by remember { mutableStateOf(100f) }
    var frequency by remember { mutableStateOf(1f) }

    Canvas(modifier = Modifier
        .fillMaxSize()
        .pointerInput(Unit) {
            detectDragGestures { _, dragAmount ->
                amplitude += dragAmount.y
                frequency += dragAmount.x / 500f 
                // Adjusting frequency based on drag
            }
        }
        .background(
            Brush.verticalGradient(
                colors = listOf(Color(0xFF003366), Color.White, Color(0xFF66B2FF))
            )
        )) {
        val width = size.width
        val height = size.height
        val path = Path()

        val halfHeight = height / 2
        val waveLength = width / frequency

        path.moveTo(0f, halfHeight)

        for (x in 0 until width.toInt()) {
            val theta = (2.0 * Math.PI * x / waveLength).toFloat()
            val y = halfHeight + amplitude * sin(theta.toDouble()).toFloat()
            path.lineTo(x.toFloat(), y)
        }

        val gradient = Brush.horizontalGradient(
            colors = listOf(Color.Blue, Color.Cyan, Color.Magenta)
        )

        drawPath(
            path = path,
            brush = gradient
        )
    }
}

Explanation

Section 5 — State-driven Animation Patterns in Jetpack Compose

Animated Line Graph

Animating UI Based on Data and State Changes

This section focuses on creating animations that are driven by changes in data or UI state, enhancing the interactivity and responsiveness of the app. We’ll explore two specific examples — animating a data graph and implementing state transitions in a multi-state UI.

A) Data Driven Graph Animation

This example demonstrates an animated line graph where the path of the graph animates in response to changes in the data set.

@Composable
fun AnimatedGraphExample() {
    var dataPoints by remember { mutableStateOf(generateRandomDataPoints(5)) }

    Column(
        modifier = Modifier
            .fillMaxSize()
            .background(Color.DarkGray)
    ) {
        AnimatedLineGraph(dataPoints = dataPoints)

        Spacer(modifier = Modifier.height(16.dp))

        Button(
            onClick = {
                dataPoints = generateRandomDataPoints(5)
            },
            modifier = Modifier.align(Alignment.CenterHorizontally),
            colors = ButtonDefaults.buttonColors(containerColor = Color.Green)
        ) {
            Text(
                "Update Data",
                fontWeight = FontWeight.Bold,
                color = Color.DarkGray,
                fontSize = 18.sp
            )
        }
    }
}

@Composable
fun AnimatedLineGraph(dataPoints: List<Float>) {
    val animatableDataPoints = remember { dataPoints.map { Animatable(it) } }
    val path = remember { Path() }

    LaunchedEffect(dataPoints) {
        animatableDataPoints.forEachIndexed { index, animatable ->
            animatable.animateTo(dataPoints[index], animationSpec = TweenSpec(durationMillis = 500))
        }
    }

    Canvas(
        modifier = Modifier
            .fillMaxWidth()
            .height(400.dp)
    ) {
        path.reset()
        animatableDataPoints.forEachIndexed { index, animatable ->
            val x = (size.width / (dataPoints.size - 1)) * index
            val y = size.height - (animatable.value * size.height)
            if (index == 0) path.moveTo(x, y) else path.lineTo(x, y)
        }
        drawPath(path, Color.Green, style = Stroke(5f))
    }
}

fun generateRandomDataPoints(size: Int): List<Float> {
    return List(size) { Random.nextFloat() }
}

Explanation

Implementing state transitions in a multi-state UI can be done using Animatable to animate between different UI states.

enum class UIState { StateA, StateB, StateC }

@Composable
fun StateTransitionUI() {
    var currentState by remember { mutableStateOf(UIState.StateA) }

    Box(
        modifier = Modifier
            .fillMaxSize()
            .background(getBackgroundColorForState(currentState)),
        contentAlignment = Alignment.Center
    ) {
        AnimatedContent(currentState = currentState)

        Button(
            onClick = { currentState = getNextState(currentState) },
            modifier = Modifier.align(Alignment.BottomCenter)
        ) {
            Text("Next State")
        }
    }
}

@Composable
fun AnimatedContent(currentState: UIState) {
    AnimatedVisibility(
        visible = currentState == UIState.StateA,
        enter = fadeIn(animationSpec = tween(durationMillis = 2000)) + expandVertically(),
        exit = fadeOut(animationSpec = tween(durationMillis = 2000)) + shrinkVertically()
    ) {
        Text("This is ${currentState.name}", fontSize = 32.sp)
    }

    // Similar blocks for B and C
}

fun getBackgroundColorForState(state: UIState): Color {
    return when (state) {
        UIState.StateA -> Color.Red
        UIState.StateB -> Color.Green
        UIState.StateC -> Color.Blue
    }
}

fun getNextState(currentState: UIState): UIState {
    return when (currentState) {
        UIState.StateA -> UIState.StateB
        UIState.StateB -> UIState.StateC
        UIState.StateC -> UIState.StateA
    }
}

Explanation

Shape Morphing

Animating the transformation between shapes involves interpolating the properties of these shapes.

@Composable
fun ShapeMorphingAnimation() {
    val animationProgress = remember { Animatable(0f) }

    LaunchedEffect(Unit) {
        animationProgress.animateTo(
            targetValue = 1f,
            animationSpec = infiniteRepeatable(
                animation = tween(2000, easing = LinearOutSlowInEasing),
                repeatMode = RepeatMode.Reverse
            )
        )
    }

    Canvas(modifier = Modifier.padding(40.dp).fillMaxSize()) {
        val sizeValue = size.width.coerceAtMost(size.height) / 2
        val squareRect = Rect(center = center, sizeValue)

        val morphedPath = interpolateShapes(progress = animationProgress.value, squareRect = squareRect)
        drawPath(morphedPath, color = Color.Blue, style = Fill)
    }
}

fun interpolateShapes(progress: Float, squareRect: Rect): Path {
    val path = Path()

    val cornerRadius = CornerRadius(
        x = lerp(start = squareRect.width / 2, stop = 0f, fraction = progress),
        y = lerp(start = squareRect.height / 2, stop = 0f, fraction = progress)
    )

    path.addRoundRect(
        roundRect = RoundRect(rect = squareRect, cornerRadius = cornerRadius)
    )

    return path
}

fun lerp(start: Float, stop: Float, fraction: Float): Float {
    return (1 - fraction) * start + fraction * stop
}

Explanation

  1. Loading and Progress Indicators — Morphing shapes can be used to create more engaging loading or progress indicators, providing a visually interesting way to indicate progress or loading states.
  2. Icon Transitions in UI — Morphing icons can be used to provide visual feedback in response to user actions. For example, a play button morphing into a pause button when clicked, or a hamburger menu icon transforming into a back arrow.
  3. Data Visualization — In complex data visualizations, morphing can help transition between different views or states of data, making it easier for users to follow and understand changes over time or between categories.

We’ll demonstrate a simple particle system to create a snowfall effect.

Snowfall Effect
data class Snowflake(
    var x: Float,
    var y: Float,
    var radius: Float,
    var speed: Float
)

@Composable
fun SnowfallEffect() {
    val snowflakes = remember { List(100) { generateRandomSnowflake() } }
    val infiniteTransition = rememberInfiniteTransition(label = "")

    val offsetY by infiniteTransition.animateFloat(
        initialValue = 0f,
        targetValue = 1000f,
        animationSpec = infiniteRepeatable(
            animation = tween(durationMillis = 5000, easing = LinearEasing),
            repeatMode = RepeatMode.Restart
        ), label = ""
    )

    Canvas(modifier = Modifier.fillMaxSize().background(Color.Black)) {
        snowflakes.forEach { snowflake ->
            drawSnowflake(snowflake, offsetY % size.height)
        }
    }
}

fun generateRandomSnowflake(): Snowflake {
    return Snowflake(
        x = Random.nextFloat(),
        y = Random.nextFloat() * 1000f,
        radius = Random.nextFloat() * 2f + 2f, // Snowflake size
        speed = Random.nextFloat() * 1.2f + 1f  // Falling speed
    )
}

fun DrawScope.drawSnowflake(snowflake: Snowflake, offsetY: Float) {
    val newY = (snowflake.y + offsetY * snowflake.speed) % size.height
    drawCircle(Color.White, radius = snowflake.radius, center = Offset(snowflake.x * size.width, newY))
}

Explanation

As we wrap up this exploration of animations in Jetpack Compose, it’s clear that animations are more than just visual embellishments. They are crucial tools for creating engaging, intuitive, and delightful user experiences.

Embracing Interactivity

From the dynamic game character movement to the interactive timeline, we’ve seen how animations can make user interactions more engaging and informative.

Crafting Realistic Experiences

The snowfall effect and the morphing shapes show this toolkit’s ability to bring realism and fluidity into the digital realm. These animations help create immersive experiences that resonate with users.

Simplifying Complexity

Whether it’s choreographing multiple elements or animating state transitions, the simplicity with which it can be done stands out.

Closing Remarks

If you liked what you read, please feel free to leave your valuable feedback or appreciation. I am always looking to learn, collaborate and grow with fellow developers.

If you have any questions feel free to message me!

Follow me on Medium for more articles — Medium Profile

Connect with me on LinkedIn and Twitter for collaboration.

Happy Animating!