I'm still fairly sure that a Modifier
using only Compose is not possible for this, but we can put together one that utilizes the techniques described in my answer here, presenting us with an option that's more convenient and much simpler to use than my first approach of stacking multiple Composables.
Since Compose has several classes with the same simple names as native ones, the code for this solution is provided as two files with their respective imports and aliases, rather than as individual functions and classes and whatnot, to help prevent mix-ups. You may wish to rearrange things more sensibly after you get them into your project.
Code files
This is the first of two local files. It holds the Modifier
factory extension function, the base class for the two different draw types, and a helper that manages a shadow's defining Outline
and the Path
used to clip its draw.
import android.graphics.Canvas
import android.graphics.Region
import android.os.Build
import android.view.ViewGroup
import androidx.annotation.DoNotInline
import androidx.annotation.RequiresApi
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.Rect
import androidx.compose.ui.geometry.RoundRect
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.geometry.isSimple
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.DefaultShadowColor
import androidx.compose.ui.graphics.Outline
import androidx.compose.ui.graphics.Path
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.asAndroidPath
import androidx.compose.ui.graphics.drawscope.DrawScope
import androidx.compose.ui.graphics.nativeCanvas
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.platform.debugInspectorInfo
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
import kotlin.math.roundToInt
import android.graphics.Outline as AndroidOutline
import android.graphics.Path as AndroidPath
fun Modifier.clippedShadow(
elevation: Dp,
shape: Shape = RectangleShape,
ambientColor: Color = DefaultShadowColor,
spotColor: Color = DefaultShadowColor,
forceViewType: Boolean = false
) = if (elevation.value <= 0F) this else composed(
factory = {
val shadow = if (Build.VERSION.SDK_INT >= 29 && !forceViewType) {
remember { RenderNodeClippedShadow() }
} else {
val localView = LocalView.current as? ViewGroup
?: throw IllegalStateException("Invalid LocalView")
val viewShadow = remember { ViewClippedShadow(localView) }
DisposableEffect(localView) { onDispose { viewShadow.remove() } }
viewShadow
}
drawBehind {
with(shadow) { draw(elevation, shape, ambientColor, spotColor) }
}
},
inspectorInfo = debugInspectorInfo {
name = "clippedShadow"
properties["elevation"] = elevation
properties["shape"] = shape
properties["ambientColor"] = ambientColor
properties["spotColor"] = spotColor
properties["forceViewType"] = forceViewType
}
)
internal sealed class ClippedShadow {
protected val shadowOutline = ShadowOutline()
fun DrawScope.draw(
shadowElevation: Dp,
shape: Shape,
ambientColor: Color,
spotColor: Color
) {
val androidCanvas = drawContext.canvas.nativeCanvas
if (!androidCanvas.isHardwareAccelerated) return
shadowOutline.setShape(shape, size, layoutDirection, this)
onUpdate(
shadowElevation.toPx(),
size.width.toInt(),
size.height.toInt(),
ambientColor.toArgb(),
spotColor.toArgb()
)
with(androidCanvas) {
save()
shadowOutline.clip(this)
CanvasUtils.enableZ(this, true)
onDraw(this)
CanvasUtils.enableZ(this, false)
restore()
}
}
abstract fun onUpdate(
elevation: Float,
width: Int,
height: Int,
ambientColor: Int,
spotColor: Int
)
abstract fun onDraw(canvas: Canvas)
}
internal class ShadowOutline {
private val androidPath = AndroidPath()
val androidOutline = AndroidOutline().apply { alpha = 1.0F }
// Adapted from androidx.compose.ui.platform.OutlineResolver
// The Android Open Source Project, licensed under Apache 2.0
fun setShape(
shape: Shape,
size: Size,
layoutDirection: LayoutDirection,
density: Density
) = when (val outline =
shape.createOutline(size, layoutDirection, density)) {
is Outline.Rectangle ->
fromRect(outline.rect, androidOutline, androidPath)
is Outline.Rounded ->
fromRoundRect(outline.roundRect, androidOutline, androidPath)
is Outline.Generic ->
fromPath(outline.path, androidOutline, androidPath)
}
fun clip(androidCanvas: Canvas) {
if (Build.VERSION.SDK_INT >= 26) {
CanvasClipHelper.clipOutPath(androidCanvas, androidPath)
} else {
@Suppress("DEPRECATION")
androidCanvas.clipPath(androidPath, Region.Op.DIFFERENCE)
}
}
private fun fromRect(
rect: Rect,
outline: AndroidOutline,
path: AndroidPath
) {
outline.setRect(
rect.left.roundToInt(),
rect.top.roundToInt(),
rect.right.roundToInt(),
rect.bottom.roundToInt()
)
path.reset()
path.addRect(
rect.left,
rect.top,
rect.right,
rect.bottom,
AndroidPath.Direction.CW
)
}
private fun fromRoundRect(
roundRect: RoundRect,
outline: AndroidOutline,
path: AndroidPath
) {
val radius = roundRect.topLeftCornerRadius.x
if (roundRect.isSimple) {
outline.setRoundRect(
roundRect.left.roundToInt(),
roundRect.top.roundToInt(),
roundRect.right.roundToInt(),
roundRect.bottom.roundToInt(),
radius
)
path.reset()
path.addRoundRect(
roundRect.left,
roundRect.top,
roundRect.right,
roundRect.bottom,
radius,
radius,
AndroidPath.Direction.CW
)
} else {
fromPath(Path().apply { addRoundRect(roundRect) }, outline, path)
}
}
private fun fromPath(
composePath: Path,
outline: AndroidOutline,
path: AndroidPath
) {
path.reset()
if (Build.VERSION.SDK_INT > 28 || composePath.isConvex) {
val androidPath = composePath.asAndroidPath()
@Suppress("DEPRECATION") // Name changed, but messy/pointless to fix
outline.setConvexPath(androidPath)
path.set(androidPath)
} else {
outline.setEmpty()
}
}
}
@RequiresApi(26)
private object CanvasClipHelper {
@DoNotInline
fun clipOutPath(canvas: Canvas, path: android.graphics.Path) {
canvas.clipOutPath(path)
}
}
The second file holds the two implementations of the base class, and a few helpers specifically for the View
version.
import android.annotation.SuppressLint
import android.graphics.Canvas
import android.graphics.Outline
import android.graphics.RenderNode
import android.os.Build
import android.view.View
import android.view.View.OnLayoutChangeListener
import android.view.ViewGroup
import android.view.ViewOutlineProvider
import androidx.annotation.DoNotInline
import androidx.annotation.RequiresApi
@RequiresApi(29)
internal class RenderNodeClippedShadow : ClippedShadow() {
private val shadowNode = RenderNode("ClippedShadow")
override fun onUpdate(
elevation: Float,
width: Int,
height: Int,
ambientColor: Int,
spotColor: Int
) {
shadowNode.apply {
setOutline(shadowOutline.androidOutline)
this.elevation = elevation
ambientShadowColor = ambientColor
spotShadowColor = spotColor
}
}
override fun onDraw(canvas: Canvas) {
canvas.drawRenderNode(shadowNode)
}
}
internal class ViewClippedShadow(ownerView: ViewGroup) : ClippedShadow() {
private val viewPainter = ViewPainter.forView(ownerView)
private val shadowView = object : View(ownerView.context) {
init {
outlineProvider = object : ViewOutlineProvider() {
override fun getOutline(view: View, outline: Outline) {
outline.set(shadowOutline.androidOutline)
}
}
}
}
init {
viewPainter.registerActiveView(shadowView)
}
fun remove() {
viewPainter.unregisterActiveView(shadowView)
}
override fun onUpdate(
elevation: Float,
width: Int,
height: Int,
ambientColor: Int,
spotColor: Int
) {
shadowView.apply {
layout(0, 0, width, height)
this.elevation = elevation
if (Build.VERSION.SDK_INT >= 28) {
ShadowColorsHelper.setColors(this, ambientColor, spotColor)
}
}
}
override fun onDraw(canvas: Canvas) {
viewPainter.drawView(shadowView, canvas)
}
}
@SuppressLint("ViewConstructor")
private class ViewPainter private constructor(
private val ownerView: ViewGroup
) : ViewGroup(ownerView.context) {
companion object {
fun forView(viewGroup: ViewGroup) = viewGroup.viewPainter
?: ViewPainter(viewGroup).also { viewGroup.viewPainter = it }
private var ViewGroup.viewPainter: ViewPainter?
get() = getTag(R.id.view_painter) as? ViewPainter
set(value) = setTag(R.id.view_painter, value)
}
private val uiThread = ownerView.handler.looper.thread
private fun runOnUiThread(block: () -> Unit) {
if (Thread.currentThread() != uiThread) {
ownerView.post(block)
} else {
block.invoke()
}
}
private val layoutListener =
OnLayoutChangeListener { _, l, t, r, b, _, _, _, _ ->
layout(0, 0, r - l, b - t)
}
init {
visibility = GONE
val owner = ownerView
owner.addOnLayoutChangeListener(layoutListener)
if (owner.isLaidOut) layout(0, 0, owner.width, owner.height)
runOnUiThread { owner.overlay.add(this) }
}
private fun detachSelf() {
val owner = ownerView
owner.removeOnLayoutChangeListener(layoutListener)
owner.viewPainter = null
runOnUiThread { owner.overlay.remove(this) }
}
private val activeViews = mutableSetOf<View>()
fun registerActiveView(view: View) {
activeViews += view
}
fun unregisterActiveView(view: View) {
activeViews.apply { remove(view); if (isEmpty()) detachSelf() }
}
fun drawView(view: View, canvas: Canvas) {
addViewInLayout(view, 0, EmptyLayoutParams)
draw(canvas)
removeViewInLayout(view)
}
override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {}
}
@RequiresApi(28)
private object ShadowColorsHelper {
@DoNotInline
fun setColors(view: View, ambientColor: Int, spotColor: Int) {
view.outlineAmbientShadowColor = ambientColor
view.outlineSpotShadowColor = spotColor
}
}
private val EmptyLayoutParams = ViewGroup.LayoutParams(0, 0)
The ViewPainter
class uses an R.id
that needs to be defined in one of your /res/values
files, often in one named ids.xml
, but it doesn't really matter.
<resources>
<item name="view_painter" type="id" />
</resources>
There is one more requirement: the CanvasUtils
object that handles the enableZ()
calls in the core draw routine. The underlying methods weren't always available in the SDK, so it takes some reflection to get at them. Luckily for us, Compose itself has to use those same methods, and they've already written a helper that does exactly what we need and nothing more, so it'd be silly for me to suggest anything else: CanvasUtils.android.kt
.
Example usage
clippedShadow()
is used the same way that shadow()
is, so there's really not much to demonstrate, code-wise. The visual results are the same as those shown in the images in my first answer on this question, so I won't bother with redundant screenshots here, though I will mention that this one can do colors out of the box. That first solution was created before shadow colors were available in Compose, but you can see how they could be added easily.
Like shadow()
, this is non-interactive and fixed on its own, but wiring it up to user interaction is quite a bit simpler than with my first approach. We'll replicate ClippedShadowFloatingActionButton()
here to demonstrate the new Modifier
accordingly. Though we're still wrapping the target Composable, it's self-contained and straightforward:
@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(),
shadowAmbientColor: Color = DefaultShadowColor,
shadowSpotColor: Color = DefaultShadowColor,
content: @Composable () -> Unit
) {
FloatingActionButton(
onClick = onClick,
modifier = modifier.clippedShadow(
elevation.elevation(interactionSource).value,
shape,
shadowAmbientColor,
shadowSpotColor
),
interactionSource = interactionSource,
shape = shape,
backgroundColor = backgroundColor,
contentColor = contentColor,
elevation = FloatingActionButtonDefaults.elevation(0.dp, 0.dp, 0.dp, 0.dp),
content = content
)
}
Again, we're simply disabling the inherent shadow on FloatingActionButton()
by zeroing out its default elevation values, and using its InteractionSource
to figure the current singular value to pass to clippedShadow()
. This new version has added parameters for shadow colors, but just like everywhere else in both Compose and the native framework, they will only have effect on API level 28 and above.
This configuration should be workable with about any Composable, but the minimum interactive component size might not play nice with smaller ones, like plain Button()
. I'm not sure that there's any way to get a Composable's original size, so the only option might be to disable LocalMinimumInteractiveComponentEnforcement
around the target, and then make size and/or position adjustments externally as needed.
@OptIn(ExperimentalMaterialApi::class)
@Composable
fun ClippedShadowButton(
onClick: () -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
elevation: ButtonElevation? = ButtonDefaults.elevation(),
shape: Shape = MaterialTheme.shapes.small,
shadowAmbientColor: Color = DefaultShadowColor,
shadowSpotColor: Color = DefaultShadowColor,
border: BorderStroke? = null,
colors: ButtonColors = ButtonDefaults.buttonColors(),
contentPadding: PaddingValues = ButtonDefaults.ContentPadding,
content: @Composable RowScope.() -> Unit
) {
CompositionLocalProvider(LocalMinimumInteractiveComponentEnforcement provides false) {
Button(
onClick = onClick,
modifier = modifier.clippedShadow(
elevation?.elevation(enabled, interactionSource)?.value ?: 0.dp,
shape,
shadowAmbientColor,
shadowSpotColor
),
enabled = enabled,
interactionSource = interactionSource,
elevation = ButtonDefaults.elevation(0.dp, 0.dp, 0.dp, 0.dp, 0.dp),
shape = shape,
border = border,
colors = colors,
contentPadding = contentPadding,
content = content
)
}
}
Incidentally, this same setup can be used just to change the shadow colors on existing Composables without having to rewrite them down to the graphics layer, simply by substituting shadow()
for clippedShadow()
above.
@Composable
fun ColoredShadowFloatingActionButton(
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(),
shadowAmbientColor: Color = DefaultShadowColor,
shadowSpotColor: Color = DefaultShadowColor,
content: @Composable () -> Unit
) {
FloatingActionButton(
onClick = onClick,
modifier = modifier.shadow(
elevation = elevation.elevation(interactionSource).value,
shape = shape,
ambientColor = shadowAmbientColor,
spotColor = shadowSpotColor,
),
interactionSource = interactionSource,
shape = shape,
backgroundColor = backgroundColor,
contentColor = contentColor,
elevation =
FloatingActionButtonDefaults.elevation(0.dp, 0.dp, 0.dp, 0.dp),
content = content
)
}
I should mention that colored shadows can be very hard to notice, if you've not used them before. It's often necessary to increase the elevation values, and/or one or both of the light source alphas in the relevant theme.
<style name="Theme.YourApp" parent="…">
…
<item name="android:spotShadowAlpha">0.19</item>
<item name="android:ambientShadowAlpha">0.039</item>
</style>
The values shown there are the defaults, so there's quite a bit of room to darken both, if needed. To illustrate, this image shows examples of the three custom button Composables we've just defined, with the elevations increased by 10.dp
, and both alpha attributes set to 0.5
.

Notes
I've neglected to mention it anywhere else yet, but if you can avoid this, you should. Specifically, if the background behind your see-through Composable is a single solid color, there's no need for the translucency to begin with. You can simply figure the resulting opaque color to use for the Composable's background instead. For example, TranslucentColor.compositeOver(Color.White)
will produce the opaque color that TranslucentColor
would look like on top of White
.
As far as I know, there's no guarantee as to which thread any part of a composition might run on, even though it all seems to be restricted to the UI thread, currently. That's not an issue for RenderNode
s, but the View
implementation attaches to the on-screen hierarchy, and therefore may potentially cause a CalledFromWrongThreadException
. I've tried to ensure that it doesn't happen, but I've not done much testing yet in that area, and there may be issues that I've not considered. The current setup is geared toward avoiding crashes at the expense of possible leaks, but neither should even be a possibility until Compose moves stuff off the UI thread.
clippedShadow()
is purely decorative. That is, it does not generate a new graphics layer like shadow()
does. To that end, if the passed elevation
is not positive, clippedShdow()
is a no-op.
The only shadow()
option that I did not incorporate is clip
, mainly to avoid the complication, but also because it can be handled with a separate Modifier
, appropriately named clip()
. If you'd like to add the option to clippedShadow()
, it should be trivial to rework this a bit to clip to ShadowOutline
's path in a drawWithContent()
.