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?
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
}
}
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
...
}
}
}
}
Related
Here's the network request I have:
fun getItems(pageNumber: Int): Single<List<Item>>
Here's my lazy grid:
#Composable
fun ItemGridView(
productTiles: List<Item>,
) {
LazyVerticalGrid(
columns = GridCells.Fixed(2),
modifier = Modifier.fillMaxSize(),
) {
items(productTiles) { item -> item.toPrettyComposableView() }
}
}
Currently, my ItemGridView will stop rendering after the first page, but I would like it to continue requesting and rendering the next page after the user reaches the last item of the page. If the api response gives me an odd number of items for the first page, for the next page, it should continue filling the grid on the right side of the rendered item instead of creating a new row.
Please help
If you want pagination in your app, perhaps you might want to take a look at the AndroidX Pagination library. It handles all sorts of cases with a nice API, it also has Jetpack Compose support by importing this library implementation("androidx.paging:paging-compose:1.0.0-alpha16").
After following the official guide and trying it out in Compose you might notice that it does have support for LazyColumn and LazyRow but it does not yet have for LazyVerticalGrid.
This extension function might come in useful to you:
fun <T : Any> LazyGridScope.items(
items: LazyPagingItems<T>,
key: ((item: T) -> Any)? = null,
span: ((item: T) -> GridItemSpan)? = null,
contentType: ((item: T) -> Any)? = null,
itemContent: #Composable LazyGridItemScope.(value: T?) -> Unit
) {
items(
count = items.itemCount,
key = if (key == null) null else { index ->
val item = items.peek(index)
if (item == null) {
PagingPlaceholderKey(index)
} else {
key(item)
}
},
span = if (span == null) null else { index ->
val item = items.peek(index)
if (item == null) {
GridItemSpan(1)
} else {
span(item)
}
},
contentType = if (contentType == null) {
{ null }
} else { index ->
val item = items.peek(index)
if (item == null) {
null
} else {
contentType(item)
}
}
) { index ->
itemContent(items[index])
}
}
And you would use it like so:
// Get hold of a Flow of PagingData from your ViewModel or something similar
val pagingListFlow: Flow<PagingData<T>> = ...
val pagingList = photosPagingList.collectAsLazyPagingItems()
LazyVerticalGrid(columns = GridCells.Fixed(columnCount)) {
// Use the extension function here
items(items = pagingList) { item ->
// Draw your composable
}
}
How to make UI changes in a paginated list with jetpack compose.
Use case
I have a paginated list which has data name(string) and like(boolean). If i click on the particular item in the list, i need to place a like button in the UI. But the image is not updated in UI based on condition.
Snippet
userList -> LazyPagingItems<AllDoctorsResponse.Data.Doctor>
//viewModel
userList.itemSnapshotList.find { it?.id == user.id }?.liked = true
// Composable
items(userList.itemCount){ index ->
userList[index]?.let {
if (it.liked == true) {
UserCardWithLike(it, onClick = { userId ->
onUserCardClicked(userId)
}, onLikeChange = { isLiked, user ->
onLikeChange(isLiked, user)
})
} else {
UserCard(it, onClick = { userId ->
onUserCardClicked(userId)
}, onLikeChange = { isLiked, user ->
onLikeChange(isLiked, user)
})
}
}
}
I don't use paging3 anymore, so I didn't test this code, but I think it will work:
items(userList.itemCount){ index ->
userList[index]?.let {
var liked by rememberSaveable { mutableStateOf(it.liked) }
if (liked == true) {
UserCardWithLike(
it,
onClick = { userId -> onUserCardClicked(userId) },
onLikeChange = { isLiked, user -> liked = false }
)
} else {
UserCard(
it,
onClick = { userId -> onUserCardClicked(userId) },
onLikeChange = { isLiked, user -> liked = true }
)
}
}
}
If you want something like notifyItemChange, it's not possible in Paging3. In that case, I suggest trying to rewrite the paging library, which is surprisingly easy.
https://gist.github.com/FishHawk/6e4706646401bea20242bdfad5d86a9e
I am making an app using compose in which I am using LazyVerticalGrid but when I update MutableStateListOf data it throw IndexOutOfBoundsException. If I use if (index < arrayList.size) everything works fine but I am still getting this exception from some of my users.
This is the simplified structure of my code.
val loading = mutableStateOf(false)
val arrayList = mutableStateListOf<String>()
fun loadData() {
coroutineScope.launch(Dispatchers.Default) {
loading.value = true
arrayList.clear()
arrayList.addAll(getData())
loading.value = false
}
}
if (loading.value == true) {
// Loading Placeholder
} else {
if (arrayList.isEmpty()) {
// Empty Placeholder
} else {
LazyVerticalGrid() {
items(arrayList.size) { index ->
// if (index < arrayList.size)
ItemLayout(arrayList[index]) // This line throw exception
}
}
}
}
Edit :-
Replacing mutableStateListOf with arrayListOf has solved my problem.
Use itemsIndexed instead like this:
itemsIndexed(arrayList) { index, item ->
ItemLayout(item)
}
I'm attempting to make a paged list of books using Jetpack Compose and Android's Paging 3 library. I am able to make the paged list and get the data fine, but the load() function of my paging data source is being called infinitely, without me scrolling the screen.
My paging data source looks like this:
class GoogleBooksBookSource #Inject constructor(
private val googleBooksRepository: GoogleBooksRepository,
private val query: String
): PagingSource<Int, Book>() {
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Book> {
val position = params.key ?: 0
return try {
val response = googleBooksRepository.searchForBookStatic(query, position)
if (response is Result.Success) {
LoadResult.Page(
data = response.data.items,
prevKey = if (position == 0) null else position - 1,
nextKey = if (response.data.totalItems == 0) null else position + 1
)
} else {
LoadResult.Error(Exception("Error loading paged data"))
}
} catch (e: Exception) {
Log.e("PagingError", e.message.toString())
return LoadResult.Error(e)
}
}
override fun getRefreshKey(state: PagingState<Int, Book>): Int? {
return state.anchorPosition?.let { anchorPosition ->
val anchorPage = state.closestPageToPosition(anchorPosition)
anchorPage?.prevKey?.plus(1) ?: anchorPage?.nextKey?.minus(1)
}
}
}
and this is the UI:
Column() {
// other stuff
LazyColumn(
modifier = Modifier.padding(horizontal = 24.dp),
content = {
for (i in 0 until searchResults.itemCount) {
searchResults[i]?.let { book ->
item {
BookCard(
book = book,
navigateToBookDetail = { navigateToBookDetail(book.id) }
)
}
}
}
}
)
}
As far as I can tell, the data loads correctly and in the correct order, but when I log the API request URLs, it's making infinite calls with an increasing startIndex each time. That would be fine if I was scrolling, since Google Books searches often return thousands of results, but it does this even if I don't scroll the screen.
The issue here was the way I was creating elements in the LazyColumn - it natively supports LazyPagingItem but I wasn't using that. Here is the working version:
LazyColumn(
modifier = Modifier.padding(horizontal = 24.dp),
state = listState,
content = {
items(pagedSearchResults) { book ->
book?.let {
BookCard(
book = book,
navigateToBookDetail = { navigateToBookDetail(book.id) }
)
}
}
}
)
In your original example, you have to use peek to check for non-null and access the list as you do only inside item block, which is lazy. Otherwise the paging capabilities will be lost and it will load the entire dataset in one go.
I have implemented LazyColumn with Paging, but I'm now trying to add sticky headers as well.
The stickyHeader() function is not available inside the items() scope, so I don't see how this should work.
#Composable
fun MovieList(movies: Flow<PagingData<Movie>>) {
val lazyMovieItems: LazyPagingItems<Movie> = movies.collectAsLazyPagingItems()
LazyColumn {
// TODO: Add sticky headers
items(lazyMovieItems) { movie ->
MovieItem(movie = movie!!)
}
}
}
How can I add the stickyHeaders?
#Composable
fun MovieList(movies: Flow<PagingData<Movie>>) {
val lazyMovieItems = movies.collectAsLazyPagingItems()
LazyColumn {
val itemCount = lazyMovieItems.itemCount
var lastCharacter: Char? = null
for (index in 0 until itemCount) {
// Gets item without notifying Paging of the item access,
// which would otherwise trigger page loads
val movie = lazyMovieItems.peek(index)
val character = movie?.name?.first()
if (movie !== null && character != lastCharacter) {
stickyHeader(key = character) {
MovieHeader(character)
}
}
item(key = movie?.id) {
// Gets item, triggering page loads if needed
val movieItem = lazyMovieItems[index]
Movie(movieItem)
}
lastCharacter = character
}
}
}