Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to prevent paging refresh flicker in Android Jetpack Compose?

I have a compose screen for a search results page. I want to show a loading indicator when the search is in progress, but on subsequent searches I want to keep the old search results under the loading indicator. Kinda like how React useDeferredValue works.

What I have right now replaces the old value with the loading indicator on every new search. The search is quick but not immediate so I'm seeing a flicker between the search results and the loading indicator. Here's a simplified example:

class MainViewModel : ViewModel() {
    private val query = MutableStateFlow("")

    @OptIn(ExperimentalCoroutinesApi::class)
    val searchResultPagingData = query.filter { it.isNotEmpty() }.flatMapLatest { query ->
        Pager(PagingConfig(10)) {
            SearchPagingSource(query)
        }.flow
    }.cachedIn(viewModelScope)

    fun search(query: String) {
        this.query.value = query
    }
}

@OptIn(FlowPreview::class)
@Composable
fun MainScreen(modifier: Modifier = Modifier, viewModel: MainViewModel = viewModel()) {
    val query = rememberTextFieldState()
    val searchResultItems = viewModel.searchResultPagingData.collectAsLazyPagingItems()

    LaunchedEffect(Unit) {
        snapshotFlow { query.text.toString() }.debounce(150.milliseconds).distinctUntilChanged()
            .collectLatest {
                viewModel.search(it)
            }
    }

    Column(modifier = modifier) {
        OutlinedTextField(state = query)
        if (query.text.isNotEmpty()) {
            when (searchResultItems.loadState.refresh) {
                is LoadState.Loading -> {
                    Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
                        CircularProgressIndicator(modifier = Modifier.size(96.dp))
                    }
                }
                is LoadState.Error -> Text("Error")
                is LoadState.NotLoading -> {
                    if (searchResultItems.itemCount > 0) {
                        LazyColumn {
                            items(
                                searchResultItems.itemCount,
                                searchResultItems.itemKey(Model::id),
                            ) { index ->
                                val searchResult = searchResultItems[index]!!
                                ListItem(headlineContent = { Text(searchResult.name) })
                                HorizontalDivider()
                            }
                        }
                    } else {
                        Text("No results")
                    }
                }
            }
        }
    }
}

How do I keep the old search results showing until the new ones have fully loaded?

like image 696
Eva Avatar asked Oct 24 '25 15:10

Eva


2 Answers

You already use the correct LoadType refresh to differentiate the initial loading of a PagingSource. But that doesn't help in your case because on a textfield change you create a new PagingSource each time.

Instead you need to keep track when the first result was loaded. You can do this in MainScreen with a new variable:

var isFirstQuery by rememberSaveable { mutableStateOf(true) }

Then you just need to adjust the when conditions accordingly:

when {
    searchResultItems.loadState.refresh is LoadState.Loading && isFirstQuery -> {
        // Your Box with the CircularProgressIndicator
    }

    searchResultItems.loadState.refresh is LoadState.Error -> Text("Error")

    else -> {
        isFirstQuery = false

        // ... (what was previously the case LoadState.NotLoading)
    }
}

The first case now additionally checks that isFirstQuery is true (you can simplify this with a When Guard if your Kotlin version supports that), resulting in the desired outcome that the CircularProgressIndicator is only displayed the first time.

The LoadState.NotLoading case is now the else case, catching also Loading with isFirstQuery false. And this is also the case where isFirstQuery needs to be set to false: From this time on, you don't want to ever display the CircularProgressIndicator anymore.

like image 68
Leviathan Avatar answered Oct 26 '25 06:10

Leviathan


Looks like LazyPagingItems keeps the old data while it is refreshing. That means I can keep track of the initial search in a state variable. If I only show the stale data while refreshing and the search takes unusually long for some reason, the app will look unresponsive without some sort of loading indicator for subsequent searches. But if I add the loading indicator immediately, then I get the flickering problem back. The solution is twofold:

  1. Add a state variable to keep track of whether a search has been made
  2. Add another state variable that updates the display of a stale search after a delay
@OptIn(FlowPreview::class)
@Composable
fun MainScreen(modifier: Modifier = Modifier, viewModel: MainViewModel = viewModel()) {
    val query = rememberTextFieldState()
    var hasSearched by rememberSaveable { mutableStateOf(false) }
    val searchResultItems = viewModel.searchResultPagingData.collectAsLazyPagingItems()

    LaunchedEffect(query) {
        snapshotFlow { query.text.toString() }.debounce(150.milliseconds).distinctUntilChanged()
            .collectLatest {
                viewModel.search(it)
            }
    }

    Column(modifier = modifier) {
        OutlinedTextField(state = query)
        when {
            query.text.isEmpty() -> {}
            hasSearched -> {
                when (searchResultItems.loadState.refresh) {
                    is LoadState.Loading -> {
                        var isPending by rememberSaveable { mutableStateOf(false) }

                        LaunchedEffect(Unit) {
                            delay(150.milliseconds)
                            isPending = true
                        }

                        SearchResultLayout(
                            searchResultItems = searchResultItems,
                            isPending = isPending,
                        )
                    }

                    is LoadState.Error -> {
                        Text("Error", color = colorScheme.error)
                        SearchResultLayout(searchResultItems = searchResultItems, isPending = true)
                    }

                    is LoadState.NotLoading -> {
                        SearchResultLayout(searchResultItems = searchResultItems)
                    }
                }
            }

            else -> {
                when (searchResultItems.loadState.refresh) {
                    is LoadState.Loading -> {
                        Box(
                            modifier = Modifier.fillMaxSize(),
                            contentAlignment = Alignment.Center
                        ) {
                            CircularProgressIndicator(modifier = Modifier.size(96.dp))
                        }
                    }

                    is LoadState.Error -> Text("Error", color = colorScheme.error)
                    is LoadState.NotLoading -> {
                        hasSearched = true

                        SearchResultLayout(searchResultItems = searchResultItems)
                    }
                }
            }
        }
    }
}

@Composable
private fun SearchResultLayout(
    searchResultItems: LazyPagingItems<Model>,
    isPending: Boolean = false,
) {
    if (searchResultItems.itemCount > 0) {
        LazyColumn {
            items(
                searchResultItems.itemCount,
                searchResultItems.itemKey(Model::id),
            ) { index ->
                val searchResult = searchResultItems[index]!!
                ListItem(
                    headlineContent = { Text(searchResult.name) },
                    colors = if (isPending) {
                        ListItemDefaults.colors(containerColor = colorScheme.surfaceDim)
                    } else {
                        ListItemDefaults.colors()
                    },
                )
                HorizontalDivider()
            }
        }
    } else {
        Surface(
            modifier = Modifier.fillMaxSize(),
            color = if (isPending) colorScheme.surfaceDim else colorScheme.surfaceVariant,
        ) {
            Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
                Text("No results", textAlign = TextAlign.Center)
            }
        }
    }
}
like image 31
Eva Avatar answered Oct 26 '25 06:10

Eva



Donate For Us

If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!