I was making simple LazyVerticalGrid containing fetched images from public api. At first i was following guide where pagination was used but this api doesnt have pages so i decided to fetch 20 images at time. Each api call retrieves 20 strings and then it is put into AsyncImage. I used fetchNextImages Boolean in order to fetch next 20 strings and it works but only if you dont switch screens Screen:
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import com.example.fortests.components.DuckImage
import com.example.fortests.components.LoadingState
sealed interface DuckViewState {
object Loading : DuckViewState
data class GridDisplay(
val ducks: List<String> = emptyList()
) : DuckViewState
}
@Composable
fun SecondScreen(
viewModel: SecondViewModel = hiltViewModel(),
onDuckClicked: (str: String) -> Unit
) {
LaunchedEffect(key1 = viewModel, block = { viewModel.fetchInitialImages() })
val viewState by viewModel.viewState.collectAsState()
val scrollState = viewModel.scrollState.value
val fetchNextImages: Boolean by remember {
derivedStateOf {
val currentCharacterCount =
(viewState as? DuckViewState.GridDisplay)?.ducks?.size
?: return@derivedStateOf false
val lastDisplayedIndex = scrollState.layoutInfo.visibleItemsInfo.lastOrNull()?.index
?: return@derivedStateOf false
return@derivedStateOf lastDisplayedIndex+1 == currentCharacterCount
}
}
LaunchedEffect(key1 = fetchNextImages, block = {
if (fetchNextImages) viewModel.fetchNextImages()
})
when (val state = viewState) {
DuckViewState.Loading -> LoadingState()
is DuckViewState.GridDisplay -> {
Column {
LazyVerticalGrid(
state = scrollState,
modifier = Modifier.padding(bottom = 90.dp),
contentPadding = PaddingValues(all = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
columns = GridCells.Fixed(2),
content = {
items(
items = state.ducks
) { duck ->
DuckImage(
onClick = { onDuckClicked(duck) },
imageUrl = "https://random-d.uk/api/${duck}.jpg"
)
}
})
}
}
}
}
ViewModel:
import android.app.Application
import androidx.compose.foundation.lazy.grid.LazyGridState
import androidx.compose.runtime.mutableStateOf
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.example.fortests.duckrepo.DuckImagesRepo
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import javax.inject.Inject
@HiltViewModel
class SecondViewModel @Inject constructor(
application: Application, private val duckImagesRepo: DuckImagesRepo
) : ViewModel() {
private val _viewState = MutableStateFlow<DuckViewState>(DuckViewState.Loading)
val viewState: StateFlow<DuckViewState> = _viewState.asStateFlow()
val scrollState = mutableStateOf(LazyGridState())
private val fetchedDucks = mutableListOf<String>()
fun fetchInitialImages() = viewModelScope.launch {
if (fetchedDucks.isNotEmpty()) return@launch
val initialData = duckImagesRepo.fetchImages()
_viewState.update {
return@update DuckViewState.GridDisplay(ducks = initialData)
}
}
fun fetchNextImages() = viewModelScope.launch {
val data = duckImagesRepo.fetchNextData()
_viewState.update { currentState ->
when (currentState) {
is DuckViewState.GridDisplay -> {
val updatedDucks = currentState.ducks + data
DuckViewState.GridDisplay(ducks = updatedDucks)
}
else -> currentState
}
}
}
}
Will it be a good solution to just save all those strings in a list?
There are several problems with your code, probably the most prominent one being that you reset the view model list whenever SecondScreen enters the composition (by executing fetchInitialImages()
). That will happen whenever you switch screens, but it will also be triggered on configuration changes like screen rotations. The entire list is then lost and a new one is created. That's probably the cause for your main issue.
But I don't think this is worth fixing. Instead, you should refactor your app to use the paging library1 and move the entire logic for loading chunks into the data layer, where it belongs. This is nothing your UI should concern itself with. You do not need to rely on the API to support paging, you can easily implement that yourself as a wrapper around any API. You just need to create a PagingSource
:
class DuckImagePagingSource() : PagingSource<Int, String>() {
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, String> {
val currentPage = params.key ?: 1
val ducks = if (currentPage == 1)
// what fetchImages() previously returned
else
// what fetchNextData() previously returned
return LoadResult.Page(
data = ducks,
prevKey = if (currentPage == 1) null else currentPage - 1,
nextKey = currentPage + 1,
)
}
override fun getRefreshKey(state: PagingState<Int, String>): Int? {
return state.anchorPosition
}
}
That's it.
Since the repository's fetchImages()
and fetchNextData()
are now part of the PagingSource, you don't need to expose them in the repository any more. Instead, expose the PagingSource itself:
class DuckImagesRepo() {
val pagingSource: DuckImagePagingSource by lazy { DuckImagePagingSource() }
}
(Or let Hilt inject it into the repository, it doesn't need to be lazily initialized)
The view model can now be cleaned up as well. You don't need viewState
and DuckViewState
anymore, you can also remove fetchInitialImages()
and fetchNextImages()
. Instead, create a Pager
from the repository's PagingSource and expose it as a flow:
@HiltViewModel
class SecondViewModel @Inject constructor(
application: Application,
private val duckImagesRepo: DuckImagesRepo,
) : ViewModel() {
val duckPager: Flow<PagingData<String>> = Pager(
PagingConfig(
pageSize = 20,
)
) { duckImagesRepo.pagingSource }
.flow
.cachedIn(viewModelScope)
val scrollState = mutableStateOf(LazyGridState())
}
Note that the flow is not converted to a StateFlow, instead cachedIn
is used, which does something similar, but takes the specificities of PagingData into account.
And finally, you can remove the loading logic from your composable:
@Composable
fun SecondScreen(
viewModel: SecondViewModel = hiltViewModel(),
onDuckClicked: (str: String) -> Unit,
) {
val ducks = viewModel.duckPager.collectAsLazyPagingItems()
val scrollState by viewModel.scrollState
if (ducks.loadState.refresh is LoadState.Loading)
LoadingState()
else
Column {
LazyVerticalGrid(
state = scrollState,
modifier = Modifier.padding(bottom = 90.dp),
contentPadding = PaddingValues(all = 16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
columns = GridCells.Fixed(2),
) {
items(ducks.itemCount) { index ->
val duck = ducks[index]
if (duck == null)
// Use any placeholder you want for data that is already requested, but not yet loaded.
// Also see PagingConfig.enablePlaceholders
CircularProgressIndicator()
else
DuckImage(
onClick = { onDuckClicked(duck) },
imageUrl = "https://random-d.uk/api/${duck}.jpg",
)
}
}
}
}
The flow is collected with collectAsLazyPagingItems
, specially designed for paging data. It returns a LazyPagingItems
object that can be queried for the current loading state and from where you can retrieve the data. It will automatically load new data when it is required and your composable is recomposed when the new data arrives.
Since the data is now held by the repository (respectively DuckImagePagingSource), the data survives whatever you do in your UI (i.e. switching screens, device reconfigurations and so on).
This is just a basic example of how you can create your own PagingSource. There are lots of configurations you can use to adjust it to your specific needs, like the actual getRefreshKey()
implementation or the various optional PagingConfig()
parameters.
1 You need both the artifacts androidx.paging:paging-runtime-ktx
and androidx.paging:paging-compose
.