androidkotlinandroid-jetpack-composeandroid-room

How to observe ordering changes to observable query using Room and Compose?


I am using an observable query to observe changes to an Android Room database and display the results in a LazyColumn. This works fine but now I want the user to be able to change the sort order. With my current implementation, changing the sort order does not cause the LazyColumn to recompose. If another part of the state changes, the LazyColumn will recompose and the updated ordering will then be reflected in the view.

My DAO looks like this:

@Dao
interface EntityDao {
    @Query("SELECT * FROM entity" +
            " ORDER BY  " +
            "      CASE :sort WHEN 'Newest' THEN created END DESC," +
            "      CASE :sort WHEN 'Oldest' THEN created END ASC")
    fun all(sort: String): Flow<List<Entity>>
}

My view model looks like this:

class EntityViewModel(application: Application) : AndroidViewModel(application) {
    private val entityDao = AppDatabase.getDatabase(context = application.baseContext).entityDao()
    var entities: Flow<List<Entity>> = entityDao.all()

    private val _selectedSortMode = MutableStateFlow(EntitySortMode.NEWEST)
    val selectedSortMode = _selectedSortMode.asStateFlow()

    private fun updateDatabaseQuery() {
        val sort = _selectedSortMode.value.string
        entities = entityDao.all(sort)
    }

    fun changeSorting(newSortMode: EntitySortMode) {
        _selectedSortMode.value = newSortMode
        updateDatabaseQuery()
    }
}

And my composable is as follows:

@Composable
fun EntityView(viewModel: EntityViewModel) {
    val entities by viewModel.entities.collectAsStateWithLifecycle(initialValue = emptyList())

    LazyColumn {
        items(
            entities,
            key = {
                it.primaryKey
            }
        ) { entity ->
            Box(
                modifier = Modifier
                    .animateItem()
                    .clickable {}
             ) {
                 RowItem(entity)
            }
         }
    }
}

I've simplified the code to the relevant parts. Retrieving and displaying data from the database works correctly, the problem is that changing the sort ordering (calling changeSorting) does not result in the LazyColumn being recomposed. The items in the list are the same, but the order is different, and that seems to prevent recomposition from occurring.


Solution

  • The problem is that you declared the entities flow as var. Compose observes the flow for changes, but it cannot observe the variable for changes. When you switch out the flow your composable will still observe the old flow. No recompositions will be triggered. Only when the composable is recomposed for some other reason the new flow will be used. You cannot reliably control this, so the only way forward is to declare entities as val and remove updateDatabaseQuery().

    Instead, you need to somehow change the existing flow. There are basically two options. Also, since you should only expose StateFlows in your view models, the following code will change that as well. It will take care of properly starting and stopping the flows as needed. Also, you won't need the initial flow value in your composables anymore.

    Now, the two options are:

    1. Base the entityDao.all() flow on the _selectedSortMode flow:

      private val _selectedSortMode = MutableStateFlow(EntitySortMode.NEWEST)
      val selectedSortMode = _selectedSortMode.asStateFlow()
      
      val entities: StateFlow<List<Entity>> = _selectedSortMode
          .flatMapLatest { entityDao.all(it.string) }
          .stateIn(
              scope = viewModelScope,
              started = SharingStarted.WhileSubscribed(5.seconds),
              initialValue = emptyList(),
          )
      
      fun changeSorting(newSortMode: EntitySortMode) {
          _selectedSortMode.value = newSortMode
      }
      

      flatMapLatest switches one flow for another. In this case the content of the _selectedSortMode flow is used to retrieve another flow, containing the sorted database entries. Whenever the value of _selectedSortMode is changed the flatMapLatest block is executed again. The resulting flow is then returned (and made into a StateFlow by using stateIn).

      Note that entities must now be declared after _selectedSortMode because the former accesses the latter.

    2. Do the sorting in Kotlin, not in SQL:

      Your DAO becomes much simpler:

      @Dao
      interface EntityDao {
          @Query("SELECT * FROM entity")
          fun all(): Flow<List<Entity>>
      }
      

      For the view model you can use the code from the first option, except you now need to replace the entities definition with this:

      val entities: StateFlow<List<Entity>> = combine(
          _selectedSortMode,
          entityDao.all(),
      ) { sortMode, entities ->
          when (sortMode) {
              EntitySortMode.NEWEST -> entities.sortedByDescending(Entity::created)
              EntitySortMode.OLDEST -> entities.sortedBy(Entity::created)
          }
      }.stateIn(
          scope = viewModelScope,
          started = SharingStarted.WhileSubscribed(5.seconds),
          initialValue = emptyList(),
      )
      

      The two flows are now combined instead of based on top of each other. When any of the flows provides a new value the entire combine block (i.e. the when statement) is executed again.

    I would prefer the second option because it is simpler and more flexible should you decide to add additional sorting options. Doing the sorting in the database is faster, though. This will only be noticeable when your list becomes large. You need to weigh the performance gain against the cleaner code to figure out which option fits best.