0

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
}

Husayn Hakeem
  • 4,184
  • 1
  • 16
  • 31

1 Answers1

1

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
            )
        }
    }
}
Thracian
  • 43,021
  • 16
  • 133
  • 222
  • I see what my approach was missing. Will use your solution to improve it, thanks! – Husayn Hakeem Feb 27 '23 at 06:23
  • You can use both approaches based on how content composables to be added. You can even wrap these composables with another Box(modifier=Modifier.layoutId) for convenience and pass list and do adding Divider isinde your custom layout instead of the Test function i posted. Both can work in different scenarios. So you still can pass divider as a param while adding those dividers inside custom layout – Thracian Feb 27 '23 at 06:33
  • 1
    Second approach is actually used by some default Composable i can't recall which one at the moment to measure required ones first then to place others based on availability. They basically assigned a Modifier.layoutId, get distincted inside `Layout` and rest is measured based on their dimensions – Thracian Feb 27 '23 at 06:38