Sticky headers with paging library in Jetpack Compose
Asked Answered
U

3

9

I'm currently playing around with the new Jetpack compose UI toolkit and I like it a lot. One thing I could not figure out is how to use stickyHeaders in a LazyColumn which is populated by the paging library. The non-paging example from the documentation is:

val grouped = contacts.groupBy { it.firstName[0] }

fun ContactsList(grouped: Map<Char, List<Contact>>) {
    LazyColumn {
        grouped.forEach { (initial, contactsForInitial) ->
            stickyHeader {
                CharacterHeader(initial)
            }

            items(contactsForInitial) { contact ->
                ContactListItem(contact)
            }
        }
    }
}

Since I'm using the paging library I cannot use the groupedBy so I tried to use the insertSeparators function on PagingData and insert/create the headers myself like this (please ignore the legacy Date code, it's just for testing):

// On my flow
.insertSeparators { before, after ->
        when {
            before == null -> ListItem.HeaderItem(after?.workout?.time ?: 0)
            after == null -> ListItem.HeaderItem(before.workout.time)
            (Date(before.workout.time).day != Date(after.workout.time).day) ->
                ListItem.HeaderItem(before.workout.time)
            // Return null to avoid adding a separator between two items.
            else -> null
        }
    }

// In my composeable
LazyColumn {
    items(workoutItems) {
        when(it) {
            is ListItem.HeaderItem -> [email protected] { Header(it) }
            is ListItem.SongItem -> WorkoutItem(it)
        }
    }
}

But this produces a list of all my items and the header items are appended at the end. Any ideas what is the right way to use the stickyHeader function when using the paging library?

Unplumbed answered 22/5, 2021 at 13:19 Comment(0)
U
12

I got it to work by looking into the source code of the items function: You must not call stickyHeader within the items function. No need to modify the PagingData flow at all. Just use peek to get the next item without triggering a reload and then layout it:

LazyColumn {
    val itemCount = workoutItems.itemCount
    var lastWorkout: Workout? = null

    for(index in 0 until itemCount) {
        val workout = workoutItems.peek(index)

        if(lastWorkout?.time != workout?.time) stickyHeader { Header(workout) }
        item { WorkoutItem(workoutItems.getAsState(index).value) } // triggers reload

        lastWorkout = workout 
    }
}
Unplumbed answered 23/5, 2021 at 10:1 Comment(8)
Thanks for this. The data shows correctly, but when I scroll new items into view (or scroll back up), then the content of some items become invisible. I think this has to do with compose not detecting that it's on screen so it just skips rendering. Is this something you've experienced as well?Flatling
I did not, make sure to call workoutItems.getAsState(index).value as this is triggering reloads from the paging library. Do not use the item you got with peek for the layouting.Unplumbed
why should we use peek while rendering sticky headers however we use get function from lazypagingItems for rendering item?Hadden
Be careful using this solution: the for loop will iterate every single time the user will fetch new items and, if you have a lot of results (e.g. hundreds of thousands or millions), it will easily run out of memory or at least degrade the scroll performance (think about iterating over a million items on the UI thread every time you fetch a new page).Obtrusive
@RobertoLeinardi Paging should only give you the current visible pages. With a appropriate page size this should not be any problem!Unplumbed
@Unplumbed Unfortunately what you says is not what I'm experiencing: try to put a logcat before the val workout and print the index: what I'm seeing is that the LazyPagingItems triggers a recomposition of the LazyColumn every time it changes (e.g. a new page is fetched), running the for loop again. If you have a long list this means running this for loop multiple times over the entire list. Btw my code is slightly different: for (index in 0 until lazyPagingItems.itemCount). From where is the indices in your for loop coming? I can't find it in LazyPagingItems.Obtrusive
getAsState is now deprecated. See: developer.android.com/jetpack/androidx/releases/pagingSunward
getAsState is now actually removed See developer.android.com/jetpack/androidx/releases/…Bazil
T
3

I believe the issue in your code was that you were calling this@LazyColumn from inside an LazyItemScope.

I experimented too with insertSeparators and reached this working LazyColumn code:

LazyColumn {
    for (index in 0 until photos.itemCount) {
        when (val peekData = photos.peek(index)) {
            is String? -> stickyHeader {
                Text(
                    text = (photos.getAsState(index).value as? String).orEmpty(),
                )
            }
            is Photo? -> item(key = { peekData?.id }) {
                val photo = photos.getAsState(index).value as? Photo
                ...
            }
        }
    }
}
Tungstite answered 12/6, 2021 at 7:52 Comment(1)
Same for this solution: the for loop will iterate every single time the user will fetch new items and, if you have a lot of results (e.g. hundreds of thousands or millions), it will easily run out of memory or at least degrade the scroll performance (think about iterating over a million items on the UI thread every time you fetch a new page).Obtrusive
C
2

Up to date solution for this is:

val lazyPagingItems = customersListPagingItems
for(index in 0 until lazyPagingItems.itemCount) {
    // important: use .peek() to get the item without causing page load
    when (val peekingItem = lazyPagingItems.peek(index)) {
        is Header -> stickerHeader(key=index or peekingItem.stableId) {
            // important: use .get() in composable to get the actual item and cause next page load
            val header = lazyPagingItems[index] as Header
            HeaderUi(header)
        }
        is Item -> item(key=index or peekingItem.stableId) {
            // important: use .get() in composable to get the actual item and cause next page load
            val item = lazyPagingItems[index] as Item
            ItemUi(item)
        }
    }
}
Cassirer answered 25/2, 2023 at 1:12 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.