I'm trying to build a column-like component with t...
# compose
s
I'm trying to build a column-like component with two slots,
hero
and
content
. If there is space enough in the component to render both slots without needing to scroll, then both slots are placed. If not, only
content
is placed, and
hero
is not. I got this to work quite easily with a custom
Layout
and just matching the full height of all the content against the max height of the layout. (See attached image, and code in thread). However, I run into trouble when I also want to make this vertically scrollable, since that makes the max height “infinite”. Any help would be appreciated!
Copy code
@Composable
fun OptionalHeroColumn(
    hero: @Composable () -> Unit,
    content: @Composable () -> Unit,
    modifier: Modifier = Modifier,
) {
    Box(modifier) {
        Layout(contents = listOf(hero, content)) { measurables, constraints ->
            val heroMeasurable = measurables[0]
            val contentMeasurable = measurables[1]

            val heroPlaceables = heroMeasurable.map { it.measure(constraints) }
            val heroHeight = heroPlaceables.sumOf { it.height }

            val contentPlaceables = contentMeasurable.map { it.measure(constraints) }
            val contentHeight = contentPlaceables.sumOf { it.height }

            val totalHeight = heroHeight + contentHeight

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

                if (totalHeight <= constraints.maxHeight) {
                    heroPlaceables.forEach { heroPlaceable ->
                        heroPlaceable.place(x = 0, y = y)

                        y += heroPlaceable.height
                    }
                }

                contentPlaceables.forEach { contentPlaceable ->
                    contentPlaceable.place(x = 0, y = y)

                    y += contentPlaceable.height
                }
            }
        }
    }
}
Copy code
@Preview("Non-scrolling – height: 300 dp", heightDp = 300)
@Preview("Non-scrolling – height: 500 dp", heightDp = 500)
@Composable
private fun SamplePreview() {
    OptionalHeroColumn(
        hero = { HeroContent() },
        content = { BodyContent() },
        modifier = Modifier.fillMaxSize(),
    )
}
s
When things are scrollable, do you still want to avoid rendering the hero content? Or can you just lay them out one under the other when you know that your height constraints are infinite regardless of the available size?
We have one place where we want to do some things differently depending on the available size, yet still be able to scroll. We solved that for this layout by passing in the screen size, which we make sure to grab before the layout has become scrollable, and therefore has infinite height constraints.
s
When it's scrollable, we still want this same behaviour. Essentially, at the bottom of the
content
is a couple of buttons that we would prefer (if possible, given the space) to show, and we're willing to remove the
hero
for that to happen. But if it's still not possible, it's okay to have it placed outside the boundaries of the component.
s
Yeah try the "get the bounds before the container becomes scrollable" trick then like we do there, I think it should fit your needs. Does it seem like it for you as well?
a
One additional thing to try to do is ensure the result is correct on the first frame to avoid flashing or other wonkiness.
Modifier.onSizeChanged
will probably result in the available size being retrieved too late, so you may want to use a
Modifier.layout
outside of the scrolling area
s
Thanks for the link! I was hoping for something that didn't involve any
onSizeChanged { fullScreenSize = it }
or similar, and achieve first-frame correctness and avoid extra recompositions.
s
Yeah, I know it's not optimal. Alex, if you want to do it in a layout around as you suggest, and have the scrollable container be a child of that custom layout, how would you also give information to that layout to know to render or not render that hero component? I am having a hard time imagining how I'd do that.
a
Something along the lines of this:
Copy code
var outerConstraints: Constraints? by remember { mutableStateOf(null) }

Box(
    modifier
        .layout { measurable, constraints ->
            outerConstraints = constraints
            val placeable = measurable.measure(constraints)
            layout(placeable.width, placeable.height) {
                placeable.placeRelative(0, 0)
            }
        }
        // instead of
        //.onSizeChanged { }
        .verticalScroll()
If you retrieve the constraints before measuring into a state holder, then the children can have access to the correct value as they’re being measured if they retrieve from the state holder I think this gets a bit more complicated once lookahead and related things start happening, but not having a correct first frame already messes with that anyway
s
Oh? 👀👀 And that is different because we explicitly set the constraints before we call measure, so when the children are measured that is already set? The difference with the onSizeChanged being that it happens I suppose after the entire measurement pass is over?
☝️ 1
s
Hmm, right, that's an interesting idea. I'm gonna try it out in a minute!
a
Right, exactly
🤯 1
thank you color 1
s
I suppose I wouldn't be able to see the desired effect in a non-interactive preview, right? Other than that, this seems to work great! Thanks!
Hmm, actually, it does seem to work for preview, but only the first time it's rendered. 😅 The first image here is with this preview code:
Copy code
@Preview("Scrolling – height: 300 dp", heightDp = 300)
@Preview("Scrolling – height: 500 dp", heightDp = 500)
@Composable
private fun ScrollableOptionalHeroColumnPreview() {
    ScrollableOptionalHeroColumn(
        hero = { HeroContent() },
        content = { BodyContent() },
        modifier = Modifier.fillMaxSize(),
    )
}
However, if I comment out the first
@Preview
annotation, I get to see the larger preview working (second picture).
Oh well, that's a “problem” for tomorrow. 💤 Thanks for all the help!
s
Heh I came back to this thread to say that I am experiencing a problem where only the first rendered preview seems to get the real value, but all the rest don't. And I realize you said the same thing too 😄 The first preview seems to be treated in some special way, it's more "live" than the others, even without being in interactive mode or something like that.
Btw did a little test to see how this actually plays out: Case 1 - onGloballyPositioned:
Copy code
var layoutSize: IntSize? by remember { mutableStateOf(null) }
Box(
    Modifier
        .onGloballyPositioned { outer = it.size }
        .fillMaxSize(),
) {
    Box(
        Modifier
            .layout { measurable, constraints ->
                logcat { "#1 outer:$layoutSize" }
                val placeable = measurable.measure(constraints)
                logcat { "#2 outer:$layoutSize" }
                layout(placeable.width, placeable.height) {
                    logcat { "#3 outer:$layoutSize" }
                    placeable.placeRelative(0, 0)
                    logcat { "#4 outer:$layoutSize" }
                }
            },
    )
}
Case 2 - onPlaced:
Copy code
var layoutSize: IntSize? by remember { mutableStateOf(null) }
Box(
    Modifier
        .onPlaced { outer = it.size }
        .fillMaxSize(),
) {
    Box(... same as above)
}
Case 3 - layout:
Copy code
var layoutSize: IntSize? by remember { mutableStateOf(null) }
Box(
    Modifier
        .layout { measurable, constraints ->
            layoutSize = IntSize(constraints.maxWidth, constraints.maxHeight)
            val placeable = measurable.measure(constraints)
            layout(placeable.width, placeable.height) {
                placeable.placeRelative(0, 0)
            }
        }
        .fillMaxSize(),
) {
    Box(... same as above)
}
I got these logs: Case 1 - onGloballyPositioned:
Copy code
#1 outer:null
#2 outer:null
#3 outer:null
#4 outer:null
#1 outer:1440 x 3120
#2 outer:1440 x 3120
#3 outer:1440 x 3120
#4 outer:1440 x 3120
Case 2 - onPlaced:
Copy code
#1 outer:null
#2 outer:null
#3 outer:1440 x 3120
#4 outer:1440 x 3120
#1 outer:1440 x 3120
#2 outer:1440 x 3120
#3 outer:1440 x 3120
#4 outer:1440 x 3120
Case 3 - layout:
Copy code
#1 outer:1440 x 3120
#2 outer:1440 x 3120
#3 outer:1440 x 3120
#4 outer:1440 x 3120
#1 outer:1440 x 3120
#2 outer:1440 x 3120
#3 outer:1440 x 3120
#4 outer:1440 x 3120
That was a fun experiment 😄
s
Yeah, I had the same experience, and decided to let it be. 😅
👍 1