androidandroid-jetpack-composeandroid-jetpackandroid-jetpack-compose-animation

How can I achieve before-after animation in Jetpack Compose?


I have used weight or changed width with infinite animation, but it is not the effect showed in the below gif. How can I achieve it in Jetpack Compose?


Solution

  • You can create shape that changes right side with a progress and clip a Box above another.

    Result

    enter image description here

    Implementation

    @Composable
    private fun BeforeAfterLayout(
        modifier: Modifier = Modifier,
        progress: Float,
        beforeLayout: @Composable BoxScope.() -> Unit,
        afterLayout: @Composable BoxScope.() -> Unit
    ) {
    
        val shape = remember(progress) {
            GenericShape { size: Size, layoutDirection: LayoutDirection ->
                addRect(
                    rect = Rect(
                        topLeft = Offset.Zero,
                        bottomRight = Offset(size.width * progress, size.height)
                    )
                )
            }
        }
    
        Box(modifier) {
            beforeLayout()
    
            Box(
                modifier = Modifier.clip(shape)
            ) {
                afterLayout()
            }
        }
    }
    

    Demo

    @Preview
    @Composable
    private fun BeforeAfterSample() {
    
        val infiniteTransition = rememberInfiniteTransition("before-after")
        val progress by infiniteTransition.animateFloat(
            initialValue = 0f,
            targetValue = 1f,
            animationSpec = infiniteRepeatable(
                animation = tween(4000, easing = LinearEasing),
                repeatMode = RepeatMode.Reverse
            ),
            label = "before-after"
        )
    
        Column(
            modifier = Modifier.padding(20.dp).fillMaxSize(),
            horizontalAlignment = Alignment.CenterHorizontally
        ) {
    
            BeforeAfterLayoutWithBlendMode(
                modifier = Modifier.clip(RoundedCornerShape(16.dp)).size(240.dp),
                progress = { progress },
                beforeLayout = {
                    Image(
                        modifier = Modifier.fillMaxSize(),
                        painter = painterResource(R.drawable.avatar_1_raster),
                        contentDescription = null,
                        contentScale = ContentScale.FillBounds
                    )
                },
                afterLayout = {
                    Image(
                        modifier = Modifier.fillMaxSize(),
                        painter = painterResource(R.drawable.avatar_5_raster),
                        contentDescription = null,
                        contentScale = ContentScale.FillBounds
                    )
                }
            )
    
            Spacer(modifier = Modifier.height(20.dp))
    
            BeforeAfterLayoutWithBlendMode(
                modifier = Modifier.clip(RoundedCornerShape(16.dp)).fillMaxWidth(.8f)
                    .aspectRatio(16 / 9f),
                progress = { progress },
                beforeLayout = {
                    Image(
                        modifier = Modifier.fillMaxSize(),
                        painter = painterResource(R.drawable.image_before_after_shoes_a),
                        contentDescription = null,
                        contentScale = ContentScale.FillBounds
                    )
                },
                afterLayout = {
                    Image(
                        modifier = Modifier.fillMaxSize(),
                        painter = painterResource(R.drawable.image_before_after_shoes_b),
                        contentDescription = null,
                        contentScale = ContentScale.FillBounds
                    )
                }
            )
    
            Spacer(modifier = Modifier.height(20.dp))
    
            BeforeAfterLayout(
                progress = progress,
                beforeLayout = {
                    Box(
                        modifier = Modifier
                            .border(4.dp, Purple400, RoundedCornerShape(16.dp))
                            .background(Color.White, RoundedCornerShape(16.dp))
                            .fillMaxWidth(.9f)
                            .height(80.dp),
                        contentAlignment = Alignment.Center
                    ) {
                        Text(
                            "Progress: ${(progress * 100).roundToInt()}",
                            color = Red400,
                            fontSize = 24.sp
                        )
                    }
                },
                afterLayout = {
                    Box(
                        modifier = Modifier
                            .border(4.dp, Purple400, RoundedCornerShape(16.dp))
                            .background(Red400, RoundedCornerShape(16.dp))
                            .fillMaxWidth(.9f)
                            .height(80.dp),
                        contentAlignment = Alignment.Center
                    ) {
                        Text(
                            "Progress: ${(progress * 100).roundToInt()}",
                            color = Color.White,
                            fontSize = 24.sp
                        )
                    }
                }
            )
    
            Spacer(modifier = Modifier.height(20.dp))
    
            val context = LocalContext.current
    
            BeforeAfterLayout(
                progress = progress,
                beforeLayout = {
                    Button(
                        modifier = Modifier
                            .padding(horizontal = 32.dp)
                            .fillMaxWidth(),
                        onClick = {
                            Toast.makeText(context, "M2 Button", Toast.LENGTH_SHORT).show()
                        }
                    ) {
                        Text("M2 Button")
                    }
                },
                afterLayout = {
                    androidx.compose.material3.Button(
                        modifier = Modifier
                            .padding(horizontal = 32.dp)
                            .fillMaxWidth(),
                        onClick = {
                            Toast.makeText(context, "M3 Button", Toast.LENGTH_SHORT).show()
                        }
                    ) {
                        androidx.compose.material3.Text("M3 Button")
                    }
                }
            )
        }
    }
    

    If you need clip both shapes, like in Buttons, if you clip one other will also show on screen since shapes don't cover do it like this

    @Composable
    private fun BeforeAfterLayout(
        modifier: Modifier = Modifier,
        progress: Float,
        beforeLayout: @Composable BoxScope.() -> Unit,
        afterLayout: @Composable BoxScope.() -> Unit
    ) {
    
        val shapeBefore = remember(progress) {
            GenericShape { size: Size, layoutDirection: LayoutDirection ->
                addRect(
                    rect = Rect(
                        topLeft = Offset(size.width * progress, 0f),
                        bottomRight = Offset(size.width, size.height)
                    )
                )
            }
        }
    
        val shapeAfter = remember(progress) {
            GenericShape { size: Size, layoutDirection: LayoutDirection ->
                addRect(
                    rect = Rect(
                        topLeft = Offset.Zero,
                        bottomRight = Offset(size.width * progress, size.height)
                    )
                )
            }
        }
    
        Box(modifier) {
            Box(
                modifier = Modifier.clip(shapeBefore)
            ) {
                beforeLayout()
            }
    
            Box(
                modifier = Modifier.clip(shapeAfter)
            ) {
                afterLayout()
            }
        }
    }
    

    Second way is using BlendMode.Clear and passing progress in a lambda to animating with single recomposition as

    @Composable
    private fun BeforeAfterLayoutWithBlendMode(
        modifier: Modifier = Modifier,
        progress: () -> Float,
        beforeLayout: @Composable BoxScope.() -> Unit,
        afterLayout: @Composable BoxScope.() -> Unit
    ) {
        Box(modifier) {
            afterLayout()
            Box(
                modifier = Modifier
                    .graphicsLayer {
                        compositingStrategy = CompositingStrategy.Offscreen
                    }
                    .drawWithContent {
                        drawContent()
                        drawRect(
                            color = Color.Transparent,
                            size = Size(size.width * progress(), size.height),
                            blendMode = BlendMode.Clear
                        )
    
                    }
            ) {
                beforeLayout()
            }
        }
    }
    

    Another way to do it is, if it's only image you wish to draw, to draw 2 images on Canvas and change dstOffset of second drawImage function.

    Also available as library with many customization options.

    https://github.com/SmartToolFactory/Compose-BeforeAfter