A marching ants border is a common UI pattern for showing something is “selected”, but Compose’s provided border() modifier is not up to the task.

So let’s build our own!

And along the way, we’ll learn a lot about Paths. Measuring paths, drawing paths, and path effects!

Info
For this, and other posts, I omit a the boilerplate for custom modifiers, only discussing Modifier.Node() implementations. See the official docs for custom modifier details.

Reproduce Border Modifier Link to heading

Lets start by recreating the features of the existing border modifier. We’ll need to take some parameters, and implement DrawModifierNode.

private class MarchingAntsBorderNode(
    var shape: Shape,
    var width: Dp,
    var brush: Brush,
) : Modifier.Node(), DrawModifierNode {
    fun ContentDrawScope.draw() {
        drawContent()

        drawOutline(
            outline = shape.createOutline(
                size = size,
                layoutDirection = layoutDirection,
                density = this,
            ),
            brush = brush,
            style = Stoke(
                // TODO(reader): Handle `Dp.Hairline`, if you need it
                width = width.toPx(),
            )
        )   
    }
}

If you compare this to Compose’s border(), you may notice that our border is drawn on the center of the shape, where-as Compose’s border insets to be inside the shape. If you go digging into the sources, a lot of work goes into handling all the cases for different Outline variations. (Also, I suspect this is why Outline exists in the first place, so you can handle common cases)

reproduce border example

Since having the ants centered on the shape is probably what we all want, we’re not going to handle insetting in this post.

If you’re looking at the sources, you’ll also note that they use CacheDrawModifierNode. We can do that too! But rule number one of optimizaing: make it work, then make it fast/small/etc.

Ants Link to heading

Now that we’ve got a modifier that draws a border, let’s add a dash of ants. Get it? Ok, I’ll stop.

Compose has us covered with PathEffect.dashPathEffect! It takes an intervals array, so lets wire this up to our Stroke argument.

private class MarchingAntsBorderNode(
    // ...
    var dashLength: Dp,
    var gapLength: Dp,
) : Modifier.Node(), DrawModifierNode {
    fun ContentDrawScope.draw() {
        drawContent()

        drawOutline(
            // ...
            style = Stoke(
                width = width.toPx(),
                pathEffect = PathEffect.dashPathEffect(
                    intervals = floatArrayOf(
                        dashLength.toPx(), 
                        gapLength.toPx(),
                    ),
                )
            )
        )   
    }
}

That was easy, too easy.

If you preview what we have so far, you’ll likely notice that one of the dashes is extra long, or the gap is too short. This is because dashPathEffect is not smart, it will just stamp out dashes until it runs out of path.

simple dashes example

But we can fix it. With Math!

All we have to do is fudge our dashes and gaps to be just a bit smaller, so the whole interval is a multiple of the path’s length. And we can get that length using PathMeasure. But for that we need to update our code to get a Path out of the Shape.

fun Path.length(): Float =
    PathMeasure()
        .apply { setPath(this@length, forceClosed = true) }
        .length
        // We could coerce externally, but that's a bigger foot-gun than
        //    always returnin a non-zero length.
        .coerceAtLeast(0.1f)

fun Shape.toPath(size: Size, layoutDirection: LayoutDirection, density: Density): Path =
    when (val outline = createOutline(size, layoutDirection, density)) {
        is Outline.Generic -> outline.path
        is Outline.Rectangle -> Path().apply { addRect(outline.rect) }
        is Outline.Rounded -> Path().apply { addRoundRect(outline.roundRect) }
    }

// Extension function for discoverability!
fun PathEffect.Companion.perfectDashPathEffect(
    pathLength: Float,
    desiredDashLength: Float,
    desiredGapLength: Float,
    phaseFraction: Float = 0f,
): PathEffect {
    val desiredInterval = desiredDashLength + desiredGapLength
    // TODO(reader): play with other rounding fuctions, just make sure it's not zero!
    val interval = pathLength / ceil(pathLength / desiredInterval)
    val dash = interval * (desiredDashLength / desiredInterval)
    return dashPathEffect(
        intervals = floatArrayOf(dash, interval - dash),
        phase = phaseFraction * interval,
    )
}

private class MarchingAntsBorderNode : Modifier.Node(), DrawModifierNode {
    fun ContentDrawScope.draw() {
        drawContent()

        val path = shape.toPath(
            size = size,
            layoutDirection = layoutDirection,
            density = this,
        )
        val pathLength = path.length() // TODO(soon): expensive! cache this

        drawPath(
            path = path,
            brush = brush,
            style = Stoke(
                width = width.toPx(),
                pathEffect = PathEffect.perfectDashPathEffect(
                    pathLength = pathLength,
                    desiredDashLength = dashLength.toPx(),
                    desiredGapLength = gapLength.toPx(),
                )
            )
        )   
    }
}

Perfect! Now that we have ants, lets teach them to march!

perfected dashes preview

Marching Link to heading

An optional parameter on dashPathEffect, and one we also support with perfectDashPathEffect, is a phase. If you’re familiar with anything cyclical, “phase” is how far along in the cycle you are. So all we need to do is animate the phase parameter, and our dashes will move!

Animations in Modifier.Nodes, especially infinite animations, can be trick since we don’t have access to the higher-level composable functions. We need to drive animations ourselves, using the coroutineScope that’s availables on the Node.

private class MarchingAntsBorderNode : Modifier.Node(), DrawModifierNode {

    private val phase = mutableFloatStateOf(0f)

    override fun onAttach() {
        // You could pass this animation spec in, or some other construct,
        // but make sure to handle it changing by cancelling the launched job!
        val spec = infiniteRepeatable<Float>(
            animation = tween(easing = LinearEasing),
        )
        // Vectorizing is how Compose's animation system works under the hood.
        // It's a little overkill for a linearly-eased float,
        //     but really useful for other easings or specs!
        val v = spec.vectorize(Float.VectorConverter)
        val zero = Float.VectorConverter.convertToVector(0f)
        val one = Float.VectorConverter.convertToVector(1f)

        coroutineScope.launch { // Cancels automatically onDetach!
            while (isActive) {
                withInfiniteAnimationFrameNanos {
                    phase.floatValue = Float.VectorConverter.convertFromVector(
                        v.getValueFromNanos(it, zero, one, zero)
                    )
                }
            }
        }
    }
}

Then we just need to pass phase.floatValue into perfectDashPathEffect, and look at that, our ant’s are marching!

animated marching ants example

Optimizing Link to heading

Our modifier as-is does a lot of work during the draw phase. Specifically, we’re measure a Path on every redraw, which can be expensive. We can delegate to CacheDrawModifierNode for behavior just like the drawWithCache modifier.

private class MarchingAntsBorderNode(
    // ...
) : DelegatingNode() { // NOTE: we changed our supers!

    // ... phase an onAttach stay the same

    private val delegate = delegate(
        CacheDrawModifierNode {
            val path = shape.toPath(
                size = size,
                layoutDirection = layoutDirection,
                density = this,
            )
            val pathLength = path.length()

            onDrawWithContent {
                drawContent()

                drawPath(
                    // ...
                )
            }
        }
    )
}

But now that we’re caching, we need to make sure we invalidate properly! You can either do this in the ModifierNodeElement.update method, or we can take a page out of the border modifier and use property setters.

private class MarchingAntsBorderNode(
    width: Dp
    // ...
) : DelegatingNode() {

    // ... Also do this for the other parameters!

    var width = width
        set(value) {
            if (field != value) {
                field = value
                delegate.invalidateDrawCache()
            }
        }

    private val delegate = // ...
}

All Together Link to heading

Finally, lets put all of this together!

class MarchingAntsBorderNode(
    width: Dp,
    brush: Brush,
    shape: Shape,
    var dashLength: Dp,
    var gapLength: Dp,
) : DelegatingNode() {

    private val phase = mutableFloatStateOf(0f)

    override fun onAttach() {
        val spec = infiniteRepeatable<Float>(
            animation = tween(easing = LinearEasing),
        )
        val v = spec.vectorize(Float.VectorConverter)
        val zero = Float.VectorConverter.convertToVector(0f)
        val one = Float.VectorConverter.convertToVector(1f)
        coroutineScope.launch {
            while (isActive) {
                withInfiniteAnimationFrameNanos {
                    phase.floatValue = Float.VectorConverter.convertFromVector(
                        v.getValueFromNanos(it, zero, one, zero)
                    )
                }
            }
        }
    }

    var width = width
        set(value) {
            if (field != value) {
                field = value
                delegate.invalidateDrawCache()
            }
        }

    var brush = brush
        set(value) {
            if (field != value) {
                field = value
                delegate.invalidateDrawCache()
            }
        }

    var shape = shape
        set(value) {
            if (field != value) {
                field = value
                delegate.invalidateDrawCache()
            }
        }

    private val delegate = delegate(
        CacheDrawModifierNode {
            val path = shape.toPath(
                size = size,
                layoutDirection = layoutDirection,
                density = this,
            )
            val pathLength = path.length()

            onDrawWithContent {
                drawContent()

                drawPath(
                    path = path,
                    brush = brush,
                    style = Stroke(
                        width = width.toPx(),
                        pathEffect = PathEffect.perfectDashPathEffect(
                            pathLength = pathLength,
                            desiredDashLength = dashLength.toPx(),
                            desiredGapLength = gapLength.toPx(),
                            phaseFraction = phase.floatValue,
                        )
                    )
                )
            }
        }
    )
}

fun PathEffect.Companion.perfectDashPathEffect(
    pathLength: Float,
    desiredDashLength: Float,
    desiredGapLength: Float,
    phaseFraction: Float = 0f,
): PathEffect {
    val desiredInterval = desiredDashLength + desiredGapLength
    val interval = pathLength / ceil(pathLength / desiredInterval)
    val dash = interval * (desiredDashLength / desiredInterval)
    return dashPathEffect(
        intervals = floatArrayOf(dash, interval - dash),
        phase = phaseFraction * interval,
    )
}

fun Shape.toPath(size: Size, layoutDirection: LayoutDirection, density: Density): Path =
    when (val outline = createOutline(size, layoutDirection, density)) {
        is Outline.Generic -> outline.path
        is Outline.Rectangle -> Path().apply { addRect(outline.rect) }
        is Outline.Rounded -> Path().apply { addRoundRect(outline.roundRect) }
    }

fun Path.length(minLength: Float = 1f): Float =
    PathMeasure()
        .apply { setPath(this@length, forceClosed = true) }
        .length
        .coerceAtLeast(minLength)