How to Create a Coin Flipper Animation in Jetpack Compose

Kappdev on 2023-12-11

How to Create a Coin Flipper Animation in Jetpack Compose

Welcome 👋

In this article, I’ll demonstrate how to create a beautiful Coin Flipper Animation in Jetpack Compose. While its practical utility might seem limited, the scope for creativity 🌟 is boundless.

Let’s dive in together and uncover the possibilities!

Image by macrovector on Freepik

Preparation

Before we start building this beautiful view, we need to define some utilities that we will use later on.

Quarter calculation

The determineQuarter function takes a degree of your rotate animation and returns its quarter which we will use to display an appropriate coin side.

fun determineQuarter(degree: Float): Int {
    // Normalize the degree value within the range 0 to 359 degrees
    val normalizedDegree = (degree + 360) % 360
    return when {
        normalizedDegree <= 90f -> 1
        normalizedDegree <= 180f -> 2
        normalizedDegree <= 270f -> 3
        else -> 4
    }
}

Custom jump animation

The jumpAnimation function creates a keyframe animation that simulates a jump with a quick ascent and a slower descent.

fun jumpAnimation(duration: Int, jumpTarget: Float) = keyframes {
    durationMillis = duration
    0f at 0
    jumpTarget at (duration * 0.3f).toInt()
    0f at duration
}

Reflected view

Since the back side of the coin will be reflected by rotation, we need to undo the reflection. I have created the ReflectVertically function for this purpose.

@Composable
fun BoxScope.ReflectVertically(
    content: @Composable BoxScope.() -> Unit
) {
    Box(
        modifier = Modifier
            .matchParentSize()
            .graphicsLayer { rotationX = 180f },
        content = content
    )
}

Fine! Now we’re ready to move on. 😊

Crafting the view

Function Signature and Parameters:

Let’s start by defining the function and its parameters.

@Composable
fun CoinFlipper(
    modifier: Modifier = Modifier,
    duration: Int = 2_000,
    maxJumpHeight: Dp = 100.dp,
    forceRange: IntRange = (5..20),
    frontSide: @Composable BoxScope.(isRunning: Boolean, flip: () -> Unit) -> Unit,
    backSide: @Composable BoxScope.(isRunning: Boolean, flip: () -> Unit) -> Unit
)

Before diving into the animation logic, it’s crucial to ensure the provided input parameters are valid. We use the require function to validate the duration and force range parameters:

require(duration > 0) { "Duration should be a positive value." }
require(forceRange.first >= 0 && forceRange.last >= forceRange.first) { "Invalid force range." }

Following the validation, we initialize the density variable for precise calculations. Additionally, we create a coroutine scope using rememberCoroutineScope().

val density = LocalDensity.current
val scope = rememberCoroutineScope()

Animation Logic:

Let’s delve into the implementation of our animation logic:

// Define animatable values for rotation and translation
val rotationAnim = remember { Animatable(0f) }
val translationAnim = remember { Animatable(0f) }

// Calculate the jump height in pixels
val jumpHeight = remember(maxJumpHeight) { with(density) { maxJumpHeight.toPx() } }
// Calculate the jump step based on jump height and force range
val jumpStep = remember(jumpHeight, forceRange) { (jumpHeight / forceRange.last) }
// Check if any animation is currently running
val isRunning = remember(rotationAnim.isRunning, translationAnim.isRunning) {
    (rotationAnim.isRunning || translationAnim.isRunning)
}

// Lambda function initiating the flip animation
val performFlip = {
    if (!isRunning) {
        // Calculate force and set rotation and translation targets
        val force = forceRange.random()
        val rotationTarget = rotationAnim.value + (force * 3) * 180f
        val translationTarget = (force * jumpStep)

        // Launch the animations
        scope.launch {
            rotationAnim.animateTo(
                targetValue = rotationTarget,
                animationSpec = tween(duration, easing = FastOutSlowInEasing)
            )
            // Reset the rotation value once the animation completes
            rotationAnim.snapTo(rotationAnim.value % 360f)
        }
        scope.launch {
            translationAnim.animateTo(
                targetValue = 0f,
                animationSpec = jumpAnimation(duration, -translationTarget)
            )
        }
    }
}

UI Composition:

To put the final touch, let's craft the UI.

Box(
    modifier = modifier.graphicsLayer {
        rotationX = rotationAnim.value
        translationY = translationAnim.value
        cameraDistance = (10f * density)
    }
) {
    // Determine the quarter and display coin sides accordingly
    val quarter = determineQuarter(rotationAnim.value)
    if (quarter == 1 || quarter == 4) {
        // Display the front side of the coin
        frontSide(isRunning, performFlip)
    } else {
        // Display the back side of the coin with vertical reflection
        ReflectVertically {
            backSide(isRunning, performFlip)
        }
    }
}

Congratulations🥳! We’ve successfully built it👏. For the complete code implementation, you can access it on GitHub Gist. In the next section, we’ll explore an example of how to utilize this animation.

Example

Here we’ll make a simple coin with the T symbol on one side and F on the other.

Note: To enhance the visual appeal of the coin, I’ll use the innerShadow modifier in this example. For a detailed explanation, refer to my related article provided below or find the code on the GitHub Gist

Inner Shadow in Jetpack Compose Welcome! In this article, I’ll demonstrate how to create an inner shadow modifier in Jetpack Compose.medium.com

Now, let's use the GenericShape to create the T and F symbols as a shape.

val TShape = GenericShape { size, _ ->
    val height = size.height
    val width = size.width

    val heightTenth = (height / 10)
    val widthTenth = (width / 10)

    moveTo((widthTenth * 3), (heightTenth * 2.5f))
    lineTo(width - (widthTenth * 3), (heightTenth * 2.5f))
    lineTo(width - (widthTenth * 3), (heightTenth * 3.5f))
    lineTo((width / 2) + (widthTenth / 2), (heightTenth * 3.5f))
    lineTo((width / 2) + (widthTenth / 2), (height - (heightTenth * 2.5f)))
    lineTo((width / 2) - (widthTenth / 2), (height - (heightTenth * 2.5f)))
    lineTo((width / 2) - (widthTenth / 2), (heightTenth * 3.5f))
    lineTo((widthTenth * 3), (heightTenth * 3.5f))
    close()
}
val FShape = GenericShape { size, _ ->
    val height = size.height
    val width = size.width

    val heightTenth = (height / 10)
    val widthTenth = (width / 10)

    moveTo((widthTenth * 3), (heightTenth * 2.5f))
    lineTo(width - (widthTenth * 3), (heightTenth * 2.5f))
    lineTo(width - (widthTenth * 3), (heightTenth * 3.5f))
    lineTo((widthTenth * 4), (heightTenth * 3.5f))
    lineTo((widthTenth * 4), (heightTenth * 4.5f))
    lineTo(width - (widthTenth * 3), (heightTenth * 4.5f))
    lineTo(width - (widthTenth * 3), (heightTenth * 5.5f))
    lineTo((widthTenth * 4), (heightTenth * 5.5f))
    lineTo((widthTenth * 4), height - (heightTenth * 2.5f))
    lineTo((widthTenth * 3), height - (heightTenth * 2.5f))
    close()
}

Also, here are the colors I’ll use:

val ShadowBlack = Color.Black.copy(0.8f)
val ShadowWhite = Color.White.copy(0.8f)
val LightShadowBlack = Color.Black.copy(0.36f)
val LightShadowWhite = Color.White.copy(0.36f)

val Gold = Color(0xFFFFD700)
val Violet = Color(0xFF5c5174)

Alright! Let's create the Coin function. There's also a small scale animation when you click on it.

@Composable
fun BoxScope.Coin(
    symbolShape: Shape,
    enabled: Boolean,
    onClick: () -> Unit
) {
    val interSource = remember { MutableInteractionSource() }

    // Collect the pressed state using interaction source
    val pressed by interSource.collectIsPressedAsState()

    // Animate the scale of the coin based on pressed state
    val coinScale by animateFloatAsState(
        targetValue = if (pressed) 1.15f else 1f,
        animationSpec = spring(dampingRatio = Spring.DampingRatioHighBouncy),
        label = "Coin Scale Animation"
    )

    Box(
        modifier = Modifier
            .matchParentSize()
            .scale(coinScale) // Scale the coin
            .background(Color.Yellow, CircleShape)
            // Apply inner shadows for a convex effect
            .innerShadow(
                shape = CircleShape, color = ShadowBlack,
                offsetY = (-2).dp, offsetX = (-2).dp
            )
            .innerShadow(
                shape = CircleShape, color = ShadowWhite,
                offsetY = 2.dp, offsetX = 2.dp
            )
            .clickable(
                interactionSource = interSource,
                indication = null,
                enabled = enabled,
                onClick = onClick
            )
    ) {
        Canvas(
            modifier = Modifier
                .matchParentSize()
                // Apply inner shadows for a concave effect
                .innerShadow(
                    shape = symbolShape, color = LightShadowWhite,
                    blur = 2.dp,
                    offsetY = (-1).dp, offsetX = (-1).dp
                )
                .innerShadow(
                    shape = symbolShape, color = LightShadowBlack,
                    blur = 2.dp,
                    offsetY = 1.dp, offsetX = 1.dp
                )
        ) {
            // Draw the symbol
            val outline = symbolShape.createOutline(size, layoutDirection, this)
            drawOutline(outline, Gold)
        }
    }
}

At last, let’s create the CoinFlipper!

CoinFlipper(
    modifier = Modifier.size(100.dp),
    maxJumpHeight = 400.dp,
    frontSide = { isRunning, flip ->
        Coin(
            symbolShape = TShape,
            enabled = !isRunning,
            onClick = flip
        )
    },
    backSide = { isRunning, flip ->
        Coin(
            symbolShape = FShape,
            enabled = !isRunning,
            onClick = flip
        )
    }
)

🎉 Amazing! Take a look at the output:

Coin Flip Demo

Thank you for reading this article! ❤️ If you found it enjoyable and valuable, show your appreciation by clapping 👏 and following Kappdev for more exciting articles 😊

🪙Wobbly Coin Animation in Jetpack Compose Welcome 👋! In this article, we’ll create a Wobbly Coin Animation with Jetpack Compose. This means the coin (or any…medium.com