I'm playing with nested scroll connections to buil...
# compose
s
I'm playing with nested scroll connections to build a custom collapsing top app bar with an image gallery in it, like in the screen recording. I've gotten this to work with a hard-coded height for the image gallery; however I would like it to (1) fill the max width, and (2) when expanded fully have an aspect ratio of 16:9, meaning the expanded height should be based on the width of the component. I can't really wrap my head around sanely providing that max height to my custom
NestedScrollConnection
. To start with, in order to actually calculate the expanded height, I wrapped my carousel in a
BoxWithConstraints
with the correct expanded size. That gives me access to
maxWidth
inside of its scope. But that
BoxWithConstraints
will then always be there with that size, which clearly isn't ideal. Ideally, the top app bar's height itself should be changing, not just the content inside of the
BoxWithConstraints
. I also tried a little bit with a
Layout
, but I couldn't quite get that to work, either. Would love some help on this, if anyone has any ideas. Code in 🧵.
Copy code
@Composable
fun CarouselTopAppBar(
    details: ClubDetails,
    nestedScrollConnection: CarouselTopAppBarNestedScrollConnection,
    navigateUp: () -> Unit,
    modifier: Modifier = Modifier,
) {
    Column(modifier) {
        SatsTopAppBar(
            title = details?.name.orEmpty(),
            navigationIcon = { UpIconButton(navigateUp) },
        )

        BoxWithConstraints(
            Modifier
                .fillMaxWidth()
                .aspectRatio(16f / 9f),
        ) {
            LaunchedEffect(nestedScrollConnection, maxHeight) {
                nestedScrollConnection.maxHeight = maxHeight
                nestedScrollConnection.currentHeight = maxHeight
            }

            ClubImagesM3Carousel(
                details.imageUrls,
                Modifier.height(nestedScrollConnection.currentHeight),
            )
        }
    }
}
Copy code
class CarouselTopAppBarNestedScrollConnection(
    private val density: Density,
) : NestedScrollConnection {
    internal var maxHeight: Dp = 1.dp // I don't like this, either
    internal var currentHeight: Dp by mutableStateOf(maxHeight)

    override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
        val delta = with(density) { available.y.toDp() }

        if (delta > 0.dp) return Offset.Zero

        return consumeScroll(delta)
    }

    override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset {
        val delta = with(density) { available.y.toDp() }

        if (delta < 0.dp) return Offset.Zero

        return consumeScroll(delta)
    }

    private fun consumeScroll(delta: Dp): Offset {
        val previousHeight = currentHeight
        val nextHeight = previousHeight + delta

        currentHeight = nextHeight.coerceIn(0.dp, maxHeight)

        val consumed = currentHeight - previousHeight

        return with(density) { DpOffset(x = 0.dp, y = consumed).toOffset() }
    }
}
Here's the code without the
BoxWithConstraints
that works exactly as it should, except that the height is hard-coded instead of being derived from the width.
Copy code
@Composable
internal fun CarouselTopAppBar(
    details: ClubDetails,
    nestedScrollConnection: CarouselTopAppBarNestedScrollConnection,
    navigateUp: () -> Unit,
    modifier: Modifier = Modifier,
) {
    Column(modifier) {
        SatsTopAppBar(
            title = details?.name.orEmpty(),
            navigationIcon = { UpIconButton(navigateUp) },
        )

        ClubImagesM3Carousel(
            details.imageUrls,
            Modifier.height(nestedScrollConnection.currentHeight),
        )
    }
}
Copy code
class CarouselTopAppBarNestedScrollConnection(
    private val density: Density,
) : NestedScrollConnection {
    private val maxHeight = 252.dp

    internal var currentHeight: Dp by mutableStateOf(maxHeight)

    override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
        val delta = with(density) { available.y.toDp() }

        if (delta > 0.dp) return Offset.Zero

        return consumeScroll(delta)
    }

    override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset {
        val delta = with(density) { available.y.toDp() }

        if (delta < 0.dp) return Offset.Zero

        return consumeScroll(delta)
    }

    private fun consumeScroll(delta: Dp): Offset {
        val previousHeight = currentHeight
        val nextHeight = previousHeight + delta

        currentHeight = nextHeight.coerceIn(0.dp, maxHeight)

        val consumed = currentHeight - previousHeight

        return with(density) { DpOffset(x = 0.dp, y = consumed).toOffset() }
    }
}
t
s
Thanks, I'll have a look at that!
t
No prob! It uses a box to lay the header over the scrollable composable. It measures the header height and then places the scrollable composable offset to the airport header height. It dispatches scrolls and flings well enough for my use, hopefully it works well for you too