Blog Infos
Author
Published
Topics
, , , ,
Published

 

As part of my daily attempt to pay the bills, I had to implement some pull-to-refresh action.

For the uninitiated, the pull-to-refresh component allows users to drag downwards at the beginning of an app’s content to refresh some data.

Kind of like this:

Enhance

While the pattern is quite common, it’s not exactly something one does on the day-to-day.

So, I went ahead and blatantly copied the official docs.

@Composable
private fun MainScreen(
viewModel: PullToRefreshViewModel = hiltViewModel()
) {
Box(
modifier = Modifier
.fillMaxWidth()
.background(Color.Blue)
) {
val isRefreshing: Boolean by viewModel.isRefreshingState.collectAsStateWithLifecycle()
PullToRefreshBox(
isRefreshing = isRefreshing,
onRefresh = {
viewModel.onRefresh()
},
) {
// ....
}
}
}
view raw MainScreen.kt hosted with ❤ by GitHub

The PullToRefreshViewModel holds the loading state and has a refresh function to test things out.

@HiltViewModel
class PullToRefreshViewModel @Inject constructor() : ViewModel() {
val isRefreshingState = MutableStateFlow(false)
fun onRefresh() {
viewModelScope.launch {
isRefreshingState.update { true }
isRefreshingState.update { false }
}
}
}

This is innocent enough, right?

Hol’ up

When I tried it out, to my surprise, the pull-to-refresh indicator was getting completely stuck. It would not leave the screen!

One would expect it to spin a bit, then hide. Or just hide immediately.

But getting stuck was not in the cards.

Wait, why is the frame rate indicator turned on?

This will hopefully be apparent by the end of this post.

Successful guesses win a Kodee plush limited edition. (lie)

Is this a bug?

My first thought was that this is a bug on the PullToRefreshBox side of things. As always, it’s never my fault.

Just to check, I put a breakpoint on the debugger and tried again.

This breakpoint was never being hit when I was initiating the pull-to-refresh action. isRefreshing was never true! 😵

How is this even possible?

Ok, so the StateFlow is clearly being updated to true first, then to false. What am I missing?

@HiltViewModel
class PullToRefreshViewModel @Inject constructor() : ViewModel() {
val isRefreshingState = MutableStateFlow(false)
fun onRefresh() {
viewModelScope.launch {
isRefreshingState.update { true }
isRefreshingState.update { false }
}
}
}

Since this made no sense, I decided to put a 2000ms delay and gave it another go:

fun onRefresh() {
viewModelScope.launch {
isRefreshingState.update { true }
delay(2000) // simulate loading
isRefreshingState.update { false }
}
}
view raw onRefresh.kt hosted with ❤ by GitHub

The indicator now animates correctly, then disappears after 2000ms.

But why does it work now?

On a 60 Hz refresh rate phone, the screen refreshes 60 times per second.

the refresh rate indicator can be turned on in developer options

60 Hz, the UI can update at most every 16.67 ms

144 Hz, the UI can update at most every 6.94 ms

If updates are emitted faster than the frame duration, compose will skip intermediate updates and only process the latest value for the next frame.

Sven Bendel was kind enough to provide a way more detailed explanation than I could have ever come up with. You can read it here. 🫡

So, in this case:

  • On a 60 Hz device
  • loadData takes 1ms to complete, for example
  • compose skips the intermediate update of true, and only processes the latest false value

The crux of the issue is that PullToRefreshBox animates and hides the indicator properly only when toggling isRefreshing in a serial manner — first to true then to false.

Update

While Material3 v1.4.0-alpha01 fixes this specific issue with PullToRefreshBox, (thanks for pointing it out Florent Guillemot 🙏), the premise of this problem is the same when writing any composable.

The compose layer is not guaranteed to receive all updates, if they happen way too fast.

While this seems self-evident now after debugging through this, it was definitely not evident when I spent almost 2 hours going mad with this. 🤣

So, what do?

If one is in the unfortunate position of trying to fix this type of bug, we need a way to:

  • Guarantee delivery of all values of a StateFlow to the compose layer — no matter how quickly it’s being updated
  • Make it refresh rate fool-proof — no one wants to bother with checking what refresh rate a device has programmatically
  • Make it a bit generic

StateFlow that will put some delay in between emissions should do. 50ms sounds more than enough.

Job Offers

Job Offers

There are currently no vacancies.

OUR VIDEO RECOMMENDATION

No results found.

Jobs

private class CooldownStateFlow<T>(
initialValue: T,
private val coroutineScope: CoroutineScope,
dispatcher: CoroutineDispatcher,
private val cooldownMillis: Long = 50L,
) {
private val singleThreadDispatcher = dispatcher.limitedParallelism(1)
private val _events =
MutableSharedFlow<T>().also {
coroutineScope.launch(singleThreadDispatcher) {
it.collect { value ->
_stateFlow.update { value }
delay(cooldownMillis)
}
}
}
private val _stateFlow = MutableStateFlow(initialValue)
val stateFlow = _stateFlow.asStateFlow()
fun update(value: T) {
coroutineScope.launch(singleThreadDispatcher) {
_events.emit(value)
}
}
}

This is extremely hacky, but hey, I don’t make the rules.

Anyways

Hope you found this somewhat useful.

markasduplicate

Later.

This article was previously published on proandroiddev.com.

Menu