Im looking for some guidance on sticky headers &am...
# compose
z
Im looking for some guidance on sticky headers & window insets! My list scrolls past the statusBar thanks to the provided PaddingValues from M3 Scaffold; but so does the sticky header - which leads to it obscuring the statusBar instead of remaining "in view" (I think my screenshot will explain this much better than I can). I can specify Modifier.statusBarsPadding() on my header and practically have it working the way I want, but that also makes it bigger at all times, which is not what I want. Any ideas on how I can solve this?
t
Are you using this contentPadding parameter from your LazyColumn? I would assume that it should work otherwise it could be bug?
z
I am! It results in the list having padding that accounts for the statusBars ... but simultaneously I only really want that for other non-sticky items. I dont think theres any easy solution, only thing I can think of is adding dynamic padding to the sticky headers that accounts for the statusBar whenever the item is at the top, latter isnt supported yet afaik.
Ill share my progress thus far here, only downside to this seemingly hackish solution is that the added padding is "correct" but incorrect - probably due to
Modifier.padding(x)
and
onGloballyPositioned
not playing well together in the same layout. I have tried using a custom layout and while the results were better, I couldnt find a way to ensure that other elements dont overlap this item (probably due to
placeable.place
with offset) though. Im not sure where to take it from here. If anyone has any ideas, please give me a poke! 💡 Layout variant (correct offset, but other items in the list overlap by 'totto'):
Copy code
private fun Modifier.p(
    paddingValues: PaddingValues,
): Modifier {
    return composed {
        var offset by remember {
            mutableIntStateOf(0)
        }

        onGloballyPositioned { coordinates ->
            val windowToLocal = coordinates.windowToLocal(Zero)
            offset = windowToLocal.y.toInt()
        }.layout { measurable, constraints ->
            val placeable = measurable.measure(constraints)

            val totto = (offset + paddingValues.calculateTopPadding().roundToPx()).let { offset ->
                if (offset >= 0) offset else 0
            }

            layout(
                width = placeable.width,
                height = placeable.height + totto,
            ) {
                placeable.place(
                    IntOffset(
                        0,
                        totto,
                    ),
                )
            }
        }
    }
}
OnGloballyPositioned stuff (numbers seem correct, but the padding isnt):
Copy code
@Composable
fun Header(
    modifier: Modifier = Modifier,
    title: String,
    secondary: @Composable (() -> Unit)? = null,
) {
    val topPadding = with(LocalDensity.current) {
        WindowInsets.statusBars.asPaddingValues().calculateTopPadding().toPx()
    }

    var extraTopPadding by remember {
        mutableStateOf(0.dp)
    }

    Surface(
        modifier = modifier.onGloballyPositioned { coordinates ->
            val offset = coordinates.positionInRoot().run {
                y - topPadding
            }
            logcat { "Offset: $offset" }

            extraTopPadding = when {
                offset <= 0 -> offset.absoluteValue
                else -> 0f
            }.dp
        },
        color = Theme.palette.background,
        content = {
            Item(
                modifier = Modifier.padding(
                    top = extraTopPadding,
                ),
                content = {
                    Text(
                        text = title,
                        style = Theme.type.title,
                        variant = Bold,
                    )
                },
                trailing = {
                    secondary?.invoke()
                },
            )
        },
    )
}
a
Putting aside insets for a second: if you just set a content padding of something arbitrarily large like
150.dp
for the
LazyColumn
, does the sticky header still overlap with that padding? If so, that definitely seems like a bug for how sticky headers work
z
The added contentPadding just makes the list scroll further, but elements end up in exactly the same place in the end regardless of being regular/sticky. I think thats the expected behavior?
t
No. The contentPadding adds a padding. So that it should exactly do what you want.
Did not used the sticky header items often. So not sure if there is maybe a bug?
I did just a short test and also in the newest compose bom and in my opinion this behavior of the sticky header is a bug. @Zoltan Demant Do you want to file a bugreport?
z
While Id love to see some kind of guideline or official support for this use-case, I dont think the current behavior is bugged. On the contrary, if they would take insets into account automatically it would probably limit their use-cases overall. I just think we need to figure out a neat way for the sticky headers to increase their paddings to account for the statusBar as you scroll them over the statusBar area. Feel free to write a bug report and share it here if you disagree though, Id love to keep an eye on it if you do!
t
I will. So this contentPadding is working exactly for this usecase. But not together with sticky headers.
stickyheaders are still marked as experimental so i think they like to get feedback.
z
Any ideas on how I can make my
LazyColumn.stickyHeader
item take
WindowInsets.statusBars
into account whenever it is stickied (in my case, thats when it overlaps the statusBar)? I can calculate its sticky state, but adding the statusBars padding "as is" to the item scrolls the list up/down, and makes for a pretty horrible experience, regardless of it being animated or not!
r
Using the
WindowInset
library, you can calculate the padding of the status bar and apply it to your
LazyColumn
z
I'm doing that, unfortunately it's not added to the sticky headers
g
I think LazyColumn ignores all content padding when sticking headers. Doesn’t matter if the padding is insets or not
Proper support for sticky headers seems to have moved to the backlog 😞 https://developer.android.com/jetpack/androidx/compose-roadmap
r
This is what I am doing in one of my projects to prevent items from scrolling over the statusbar
z
@gsala I think theyre treated just like any regular item in that regard! The padding values you specify are just a sum total of all paddings, so probably no way to figure out what is what. Still think this use-case is valid though! @Rafs Ive done this too! It works, but also cuts off all content, this is what Im trying to work around.
g
Totally valid. I’ve been trying that out just this week. But nothing you can do if the sticky header just completely ignores the content padding 🤷
😅 1
I guess you could check the implementation of
stickyHeader
copy it and adjust
homer disappear 1
t
Unfortunately most of the time it is not possible without huge effort to do custom implementations in LazyColumns and LazyRows. All code is internal or private. Most of the time you end up in a complete re implementation of the whole thing.
Edge to edge design is still a challenge also without sticky headers btw.
s
Besides sticky headers obviously being broken, what else is a big challenge there? Care to elaborate?
t
I had just the problem getting the window instests as padding values. I know there is this function WindowInsets.systemBars.asPaddingValues() but it does not respect already sonsumed insets.
So when you do have more complex layouts you have to consider many special cases
e.g. you do have a bottom bar. Insets should be consumed by the bottom bar and not used as padding for the main content
s
Ah yes, this issue https://kotlinlang.slack.com/archives/CJLTWPH7S/p1693433002766179?thread_ts=1693391568.660129&amp;cid=CJLTWPH7S But yeah, the solution exists, just isn't quite straightforward, I do agree.
t
Just wanted to point out that it is a challenge 😄
But for sticky headers i think there is no workaround. So we have to wait for the android devs until we can use it
👍 1
s
You can get away by simply not using .asPaddingValues() and it just works. But as soon as you do need that, you need to do something like this https://github.com/HedvigInsurance/android/blob/develop/app/feature/feature-home/src/main/kotlin/com/hedvig/android/feature/home/home/ui/HomeDestination.kt#L309-L315
Or apply insets to all sticky headers, but that's gonna look jarring too, in a different way.
t
Is this the workaround for window insets or for sticky headers with contentPadding?
s
For .asPaddingValues not respecting already consumed insets
t
Yea but when you want to use sticky headers you can not use contentPadding
👍 1
Until the stickyHeader api is fixed
👍 1
Or maybe you could implement a composable which monitors its current position and applys padding dynamically.
But yea would be difficult to get it right
z
Or maybe you could implement a composable which monitors its current position and applys padding dynamically.
This works, but as soon as you start adding the padding to the item, the list scrolls unexpectedly (to the user). Ive even tried just translating the item to dodge the statusBars, but then theres just a hole where you see the underlying items scroll through 😅
Or apply insets to all sticky headers, but that's gonna look jarring too, in a different way.
Unfortunately yes, I think just applying
Modifier.padding(x)
to the LazyColumn itself is the best workaround for now. You dont get content scrolling undeneath the statusBar, but all other options just look worse imo.
1
😞 1
The only other option I can think of for now, is tweaking the "is sticky" (floating) logic to return floats 0-1f (not sticky - completely sticky). I have not been able to get that working however. That way you can do
WindowInsets.padding * floatingFraction
and perhaps, just maybe, it wont make the LazyColumn scroll oddly.
Copy code
@Composable
override fun floating(
    key: Any,
): State<Boolean> {
    return remember(state) {
        derivedStateOf {
            state.layoutInfo.visibleItemsInfo.matchFloating { info ->
                info.key == key
            }
        }
    }
}

private inline fun List<LazyListItemInfo>.matchFloating(
    accept: (LazyListItemInfo) -> Boolean,
): Boolean {
    if (size >= 2) {
        val current = this[0]

        if (accept(current)) {
            val next = this[1]
            return current.size + current.offset >= next.offset
        }
    }

    return false
}
t
Did you tried to add just an offset instead of padding? I just experimenting a little bit with that and come to this prototype
Copy code
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun StickHeaderIssue() {
    LazyColumn(
        modifier = Modifier.fillMaxWidth(),
        contentPadding = WindowInsets.systemBars.asPaddingValues(),
        verticalArrangement = Arrangement.spacedBy(24.dp)
    ) {
        stickyHeader {
            Text("Sticky Header 1", Modifier.locationAwareInsetsOffset(WindowInsets.systemBars, "Header 1"))
        }
        items(5) { index ->
            Text(text = "Item $index")
        }
        stickyHeader {
            Text("Sticky Header 2", Modifier.locationAwareInsetsOffset(WindowInsets.systemBars, "Header 2"))
        }
        items(30) { index ->
            Text(text = "Item $index")
        }
    }
}

@Stable
fun Modifier.locationAwareInsetsOffset(insets: WindowInsets, label: String): Modifier = this.then(
    LocationAwareInsetsOffsetModifier(insets, label)
)

internal class LocationAwareInsetsOffsetModifier(
    private val insets: WindowInsets,
    private val label: String
) : LayoutModifier {
    override fun MeasureScope.measure(
        measurable: Measurable,
        constraints: Constraints
    ): MeasureResult {
        val insetsTop = insets.getTop(this)
        val placeable = measurable.measure(constraints)
        val width = constraints.constrainWidth(placeable.width)
        val height = constraints.constrainHeight(placeable.height)
        return layout(width, height) {
            val posY = coordinates?.localToWindow(Offset.Zero)?.let { pos ->
                pos.y.toInt()
            } ?: 0
            val top = (insetsTop - posY).coerceIn(0, insetsTop)
            log("$label - insets: $insetsTop topOffset: $top")
            placeable.place(0, top)
        }
    }
}
Just for top insets currently. And not perfect but maybe a start?
z
Very cool, this is really similar to what I did and meant by translating the header (sorry, PTSD from views and translate I think)! Now the only problem is that the regular items will show up above the sticky header (in the space left by the offset) as you scroll. Im not sure if theres a simple solution to that as well? Ive contemplated if you could simply draw a background above the now offset header, but nothing Ive tried has worked great for that.
t
This behavior is normal. If you compare it to stickyHeader without contentPadding. Of course if you want to use stickyHeader you should put a background behind the header. Currently the only difference i see is that the first stickyHeader do not scroll up when the second stickyHeader scrolls to the top.
But maybe as a first workaround solution it could work for some use cases.
No the problem is that the first stickyHeader is on top of the second
Not really usable
But maybe someone has an idea how to fix the code 😄
@Zoltan Demant if you do not want that the list scrolls behind the title than you also do not need contentPadding. And than you do not have any problem.
z
Yes, true - in my case my first stickyHeader is not the first item in the list, so I need both 😞
t
Ah ok i see.
So as soon as the first header is on top you want it to occupy the statusbar completly
z
Exactly 😃
t
I think even if the android devs fix the stickyHeader api to respect contentPadding this will not be possible
🧠 1
I think i do have the workaround now working correctly. Unfortunately not for you special case Zoltan 😞 (But in my opinion this is not part of the bug)
Copy code
@Composable
fun StickHeaderIssue() {
    val listState = rememberLazyListState()
    LazyColumn(
        modifier = Modifier.fillMaxWidth(),//.windowInsetsPadding(WindowInsets.systemBars),
        state = listState,
        contentPadding = WindowInsets.systemBars.asPaddingValues(),
        verticalArrangement = Arrangement.spacedBy(24.dp)
    ) {
        items(5) { index ->
            Text(text = "Item $index")
        }
        stickyHeaderContentPaddingAware(listState, key = "A") {
            Box(
                modifier = Modifier.background(Color.Blue.copy(alpha = 0.5f))) {
                Text(
                    "Sticky Header 1"
                )
            }
        }
        items(5) { index ->
            Text(text = "Item $index")
        }
        stickyHeaderContentPaddingAware(listState, key = "B") {
            Box(modifier = Modifier.background(Color.Blue.copy(alpha = 0.5f))) {
                Text(
                    "Sticky Header 2"
                )
            }
        }
        items(30) { index ->
            Text(text = "Item $index")
        }
    }
}

private data class StickyType(val contentType: Any?)

@OptIn(ExperimentalFoundationApi::class)
fun LazyListScope.stickyHeaderContentPaddingAware(
    listState: LazyListState,
    key: Any,
    contentType: Any? = null,
    content: @Composable LazyItemScope.() -> Unit
) {
    stickyHeader(
        key = key,
        contentType = StickyType(contentType),
        content = {
            Layout(content = { content() }) { measurables, constraints ->
                val placeable = measurables.first().measure(constraints)
                val width = constraints.constrainWidth(placeable.width)
                val height = constraints.constrainHeight(placeable.height)
                layout(width, height) {
                    val posY = coordinates?.localToWindow(Offset.Zero)?.let { pos ->
                        pos.y.toInt()
                    } ?: 0
                    val paddingTop = listState.layoutInfo.beforeContentPadding
                    var top = (paddingTop - posY).coerceIn(0, paddingTop)
                    if (top > 0) {
                        val second = listState.layoutInfo.visibleItemsInfo
                            .filter { it.contentType is StickyType }
                            .getOrNull(1)
                        if (second != null && second.key != key) {
                            val secondOffset = second.offset
                            if (secondOffset <= height) {
                                top -= (height - secondOffset)
                            }
                        }
                    }
                    placeable.place(0, top)
                }
            }
        }
    )
}
z
Nice, great work!! 👍🏽 Ill try to adapt this to my situation when time allows, maybe I can hack something to cover the statusBar together on top of it! Thank you! 🌟
t
Just realizing that this only works for edge to edge design. I have to adapt it a littlebit to be more general.
z
My brain is a bit fried but Id think that you could acheive that simply by not specifying any insets? I realize that you removed insets from the latest edition, but isnt the overall padding already handled for you (topAppBar etc) so that you only need to take the windowInsets padding into account? Again, my brain is a bit fried at this point (long day) 😅
t
The fix should be general to LazyColumn independent of insets. So when you want you could use the insets as padding values. The problem in my code is that i use the
Copy code
val posY = coordinates?.localToWindow(Offset.Zero)?.let { pos ->
                        pos.y.toInt()
                    } ?: 0
window coordinates.
I just fixed that by replacing this line with this line:
Copy code
val posY = coordinates?.positionInParent()?.y?.toInt() ?: 0
Now it should be general i hope 😄
z
🤞🏽
t
Complete code:
Copy code
private data class StickyType(val contentType: Any?)

@OptIn(ExperimentalFoundationApi::class)
fun LazyListScope.stickyHeaderContentPaddingAware(
    listState: LazyListState,
    key: Any,
    contentType: Any? = null,
    content: @Composable LazyItemScope.() -> Unit
) {
    stickyHeader(
        key = key,
        contentType = StickyType(contentType),
        content = {
            Layout(content = { content() }) { measurables, constraints ->
                val placeable = measurables.first().measure(constraints)
                val width = constraints.constrainWidth(placeable.width)
                val height = constraints.constrainHeight(placeable.height)
                layout(width, height) {
                    val posY = coordinates?.positionInParent()?.y?.toInt() ?: 0
                    val paddingTop = listState.layoutInfo.beforeContentPadding
                    log("Viewport start offset: ${listState.layoutInfo.viewportStartOffset} padding: $paddingTop")
                    var top = (paddingTop - posY).coerceIn(0, paddingTop)
                    if (top > 0) {
                        val second = listState.layoutInfo.visibleItemsInfo
                            .filter { it.contentType is StickyType }
                            .getOrNull(1)
                        if (second != null && second.key != key) {
                            val secondOffset = second.offset
                            if (secondOffset <= height) {
                                top -= (height - secondOffset)
                            }
                        }
                    }
                    placeable.place(0, top)
                }
            }
        }
    )
}
z
Btw, using contentType in from visibleItemsInfo is really nice; I didnt know you could do that!
t
🙂 It is a workaround. Maybe i misused it here 😄
Also i do need a unique key to identify the current stickyHeader insider of the list
z
Its not perfect by any means... but managed to hack something together for this. Since it doesnt affect the layout, the odd scroll UX is gone; I think a perfect solution has to use layout as well, but I dont know how to make that work so 🤷🏽‍♂️
Copy code
val statusBarsPadding = WindowInsets.statusBars.asPaddingValues()

val topPadding by transitionDp(
    if (active) {
        statusBarsPadding.calculateTopPadding()
    } else {
        Zero
    },
)

Surface(
    modifier = modifier.offset(y = topPadding),
    elevation = elevation,
    color = color,
    content = content,
)

Box(Modifier.background(color).requiredHeight(topPadding).fillMaxWidth())