'How can we fix the material shadow glitch on transparent/translucent Composables?

If you didn't know already, there's a defect with Android's material shadows, the ones that came with Material Design and its concepts of surfaces, lighting, and elevation. Also, if you didn't know, Compose utilizes many of the same graphics APIs as the View framework, including those responsible for said shadows, so it has the same glitch that Views do, at least for now.

Screenshot of basic examples to compare and contrast.

Card(), FloatingActionButton(), ExtendedFloatingActionButton(), and Surface() shown with and without translucent backgrounds.

For reasons I won't get into here,* I don't believe that there is any proper fix for this – i.e., I don't think that the platform offers any method or configuration by which to clip out or otherwise remove that artifact – so we're left with workarounds. Additionally, a main requirement is that the shadows appear exactly as the platform ones normally would, so any method that draws shadows with other techiques, like a uniform gradient or blur or whatnot, are not acceptable.

Given that, can we create a robust, generally applicable solution in Compose?

I personally landed on an overall approach of disabling the original shadow and drawing a clipped replica in its place. (I realize that simply punching a hole through it is not how shadows work realistically, but that seems to be the predominately expected effect.) I've shared an example of the Compose version of this in an answer below, but the primary motivation for this question was to check for better ideas before this is put into a library.

I'm sure that there are technical details in my example that can be improved, but I'm mainly curious about fundamentally different approaches or suggestions. I'm not interested in, for instance, somehow using drawBehind() or Canvas() instead to do essentially the same thing, or refactoring parameters just to slot the content in, etc. I'm thinking more along the lines of:

  • Can you devise some other (more performant) way to trim that artifact without creating and clipping a separate shadow object? With Views, about the only way I found was to draw the View twice, with the content clipped in one draw and the shadow disabled in the other. I eventually decided against that, though, given the overhead.

  • Can this be extracted to a Modifier and extension, similar to the *GraphicsLayerModifiers and shadow()/graphicsLayer()? I've not yet fully wrapped my head around all of Compose's concepts and capabilities, but I don't think so.

  • Is there any other way to make this generally applicable, without requiring additional wiring? The shadow object in my example depends on three optional parameters with defaults from the target composable, and I can't think of any way to get at those, apart from wrapping the target with another composable.


* Those reasons are outlined in my question here.



Solution 1:[1]

We're going to use FloatingActionButton() for this local example, since it supports about every option we need to consider, but this should be workable with any Composable. For convenience, I've wrapped several more common ones with this solution and assembled them in this GitHub gist, if you'd want to try something other than just FloatingActionButton().

We want our wrapper Composable to act as a drop-in replacement, so its parameter list and default values are copied exactly from FloatingActionButton():

@Composable
fun ClippedShadowFloatingActionButton(
    onClick: () -> Unit,
    modifier: Modifier = Modifier,
    interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
    shape: Shape = MaterialTheme.shapes.small.copy(CornerSize(percent = 50)),
    backgroundColor: Color = MaterialTheme.colors.secondary,
    contentColor: Color = contentColorFor(backgroundColor),
    elevation: FloatingActionButtonElevation = FloatingActionButtonDefaults.elevation(),
    content: @Composable () -> Unit
) {
    Layout(
        {
            ClippedShadow(
                elevation = elevation.elevation(interactionSource).value,
                shape = shape,
                modifier = modifier
            )
            FloatingActionButton(
                onClick = onClick,
                modifier = modifier,
                interactionSource = interactionSource,
                shape = shape,
                backgroundColor = backgroundColor,
                contentColor = contentColor,
                elevation = FloatingActionButtonDefaults.elevation(0.dp, 0.dp, 0.dp, 0.dp),
                content = content
            )
        },
        modifier
    ) { measurables, constraints ->
        require(measurables.size == 2)

        val shadow = measurables[0]
        val target = measurables[1]

        val targetPlaceable = target.measure(constraints)
        val width = targetPlaceable.width
        val height = targetPlaceable.height

        val shadowPlaceable = shadow.measure(Constraints.fixed(width, height))

        layout(width, height) {
            shadowPlaceable.place(0, 0)
            targetPlaceable.place(0, 0)
        }
    }
}

We're basically wrapping a FloatingActionButton() and our replica shadow Composable in a Layout() optimized for the setup. Most of the parameters are passed untouched to the wrapped FloatingActionButton() except for the elevation, which we zero out to disable the inherent shadow. Instead, we direct to our ClippedShadow() the appropriate raw elevation value, which is calculated here using the FloatingActionButtonElevation and InteractionSource parameters. Simpler Composables like Card() will have stateless elevation values in Dp that can be passed straight through.

ClippedShadow() itself is another custom Layout(), but with no content:

@Composable
fun ClippedShadow(elevation: Dp, shape: Shape, modifier: Modifier = Modifier) {
    Layout(
        modifier
            .drawWithCache {
                // Naive cache setup similar to foundation's Background.
                val path = Path()
                var lastSize: Size? = null

                fun updatePathIfNeeded() {
                    if (size != lastSize) {
                        path.reset()
                        path.addOutline(
                            shape.createOutline(size, layoutDirection, this)
                        )
                        lastSize = size
                    }
                }

                onDrawWithContent {
                    updatePathIfNeeded()
                    clipPath(path, ClipOp.Difference) {
                        [email protected]()
                    }
                }
            }
            .shadow(elevation, shape)
    ) { _, constraints ->
        layout(constraints.minWidth, constraints.minHeight) {}
    }
}

We need it only for its shadow and Canvas access, which we get with two simple Modifier extensions. drawWithCache() lets us keep a simple Path cache we use to clip and restore around the entire content draw, and shadow() is rather self-explanatory. With this Composable layered behind the target, whose own shadow is disabled, we get the desired effect:

Screenshot of the question's examples fixed.

As in the question, the first three are Card(), FloatingActionButton(), and ExtendedFloatingActionButton(), but wrapped in our fix. To demonstrate that the InteractionSource/elevation redirect works as intended, this brief gif shows two FloatingActionButton()s with fully transparent backgrounds side by side; the one on the right has our fix applied.

For the fourth example in the image of fixes above, we used a solo ClippedShadow(), simply to illustrate that it works on its own, as well:

ClippedShadow(
    elevation = 10.dp,
    shape = RoundedCornerShape(10.dp),
    modifier = Modifier.size(FabSize)
)

Just like the regular composables, it should work with any Shape that's valid for the current API level. Arbitrary convex Paths work on all relevant versions, and starting with API level 29 (Q), concave ones do, too.

Screenshot of ClippedShadow() with various Shapes made from irregular Paths.

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1