2

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.

ZedAlpha
  • 219
  • 2
  • 8

4 Answers4

9

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) {
                        this@onDrawWithContent.drawContent()
                    }
                }
            }
            .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.

ZedAlpha
  • 219
  • 2
  • 8
1

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.

Screenshot of ClippedShadowFloatingActionButton, ClippedShadowButton, and ColoredShadowFloatingActionButton examples

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 RenderNodes, 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().

ZedAlpha
  • 219
  • 2
  • 8
  • 1
    I've added the `Modifier` and extension to [my library here](https://github.com/zed-alpha/shadow-gadgets), if that might be more convenient, but I've not had a chance to update this answer yet. – ZedAlpha May 12 '23 at 07:46
0

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.

Ragefn
  • 1
  • 1
0

Just set background parameter:

backgroundColor = colors.background

Example for PullRefreshIndicator:

PullRefreshIndicator(
    refreshing = refreshing,
    state = pullRefreshState,
    modifier = Modifier.align(
        Alignment.TopCenter
    ),
    backgroundColor = colors.background
)
helvete
  • 2,455
  • 13
  • 33
  • 37