9

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.

A few different see-through Composables showing the shadow defect.

That's a FloatingActionButton, an ExtendedFloatingActionButton, and a Card with translucent backgrounds, showing the defect.

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 techniques, 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.


Clarifications:

  • It is necessary to preserve the Composable's translucency/transparency, so we can still see what's behind it. Setting an opaque background color will not work here. The artifact needs to be fixed, not just covered over.

    If an opaque color would work for your particular setup – e.g, if the underlying content is a single solid color – you can simply use the Color#compositeOver() function to figure the opaque color that would result from your translucent one blending with the color behind it.

  • This question is asking specifically about material shadows, the ones that are cast by Views/RenderNodes, and that cooperate with the on-screen draw routine and its two-source lighting model. android.graphics.Paint's shadow layer is a separate mechanism that does not interact with that model, and therefore is not generating material shadows. It's just drawing a static, uniform gradient, which is noted above as being unacceptable here because it looks completely different than the material shadows.

    If Paint's shadow layer would be suitable for your particular setup, there are many examples already available on-site, like those on this question. There's no need to repeat them here.

1
  • This isn't strictly relevant to the question here, but if somebody happens upon this post looking for how to clip Compose's new Modifier.dropShadow() in the same manner, I've a couple quick examples in this gist. They use Modifier.composed() so they're not as optimized as they could be, but they're probably fine for most general use cases. I may eventually put together Modifier.Node versions, but that should probably be a whole new post. Commented Mar 1 at 21:17

3 Answers 3

21

Compose's new GraphicsLayer API allows this to be accomplished through a Modifier and extension, meaning that it can be used just like the inbuilt shadow() without requiring a bespoke setup for each different Composable.*

In both Views and Compose, material shadows are ultimately cast by RenderNodes, and the new GraphicsLayer is a basically a thin wrapper around those, which means that we can now create and manipulate the basest possible {SDK} element necessary.

If you're not terribly concerned with the specific inspectable names and properties, a minimal drop-in replacement for shadow() might look something like:

import androidx.compose.runtime.Stable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.graphics.ClipOp
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.DefaultShadowColor
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.addOutline
import androidx.compose.ui.graphics.drawscope.clipPath
import androidx.compose.ui.graphics.layer.drawLayer
import androidx.compose.ui.graphics.layer.setOutline
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp

@Stable
fun Modifier.clippedShadow(
    elevation: Dp,
    shape: Shape = RectangleShape,
    clip: Boolean = elevation > 0.dp,
    ambientColor: Color = DefaultShadowColor,
    spotColor: Color = DefaultShadowColor
): Modifier {

    val first = if (elevation > 0.dp) {
        drawWithCache {
            val shadow = obtainGraphicsLayer()
            val clipPath = Path()

            val outline = shape.createOutline(size, layoutDirection, this)
            shadow.run { setOutline(outline); record { } }
            clipPath.run { rewind(); addOutline(outline) }

            onDrawBehind {
                shadow.shadowElevation = elevation.toPx()
                shadow.ambientShadowColor = ambientColor
                shadow.spotShadowColor = spotColor

                clipPath(clipPath, ClipOp.Difference) { drawLayer(shadow) }
            }
        }
    } else {
        this
    }

    return if (clip) first.clip(shape) else first
}

To give you an idea of the results, here are the question's examples with clippedShadow() used in place of their intrinsic shadows, which were disabled by passing zeroes for their elevation values:

The original examples using clippedShadow() instead.


Notes

  • Unfortunately, the above solution will likely break on API levels 24..28 (Nougat through Pie) due to some problematic variations in the underlying native graphics stuff on those versions. Defects can show in multiple ways: the clip region may go out of alignment, the shadow might get clipped to the rectangular bounds as well, etc.

    There is, however, a single remedy for all of those issues: draw the shadow through a compositing GraphicsLayer. This is relatively simple to do, but the solution ends up being a bit too long for this post, and the extra code is wholly unnecessary anyway if your minSdk is already >28, so I've put the full-blown solution in this separate gist.

  • If you'd prefer to have proper inspectable info, the current recommendation is to create your own modifier from scratch and override the InspectorInfo.inspectableProperties() function in the ModifierNodeElement. It just so happens that the examples in that separate gist are set up as such.

    The ClippedShadowModifierKMP file is analogous to the solution given here, and both seem to work on at least KMP Desktop too, so far. If you're supporting API levels 24..28 on Android, though, you'll probably need the version in ClippedShadowModifierAndroid to avoid the defects mentioned above.

  • I've updated the :compose module in my utility library to use this method as well. In addition to the clip fix, the library also offers a color compat feature that can tint shadows on versions before Pie, and that's been similarly refactored to use GraphicsLayers. It's essentially the same compositing layer trick as shown in the gist, but with a color filter set too. If you're curious about the details, you might have a look at the core file there, BaseShadowModifier.kt.



* The original version of this answer used an empty Layout and a draw Modifier to effect the clipped shadow, with another Layout needed to stack the first one behind the wrapped target Composable. If you'd like to see that version, please refer to this previous revision. Do note that it suffers from some of the same issues on 24..28 as the solution given here.

Sign up to request clarification or add additional context in comments.

5 Comments

It doesn't solve the problem in my case when I use it on compose desktop
@Roony Yeah, sorry, this whole post is really only meant to be for Android. I guess it's not very obvious unless you look at the tags on the question, but Stack Overflow doesn't want us to put tag names in the title. I haven't had a chance to play with Compose desktop yet, so I can't give any suggestions at the moment, but I'll let ya know if I learn anything useful in the near future. Cheers!
Actually I know that it's not about compose desktop, but I thought it would work regardless because both are compose.
@Roony Hey, I know it's been a while, but if you're still interested, this new solution seems to work on desktop: i.sstatic.net/nSrcepQP.png.
Not working on that project anymore, but will definitely benefit from this in the future, thanks!
1

Compose for Desktop has the same problem. I haven't tried it on Android yet, but on Desktop, if a shape has a path consisting of two conturs (it could also be a pierced shape variant like a donut), then its shadow will be solid. Therefore, for the case of a custom shape, you can use a workaround and draw a small dot in the distance. If the dot is less than a pixel it won't work, but it could very well be off screen.

class CustomShapeForShadow(private val path: Path) : Shape {
  override fun createOutline(
    size: Size,
    layoutDirection: LayoutDirection,
    density: Density
  ): Outline {
    val p2 = Path().apply {
        addRect(Rect(Offset(-100000f, -100000f), Size(10f, 10f)))
    }
    val pathRez = Path()
    pathRez.op(path, p2, PathOperation.Union)
    return Outline.Generic(path = pathRez)
  }
}

This solution does not look good. But at least in the first approximation, it works. Perhaps it is necessary to be more careful with the remoteness of this dot. If you use the resulting shape only for the shadow or surface, it seems that there are no performance problems. But if this shape is also use for the background and borders, the load is clearly visible at greater distances. That is why it is better to use a separate shape for background and borders. The result is not like in the example above, but it no longer looks like a defect.

1 Comment

I didn't understand what you meant when I first saw this, but now I do. I hadn't thought of going this route. It's actually more true-to-life than just cutting a hole through the shadow, and if you tone down the theme alphas a bit, I think it looks pretty good, actually: i.sstatic.net/vp9fW7o7.png. Nice idea. It does work similarly on Android, btw, just FYI.
-3

Just set background parameter:

backgroundColor = colors.background

Example for PullRefreshIndicator:

PullRefreshIndicator(
    refreshing = refreshing,
    state = pullRefreshState,
    modifier = Modifier.align(
        Alignment.TopCenter
    ),
    backgroundColor = colors.background
)

1 Comment

The title explicitly mentions "transparent/translucent Composables". Applying an opaque background color does indeed obscure the shadow, but it doesn't address the issue this post is targeting.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.