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.
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
}
}
// GitHub page API is 1 based: https://developer.github.com/v3/#pagination
private const val GITHUB_STARTING_PAGE_INDEX = 1
class GithubPagingSource(
private val service: GithubService,
private val query: String
) : PagingSource<Int, Repo>() {
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Repo> {
val position = params.key ?: GITHUB_STARTING_PAGE_INDEX
val apiQuery = query + IN_QUALIFIER
return try {
val response = service.searchRepos(apiQuery, position, params.loadSize)
val repos = response.items
val nextKey = if (repos.isEmpty()) {
null
} else {
// initial load size = 3 * NETWORK_PAGE_SIZE
// ensure we're not requesting duplicating items, at the 2nd request
position + (params.loadSize / NETWORK_PAGE_SIZE)
}
LoadResult.Page(
data = repos,
prevKey = if (position == GITHUB_STARTING_PAGE_INDEX) null else position - 1,
nextKey = nextKey
)
} catch (exception: IOException) {
return LoadResult.Error(exception)
} catch (exception: HttpException) {
return LoadResult.Error(exception)
}
}
// The refresh key is used for subsequent refresh calls to PagingSource.load after the initial load
override fun getRefreshKey(state: PagingState<Int, Repo>): Int? {
// We need to get the previous key (or next key if previous is null) of the page
// that was closest to the most recently accessed index.
// Anchor position is the most recently accessed index
return state.anchorPosition?.let { anchorPosition ->
state.closestPageToPosition(anchorPosition)?.prevKey?.plus(1)
?: state.closestPageToPosition(anchorPosition)?.nextKey?.minus(1)
}
}
}
This is the code of getRefreshKey function of the paging3 codelab.
I thought that it's just okay to return state.anchorPosition. But why is this returning closestPagetToPosition's previous key plus 1??
This is the link of the paging 3 code lab.
Well, state.anchorPosition is index of the first item you see in this list, getRefreshKey should return the key to load on refresh.
In this example, key is index of page and each page typically contains many items, lets say 20. Now loading two pages would mean using keys 1 and 2 and having 40 items loaded.
In this situation, when you are looking at item on index 30, state.anchorPosition is 30 as well, but you definitely don't want your refresh key to be 30, you need it to be 2 because item on index 30 comes from page 2. This is what closestPageToPosition does.
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
}
}
}
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
...
}
}
}
}
I imitated the codelab and implemented the getRefreshKey() method, but since the params.loadSize is 3*PAGE_SIZE after refresh(that is, to delete an item or edit an item), most of the probability of my recyclerview will not return to the original position. What should I do?
This is my pagingSource:
class PasswordPagingSource2(
val type: String,
val service: PasswordService,
val PAGE_SIZE:Int) :
PagingSource<Int, Any>() {
private val PAGE_INDEX = 0
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, Any> {
val page = params.key ?: PAGE_INDEX
return try {
val list =
service.getPasswords(
"{\"type\":\"$type\"}",
params.loadSize, page * PAGE_SIZE).items
val preKey = if (page > 0) page - 1 else null
val nextKey = if (list.isNotEmpty()) page + (params.loadSize/PAGE_SIZE)else null
return LoadResult.Page(data =list, prevKey = preKey, nextKey = nextKey)
} catch (exception: IOException) {
return LoadResult.Error(Throwable("IO Error"))
} catch (exception: HttpException) {
return LoadResult.Error(Throwable("Network Error"))
}
}
override fun getRefreshKey(state: PagingState<Int, Any>): Int? {
val page = state.anchorPosition?.let { anchorPosition->
state.closestPageToPosition(anchorPosition)?.prevKey?.plus(1)
?:state.closestPageToPosition(anchorPosition)?.nextKey?.minus(1)
}
return page
}
}
It is your responsibility to ensure that initialLoadSize will load enough items to cover the viewport so that on invalidation, you will load enough items such that the previous list, and the initial refresh after invalidation overlap so that DiffUtil can help you resume scrolling position.
If you want to keep your pageSize the same, you can modify initialLoadSize in PagingConfig instead. As a rule of thumb, you want it to load at least 2x the number of visible items at once, both before and after the last PagingState.anchorPosition.