Wrapping a Fancy Ribbon around any Composable

Wrapping a Fancy Ribbon around any Composable

How to animate a ribbon that wraps around UI elements in Jetpack Compose

In this quick article, we will learn how to build a ribbon modifier that draws a path that wraps around any Compose UI element. This fancy effect can be used as a hover effect on a "hero button", or to call attention to a new action.

0:00
/0:05

Final result of a modifier wrapping around a button

Drawing the Ribbon

Let's start with simply drawing a winding path. In a recent video, I went over using polar coordinates for defining paths.

I created the polarLineTo and polarMoveTo extension functions, that take in a degree, radius and origin, which are necessary to plot points in a polar coordinate space.

We can use these functions to draw a path that resembles a ribbon by giving it an increasing degree and moving origin.

private fun createRibbon(  
    start: Offset,  
    end: Offset,  
    radius: Float,  
    loops: Float = 5f,  
    startAngle: Float = 90f,  
    resolution: Int = 1000  
): Path {  
    val ribbon = Path()  
    ribbon.moveTo(start)  
    (0..resolution).forEach { i ->  
        val t = i / resolution.toFloat()  
        val min = min(startAngle, (360f * loops) - startAngle)  
        val max = max(startAngle, (360f * loops) - startAngle)  
        val degree = lerp(  
            start = min,  
            stop = max,  
            fraction = t  
        )  
        if (i == 0) {  
            ribbon.polarMoveTo(  
                degrees = degree,  
                distance = radius,  
                origin = start  
            )  
        }  
        ribbon.polarLineTo(  
            degrees = degree,  
            distance = radius,  
            origin = lerp(  
                start = start,  
                stop = end,  
                fraction = t,  
            ),  
        )  
    }  
  
    return ribbon  
}

This function takes in the start and end point of the ribbon, plus other details like the radius of the arcs and how many loops to include, and returns a path that we can draw.

This function essentially drags a circle along the line, while plotting points along its circumference.

0:00
/0:06

Illustration of a spiral path being drawn by a moving circle

Using this function, we can create a ribbon that matches the dimensions of any element, and then draw it.

val loops = loops - .5f // Trim the ribbon by 180º to cut the "tail"
val ribbonPath = createRibbon(  
    start = Offset(0f, size.height * .5f),  
    end = Offset(size.width, size.height * .5f),  
    radius = (size.height * .5f) + stroke.toPx(),  
    startAngle = -90f,  
    loops = loops,  
)

The start and end points would be the left and right edges of the view, respectively. And the radius would be half its height, plus some extra room for the stroke width. If you would like a vertical ribbon, flip these measurements.

Ribbon path rendered over a button

Drawing it over our element gives us the ribbon we want with the correct dimensions, but it's lacking depth. Let's try making it seem like the ribbon is wrapping around the button.

Adding Depth

0:00
/0:04

As you can see, the secret to making this effect is splitting the path into two parts, and then drawing one portion above, and the other below the button.

One way we can do this is by splitting the path into segments that are sloping upwards and those that are sloping downwards.

val measure = PathMeasure()  
measure.setPath(ribbonPath, false)  
  
var isPositive = measure.getTangent(0f).y > 0f  
val distanceArray = mutableListOf<Float>()  
var segmentStartDistance = 0f  
  
val resolution = 500  
for (i in 0..resolution) {  
    val t = i / resolution.toFloat()  
    val distance = t * measure.length  
    val tan = measure.getTangent(distance)  
    val currentIsPositive = tan.y > 0f  
  
    if (currentIsPositive != isPositive) {  
        val segmentLength = distance - segmentStartDistance  
        distanceArray.add(segmentLength)  
  
        segmentStartDistance = distance  
        isPositive = currentIsPositive  
    } else if (i == resolution) {  
        val segmentLength = distance - segmentStartDistance  
        distanceArray.add(segmentLength)  
    }  
}

This code produces distanceArray, in which each item represents the length of a segment that entirely slopes upwards or downwards.

0:00
/0:07

It does this by traversing the ribbon path and checking the tangent at each position. Then, it checks the y value of the tangent and checks if it changed from positive to negative.

Using this distanceArray we can draw the ribbon into parts inside a onDrawWithContent, which allows to define the z-depth of our content

onDrawWithContent {
	drawPath(  
	    path = ribbonPath,  
	    color = color,  
	    style = Stroke(  
	        width = stroke.toPx(),
	        pathEffect = PathEffect.dashPathEffect(  
				intervals = distanceArray.toFloatArray(),  
			),
	    )  
	)
	
	drawContent()
	
	drawPath(  
	    path = ribbonPath,  
	    color = color,  
	    style = Stroke(  
	        width = stroke.toPx(),   
	        pathEffect = PathEffect.dashPathEffect(  
				intervals = distanceArray.toFloatArray(),  
				phase = distanceArray.first()  
			),  
	    )  
	)
}

We first draw the ribbonPath with the distanceArray applied as the intervals in a dash path effect. This will only draw the segments of the path that are sloping downwards.

Then we draw the content right in the middle using the drawContent function.

Finally we draw the segments that are sloping upwards. We still use the same distanceArray, but we set the phase to be the length of the first segment. This offsets the effect to only draw the remaining segments.

And with that, we can put all this logic into a neat modifier for us to apply anywhere we would need it.
You can find the full code below.

If you have any questions, or build something cool with this, please let me know below. Also if you have any thoughts or criticisms on the video, I would love to hear that as well.

Thanks for reading and good luck!


Ribbon Modifier Code

import androidx.compose.ui.Modifier  
import androidx.compose.ui.draw.drawWithCache  
import androidx.compose.ui.geometry.Offset  
import androidx.compose.ui.graphics.Brush  
import androidx.compose.ui.graphics.Path  
import androidx.compose.ui.graphics.PathEffect  
import androidx.compose.ui.graphics.PathMeasure  
import androidx.compose.ui.graphics.StrokeCap  
import androidx.compose.ui.graphics.StrokeJoin  
import androidx.compose.ui.graphics.drawscope.Stroke  
import androidx.compose.ui.unit.Dp  
import androidx.compose.ui.unit.dp  
import androidx.compose.ui.util.lerp  
import kotlin.math.PI  
import kotlin.math.cos  
import kotlin.math.max  
import kotlin.math.min  
import kotlin.math.sin  


private fun Modifier.ribbon(  
    brush: Brush,  
    stroke: Dp = 4.dp,  
    loops: Int = 7,  
    progress: Float = 1f,  
): Modifier {  
    return drawWithCache {  
        val loops = loops - .5f  
        val ribbonPath = createRibbon(  
            start = Offset(0f, size.height * .5f),  
            end = Offset(size.width, size.height * .5f),  
            radius = (size.height * .5f) + stroke.toPx(),  
            startAngle = -90f,  
            loops = loops,  
        )  
  
        val measure = PathMeasure()  
        measure.setPath(ribbonPath, false)  
  
        var isPositive = measure.getTangent(0f).y > 0f  
        val distanceArray = mutableListOf<Float>()  
        var segmentStartDistance = 0f  
  
        val resolution = 500  
        for (i in 0..resolution) {  
            val t = i / resolution.toFloat()  
            val distance = t * measure.length  
            val tan = measure.getTangent(distance)  
            val currentIsPositive = tan.y > 0f  
  
            if (currentIsPositive != isPositive) {  
                val segmentLength = distance - segmentStartDistance  
                distanceArray.add(segmentLength)  
  
                segmentStartDistance = distance  
                isPositive = currentIsPositive  
            } else if (i == resolution) {  
                val segmentLength = distance - segmentStartDistance  
                distanceArray.add(segmentLength)  
            }  
        }  
  
        onDrawWithContent {  
            if (progress > 0f)  
                drawPath(  
                    path = ribbonPath,  
                    brush = brush,  
                    style = Stroke(  
                        width = stroke.toPx(),  
                        cap = StrokeCap.Round,  
                        join = StrokeJoin.Round,  
                        pathEffect = PathEffect.chainPathEffect(  
                            PathEffect.dashPathEffect(  
                                intervals = distanceArray.toFloatArray(),  
                            ),  
                            PathEffect.dashPathEffect(  
                                intervals = floatArrayOf(  
                                    measure.length * progress,  
                                    measure.length,  
                                )  
                            )  
                        )  
                    )  
                )  
  
            drawContent()  
  
            if (progress > 0f)  
                drawPath(  
                    path = ribbonPath,  
                    brush = brush,  
                    style = Stroke(  
                        width = stroke.toPx(),  
                        cap = StrokeCap.Round,  
                        join = StrokeJoin.Round,  
                        pathEffect = PathEffect.chainPathEffect(  
                            PathEffect.dashPathEffect(  
                                intervals = distanceArray.toFloatArray(),  
                                phase = distanceArray.first()  
                            ),  
                            PathEffect.dashPathEffect(  
                                intervals = floatArrayOf(  
                                    measure.length * progress,  
                                    measure.length,  
                                )  
                            )  
                        )  
                    )  
                )  
        }  
    }
}  
  
  
private fun createRibbon(  
    start: Offset,  
    end: Offset,  
    radius: Float,  
    startAngle: Float = 90f,  
    loops: Float = 5f,  
    resolution: Int = 1000  
): Path {  
    val ribbon = Path()  
    ribbon.moveTo(start)  
    (0..resolution).forEach { i ->  
        val t = i / resolution.toFloat()  
        val min = min(startAngle, (360f * loops) - startAngle)  
        val max = max(startAngle, (360f * loops) - startAngle)  
        val degree = lerp(  
            start = min,  
            stop = max,  
            fraction = t  
        )  
        if (i == 0) {  
            ribbon.polarMoveTo(  
                degrees = degree,  
                distance = radius,  
                origin = start  
            )  
        }  
        ribbon.polarLineTo(  
            degrees = degree,  
            distance = radius,  
            origin = androidx.compose.ui.geometry.lerp(  
                start = start,  
                stop = end,  
                fraction = t,  
            ),  
        )  
    }  
    return ribbon  
}  
  
private fun Path.polarMoveTo(  
    degrees: Float,  
    distance: Float,  
    origin: Offset = Offset.Zero  
) = moveTo(polarToCart(degrees, distance, origin))  

  
private fun Path.polarLineTo(  
    degrees: Float,  
    distance: Float,  
    origin: Offset = Offset.Zero  
) = lineTo(polarToCart(degrees, distance, origin))   
  
private fun polarToCart(  
    degrees: Float,  
    distance: Float,  
    origin: Offset = Offset.Zero  
): Offset = Offset(  
    x = distance * cos(-degrees * (PI / 180)).toFloat(),  
    y = distance * sin(-degrees * (PI / 180)).toFloat(),  
) + origin

Subscribe for UI recipes

Mastodon