Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

LazyColumn only updates data from Room after scrolling

After navigating to the screen, the data is retrieved through ViewModel and Room, but LazyColumn won't update, only after scrolling.

I'm using a data class to create a list of sections called sectionList. So I overwrite that list calling the function getSectionListUpdated(). In CollectionScreen I save that list in a variable which populates the LazyColumn.

I think, the problem is the way I provide the data to the LazyColumn. I've also tried remember and mutableStateOf, to no avail.

Here’s my ViewModel:

class CollectionViewModel(val dao: StickerDao) : ViewModel() {
    fun getSectionListUpdated(): List<SectionsStats> {
        var counter = 0
        for (section in sectionList) {
            viewModelScope.launch {
                sectionList[counter].ownedStickersSection =
                    dao.getOwnedStickersBySection(section.sectionName).size
            }
            counter += 1
        }
        var sectionListUpdated = sectionList
        return sectionListUpdated
    }
    
    var sectionList = listOf<SectionsStats>(
        SectionsStats("First Section", 0, 8),
        SectionsStats("Stadiums", 0, 11),
        SectionsStats("Qatar", 0, 20),
        SectionsStats("Ecuador", 0, 20),
        SectionsStats("Senegal", 0, 20),
        SectionsStats("Netherlands", 0, 20),
        SectionsStats("England", 0, 20),
        SectionsStats("Iran", 0, 20),
    )
}

Here's the Screen:

@Composable
fun CollectionScreen(navController: NavController) {
    val context = LocalContext.current.applicationContext
    val dao = StickerDatabase.getInstance(requireNotNull(context).applicationContext).stickerDao
    val viewModel = viewModel<CollectionViewModel>(
        factory = object : ViewModelProvider.Factory {
            override fun <T : ViewModel> create(modelClass: Class<T>): T {
                if (modelClass.isAssignableFrom(CollectionViewModel::class.java))
                    return CollectionViewModel(dao) as T
                throw IllegalArgumentException("Unknown ViewModel")
            }
        }
    )

    var sectionList = viewModel.getSectionListUpdated()

    LazyColumn {
        items(sectionList) { section ->
            CardSection(navController, section.sectionName, section.ownedStickersSection, section.allStickersSection)
        }
    }
}

Here’s the Data Class:

data class SectionsStats(
    val sectionName: String,
    var ownedStickersSection: Int,
    val allStickersSection: Int,
)

And here´s the DAO:

@Dao
interface StickerDao {
    @Query("SELECT * FROM sticker_table WHERE section = :section AND owned >= 1 ")
    suspend fun getOwnedStickersBySection(section: String): List<Sticker>
}
like image 213
JaimeHdz Avatar asked Jan 25 '26 12:01

JaimeHdz


1 Answers

The main issue is that Composables only update when State variables change.

getSectionListUpdated() doesn't return a State, so changes to the returned list do not trigger a recomposition.

Before you try to wrap the list in a MutableState, there is a much better approach: Kotlin Flows. A Flow is an asynchronous data container that can be observed for changes and can also be converted into a State. Room fully supports flows, so you should change your DAO functions to return Flows instead:

@Query("SELECT * FROM sticker_table WHERE section = :section AND owned >= 1 ")
fun getOwnedStickersBySection(section: String): Flow<List<Sticker>>

Note that functions that return a Flow usually shouldn't be suspending anymore.

Although the view model will retrieve this flow, the general idea is that it is only observed for changes where it is actually needed. In this case it is your UI, specifically your CollectionScreen. The view model should therefore only pass this flow through.

The only thing the view model needs to do is to convert the content of the flow from a List<Sticker> to a List<SectionsStats>. Before we do that, there is one more important part that needs to be addressed, namely the following property of SectionsStats:

var ownedStickersSection: Int,

It is declared as var. That is incompatible with Composables (Flows too). Everything that can be changed must be stored in a MutableState. Allowing a simple Int variable to be changed (what you do when you declare it as var), prevents Compose from detecting any changes to it. In general, all Compose state should be immutable. If you store your state in a data class, then all properies must be declared as val:

data class SectionsStats(
    val sectionName: String,
    val ownedStickersSection: Int,
    val allStickersSection: Int,
)

With this change you are not allowed to set sectionList[counter].ownedStickersSection anymore. Although that solves the issue from your question, you must now restructure the rest of your code to accomodate for this. This basically boils down to where allStickersSection comes from. It looks like the source of that data is in your view model (the property sectionList). That seems pretty awkward. You already have a database at hand, why don't you just store that data there? I would strongly suggest you add a section table to the database to hold this information.

But for now let's just keep it in the view model, without changing the database structure. We just need to refactor it a bit:

private val sections = mapOf(
    "First Section" to 8,
    "Stadiums" to 11,
    "Qatar" to 20,
    "Ecuador" to 20,
    "Senegal" to 20,
    "Netherlands" to 20,
    "England" to 20,
    "Iran" to 20,
)

This is now a simple mapping of sections to their allStickers value.

The simplest thing to do now is to obtain the entries of all sections from the database, filter them to contain only those that are present in the above map, and create a SectionsStats object for each by merging the values from the database with the values from the sections.

That may sound complicated, but it is actually simpler than your existing code. The following must be changed:

  1. The DAO function should return all sections now with their respective number of entries calculated directly in the database, so only one query is needed to retrieve all sections:

    @Query("SELECT section, COUNT(*) AS owned FROM sticker_table WHERE owned >= 1 GROUP BY section")
    fun sectionsWithStickers(): Flow<List<Section>>
    

    The result is not a list of Stickers anymore, but a list of this new type (and then wrapped in a Flow):

    data class Section(
        val section: String,
        val owned: Int,
    )
    
  2. The returned list of Sections needs to be converted to a list of SectionsStats. For that, add the following helper function to your view model:

    private fun listOfSectionsStats(ownedSections: List<Section>): List<SectionsStats> =
        sections.map { (section, allStickers) ->
            SectionsStats(
                sectionName = section,
                ownedStickersSection = ownedSections.firstOrNull { it.section == section }?.owned ?: 0,
                allStickersSection = allStickers,
            )
        }
    

    For each section it retrieves the corresponding number of entries with owned stickers and creates a SectionsStats object. Since a new object is created here, this works perfectly fine with all properties declared as val.

    All sections without database entries have their ownedStickersSection set to 0.

    If you move the allStickers data from the view model in the database as well, then merging the data can entirely be done in the database using a database JOIN. You could then directly retrieve a list of SectionsStats with your DAO function and don't need this listOfSectionsStats anymore.

  3. As already mentioned above, the database flow should be passed through the view model, just replacing its content. With the new helper function that can be accomplished like this:

    val sectionList: StateFlow<List<SectionsStats>> = dao.sectionsWithStickers()
        .map(::listOfSectionsStats)
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5.seconds),
            initialValue = emptyList(),
        )
    

    The map function is used here to replace the content by calling listOfSectionsStats. Afterwards the flow is converted to a StateFlow by calling stateIn: That is needed so it is more Compose-friendly. If you want to learn more about that have a look at the official Android documentation or other questions here on Stack Overflow.

  4. The only thing left to do is for your composable to finally collect this flow and convert it into a Compose State. Instead of calling viewModel.getSectionListUpdated() (you should remove that function, it isn't needed anymore), simply use this:

    val sectionList by viewModel.sectionList.collectAsStateWithLifecycle()
    

And that's it. Although sectionList is still a List<SectionsStats>, this list is now backed by a Compose State (it is just unwrapped for convenience by using the by delegation). So whenever this list changes (i.e. the database returns the query result), a recomposition is triggered, and the UI is updated. This is the intended way for the UI to cleanly react to changed data.

And finally, by using Flows, you get one additional benefit free of charge: Room updates the Flow automatically, when anything changes in the database. So when you insert a new entry or change an existing one, the list in the UI is updated as well. With the code you had previously, you would have had to remember to repeat the call to getSectionListUpdated() yourself. That is not necessary anymore.

like image 174
Leviathan Avatar answered Jan 27 '26 02:01

Leviathan



Donate For Us

If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!