Hi, I've a Box with header / footer and a scrollab...
# compose
d
Hi, I've a Box with header / footer and a scrollable content that can cover header and footer. The scrollable content is blocking clicks inside header and footer. What are my options?
Copy code
Box(Modifier.fillMaxWidth) {
  Header()
  Footer(Modifier.align(Alignement.BottomCenter))
  Main(
    modifier = Modifier.fillMaxWidth()
        .verticalScroll(state)
        .padding(headerFooterPadding)
  )
}
Is it intended for the scrollable container to block clicks happening in Header and Footer? What are my options?
o
Seems like similar question: https://kotlinlang.slack.com/archives/CJLTWPH7S/p1669874503091009?thread_ts=1669873031.542359&cid=CJLTWPH7S Just place
Header
and
Footer
composables after
Main
in the
Box
, so they are “above” your main scrollable content
d
I cannot place them above my scrollable content, I want them to be behind it when i scroll, not on top
o
Oh, so yeah, the composable above (
Main
) will intercept clicks 🤷 Why do you want them to be behind? 🤔
d
Because the design looks like this:
header above, footer below, the content is supposed to float in the middle and if it is higher than the available screen scroll up / down covering the header and footer respectively
header and footer should not move
o
Hmm, what if you flip the order of
.verticalScroll
and
.padding
?
d
than the scrolling content will disappear instead of covering header/footer
I've an idea, going to try something
nope, didn't work
o
I don’t know then 🙈
d
I wonder if this should be done with Nested scroll
The frustrating thing is I have the UI perfectly showing what I want but no touch.. or.. the header / footer scrolling with the content and working touches... I cannot get both of those to work together. I'm thinking of measuring the positioning of header and footer and bring them on top if they are visible (according to the scroll offset)... But that is an horrible hack
c
If you know the size of the header and footer, maybe just put an empty header + footer on top of the scrolling content that receives the clicks?
d
I tough of that, but it will not show any click feedback, I don't really like that solution 🙂
This is my workaround
Copy code
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun AuthScreen(
    header: @Composable BoxScope.() -> Unit = {},
    footer: @Composable BoxScope.() -> Unit = {},
    content: @Composable ColumnScope.() -> Unit
) {
    val scrollState = rememberScrollState()
    // this is used to make room for footer an header below/above the main scrolling content
    var headerHeightPx by remember { mutableStateOf(0) }
    var footerHeightPx by remember { mutableStateOf(0) }
    val (headerHeight, footerHeight) = with(LocalDensity.current) {
        headerHeightPx.toDp() to footerHeightPx.toDp()
    }
    val headerFooterPadding = PaddingValues(
        top = animateDpAsState(headerHeight + 24.dp).value,
        bottom = animateDpAsState(footerHeight + 24.dp).value,
        start = 24.dp,
        end = 24.dp,
    )

    // these are used to detect overlapping and changing zIndex of header/footer
    // knowing this will allow us to change the zIndex
    // low zeta index (-1f) = view draw behind and doesn't get touch events
    // high zeta index (1f) = view draw above and do get touch events
    // we would like the view to ALWAYS draw behind but receive touch events only when visible
    // I could NOT find a way to do this, and so this ugly code try to figure out if the components
    // overlap, if they do we send them behind, if they don't we keep tham in front
    // this is not optimal, the user can see the content but cannot click it sometimes if it is just
    // a bit overlapped
    var headerLayoutCoord by remember { mutableStateOf<LayoutCoordinates?>(null) }
    var footerLayoutCoord by remember { mutableStateOf<LayoutCoordinates?>(null) }
    var mainLayoutCoord by remember { mutableStateOf<LayoutCoordinates?>(null) }

    // movements of the ui do not trigger a change in the LayoutCoordinates
    // for example an inset changing or some external padding
    // this means we need to recompute the overlap every time some of those change
    val imeBottom = WindowInsets.ime.getBottom(LocalDensity.current)
    val headerOverlapping = remember(
        scrollState.isScrollInProgress,
        headerLayoutCoord,
        mainLayoutCoord,
        LocalContainerPadding.current,
        imeBottom
    ) {
        // while scroll in progress --> always behind
        scrollState.isScrollInProgress ||
                // we have a tolerance for overlapping
                headerLayoutCoord?.isCoveredBy(mainLayoutCoord, .2f)
                // default is we hide behind
                ?: true
    }
    val footerOverlapping = remember(
        scrollState.isScrollInProgress,
        footerLayoutCoord,
        mainLayoutCoord,
        LocalContainerPadding.current,
        imeBottom
    ) {
        scrollState.isScrollInProgress ||
                footerLayoutCoord?.isCoveredBy(mainLayoutCoord, .2f)
                ?: true
    }
    val headerComposable: @Composable () -> Unit = {
        Box(
            modifier = Modifier
                .zIndex(if (headerOverlapping) -1f else 1f)
                .fillMaxSize()
                .padding(LocalContainerPadding.current)
                .consumedWindowInsets(LocalContainerPadding.current)
                .imePadding()
                .padding(top = 24.dp) // no padding bottom to minimize overlapping
                .padding(horizontal = 24.dp)
        ) {
            Box(
                modifier = Modifier
                    .align(Alignment.TopCenter)
                    .fillMaxWidth()
                    .onSizeChanged { headerHeightPx = it.height }
                    .onGloballyPositioned {
                        headerLayoutCoord = it
                    },
                content = header,
            )
        }
    }
    val footerComposable: @Composable () -> Unit = {
        Box(
            modifier = Modifier
                .zIndex(if (footerOverlapping) -1f else 1f)
                .fillMaxSize()
                .padding(LocalContainerPadding.current)
                .consumedWindowInsets(LocalContainerPadding.current)
                .imePadding()
                .padding(bottom = 24.dp) // no padding top to minimize overlapping
                .padding(horizontal = 24.dp)
        ) {
            Box(
                modifier = Modifier
                    .align(Alignment.BottomCenter)
                    .fillMaxWidth()
                    .onSizeChanged { footerHeightPx = it.height }
                    .onGloballyPositioned {
                        footerLayoutCoord = it
                    },
                content = footer,
            )
        }
    }
    Box(
        modifier = Modifier
            .fillMaxSize(),
        contentAlignment = Alignment.Center,
    ) {
        headerComposable.invoke()
        footerComposable.invoke()
        Box(
            modifier = Modifier
                .fillMaxSize()
                .verticalScroll(scrollState)
                .padding(LocalContainerPadding.current)
                .consumedWindowInsets(LocalContainerPadding.current)
                .imePadding()
                .padding(24.dp)
                .padding(headerFooterPadding),
            contentAlignment = Alignment.Center,
        ) {
            // this is basically a Card with some other stuff of my design
            FormSurface(
                modifier = Modifier
                    .fillMaxWidth()
                    .onGloballyPositioned { mainLayoutCoord = it },
                content = content,
            )
        }
    }
}
This is the other function I use
Copy code
private fun LayoutCoordinates.isCoveredBy(
    cover: LayoutCoordinates?,
    tolerance: Float = 0f
): Boolean? {
    if (cover == null) return null
    val selfBox = boundsInRoot()
    val coverBox = cover.boundsInRoot()
    val intersect = selfBox.intersect(coverBox)
    if (intersect.isEmpty) return false
    if (tolerance <= 0f) return true
    val intersectArea = intersect.width * intersect.height
    val selfArea = selfBox.width * selfBox.height
    val ratio = intersectArea / selfArea
    return ratio > tolerance
}
As you can see this also handle imePadding and other padding that I provide from outside (from a Scaffold)
I find it annoying that I can't apply insets modifier to a
PaddingValues()
by the way
a
This can be done by using nested scroll. Definitely easier and better than your workaround.
d
@Albert Chang mind giving a nudge in the right direction? A snippet with pseudo code or something? Cause I can't figure out a way to make the nested scroll work in this situation maintaining the touch control on header and footer only when they are visible (not covered)
I've insets handling in there too, which complicates it
d
Hum, I'll try with this, thanks Albert (I'll be back)
Thanks I've started from your code and modified it to my needs May I ask why you used
Snapshot.withoutReadObservation
? I kept it (a bit different and moved in layout) but I'm not sure why it is needed or why you didn't directly set the offset instead of going through this
Snapshot
, thanks! I also have another small issue (see below the code). Here's my code, I computed min / max offset instead of headerHeight / footerHeight because I wanted the scrolling to be minimal to reveal those. I also let the main content in the center without necessarely taking all the space (this allowed me to compute the minimal offset). This however come with an issue (see screnshot / below the code)
Copy code
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun AuthScreen(
    header: @Composable (BoxScope.() -> Unit)? = null,
    footer: @Composable (BoxScope.() -> Unit)? = null,
    content: @Composable ColumnScope.() -> Unit
) {
    val scrollState = rememberScrollState()
    var minOffset by remember { mutableStateOf(0f) }
    var maxOffset by remember { mutableStateOf(0f) }
    var offset by rememberSaveable { mutableStateOf(0f) }

    val nestedScrollConnection = remember {
        object : NestedScrollConnection {
            override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset =
                when {
                    available.y < 0 && offset > 0f -> {
                        // if I'm scrolling up and I've offset to consume consume it
                        val toConsume = available.y.coerceAtLeast(-offset)
                        offset += toConsume
                        Offset(0f, toConsume)
                    }
                    available.y > 0 && offset < 0f -> {
                        // if I'm scrolling down and I've offset to consume consume it
                        val toConsume = available.y.coerceAtMost(-offset)
                        offset += toConsume
                        Offset(0f, toConsume)
                    }
                    else -> Offset.Zero
                }

            override fun onPostScroll(
                consumed: Offset,
                available: Offset,
                source: NestedScrollSource
            ): Offset {
                val newOffset = (offset + available.y).coerceIn(minOffset, maxOffset)
                val toConsume = newOffset - offset
                offset += toConsume
                return Offset(0f, toConsume)
            }
        }
    }

    Layout(
        content = {
            if (header != null) {
                Box(
                    content = header,
                    modifier = Modifier
                        .layoutId("header")
                        .fillMaxWidth()
                        .padding(top = 24.dp)
                        .padding(horizontal = 24.dp),
                )
            }
            if (footer != null) {
                Box(
                    content = footer,
                    modifier = Modifier
                        .layoutId("footer")
                        .fillMaxWidth()
                        .padding(bottom = 24.dp)
                        .padding(horizontal = 24.dp),
                )
            }
            Spacer(
                modifier = Modifier
                    .layoutId("imePadding")
                    .consumedWindowInsets(LocalContainerPadding.current)
                    .imePadding()
            )
            Box(
                modifier = Modifier
                    .layoutId("main")
                    .fillMaxWidth(),
                contentAlignment = Alignment.Center,
            ) {
                FormSurface(
                    modifier = Modifier
                        .fillMaxWidth()
                        .verticalScroll(scrollState)
                        .consumedWindowInsets(LocalContainerPadding.current)
                        .imePadding()
                        .padding(24.dp),
                    content = content,
                )
            }
        },
        modifier = Modifier
            .fillMaxSize()
            .padding(LocalContainerPadding.current)
            .nestedScroll(nestedScrollConnection)
    ) { measurables, constraints ->
        val relaxedConstraints = constraints.copy(minWidth = 0, minHeight = 0)
        val headerPlaceable = measurables.firstOrNull { it.layoutId == "header" }
            ?.measure(relaxedConstraints)
        val footerPlaceable = measurables.firstOrNull { it.layoutId == "footer" }
            ?.measure(relaxedConstraints)
        val imeSpacePlaceable = measurables.first { it.layoutId == "imePadding" }
            .measure(relaxedConstraints)
        val headerH = headerPlaceable?.height ?: 0
        val footerH = footerPlaceable?.height ?: 0

        val contentPlaceable = measurables.first { it.layoutId == "main" }
            .measure(relaxedConstraints)


        layout(constraints.maxWidth, constraints.maxHeight) {
            val footerY = constraints.maxHeight - footerH - imeSpacePlaceable.height
            val contentY = (constraints.maxHeight - contentPlaceable.height) / 2
            val offsetMin = (footerY - contentY - contentPlaceable.height)
                .coerceAtMost(0).toFloat()
            val offsetMax = headerH - contentY
                .coerceAtLeast(0).toFloat()
            minOffset = offsetMin
            maxOffset = offsetMax
            // this is an ugly safeguard, I don't even know if it is needed
            Snapshot.withoutReadObservation {
                if (offset < offsetMin) {
                    offset = offsetMin
                } else if (offset > offsetMax) {
                    offset = offsetMax
                }
            }
            headerPlaceable?.place(0, 0)
            footerPlaceable?.place(0, footerY)
            contentPlaceable.placeWithLayer(0, contentY) {
                translationY = offset
            }
        }
    }
}
The only issue I see with all of this is that if I want the indication to start from the edge of the screen I will have to make the main content full screen. Do you have some idea on how I can move that indication on the edge of the screen while still being able to know how much I need to span my offset? Thanks
This is even better
Copy code
val nestedScrollConnection = remember {
    object : NestedScrollConnection {
        override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset =
            when {
                available.y < 0 && offset > minOffset -> {
                    // if I can consume with offset do it
                    val toConsume = available.y.coerceAtLeast(minOffset - offset)
                    offset += toConsume
                    Offset(0f, toConsume)
                }
                available.y > 0 && offset < maxOffset -> {
                    // if I can consume with offset do it
                    val toConsume = available.y.coerceAtMost(maxOffset - offset)
                    offset += toConsume
                    Offset(0f, toConsume)
                }
                else -> Offset.Zero
            }
    }
}
still has the issue with indication showing there
a
Snapshot.withoutReadObservation
is an optimization. Since we are reading
offset
in the layout scope, without it, the change of
offset
will cause a re-layout, which is completely unnecessary.
d
I see, thanks!
a
I don’t think you can move the over scroll effect. You shouldn’t do so anyway because the header and the footer are not part of the scrollable content.
d
I guess I'll just disable the overscroll effect before Android 12