androidkotlinandroid-jetpack-composeandroid-paging-3

How to save the scroll state whilst fetching data partially


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?


Solution

  • 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.