8

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 -> this@LazyColumn.stickyHeader { 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?

Muhammad Arslan
  • 380
  • 1
  • 4
  • 10
Cilenco
  • 6,951
  • 17
  • 72
  • 152

3 Answers3

10

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 
    }
}
Cilenco
  • 6,951
  • 17
  • 72
  • 152
  • 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? – Duncan Lukkenaer May 26 '21 at 11:44
  • 1
    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. – Cilenco May 26 '21 at 11:51
  • why should we use peek while rendering sticky headers however we use get function from lazypagingItems for rendering item? – Vyshakh Amarnath Aug 01 '22 at 10:19
  • 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). – Roberto Leinardi Nov 07 '22 at 09:18
  • @RobertoLeinardi Paging should only give you the current visible pages. With a appropriate page size this should not be any problem! – Cilenco Nov 09 '22 at 06:39
  • @Cilenco 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`. – Roberto Leinardi Nov 09 '22 at 09:25
  • `getAsState` is now deprecated. See: https://developer.android.com/jetpack/androidx/releases/paging – Dhagz Dec 12 '22 at 08:15
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
                ...
            }
        }
    }
}
TheLuckyCoder
  • 536
  • 9
  • 10
  • 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). – Roberto Leinardi Nov 07 '22 at 09:50
0

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)
        }
    }
}
alashow
  • 2,735
  • 3
  • 21
  • 47