Thracian
Thracian

Reputation: 66899

Jetpack Compose how does SubcomposeLayout work?

As can be seen in official documents there is layout named SubcomposeLayout defined as

Analogue of Layout which allows to subcompose the actual content during the measuring stage for example to use the values calculated during the measurement as params for the composition of the children.

Possible use cases:

You need to know the constraints passed by the parent during the composition and can't solve your use case with just custom Layout or LayoutModifier. See androidx.compose.foundation.layout.BoxWithConstraints.

You want to use the size of one child during the composition of the second child.

You want to compose your items lazily based on the available size. For example you have a list of 100 items and instead of composing all of them you only compose the ones which are currently visible(say 5 of them) and compose next items when the component is scrolled.

I searched Stackoverflow with SubcomposeLayout keyword but couldn't find anything about it, created this sample code, copied most of it from official document, to test and learn how it works

@Composable
private fun SampleContent() {

    Column(
        modifier = Modifier
            .fillMaxSize()
            .verticalScroll(rememberScrollState())
    ) {
        SubComponent(
            mainContent = {
                Text(
                    "MainContent",
                    modifier = Modifier
                        .background(Color(0xffF44336))
                        .height(60.dp),
                    color = Color.White
                )
            },
            dependentContent = {
                val size = it

                println("🤔 Dependent size: $size")
                Column() {

                    Text(
                        "Dependent Content",
                        modifier = Modifier
                            .background(Color(0xff9C27B0)),
                        color = Color.White
                    )
                }
            }
        )

    }
}

@Composable
private fun SubComponent(
    mainContent: @Composable () -> Unit,
    dependentContent: @Composable (IntSize) -> Unit
) {

    SubcomposeLayout { constraints ->

        val mainPlaceables = subcompose(SlotsEnum.Main, mainContent).map {
            it.measure(constraints)

        }

        val maxSize = mainPlaceables.fold(IntSize.Zero) { currentMax, placeable ->
            IntSize(
                width = maxOf(currentMax.width, placeable.width),
                height = maxOf(currentMax.height, placeable.height)
            )
        }

        layout(maxSize.width, maxSize.height) {

            mainPlaceables.forEach { it.placeRelative(0, 0) }

            subcompose(SlotsEnum.Dependent) {
                dependentContent(maxSize)
            }.forEach {
                it.measure(constraints).placeRelative(0, 0)
            }

        }
    }
}

enum class SlotsEnum { Main, Dependent }

It's supposed to re-measure a component based on another component size but what this code actually does is a mystery to me.

  1. How does subcompose function work?
  2. What's the point of slotId and can we get slotId in a way?

The description for subCompose function

Performs subcomposition of the provided content with given slotId. Params: slotId - unique id which represents the slot we are composing into. If you have fixed amount or slots you can use enums as slot ids, or if you have a list of items maybe an index in the list or some other unique key can work. To be able to correctly match the content between remeasures you should provide the object which is equals to the one you used during the previous measuring. content - the composable content which defines the slot. It could emit multiple layouts, in this case the returned list of Measurables will have multiple elements.

Can someone explain what it means or/and provide a working sample for SubcomposeLayout?

Upvotes: 16

Views: 20033

Answers (3)

Thracian
Thracian

Reputation: 66899

Recently i needed to use almost the same SubcomposeLayout in question. I needed a Slider with a Composable thumb that i needed to get its width so i can set start and end of track and full width of Slider i was getting from BoxWithConstraints.

enum class SlotsEnum {
    Slider, Thumb
}

/**
 * [SubcomposeLayout] that measure [thumb] size to set Slider's track start and track width.
 * @param thumb thumb Composable
 * @param slider Slider composable that contains **thumb** and **track** of this Slider.
 */
@Composable
private fun SliderComposeLayout(
    modifier: Modifier = Modifier,
    thumb: @Composable () -> Unit,
    slider: @Composable (IntSize, Constraints) -> Unit
) {
    SubcomposeLayout(modifier = modifier) { constraints: Constraints ->

        // Subcompose(compose only a section) main content and get Placeable
        val thumbPlaceable: Placeable = subcompose(SlotsEnum.Thumb, thumb).map {
            it.measure(constraints)
        }.first()

        // Width and height of the thumb Composable
        val thumbSize = IntSize(thumbPlaceable.width, thumbPlaceable.height)

        // Whole Slider Composable
        val sliderPlaceable: Placeable = subcompose(SlotsEnum.Slider) {
            slider(thumbSize, constraints)
        }.map {
            it.measure(constraints)
        }.first()


        val sliderWidth = sliderPlaceable.width
        val sliderHeight = sliderPlaceable.height

        layout(sliderWidth, sliderHeight) {
            sliderPlaceable.placeRelative(0, 0)
        }
    }
}

Measured thumb and send its dimensions as IntSize and Constraints to Slider, and only placed Slider since thumb is already placed insider Slider, placing here creates two thumbs.

And used it as

    SliderComposeLayout(
        modifier = modifier
            .minimumTouchTargetSize()
            .requiredSizeIn(
                minWidth = ThumbRadius * 2,
                minHeight = ThumbRadius * 2,
            ),
        thumb = { thumb() }
    ) { thumbSize: IntSize, constraints: Constraints ->

        val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl

        val width = constraints.maxWidth.toFloat()
        val thumbRadiusInPx = (thumbSize.width / 2).toFloat()

        // Start of the track used for measuring progress,
        // it's line + radius of cap which is half of height of track
        // to draw this on canvas starting point of line
        // should be at trackStart + trackHeightInPx / 2 while drawing
        val trackStart: Float
        // End of the track that is used for measuring progress
        val trackEnd: Float
        val strokeRadius: Float
        with(LocalDensity.current) {

            strokeRadius = trackHeight.toPx() / 2
            trackStart = thumbRadiusInPx.coerceAtLeast(strokeRadius)
            trackEnd = width - trackStart
        }
    // Rest of the code
}

Result

enter image description here

Github link for the code

Upvotes: 5

Thracian
Thracian

Reputation: 66899

I made a sample based on the sample provided by official documents and @chuckj's answer but still not sure if this efficient or right way to implement it.

It basically measures longest component sets parent width and remeasures shorter one with minimumWidth of Constraint and resizes short one as can be seen in this gif. This is how whatsapp scales quote and message length basically.

enter image description here

Orange and pink containers are Columns, which direct children of DynamicWidthLayout, that uses SubcomposeLayout to remeasure.

@Composable
private fun DynamicWidthLayout(
    modifier: Modifier = Modifier,
    mainContent: @Composable () -> Unit,
    dependentContent: @Composable (IntSize) -> Unit
) {

    SubcomposeLayout(modifier = modifier) { constraints ->


        var mainPlaceables: List<Placeable> = subcompose(SlotsEnum.Main, mainContent).map {
            it.measure(constraints)
        }

        var maxSize =
            mainPlaceables.fold(IntSize.Zero) { currentMax: IntSize, placeable: Placeable ->
                IntSize(
                    width = maxOf(currentMax.width, placeable.width),
                    height = maxOf(currentMax.height, placeable.height)
                )
            }

        val dependentMeasurables: List<Measurable> = subcompose(SlotsEnum.Dependent) {
            // 🔥🔥 Send maxSize of mainComponent to
            // dependent composable in case it might be used
            dependentContent(maxSize)
        }

        val dependentPlaceables: List<Placeable> = dependentMeasurables
            .map { measurable: Measurable ->
                measurable.measure(Constraints(maxSize.width, constraints.maxWidth))
            }

        // Get maximum width of dependent composable
        val maxWidth = dependentPlaceables.maxOf { it.width }


        println("🔥 DynamicWidthLayout-> maxSize width: ${maxSize.width}, height: ${maxSize.height}")

        // If width of dependent composable is longer than main one, remeasure main one
        // with dependent composable's width using it as minimumWidthConstraint
        if (maxWidth > maxSize.width) {

            println("🚀 DynamicWidthLayout REMEASURE MAIN COMPONENT")

            // !!! 🔥🤔 CANNOT use SlotsEnum.Main here why?
            mainPlaceables = subcompose(2, mainContent).map {
                it.measure(Constraints(maxWidth, constraints.maxWidth))
            }
        }

        // Our final maxSize is longest width and total height of main and dependent composables
        maxSize = IntSize(
            maxSize.width.coerceAtLeast(maxWidth),
            maxSize.height + dependentPlaceables.maxOf { it.height }
        )


        layout(maxSize.width, maxSize.height) {

            // Place layouts
            mainPlaceables.forEach { it.placeRelative(0, 0) }
            dependentPlaceables.forEach {
                it.placeRelative(0, mainPlaceables.maxOf { it.height })
            }
        }
    }
}


enum class SlotsEnum { Main, Dependent }

Usage

@Composable
private fun TutorialContent() {

    val density = LocalDensity.current.density

    Column(
        modifier = Modifier
            .fillMaxSize()
            .verticalScroll(rememberScrollState())
    ) {


        var mainText by remember { mutableStateOf(TextFieldValue("Main Component")) }
        var dependentText by remember { mutableStateOf(TextFieldValue("Dependent Component")) }


        OutlinedTextField(
            modifier = Modifier
                .padding(horizontal = 8.dp)
                .fillMaxWidth(),
            value = mainText,
            label = { Text("Main") },
            placeholder = { Text("Set text to change main width") },
            onValueChange = { newValue: TextFieldValue ->
                mainText = newValue
            }
        )

        OutlinedTextField(
            modifier = Modifier
                .padding(horizontal = 8.dp)
                .fillMaxWidth(),
            value = dependentText,
            label = { Text("Dependent") },
            placeholder = { Text("Set text to change dependent width") },
            onValueChange = { newValue ->
                dependentText = newValue
            }
        )

        DynamicWidthLayout(
            modifier = Modifier
                .padding(8.dp)
                .background(Color.LightGray)
                .padding(8.dp),
            mainContent = {

                println("🍏 DynamicWidthLayout-> MainContent {} composed")

                Column(
                    modifier = Modifier
                        .background(orange400)
                        .padding(4.dp)
                ) {
                    Text(
                        text = mainText.text,
                        modifier = Modifier
                            .background(blue400)
                            .height(40.dp),
                        color = Color.White
                    )
                }
            },
            dependentContent = { size: IntSize ->


                // 🔥 Measure max width of main component in dp  retrieved
                // by subCompose of dependent component from IntSize
                val maxWidth = with(density) {
                    size.width / this
                }.dp

                println(
                    "🍎 DynamicWidthLayout-> DependentContent composed " +
                            "Dependent size: $size, "
                            + "maxWidth: $maxWidth"
                )

                Column(
                    modifier = Modifier
                        .background(pink400)
                        .padding(4.dp)
                ) {

                    Text(
                        text = dependentText.text,
                        modifier = Modifier
                            .background(green400),
                        color = Color.White
                    )
                }
            }
        )
    }
}

And full source code is here.

Upvotes: 12

chuckj
chuckj

Reputation: 29545

It's supposed to re-measure a component based on another component size...

SubcomposeLayout doesn't remeasure. It allows deferring the composition and measure of content until its constraints from its parent are known and some its content can be measured, the results from which and can be passed as a parameter to the deferred content. The above example calculates the maximum size of the content generated by mainContent and passes it as a parameter to deferredContent. It then measures deferredContent and places both mainContent and deferredContent on top of each other.

The simplest example of how to use SubcomposeLayout is BoxWithConstraints that just passes the constraints it receives from its parent directly to its content. The constraints of the box are not known until the siblings of the box have been measured by the parent which occurs during layout so the composition of content is deferred until layout.

Similarly, for the example above, the maxSize of mainContent is not known until layout so deferredContent is called in layout once maxSize is calculated. It always places deferredContent on top of mainContent so it is assumed that deferredContent uses maxSize in some way to avoid obscuring the content generated by mainContent. Probably not the best design for a composable but the composable was intended to be illustrative not useful itself.

Note that subcompose can be called multiple times in the layout block. This is, for example, what happens in LazyRow. The slotId allows SubcomposeLayout to track and manage the compositions created by calling subcompose. For example, if you are generating the content from an array you might want use the index of the array as its slotId allowing SubcomposeLayout to determine which subcompose generated last time should be used to during recomposition. Also, if a slotid is not used any more, SubcomposeLayout will dispose its corresponding composition.

As for where the slotId goes, that is up to the caller of SubcomposeLayout. If the content needs it, pass it as a parameter. The above example doesn't need it as the slotId is always the same for deferredContent so it doesn't need to go anywhere.

Upvotes: 26

Related Questions