Compose LazyColumn with Paging3 scrolls up when item updated

107 Views Asked by At

I am using Paging3 with RemoteMediator for getting remote data, saving it in Room database and showing it in my Composable screen. On item click, opens detail screen and can update the data of that item saved in database. Whenever an item it's updated, because the paged data is collected as a Flow, it automatically updates de list.

The problem is that whenever an update is done in the detail view, once I go back, the list scrolls (without seeing the scrolling effect) up till the top of the list, like if the whole list was updated instead of just that item.

the screen:

@OptIn(ExperimentalMaterial3Api::class)
@SuppressLint("UnusedMaterial3ScaffoldPaddingParameter")

@Composable
fun ReposListScreen(navController: NavController,
                    viewModel: RepositoriesViewModel = hiltViewModel()) {

val repos = viewModel.data.collectAsLazyPagingItems()
val snackBarHostState = remember { SnackbarHostState() }
val coroutineScope = rememberCoroutineScope()

Scaffold(
    topBar = {
        TopAppBar(
            title = { Text(text = stringResource(id = R.string.str_best_rated_repos)) }
        )
    },
    snackbarHost = { SnackbarHost(snackBarHostState) },
    bottomBar = {
        BottomNavigationBarComponent() {
            coroutineScope.launch {
                snackBarHostState.showSnackbar("You pressed $it.")
            }
        }
    }
) {
    LazyColumn(
        modifier = Modifier.padding(paddingValues = it)
    ) {

        when (val appendState = repos.loadState.append) {
            is LoadState.Loading -> {
                item { LoadingNextPageItem(modifier = Modifier.fillMaxWidth()) }
            }

            is LoadState.Error -> {
                item {
                    ErrorMessage(
                        modifier = Modifier
                            .fillMaxWidth(),
                        message = appendState.error.localizedMessage.orEmpty(),
                        onClickRetry = { repos.retry() }
                    )
                }
            }

            else -> {}
        }

        items(count = repos.itemCount, key = repos.itemKey { it.id }) { index ->
            repos[index]?.let { repo ->
                RepoItem(
                    repo,
                    onRepoClicked = {
                        navController.navigate(Routes.RepoDetailScreen.createRoute(repo.id))
                    }
                )
            }
        }

        item { Spacer(modifier = Modifier.padding(4.dp)) }
    }
}

}

the viewmodel:

@HiltViewModel
class RepositoriesViewModel @Inject constructor(
    private val getPagedBestRatedReposUseCase: GetPagedBestRatedReposUseCase
) : ViewModel() {
private var _state: MutableStateFlow<UiState> = MutableStateFlow(UiState(loading = true))
val state: StateFlow<UiState> = _state.asStateFlow()

private var _data: MutableStateFlow<PagingData<Repository>> =
    MutableStateFlow(PagingData.empty())
val data: StateFlow<PagingData<Repository>> = _data.asStateFlow()

init {
    viewModelScope.launch(Dispatchers.IO) {
        _state.value = UiState(loading = true)
        getPagedBestRatedReposUseCase()
            .cachedIn(viewModelScope)
            .catch {
                _state.update {
                    it.copy(error = true, loading = false)
                }
            }
            .collect { repos ->
                _state.update {
                    it.copy(
                        dataSource = repos,
                        loading = false, error = false
                    )
                }
                _data.update { repos }
            }
    }
}

}

THE ANSWER BELOW

Okey, I found the answer thanks to @BenjyTec comment, that guided me till this issue: Google Issue

The thing is that val data = viewModel.data.collectAsLazyPagingItems() should be called before the initialization of the NavHost, and I was calling it in a composable screen called inside NavHost.

3

There are 3 best solutions below

0
alGhul7 On BEST ANSWER

Okey, I found the answer thanks to @BenjyTec comment, that guided me till this issue: Google Issue

The thing is that val data = viewModel.data.collectAsLazyPagingItems() should be called before the initialization of the NavHost, and I was calling it in a composable screen called inside NavHost.

0
BenjyTec On

The LazyColumn should store the scroll position by default. There is however an issue on the Google Issue Tracker that describes the same problem.

It might happen in your case that while you navigate to the next screen and then come back, the Composable resubscribes to the Flow, and while that happens, the Flow is reset to the initial value for a moment.

In this short moment however, the scroll position of the LazyColumn is reset and not restored when the actual Flow data comes back in.

As a workaround, you can try to resolve it as follows:

if (repos.isNotEmpty()) {
    LazyColumn() {
       // .....
    }
}

This might prevent the scroll position to be reset when the list is empty for a moment.


Alternatively, you can store and restore the scroll position in your ViewModel. First, add two new properties to your ViewModel:

class HomeViewModel () : ViewModel() {

    // store the current scroll states
    var scrollIndex: Int by mutableStateOf(0)
    var scrollOffset: Int by mutableStateOf(0)
}

Then you could access these two fields as follows:

@Composable
HomeScreen(viewModel: HomeViewModel = viewModel()) {

    val scrollState: LazyListState = rememberLazyListState(
        viewModel.scrollIndex, 
        viewModel.scrollOffset
    )

    // after each scroll, update values in ViewModel
    LaunchedEffect(key1 = scrollState.isScrollInProgress) {
        if (!scrollState.isScrollInProgress) {
            viewModel.scrollIndex = scrollState.firstVisibleItemIndex
            viewModel.scrollOffset = scrollState.firstVisibleItemScrollOffset
        }
    }
}

And assign the scrollState to the LazyColumn as follows:

LazyColumn(state = scrollState, /** ... **/) {
    //...
}
4
Jan Bína On

You shouldn't collect Flow<PagingData> inside of your ViewModel to make StateFlow out of it, that's wrong and it's causing your problem. Also, the way you handle loading and error states is wrong - .catch will never be called, as paging never throws exceptions but rather handles the errors internally. You should expose the paging flow as you get it from the library:

val data = getPagedBestRatedReposUseCase().cachedIn(viewModelScope)

then in your composable, you collect it with collectAsLazyPagingItems(). The resulting LazyPagingItems instance has the items, but also loadState , which you should use instead of your own loading and error implementation.