Animation / Compose / Jetpack

Compose: Strikethru Animation

Regular readers of Styling Android will know that I rather like animating things. That’s a topic that I’ve covered frequently! There’s an occasional series where I cover techniques for animating icons. For togglable icons this generally uses <animated-selector />. However, Compose does not support this. If you try and inflate a resource containing this tag, you’ll get an error. In this post, we’ll look at how we can achieve a strikethru animation like this one.

Jetpack Compose does not support <animated-selector /> because it does not model state in the same way as traditional Views do. It is actually more flexible than Views but requires us to become a little creative when it comes to creating animations such as the strikethru in the GIF.

The technique that I came up with is quite different to the previous post on this. But the result is far more flexible. The previous solution was a self-contained drawable resource. This solution works for any composable!

Concept

The basic concept here is that we’ll create an animated overlay. A custom Modifier wraps this, and we can apply the modifier to any composable.

The strikethru itself is two lines, which animate as the state changes. The first line is opaque, and the second line masks the underlying composable. This masking is important because it will allow any background to show through. It is possible to produce the correct illusion by drawing a line of the background colour. However, this is impossible if the background colour is not known, or the background is not a static colour. So masking is much better.

StrikethruOverlay

To actually create the Strikethru, we’ll need to draw directly to the Canvas. Compose allows this through DrawScope:

interface AnimatedOverlay {
    fun drawOverlay(drawScope: DrawScope)
}

class StrikethruOverlay(
    private val color: Color = Color.Black,
    private var widthDp: Dp = 4.dp,
    private val getProgress: () -> Float
) : AnimatedOverlay {

    @Suppress("MagicNumber")
    override fun drawOverlay(drawScope: DrawScope) {
        with(drawScope) {
            val width = density.run { widthDp.toPx() }
            val halfWidth = width / 2f
            val progressHeight = size.height * getProgress()
            rotate(-45f) {
                drawLine(
                    color = color,
                    start = Offset(size.center.x + halfWidth, 0f),
                    end = Offset(size.center.x + halfWidth, progressHeight),
                    strokeWidth = width,
                    blendMode = BlendMode.Clear
                )
                drawLine(
                    color = color,
                    start = Offset(size.center.x - halfWidth, 0f),
                    end = Offset(size.center.x - halfWidth, progressHeight),
                    strokeWidth = width
                )
            }
        }
    }
}

I’ve defined the AnimatedOverlay interface so that other animations can be created and applied using the same pattern. StrikethruOverlay is an implementation of this. It takes arguments that define the colour and width of the lines. It also takes a lambda to look up the current progress of the animation. I am grateful to Doris Liu who suggested a lambda here. Previously I was passing in progress as a Float. This float was coming from a remembered mutable state. Doris pointed out that whenever progress changed it would trigger a recomposition each time. This is because the composition is reading the state, and would recompose when it changed. But, by using a lambda instead, it is the drawOverlay() function that reads the state. When the state changes only the draw function itself is invalidated. This means far less work, so less possibility of jank in the animation.

The drawOverlay() function first calculates the height of the lines based upon the current progress value. Then it draws the two lines vertically side by side. The first line has blendMode = BlendMode.Clear which will perform the masking. The second line is the opaque one. These are both inside a rotate() block which will render them diagonally.

Custom Modifier

We can now wrap this inside a custom Modifier:

@Suppress("MagicNumber")
fun Modifier.animatedOverlay(animatedOverlay: AnimatedOverlay) = this.then(
    Modifier
        .graphicsLayer {
            // This is required to render to an offscreen buffer
            // The Clear blend mode will not work without it
            alpha = 0.99f
        }
        .drawWithContent {
            drawContent()
            animatedOverlay.drawOverlay(this)
        }
)

The this.then() wrapper will add this Modifier after any existing Modifiers.

The graphicsLayer modifier is a slight hack, but is necessary. Alpha compositing is computational quite expensive. In many cases, it is not required. The default behaviour of Compose is to render directly to the display without alpha compositing. This is far more efficient. However, if an alpha other than 1.0 is applied to the graphics layer, then it will force compositing to an offscreen buffer. The blend mode used to create the mask requires alpha compositing, so we use an alpha value of 0.99f. This enables alpha compositing, but the transparency applied to the entire canvas will be undetectable to the human eye. I understand that this is a known issue, and we might get an explicit flag to enable/disable alpha compositing. But for now, we need to use this method.

The drawWithContent modifier allows us to draw before or after the content of the composable to which this modifier is applied. Here we draw the content of the composable, then render the overlay. So this will draw the overlay on top of the composable. The blend mode and alpha compositing will mask out the composable.

Applying the Modifier

We can now apply this to any composable. In this example, I’m using the ShoppingCart Material icon. However, the sample source also applies this to an eye VectorDrawable from the original post. It will work with any composable. But the strikethru may look slightly odd on very wide ones.

            if (state) 1f else 0f
        }
        val overlay = StrikethruOverlay(
            color = MaterialTheme.colors.primary,
            widthDp = 4.dp,
            getProgress = { progress }
        )
        Icon(
            modifier = modifier
                .clickable { enabled = !enabled }
                .padding(8.dp)
                .animatedOverlay(overlay)
                .padding(12.dp)
                .size(52.dp),
            imageVector = imageVector,
            tint = MaterialTheme.colors.primary,
            contentDescription = null
        )
    }
}

A remembered mutable state Boolean named enabled drives everything. Tapping on the Icon toggles this. When this changes, the transition updates. A transition is best when we want to trigger multiple animations. In this case there’s only one. However, I found that the Android Studio animation preview tool didn’t recognise the animation if I used:

val p = animateFloatAsState(if (enabled) 1f else 0f)

But, essentially, adding the transition is doing the same thing.

Next we create the StrikethruOverlay with appropriate line parameters, plus the lambda to lookup the progress from the animatedFloat we just created. This is applied to the Icon.

So when the user taps the Icon it toggles the enabled state. This triggers a new transition that animates the progress float based upon the new state value.

That gives us the desired behaviour:

Conclusion

We have the strikethru working without any knowledge of what the underlying composable is, or the background. Other overlay effects are possible using the same approach. Just create a new AnimatedOverlay implementation.

I am most grateful to both Doris Liu, Nader Jawad, and Nick Butcher who reviewed my original code and the first draft of this post. They offered suggestions which improved them both significantly. Thanks, folks!

The source code for this article is available here.

© 2021, Mark Allison. All rights reserved.

Copyright © 2021 Styling Android. All Rights Reserved.
Information about how to reuse or republish this work may be available at http://blog.stylingandroid.com/license-information.

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.