androidkotlinandroid-jetpack-composekotlin-flowkotlin-stateflow

How to change the value of a repository Flow?


I came across this article:

https://proandroiddev.com/loading-initial-data-in-launchedeffect-vs-viewmodel-f1747c20ce62

In this article, the author, while describing the advantages and disadvantages of different data initialization approaches, concludes that it's best to do it using StateFlow. I decided to give it a try, considering that I use the UiState wrapper as a common state for the screen and MyRepo as the data source:

sealed class UiState<out T> {
    object Loading : UiState<Nothing>()
    data class Error(val code: Int? = null, val message: String? = null) : UiState<Nothing>()
    data class Content<T>(val data: T) : UiState<T>()
}

class MyRepo {
    fun getMyData(): Flow<List<String>> {
        return flow {
            delay(1000)
            emit(listOf("1", "2"))
        }
    }
}

This is my implementation before the changes:

class MyViewModel1(repo: MyRepo) : ViewModel() {
    data class ScreenStateUI(
        val data: List<String> = emptyList(),
        val title: String = "Title",
    )

    private val _screenUiState: MutableStateFlow<UiState<ScreenStateUI>> =
        MutableStateFlow(UiState.Loading)
    val screenUiState: StateFlow<UiState<ScreenStateUI>> = _screenUiState.asStateFlow()

    init {
        viewModelScope.launch {
            repo.getMyData()
                .map<List<String>, UiState<ScreenStateUI>> { UiState.Content(ScreenStateUI(data = it)) }
                .collectLatest {
                    if (it is UiState.Content) {
                        _screenUiState.emit(it)
                    }
                }
        }
    }

    fun updateTitle(title: String) {
        _screenUiState.update {
            if (it is UiState.Content) {
                it.copy(data = it.data.copy(title = title))
            } else {
                it
            }
        }
    }
}

During initialization, the state is loaded, and the UI can subscribe to changes via screenUiState. This means there are no issues if you need to use fun updateTitle(title: String).

Now I’ve changed the implementation to remove the initialization in the init block and make everything happen as soon as the UI subscribes to the event:

class MyViewModel2(repo: MyRepo) : ViewModel() {
    data class ScreenStateUI(
        val data: List<String> = emptyList(),
        val title: String = "Title",
    )

    val screenUiState: StateFlow<UiState<ScreenStateUI>> by lazy {
        repo.getMyData()
            .map<List<String>, UiState<ScreenStateUI>> { UiState.Content(ScreenStateUI(data = it)) }
            .onStart { emit(UiState.Loading) }
            .catch { emit(UiState.Error(message = it.message)) }
            .stateIn(
                scope = viewModelScope,
                started = SharingStarted.WhileSubscribed(5000),
                initialValue = UiState.Loading,
            )
    }

    fun updateTitle(title: String) {
        ...
    }
}

So, everything seems to look fine, and indeed, the UI now triggers requests only when it subscribes. However, the state can no longer be updated, and consequently, the function updateTitle cannot update the title anymore.

What am I missing here?


Solution

  • You have two sources of data. The repository provides the list, the title is stored in the view model.

    What you are missing is the storage for the title. Just add another MutableStateFlow that holds the title. It can be changed as usual and it can also be incorporated into your chain of flow operations:

    class MyViewModel2(repo: MyRepo) : ViewModel() {
        data class ScreenStateUI(
            val data: List<String>,
            val title: String,
        )
    
        private val titleFlow = MutableStateFlow("Title")
    
        val screenUiState: StateFlow<UiState<ScreenStateUI>> by lazy {
            combine(
                repo.getMyData(),
                titleFlow,
            ) { data, title ->
                UiState.Content<ScreenStateUI>(ScreenStateUI(data = data, title = title))
            }
                .onStart<UiState<ScreenStateUI>> { emit(UiState.Loading) }
                .catch<UiState<ScreenStateUI>> { emit(UiState.Error(message = it.message)) }
                .stateIn<UiState<ScreenStateUI>>(
                    scope = viewModelScope,
                    started = SharingStarted.WhileSubscribed(5000),
                    initialValue = UiState.Loading,
                )
        }
    
        fun updateTitle(title: String) {
            titleFlow.value = title
        }
    }
    

    combine takes multiple flows as input and merges their content. This is why titleFlow needs to be a flow, otherwise screenUiState cannot be updated with the title.

    If, later on, you decide to store the title somewhere else (outside of the view model, in a database for example), you just need to replace titleFlow with the flow from that new data source and change updateTitle to call that data source's update method accordingly.