androidandroid-jetpack-composeandroid-jetpack-compose-layout

Jetpack compose - Building a column with children separated by a divider


I'm trying to build a custom Column whose children are separated by a divider that's provided to it. The divider should only be applied between children that are actually rendered.

I initially thought of trying to replicate Arrangement.separatedBy() that Column uses, but it doesn't seem possible for my use case. I ended up going with the custom composable approach, and came up with the following implementation, but ran into an issue with measuring the dividers.

Any help/pointers would be appreciated.


@Composable
fun ColumnWithChildrenSeparatedByDivider(
    modifier: Modifier = Modifier,
    divider: @Composable () -> Unit,
    content: @Composable () -> Unit,
) {
    Layout(
        modifier = modifier,
        contents = listOf(content, divider),
    ) { measurables, constraints ->
        val contentPlaceables = measurables.first().map { measurable ->
            measurable.measure(constraints)
        }

        // Only take into account children that will actually be rendered
        val contentToRenderCount = contentPlaceables.map { it.width > 0 }.count()

        // This crashes, since I can't measure the same measurable more than once
        val dividerPlaceables = List(contentToRenderCount - 1) { measurables[1].first().measure(constraints) } 

        layout(constraints.maxWidth, constraints.maxHeight) {
            var yPosition = 0
            var dividerIndex = 0

            for (contentPlaceable in contentPlaceables) {
                if (contentPlaceable.height <= 0) {
                    continue
                }

                // Place child
                contentPlaceable.place(x = 0, y = yPosition)
                yPosition += contentPlaceable.height

                // Place divider
                val dividerPlaceable = dividerPlaceables[dividerIndex++]
                dividerPlaceable.place(x = 0, y = yPosition)
                yPosition += dividerPlaceable.height
            }
        }
    }
}

@Composable
fun Divider() {
    // Could be anything
}


Solution

  • Even if you were able to measure multiple times with

    measurables[1].first().measure(constraints)
    

    you wouldn't be able to place same placable that is placed already.

    You can either multiply the number of Dividers to match content size with initial number max of available content size such as, i used a fixed number for demonstration.

       @Composable
    fun ColumnWithChildrenSeparatedByDivider(
        modifier: Modifier = Modifier,
        divider: @Composable () -> Unit,
        content: @Composable () -> Unit,
    ) {
    
        val dividers = @Composable {
            repeat(15) {
                divider()
            }
        }
    
        Layout(
            modifier = modifier,
            contents = listOf(content, dividers),
        ) { measurables, constraints ->
    
            val contentPlaceables = measurables.first().map { measurable ->
                measurable.measure(constraints)
            }
    
            // Only take into account children that will actually be rendered
            val contentToRenderCount = contentPlaceables.map { it.width > 0 }.count()
    
            val dividerPlaceables = measurables[1].take(contentToRenderCount).map { measurable ->
                measurable.measure(constraints)
            }
    
            // Also using Constraints maxHeight results no modifier
            // layouts to cover size of parent as well. It's better to check if
            // modifier has fixed height and finite height if so use sum of heights else max
            // height from constraints.
    
            val hasFixedHeight = constraints.hasFixedHeight
            val hasBoundedHeight = constraints.hasBoundedHeight
    
            val height = if (hasFixedHeight && hasBoundedHeight) {
                constraints.maxHeight
            } else contentPlaceables.sumOf { it.height } + dividerPlaceables.sumOf { it.height }
    
            layout(constraints.maxWidth, height) {
                var yPosition = 0
                var dividerIndex = 0
    
                for (contentPlaceable in contentPlaceables) {
                    if (contentPlaceable.height <= 0) {
                        continue
                    }
    
                    // Place child
                    contentPlaceable.place(x = 0, y = yPosition)
                    yPosition += contentPlaceable.height
    
                    // Place divider
                    val dividerPlaceable = dividerPlaceables[dividerIndex++]
                    dividerPlaceable.place(x = 0, y = yPosition)
                    yPosition += dividerPlaceable.height
                }
            }
        }
    }
    

    Usage

    @Preview
    @Composable
    private fun Test() {
        ColumnWithChildrenSeparatedByDivider(modifier = Modifier
            .fillMaxWidth()
            .border(2.dp, Color.Red),
            content = {
                Text(text = "Hello World")
                Text(text = "Hello World")
                Text(text = "Hello World")
                Text(text = "Hello World")
                Box(modifier = Modifier.width(0.dp))
                Box(modifier = Modifier.width(0.dp))
                Text(text = "Hello")
            },
            divider = {
                Divider(
                    modifier = Modifier
                        .fillMaxWidth()
                        .height((1.dp))
                )
            }
        )
    }
    

    Other option is to use single param content: @Composable () -> Unit

    then either give Modifier.layoutId() to each content and divider and check those or use modulus for even and odd positions with indexing to match non zero width content with matching divider.

    @Composable
    fun ColumnWithChildrenSeparatedByDivider(
        modifier: Modifier = Modifier,
        content: @Composable () -> Unit,
    ) {
        val measurePolicy = remember {
            MeasurePolicy { measurables, constraints ->
    
                val contentPlaceables = hashMapOf<Int, Placeable>()
                measurables.filter {
                    it.layoutId == "content"
                }.mapIndexed { index, measurable ->
                    contentPlaceables[index] = measurable.measure(
                        constraints.copy(minWidth = 0)
                    )
                }
    
                val contentPlaceablesMap = contentPlaceables.filterValues {
                    it.width > 0
                }
    
                val contentList = contentPlaceablesMap.values.toList()
    
                val dividerPlaceables = measurables.filter {
                    it.layoutId == "divider"
                }.map {
                    it.measure(constraints)
                }.filterIndexed { index, _ ->
                    contentPlaceablesMap.contains(index)
                }
    
                val hasFixedHeight = constraints.hasFixedHeight
                val hasBoundedHeight = constraints.hasBoundedHeight
    
                val height = if (hasFixedHeight && hasBoundedHeight) {
                    constraints.maxHeight
                } else contentList.sumOf { it.height } + dividerPlaceables.sumOf { it.height }
    
    
                layout(constraints.maxWidth, height) {
                    var yPosition = 0
                    var dividerIndex = 0
    
                    for (contentPlaceable in contentList) {
                        if (contentPlaceable.height <= 0) {
                            continue
                        }
    
                        // Place child
                        contentPlaceable.place(x = 0, y = yPosition)
                        yPosition += contentPlaceable.height
    
                        // Place divider
                        val dividerPlaceable = dividerPlaceables[dividerIndex++]
                        dividerPlaceable.place(x = 0, y = yPosition)
                        yPosition += dividerPlaceable.height
                    }
                }
            }
        }
    
        Layout(
            modifier = modifier,
            content = content,
            measurePolicy = measurePolicy
        )
    }
    

    Usage

    @Preview
    @Composable
    private fun Test() {
    
        val content = mutableListOf<@Composable () -> Unit>(
            { Text(text = "Hello1", modifier = Modifier.layoutId("content")) },
            { Text(text = "Hello2" , modifier = Modifier.layoutId("content")) },
            { Text(text = "Hello3", modifier = Modifier.layoutId("content")) },
            { Text(text = "Hello4", modifier = Modifier.layoutId("content")) },
            { Box(modifier = Modifier.width(0.dp).layoutId("content")) },
            { Box(modifier = Modifier.width(0.dp).layoutId("content")) },
            { Text(text = "Hello5", modifier = Modifier.layoutId("content")) }
        )
        
        ColumnWithChildrenSeparatedByDivider(
            modifier = Modifier.fillMaxWidth()
        ) {
            content.forEach {
                it()
                Divider(
                    modifier = Modifier
                        .layoutId("divider")
                        .fillMaxWidth(),
                    color = Color.Red,
                    thickness = 3.dp
                )
            }
        }
    }