Dealing with Flow and updating UI

Good afternoon.

I’m trying to solve the exercise proposed by google to learn how to code in kotlin.

The problem statement is the following :

you have a db with “airport” with some information.
You should display them on a screen.
The displayed “airport” depends on what you are currently typing in the research bar.
When you click on a airport, the screen should be updated with the available “flight”.

My code seems to work when I’m not using “Flow”, I’m then using viewModelScope.launch and updating the state. However, from my understanding, it’s possible to get the same results using “Flow” and it seems to be easier and more readable.

But I’m having some issues with “Flow”,
When I type the airport, everything works fine, or it seems so, when I click on a airport, nothing changes on the screen but it seems the values in the viewModel are updated (Log.d). When I remove or I type one more letter in the research bar, my screen is updated and shows the expected results.
There is something that I don’t get with Flow, I expected that the recomposition works when a new value was received from the flow.

Here are the main parts related to this question .

class HomeScreenViewModel (private val airportDao: AirportDao): ViewModel() {
    // Game UI state
    private val _uiState = MutableStateFlow(HomeScreenUiState())
    val uiState: StateFlow<HomeScreenUiState> = _uiState.asStateFlow()


    private var searchAirport by mutableStateOf("")



    fun updateSearchAirport(searchedAirport : String){
        searchAirport = searchedAirport

    }

    fun updateDisplayedAirports(): Flow<List<Airport>> {

        val updatedDisplayedAirports = airportDao.getAirportsByIataCode(searchAirport).combine(airportDao.getAirportsByName(searchAirport)) { list1, list2 ->
            // Combine or concatenate the lists as needed
            (list1 + list2).distinct()
        }

        Log.d("updateDisplayedAirpots", updatedDisplayedAirports.toString())
        return updatedDisplayedAirports


    }

    fun updateSelectedAirport(airport : Airport){
        _uiState.update {
            it.copy(
                selectedAirport = airport
            )
        }
    }

    fun updatePotentialFlights(): Flow<List<Flight>>{

        var flightId = 1

        val selectedAirport = _uiState.value.selectedAirport

        if (selectedAirport != null) {
            val updatedPotentialFlights = airportDao.getAllAirportExcept(selectedAirport.iata_code)
                .map { originalList ->
                    originalList.map { Flight(flightId++, selectedAirport, it) }
                }


            return updatedPotentialFlights

        } else {

            return flow {
                emit(emptyList<Flight>())
            }
        }

    }

    fun isFlightInFavorite(flight: Flight) : Boolean{
        return flight in uiState.value.favorite
    }

    fun getSearchedAirport() : String {
        return searchAirport
    }

    fun updateFavorite(flight: Flight) {

        _uiState.update { currentState ->
            val updatedFavorites = currentState.favorite.toMutableList()

            if (flight in updatedFavorites) {
                updatedFavorites.remove(flight)
            } else {
                updatedFavorites.add(flight)
            }

            currentState.copy(favorite = updatedFavorites)
        }
    }

    companion object {
        val factory : ViewModelProvider.Factory = viewModelFactory {
            initializer {
                val application = (this[APPLICATION_KEY] as FlightResearchApplication)
                HomeScreenViewModel(application.database.AirportDao())
            }
        }
    }



}

data class HomeScreenUiState(
    var favorite: MutableList<Flight> = mutableListOf(),
    var selectedAirport : Airport ?= null
)

and where I collect the states in my screen

@Composable
fun HomeScreen(
               homeScreenViewModel: HomeScreenViewModel = viewModel(factory = HomeScreenViewModel.factory),
               onFavoriteClicked: (Flight) -> Unit,
               isFlightInFavorite: (Flight) -> Boolean,
               onAirportClicked : (Airport) ->Unit,
               modifier: Modifier = Modifier) {

    val potentialAirports by homeScreenViewModel.updateDisplayedAirports().collectAsState(emptyList())
    val potentialFlights by homeScreenViewModel.updatePotentialFlights().collectAsState(emptyList())

//    val homeScreenUiState by homeScreenViewModel.uiState.collectAsState()

    Column(){
        ResearchTopBar(homeScreenViewModel.getSearchedAirport(),
            onSearchedAirportChanged = { homeScreenViewModel.updateSearchAirport(it) }
        )
        LazyColumn(){


            if (potentialFlights.isEmpty()){
                itemsIndexed(potentialAirports) { _, airport ->
                    AirportCard(airport,
                        onAirportClicked = onAirportClicked)}

            } else {
                itemsIndexed(potentialFlights) { _, flight ->
                    FlightCard(homeScreenViewModel,
                        flight,
                        isFlightInFavorite ,
                        onFavoriteClicked)

                }

            }

        }

    }

}

If it’s easier for you here is the github repo, the branch is “with_flow”

Could you explain me why i got this issue ? I tried to put the output of "updatePotentialFlights() in the HomeScreenState But i got a similar issue.

Thanks in advance for your help.

I’m not very familiar with Android and Compose, but the flow returned from updatePotentialFlights() doesn’t react to changes to selectedAirport. At the time you call updatePotentialFlights() it checks what’s the selected airport, it returns its flights, then it doesn’t observe selectedAirport for changes.

I probably can’t provide a fully working example, but it should be something along lines:

fun updatePotentialFlights(): Flow<List<Flight>> =
    uiState.flatMapLatest { state ->
        val selectedAirport = state.selectedAirport

        if (selectedAirport != null) { ... } else { ... }
    }

To not reload flights when something other than the selected airport changes in the state, it probably makes sense to look specifically for its changes:

uiState
    .map { it.selectedAirport }
    .distinctUntilChanged()
    .flatMapLatest { selectedAirport ->
        ...
    }

Indeed it works but I still have a question for you then.
I don’t really understand how it then works. why is it needed to add the flatMapLatest part.

the “onAirportCardClicked” is described here :

                    HomeScreen(
                        homeScreenViewModel = homeScreenViewModel,
                        onAirportClicked = { homeScreenViewModel.updateSelectedAirport(it)
                                           homeScreenViewModel.updatePotentialFlights()},
                        onFavoriteClicked = { homeScreenViewModel.updateFavorite(it) },
                        isFlightInFavorite = { homeScreenViewModel.isFlightInFavorite(it) }
                    )

When I click on a AirportCard,
homeScreenViewModel.updateSelectedAirport my uiState is then updated
Following by homeScreenViewModel.updatePotentialFlights which then should read the newest value of the uiState isn’t it ?

No, it shouldn’t. How do you expect it to work exactly? How should it “read the newest value”, how could it even know there is a new value?

Flow can be viewed as an observable value. uiState can be observed for changes to selectedAirport. UI observes a flow returned from updatePotentialFlights(), which in turn should observe uiState and update itself whenever selecedAirport changes. In your code it doesn’t do that. updatePotentialFlights() returns a fixed empty flow: flow { emit(emptyList<Flight>()) }. It doesn’t react to future changes to selectedAirport.

flatMapLatest observes uiState and whenever selectedAirport changes, it emits new values downstream.

I thought that was as following the code is executed in this order :

  1. When we click on AirportCard, homeScreenViewModel.updateSelectedAirport is called.
  2. the uiState is then updated with the new value with the airport we clicked on.
  3. updatePotentialFlights is called since it was the second function to be called in onAirportClicked, the line selectedAirport = _uiState.value.selectedAirport allows us to read the updated value (the airport we clicked on). the flow is then collected with that value.
  4. Recomposition is triggered and we receive the new state of potentialFlights with this line val potentialFlights by homeScreenViewModel.updatePotentialFlights().collectAsState(emptyList())
  5. the rest of the HomeScreen() function is executed with the new potentialFlights.

I suppose i don’t understand completely the link between recomposition and the flows/states in the viewModel. For example, why don’t we have the same issue with updateDisplayedAirports()

For this one my understanding was the following,

  1. the flow is collected the first time with val potentialAirports by homeScreenViewModel.updateDisplayedAirports().collectAsState(emptyList())
  2. We type a letter in the research bar,
  3. updateSearchAirport is called, searchAirport got the letter as its value
  4. Recomposition is triggered
  5. val potentialAirports by homeScreenViewModel.updateDisplayedAirports().collectAsState(emptyList()) is executed. We receive the new state of the flow received by calling updateDisplayedAirports(), in the viewModel, searchAirport is updated with our ‘letter’ typed in the researchBar then we receive the corresponding flow

Ok, I missed that line. But calling updatePotentialFlights() doesn’t really update anything, does it? It only creates a flow and returns it. It is like a getter that you called, but you didn’t use its result - it’s noop. Names of these functions are very confusing.

I’m not sure to be honest. I don’t see anything that would cause recomposition after calling updateSearchAirport(). Do you use exactly the code as above or it is possible you added some changes when experimenting?

1 Like

Thanks for your time. I have not had much time lately but once more your comments helped me a lot to understand it. I didn’t have the issue with updateDisplayedAirports because the string state is modified then a recomposition appeared.