How to make Rainbow border animation for box in compose?
T

1

6

How to make Rainbow border animation for box in compose, all the examples that I saw are for Circle and they use rotate with drawbehind, but what I really need is to make the same for a Box in compose.

Thanks

With rotate but it didn´t work

Tolson answered 24/4, 2023 at 18:0 Comment(0)
G
14

This can be accomplished using BlendModes. If you are not familiar with BlendModes you can check out answers below.

Jetpack Compose Applying PorterDuffMode to Image

How to clip or cut a Composable?

Result

enter image description here

You need to create a Brush.sweepGradient with rainbow colors first

val gradientColors = listOf(
    Color.Red,
    Color.Magenta,
    Color.Blue,
    Color.Cyan,
    Color.Green,
    Color.Yellow,
    Color.Red
)

Then need draw this sweep gradient as circle that overflows from our composable then we will draw a rectangle for borders with some color and apply BlendMode.SrcIn on circle to get rectangle shape with circle brush we rotate with infinite animation

fun Modifier.drawRainbowBorder(
    strokeWidth: Dp,
    durationMillis: Int
) = composed {

    val infiniteTransition = rememberInfiniteTransition(label = "rotation")
    val angle by infiniteTransition.animateFloat(
        initialValue = 0f,
        targetValue = 360f,
        animationSpec = infiniteRepeatable(
            animation = tween(durationMillis, easing = LinearEasing),
            repeatMode = RepeatMode.Restart
        ), label = "rotation"
    )

    val brush = Brush.sweepGradient(gradientColors)

    Modifier.drawWithContent {

        val strokeWidthPx = strokeWidth.toPx()
        val width = size.width
        val height = size.height

        drawContent()

        with(drawContext.canvas.nativeCanvas) {
            val checkPoint = saveLayer(null, null)

            // Destination
            drawRect(
                color = Color.Gray,
                topLeft = Offset(strokeWidthPx / 2, strokeWidthPx / 2),
                size = Size(width - strokeWidthPx, height - strokeWidthPx),
                style = Stroke(strokeWidthPx)
            )

            // Source
            rotate(angle) {

                drawCircle(
                    brush = brush,
                    radius = size.width,
                    blendMode = BlendMode.SrcIn,
                )
            }

            restoreToCount(checkPoint)
        }
    }
}

Edit

If you wish to draw with Shape you can use function below. This function needs to use Modifier.clip because drawing a Outline that is smaller than original shape size with

         val outline = shape.createOutline(
                size = Size(
                    size.width - strokeWidthPx,
                    size.height - strokeWidthPx
                ),
                layoutDirection = layoutDirection,
                density = density
            )

creates very small but noticeable blank space near corners with RoundedCornerShape and probably with other custom shapes depending how they are aligned. I checked out how Modifier.border prevents this, it checks for 3 shape types

                when (val outline = shape.createOutline(size, layoutDirection, this)) {
                    is Outline.Generic ->
                        drawGenericBorder(
                            borderCacheRef,
                            brush,
                            outline,
                            fillArea,
                            strokeWidthPx
                        )
                    is Outline.Rounded ->
                        drawRoundRectBorder(
                            borderCacheRef,
                            brush,
                            outline,
                            topLeft,
                            borderSize,
                            fillArea,
                            strokeWidthPx
                        )
                    is Outline.Rectangle ->
                        drawRectBorder(
                            brush,
                            topLeft,
                            borderSize,
                            fillArea,
                            strokeWidthPx
                        )
                }

When it's generic type it creates mask Path which i don't intend to do for this example but if you don't want to clip content you can implement similar approach.

fun Modifier.drawAnimatedBorder(
    strokeWidth: Dp,
    shape: Shape,
    brush: (Size) -> Brush = {
        Brush.sweepGradient(gradientColors)
    },
    durationMillis: Int
) = composed {
    
    val infiniteTransition = rememberInfiniteTransition(label = "rotation")
    val angle by infiniteTransition.animateFloat(
        initialValue = 0f,
        targetValue = 360f,
        animationSpec = infiniteRepeatable(
            animation = tween(durationMillis, easing = LinearEasing),
            repeatMode = RepeatMode.Restart
        ), label = "rotation"
    )

    Modifier
        .clip(shape)
        .drawWithCache {
            val strokeWidthPx = strokeWidth.toPx()

            val outline: Outline = shape.createOutline(size, layoutDirection, this)

            val pathBounds = outline.bounds

            onDrawWithContent {
                // This is actual content of the Composable that this modifier is assigned to
                drawContent()

                with(drawContext.canvas.nativeCanvas) {
                    val checkPoint = saveLayer(null, null)

                    // Destination

                    // We draw 2 times of the stroke with since we want actual size to be inside
                    // bounds while the outer stroke with is clipped with Modifier.clip

                    // 🔥 Using a maskPath with op(this, outline.path, PathOperation.Difference)
                    // And GenericShape can be used as Modifier.border does instead of clip
                    drawOutline(
                        outline = outline,
                        color = Color.Gray,
                        style = Stroke(strokeWidthPx * 2)
                    )

                    // Source
                    rotate(angle) {

                        drawCircle(
                            brush = brush(size),
                            radius = size.width,
                            blendMode = BlendMode.SrcIn,
                        )
                    }
                    restoreToCount(checkPoint)
                }
            }
        }
}

Usage

@Preview
@Composable
private fun AnimatedRainbowBorderSample() {

    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(20.dp),
        horizontalAlignment = Alignment.CenterHorizontally
    ) {

        Box(
            modifier = Modifier
                .size(140.dp, 100.dp)
                .drawRainbowBorder(
                    strokeWidth = 4.dp,
                    durationMillis = 3000
                ),
            contentAlignment = Alignment.Center
        ) {
            Text(text = "Hello World", fontSize = 20.sp)
        }

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

        Box(
            modifier = Modifier
                .drawAnimatedBorder(
                    strokeWidth = 4.dp,
                    durationMillis = 2000,
                    shape = RoundedCornerShape(10.dp)
                )
                .padding(12.dp),
            contentAlignment = Alignment.Center
        ) {
            Text(text = "Hello World", fontSize = 20.sp)
        }

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

        Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) {
            Box(
                modifier = Modifier
                    .size(120.dp)
//                .border(2.dp, Color.Black, RoundedCornerShape(20.dp))
                    .drawAnimatedBorder(
                        strokeWidth = 6.dp,
                        durationMillis = 3000,
                        shape = RoundedCornerShape(20.dp)
                    ),
                contentAlignment = Alignment.Center
            ) {
                Image(
                    modifier = Modifier
                        .matchParentSize(),
                    painter = painterResource(id = R.drawable.avatar_1_raster),
                    contentDescription = null,
                    contentScale = ContentScale.FillBounds
                )
            }

            Box(
                modifier = Modifier
                    .size(120.dp)
                    .drawAnimatedBorder(
                        strokeWidth = 6.dp,
                        durationMillis = 3000,
                        shape = CircleShape
                    ),
                contentAlignment = Alignment.Center
            ) {
                Image(
                    modifier = Modifier
                        .matchParentSize(),
                    painter = painterResource(id = R.drawable.avatar_2_raster),
                    contentDescription = null,
                    contentScale = ContentScale.FillBounds
                )
            }
        }

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

        Box(
            modifier = Modifier
                .size(80.dp)
                .drawAnimatedBorder(
                    strokeWidth = 4.dp,
                    durationMillis = 2000,
                    shape = CircleShape
                )
        )
        Spacer(modifier = Modifier.height(10.dp))
        Box(
            modifier = Modifier
                .drawAnimatedBorder(
                    strokeWidth = 4.dp,
                    durationMillis = 2000,
                    shape = CutCornerShape(8.dp)
                )
                .padding(12.dp),
            contentAlignment = Alignment.Center
        ) {
            Text(text = "Hello World", fontSize = 20.sp)
        }

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

        Box(
            modifier = Modifier
                .drawAnimatedBorder(
                    strokeWidth = 4.dp,
                    durationMillis = 2000,
                    shape = createBubbleShape(
                        arrowWidth = 20f,
                        arrowHeight = 20f,
                        arrowOffset = 20f,
                        arrowDirection = ArrowDirection.Left
                    )
                )
                .padding(12.dp),
            contentAlignment = Alignment.Center
        ) {
            Text(text = "Hello World", fontSize = 20.sp)
        }

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

        Box(
            modifier = Modifier
                .drawAnimatedBorder(
                    brush = {
                        Brush.sweepGradient(
                            colors = listOf(
                                Color.Gray,
                                Color.White,
                                Color.Gray,
                                Color.White,
                                Color.Gray
                            )
                        )
                    },
                    strokeWidth = 4.dp,
                    durationMillis = 2000,
                    shape = RoundedCornerShape(10.dp)
                )
                .padding(12.dp),
            contentAlignment = Alignment.Center
        ) {
            Text(text = "Hello World", fontSize = 20.sp)
        }
    }
}

Extra bubble shape

fun createBubbleShape(
    arrowWidth: Float,
    arrowHeight: Float,
    arrowOffset: Float,
    arrowDirection: ArrowDirection
): GenericShape {

    return GenericShape { size: Size, layoutDirection: LayoutDirection ->

        val width = size.width
        val height = size.height

        when (arrowDirection) {
            ArrowDirection.Left -> {
                moveTo(arrowWidth, arrowOffset)
                lineTo(0f, arrowOffset)
                lineTo(arrowWidth, arrowHeight + arrowOffset)
                addRoundRect(
                    RoundRect(
                        rect = Rect(left = arrowWidth, top = 0f, right = width, bottom = height),
                        cornerRadius = CornerRadius(x = 20f, y = 20f)
                    )
                )
            }

            ArrowDirection.Right -> {
                moveTo(width - arrowWidth, arrowOffset)
                lineTo(width, arrowOffset)
                lineTo(width - arrowWidth, arrowHeight + arrowOffset)
                addRoundRect(
                    RoundRect(
                        rect = Rect(
                            left = 0f,
                            top = 0f,
                            right = width - arrowWidth,
                            bottom = height
                        ),
                        cornerRadius = CornerRadius(x = 20f, y = 20f)
                    )
                )
            }

            ArrowDirection.Top -> {
                moveTo(arrowOffset, arrowHeight)
                lineTo(arrowOffset + arrowWidth / 2, 0f)
                lineTo(arrowOffset + arrowWidth, arrowHeight)

                addRoundRect(
                    RoundRect(
                        rect = Rect(
                            left = 0f,
                            top = arrowHeight,
                            right = width,
                            bottom = height
                        ),
                        cornerRadius = CornerRadius(x = 20f, y = 20f)
                    )
                )
            }

            else -> {
                moveTo(arrowOffset, height - arrowHeight)
                lineTo(arrowOffset + arrowWidth / 2, height)
                lineTo(arrowOffset + arrowWidth, height - arrowHeight)

                addRoundRect(
                    RoundRect(
                        rect = Rect(
                            left = 0f,
                            top = 0f,
                            right = width,
                            bottom = height - arrowHeight
                        ),
                        cornerRadius = CornerRadius(x = 20f, y = 20f)
                    )
                )
            }
        }
    }

}

enum class ArrowDirection {
    Left, Right, Top, Bottom
}

Full sample available in this tutorial with resources and everything else

Gentianella answered 24/4, 2023 at 19:4 Comment(6)
Man, I liked you answer, and I have a question to you. I am creating an app on wear os using ScalingLazyColumn, and I want to have circular border, and when the app is loading something (for example an api request) I want to use this border like a Spinner to user know the app is loading something, and when loads done the circle stop the rotation. I tried your solution and the rainbow border is below the list content, like a background. I tried some solutions like z index but doesn't work. Any tips?Bye
You can call drawContent() first for border to be drawn on top of content. drawContent() this is the actual content, Text in the examples above. Modifier.drawWithContent unlike Modifier.drawBehind lets you choose order of drawing and actual contentGentianella
You can test this drawing a circle by changing order of drawContent and drawCircle to see the difference for instanceGentianella
Thanx for this detailed answer Could you also add the createBubbleShape() function to the answer I want to try this out, but it isn't thereBiotechnology
@TonyStarkus updated answer with Modifier.drawWithCache and now content is drawn first and also fixed an issue where pixels at the edges don't match using clip. Also explained how Modifier.border prevents it.Gentianella
@Biotechnology added bubbleShape function as you requestedGentianella

© 2022 - 2024 — McMap. All rights reserved.