kotlincompose-recompositioncompose-desktop

After sorting a list LazyColumn recomposes, but right after that, recomposes again reverting the sort order


In my View composable I have a list of items in a LazyColumn and a "Sort" button that toggles between ascending and descending sort order. The list comes from my state holder class ViewComponent. This works fine.

I also have a "Search" button that retrieves data from a database that is then passed to View to replace the displayed data.

Although the data is replaced, the sort order is now broken: Whenever I press the "Sort" button the list is sorted and then instantly reverted back. I verified with println that the sort is actually executed as intended, but as soon as that finishes another recomposition seems to happen that resets the sort.

Is it possible that the function injectListFromDatabase is responsible for it? It only replaces the data class with the values from the database.

For clarity's sake I reduced my data classes to a bare minimum, but they actually have much more properties. The LazyColumn displays a list of GetToolsWithService objects:

data class GetToolsWithService(
    val id: Int,
)

data class ToolListState(
    val tools: List<GetToolsWithService>,
)

This is my View with the LazyColumn:

@Composable
fun View(
    component: ViewComponent = ViewComponent(),
    listFromDatabase: List<GetToolsWithService>,
) {
    if (listFromDatabase.isNotEmpty()) component.injectListFromDatabase(listFromDatabase)

    val stateList by component.stateList

    Button(onClick = {
        component.sort()
    }) {
        Text("Sort")
    }

    LazyColumn {
        items(stateList.tools) { Text("$it") }
    }
}

The stateholder:

class ViewComponent(
    private val sortTable: SortTable = SortTable(),
    initialListOfTools: List<GetToolsWithService> = List(10) { GetToolsWithService(it) },
) {
    val stateList: MutableState<ToolListState> = mutableStateOf(ToolListState(initialListOfTools))

    fun sort() {
        println("Sort Start")
        stateList.value = stateList.value.copy(tools = sortTable(stateList.value.tools))
        stateList.value.tools.forEach { println(it) }
        println("Sort end")
    }

    fun injectListFromDatabase(list: List<GetToolsWithService>) {
        stateList.value = stateList.value.copy(tools = list)
    }
}

And this is used for sorting:

enum class SortOrder {
    ASCENDING, DESCENDING
}

class SortTable(
    private var sortingOrderData: SortOrder = SortOrder.ASCENDING,
) {
    operator fun invoke(list: List<GetToolsWithService>): List<GetToolsWithService> =
        if (sortingOrderData == SortOrder.DESCENDING) {
            sortingOrderData = SortOrder.ASCENDING
            list.sortedBy { it.id }
        } else {
            sortingOrderData = SortOrder.DESCENDING
            list.sortedByDescending { it.id }
        }
}

The following is only used to emulate how the data from the database is passed to the View:

Column {
    var list: List<GetToolsWithService> by remember { mutableStateOf(emptyList()) }

    Button(onClick = {
        list = List(10) { GetToolsWithService(it + 100) } // "+100" so the items can be more easily distinguished
    }) { Text("Search") }

    View(
        listFromDatabase = list,
    )
}

Solution

  • The problem lies indeed with how you call injectListFromDatabase. On every (re-)composition this line of code is executed:

    if (listFromDatabase.isNotEmpty()) component.injectListFromDatabase(listFromDatabase)
    

    That doesn't hurt as long as the list is empty, but when "Search" was pressed this is not only executed once, it is repeated on every recomposition.

    Let's take a look at what that means when "Sort" is pressed after "Search":

    1. component.sort() is called and changes the stateList so it is now properly sorted. You already verified that by your println statements.
    2. The change to stateList triggers a recomposition so View is executed again.
    3. injectListFromDatabase is called. This replaces the content of stateList with listFromDatabase. Now, this means that the list we just sorted in 1. is never displayed, it is just thrown away and listFromDatabase is used instead.
    4. If the "Sort" button is pressed again all of this repeats from 1., so nothing will ever change.

    The problem is that injectListFromDatabase is not only called when listFromDatabase changes, it is also called on every other recomposition.

    The fix is simple:

    LaunchedEffect(listFromDatabase) {
        if (listFromDatabase.isNotEmpty()) component.injectListFromDatabase(listFromDatabase)
    }
    

    This wraps injectListFromDatabase in a lambda that is only executed when the LaunchedEffect's parameters change. I supplied listFromDatabase as the parameter, so this effectively skips the execution of injectListFromDatabase for each recomposition where listFromDatabase didn't change - as it is the case when the "Sort" button is pressed.

    Sorting works now also after the "Search" button was pressed.