Issue
My repo has the following function:
override fun getTopRatedMoviesStream(): Flow<List<Movie>>
I have the following Result wrapper:
sealed interface Result<out T> {
data class Success<T>(val data: T) : Result<T>
data class Error(val exception: Throwable? = null) : Result<Nothing>
object Loading : Result<Nothing>
}
fun <T> Flow<T>.asResult(): Flow<Result<T>> {
return this
.map<T, Result<T>> {
Result.Success(it)
}
.onStart { emit(Result.Loading) }
.catch { emit(Result.Error(it)) }
}
And finally, my ViewModel
has the following UiState logic:
data class HomeUiState(
val topRatedMovies: TopRatedMoviesUiState,
val isRefreshing: Boolean
)
@Immutable
sealed interface TopRatedMoviesUiState {
data class Success(val movies: List<Movie>) : TopRatedMoviesUiState
object Error : TopRatedMoviesUiState
object Loading : TopRatedMoviesUiState
}
class HomeViewModel @Inject constructor(
private val movieRepository: MovieRepository
) : ViewModel() {
private val topRatedMovies: Flow<Result<List<Movie>>> =
movieRepository.getTopRatedMoviesStream().asResult()
private val isRefreshing = MutableStateFlow(false)
val uiState: StateFlow<HomeUiState> = combine(
topRatedMovies,
isRefreshing
) { topRatedResult, refreshing ->
val topRated: TopRatedMoviesUiState = when (topRatedResult) {
is Result.Success -> TopRatedMoviesUiState.Success(topRatedResult.data)
is Result.Loading -> TopRatedMoviesUiState.Loading
is Result.Error -> TopRatedMoviesUiState.Error
}
HomeUiState(
topRated,
refreshing
)
}
.stateIn(
scope = viewModelScope,
started = WhileUiSubscribed,
initialValue = HomeUiState(
TopRatedMoviesUiState.Loading,
isRefreshing = false
)
)
fun onRefresh() {
viewModelScope.launch(exceptionHandler) {
movieRepository.refreshTopRated()
isRefreshing.emit(true)
isRefreshing.emit(false)
}
}
The issue is the TopRatedMoviesUiState.Loading
state is only emitted once on initial load but not when user pulls to refresh and new data is emitted in movieRepository.getTopRatedMoviesStream()
. I understand that it is because .onStart
only emits first time the Flow is subscribed to.
Do I somehow resubscribe to Flow when refresh is performed? Refresh does not always return new data from repo so how in this case, how do I avoid duplicate emission?
Solution
You emit TopRatedMoviesUiState.Loading
in onStart
. So what you describe is totally expected. Loading
is emitted when the stream starts. I.e. when you start collecting. In your case by stateIn
.
Looking at it from another perspective, how should your result wrapper know that the repository is currently loading? And that's also the answer. Only two places in your code know that you are reloading.
- Either subscribe to a fresh flow whenever you call refreshTopRated and complete the flow after emitting the final result.
- Or emit a loading state right from the repository before you start loading.
I'd prefer the later one.
Neither solution will save you from emitting the same result again and again. For that, you'd need a new state like 'Unchanged' that your repository emits when it finds no new data. But please evaluate if this optimization is required. What is the cost of an extra emission?
That being said, here are some more questions, challenging your code. Hopefully guiding you to a solid implementation:
- Why do you need the
isRefreshing
StateFlow? Is there a difference in the UI wether you initially load or wether you refresh? - Why do you need
topRated
to be a StateFlow? Wouldn't a regular Flow do? - Emitting two items to a StateFlow in succession (i.e.
isRefreshing.emit(true); isRefreshing.emit(false)
) might loose the first emission [1]. As for state, only the most recent value is relevant. States are not events!
[1]: StateFlow has a replay buffer of 1 and a buffer overflow policy of DROP_OLDEST. See State flow is a shared flow in kotlinx-coroutines-core/kotlinx.coroutines.flow/StateFlow
Answered By - Uli Luckas
Answer Checked By - Clifford M. (JavaFixing Volunteer)