Search code examples
androidandroid-jetpack-composeandroid-jetpack-compose-canvas

How to use Jetpack Compose Canvas with Layout with dynamic size


I want to set androidx.compose.foundation.Canvas size dynamically after measuring measurables and placing placeables with layout function but Canvas requires a Modifier with specific size how can achieve this with Layout and Canvas and in efficient way.

enter image description here

What i want to achieve is as in the image to set content and also add some space and draw an arrow in red area to create speech bubble as can be seen here.

First Container in image is the correct one with Modifier.layout and Modifier.drawbackground as

@Composable
private fun MyComposable(modifier: Modifier = Modifier) {

    Row(
        modifier = Modifier

    ) {
        var totalSize = Size.Zero

        val layoutModifier = modifier
            .layout { measurable, constraints ->

                val placeable = measurable.measure(constraints)
                totalSize = Size(placeable.width + 50f, placeable.height.toFloat())

                println("MyComposable() LAYOUT placeable: $placeable")

                layout(totalSize.width.roundToInt(), placeable.height) {
                    placeable.place(0, 0)
                }

            }
            .drawBehind {
                println("MyComposable() drawBehind size: $size")
                drawRect(Color.Red, size = totalSize, style = Stroke(2f))
                drawRect(Color.Blue, size = size, style = Stroke(2f))
            }


        Column(modifier = layoutModifier) {
            Text("First Text")
            Text("Second Text")
        }

    }
}

And logs

I: MyComposable() LAYOUT placeable width: 250, height: 124
I: MyComposable() drawBehind size: Size(250.0, 124.0)

With Layout and Canvas

@Composable
private fun CanvasLayout(modifier: Modifier = Modifier, content: @Composable () -> Unit) {

    var widthInDp by remember { mutableStateOf(0.dp) }
    var heightInDp by remember { mutableStateOf(0.dp) }

    Layout(content = content, modifier = modifier) { measurables, constraints ->

        val placeables = measurables.map { measurable ->
            measurable.measure(constraints)
        }

        val maxWidth = placeables.maxOf { it.width } + 50
        val height = placeables.sumOf { it.height }

        widthInDp = (maxWidth / density).toDp()
        heightInDp = (height / density).toDp()
        println("🚗 CanvasLayout() LAYOUT maxWidth: $maxWidth, height: $height placeables: ${placeables.size}")

        var yPos = 0
        layout(maxWidth, height) {
            placeables.forEach { placeable: Placeable ->
                placeable.placeRelative(0, yPos)
                yPos += placeable.height
            }
        }
    }

    androidx.compose.foundation.Canvas(
        modifier = Modifier.size(widthInDp, heightInDp),
        onDraw = {
            println("🚙 CanvasLayout() CANVAS size: $size")
            drawRect(Color.Blue, size = size, style = Stroke(2f))
        }
    )
}

As can be seen in image bottom rectangle is drawn below Text composables instead of around them and calls layout twice

I: 🚗 CanvasLayout() LAYOUT maxWidth: 300, height: 124 placeables: 2
I: 🚙 CanvasLayout() CANVAS size: Size(0.0, 0.0)
I: 🚗 CanvasLayout() LAYOUT maxWidth: 300, height: 124 placeables: 2
I: 🚙 CanvasLayout() CANVAS size: Size(114.0, 47.0)

What's the correct and efficient way to use Layout and Canvas together?


Solution

  • Sometimes it requires a dynamic size which requires you to modify Canvas size either as in this question.

    Since Canvas is a Spacer

    @Composable
    fun Canvas(modifier: Modifier, onDraw: DrawScope.() -> Unit) =
        Spacer(modifier.drawBehind(onDraw))
    

    draw behind works fine.

    But sometimes you design your layout with Canvas in a way canvas happens to be in a statless Composable in bottom layer such as in default Slider Source code.

    fun Slider() {
       // States rememberables
      SliderImpl()
    }
    
    fun SliderImpl() {
       Canvas()
       // Other Composables like thumb
    }
    

    When you need to pass dynamic size to Canvas there are few options

    1- Using Modifier.onSizeChanged{} on your layout you want to get your dimension but this is not advised, and it might cause an infinite loop you set size and read again

    2- Using a BoxWithConstraints which returns min/maxWidth, min/maxHeight but max constraints doesn't always correspond to bounds of your layout. It works when your Composable fills whole layout when Modifier.fillMax used or you have a fixed size.

    3- Other option is to use a SubcomposeLayout where you create main(you want to measure) and dependent(you want to pass measurement to) Composables. Main component gets subcomposed and passes dimension to dependent one and you set it.

    This logic is used by Scaffold, TabRow to measure and space Composables based on other. You can check this link about SubcomposeLayout for more details. BoxWithConstraints, and LazyList are also SubcomposeLayouts

    4- LookAheadLayout, this might work either, i haven't tried yet, when i do i will edit this answer with a sample