Thracian
Thracian

Reputation: 67218

How to clip or cut a Composable?

enter image description here

How to clip or cut Composable content to have Image, Button or Composables to have custom shapes? This question is not about using Modifier.clip(), more like accomplishing task with alternative methods that allow outcomes that are not possible or when it's difficult to create a shape like cloud or Squircle.

This is share your knowledge, Q&A-style question which inspired by M3 BottomAppBar or BottomNavigation not having cutout shape, couldn't find question, and drawing a Squircle shape being difficult as in this question.

More and better ways of clipping or customizing shapes and Composables are more than welcome.

Upvotes: 10

Views: 6737

Answers (1)

Thracian
Thracian

Reputation: 67218

One of the ways for achieving cutting or clipping a Composable without the need of creating a custom Composable is using

Modifier.drawWithContent{} with a layer and a BlendMode or PorterDuff modes.

With Jetpack Compose for these modes to work you either need to set alpha less than 1f or use a Layer as in answer here.

I go with layer solution because i don't want to change content alpha

fun ContentDrawScope.drawWithLayer(block: ContentDrawScope.() -> Unit) {
    with(drawContext.canvas.nativeCanvas) {
        val checkPoint = saveLayer(null, null)
        block()
        restoreToCount(checkPoint)
    }
}

block lambda is the draw scope for Modifier.drawWithContent{} to do clipping

and another extension for simplifying further

fun Modifier.drawWithLayer(block: ContentDrawScope.() -> Unit) = this.then(
    Modifier.drawWithContent {
        drawWithLayer {
            block()
        }
    }
)

Clip button at the left side

First let's draw the button that is cleared a circle at left side
@Composable
private fun WhoAteMyButton() {
    val circleSize = LocalDensity.current.run { 100.dp.toPx() }
    Box(
        modifier = Modifier
            .fillMaxWidth()
            .drawWithLayer {
                // Destination
                drawContent()
               
                // Source
                drawCircle(
                    center = Offset(0f, 10f),
                    radius = circleSize,
                    blendMode = BlendMode.SrcOut,
                    color = Color.Transparent
                )
            }
    ) {
        Button(
            modifier = Modifier
                .padding(horizontal = 10.dp)
                .fillMaxWidth(),
            onClick = { /*TODO*/ }) {
            Text("Hello World")
        }
    }
}

We simply draw a circle but because of BlendMode.SrcOut intersection of destination is removed.

Clip button and Image with custom image

For squircle button i found an image from web

And clipped button and image using this image with

@Composable
private fun ClipComposables() {
    Row(
        modifier = Modifier.fillMaxWidth(),
        horizontalArrangement = Arrangement.SpaceEvenly
    ) {
        val imageBitmap = ImageBitmap.imageResource(id = R.drawable.squircle)

        Box(modifier = Modifier
            .size(150.dp)
            .drawWithLayer {

                // Destination
                drawContent()

                // Source
                drawImage(
                    image = imageBitmap,
                    dstSize = IntSize(width = size.width.toInt(), height = size.height.toInt()),
                    blendMode = BlendMode.DstIn
                )

            }
        ) {

            Box(
                modifier = Modifier
                    .size(150.dp)
                    .clickable { }
                    .background(MaterialTheme.colorScheme.inversePrimary),
                contentAlignment = Alignment.Center
            ) {
                Text(text = "Squircle", fontSize = 20.sp)
            }
        }

        Box(modifier = Modifier
            .size(150.dp)
            .drawWithLayer {
                // Destination
                drawContent()

                // Source
                drawImage(
                    image = imageBitmap,
                    dstSize = IntSize(width = size.width.toInt(), height = size.height.toInt()),
                    blendMode = BlendMode.DstIn
                )

            }
        ) {

            Image(
                painterResource(id = R.drawable.squirtle),
                modifier = Modifier
                    .size(150.dp),
                contentScale = ContentScale.Crop,
                contentDescription = ""
            )
        }

    }
}

There are 2 things to note here

1- Blend mode is BlendMode.DstIn because we want texture of Destination with shape of Source 2- Drawing image inside ContentDrawScope with dstSize to match Composable size. By default it's drawn with png size posted above.

Creating a BottomNavigation with cutout shape

@Composable
private fun BottomBarWithCutOutShape() {
    val density = LocalDensity.current
    val shapeSize = density.run { 70.dp.toPx() }

    val cutCornerShape = CutCornerShape(50)
    val outline = cutCornerShape.createOutline(
        Size(shapeSize, shapeSize),
        LocalLayoutDirection.current,
        density
    )

    val icons =
        listOf(Icons.Filled.Home, Icons.Filled.Map, Icons.Filled.Settings, Icons.Filled.LocationOn)

    Box(
        modifier = Modifier.fillMaxWidth()
    ) {
        BottomNavigation(
            modifier = Modifier
                .drawWithLayer {
                    with(drawContext.canvas.nativeCanvas) {

                        val checkPoint = saveLayer(null, null)
                        val width = size.width

                        val outlineWidth = outline.bounds.width
                        val outlineHeight = outline.bounds.height

                        // Destination
                        drawContent()

                        // Source
                        withTransform(
                            {
                                translate(
                                    left = (width - outlineWidth) / 2,
                                    top = -outlineHeight / 2
                                )
                            }
                        ) {
                            drawOutline(
                                outline = outline,
                                color = Color.Transparent,
                                blendMode = BlendMode.Clear
                            )
                        }

                        restoreToCount(checkPoint)
                    }
                },
            backgroundColor = Color.White
        ) {

            var selectedIndex by remember { mutableStateOf(0) }

            icons.forEachIndexed { index, imageVector: ImageVector ->
                if (index == 2) {
                    Spacer(modifier = Modifier.weight(1f))
                    BottomNavigationItem(
                        icon = { Icon(imageVector, contentDescription = null) },
                        label = null,
                        selected = selectedIndex == index,
                        onClick = {
                            selectedIndex = index
                        }
                    )
                } else {
                    BottomNavigationItem(
                        icon = { Icon(imageVector, contentDescription = null) },
                        label = null,
                        selected = selectedIndex == index,
                        onClick = {
                            selectedIndex = index
                        }
                    )
                }
            }
        }

        // This is size fo BottomNavigationItem
        val bottomNavigationHeight = LocalDensity.current.run { 56.dp.roundToPx() }

        FloatingActionButton(
            modifier = Modifier
                .align(Alignment.TopCenter)
                .offset {
                    IntOffset(0, -bottomNavigationHeight / 2)
                },
            shape = cutCornerShape,
            onClick = {}
        ) {
            Icon(imageVector = Icons.Default.Add, contentDescription = null)
        }
    }
}

This code is a bit long but we basically create a shape like we always and create an outline to clip

    val cutCornerShape = CutCornerShape(50)
    val outline = cutCornerShape.createOutline(
        Size(shapeSize, shapeSize),
        LocalLayoutDirection.current,
        density
    )

And before clipping we move this shape section up as half of the height to cut only with half of the outline

withTransform(
{
    translate(
        left = (width - outlineWidth) / 2,
        top = -outlineHeight / 2
    )
}
) {
    drawOutline(
        outline = outline,
        color = Color.Transparent,
        blendMode = BlendMode.Clear
    )
}

Also to have a BottomNavigation such as BottomAppBar that places children on both side i used a Spacer

icons.forEachIndexed { index, imageVector: ImageVector ->
    if (index == 2) {
        Spacer(modifier = Modifier.weight(1f))
        BottomNavigationItem(
            icon = { Icon(imageVector, contentDescription = null) },
            label = null,
            selected = selectedIndex == index,
            onClick = {
                selectedIndex = index
            }
        )
    } else {
        BottomNavigationItem(
            icon = { Icon(imageVector, contentDescription = null) },
            label = null,
            selected = selectedIndex == index,
            onClick = {
                selectedIndex = index
            }
        )
    }
}

Then we simply add a FloatingActionButton, i used offset but you can create a bigger parent and put our custom BottomNavigation and button inside it.

Upvotes: 14

Related Questions