Jetpack Compose, how to reset LazyColumn position when new data are set?

Viewed 876

I'm working on a search page made in Compose with LazyColumn, everything works fine except for the wanted behavior of LazyColumn returing to first item when data changes.

This is my actual implementation of lazy column:

@Composable
fun <DataType : Any> GenericListView(
    itemsList: SnapshotStateList<DataType>, // this list comes from the search page viewmodel
    modifier: Modifier = Modifier.fillMaxSize(),
    spacing: Dp = 24.dp,
    padding: PaddingValues = PaddingValues(0.dp),
    item: @Composable (DataType) -> Unit
) {

    val listState: LazyListState = rememberLazyListState()
    val coroutineScope = rememberCoroutineScope()

    LazyColumn(
        verticalArrangement = Arrangement.spacedBy(spacing),
        state = listState,
        modifier = modifier.padding(padding)
    ) {
        items(itemsList) {
            item(it)
        }
    }

    SideEffect {
        Log.i("->->->->->->->", "side effect launched")
        coroutineScope.launch {
            listState.scrollToItem(0)
        }
    }
}

As docs says, SideEffect should be called everytime the function is recomposed, but it appear to be working only in debug mode with breakpoints in SideEffect, otherwise, it works only when the whole page is first created.

I've already tried with LaunchedEffect instead of SideEffect, using itemsList as key, but nothing happened.

Why my code works only in debug mode ? Or better, an already made working solution to reset position when new data are set ?

2 Answers

SideEffect doesn't work because Compose is not actually recomposing the whole view when the SnapshotStateList is changed: it sees that only LazyColumn is using this state value so only this function needs to be recomposed.

To make it work you can change itemsList to List<DataType> and pass plain list, like itemsList = mutableStateList.toList() - it'll force whole view recomposition.


LaunchedEffect with passed SnapshotStateList doesn't work for kind of the same reason: it compares the address of the state container, which is not changed. To compare the items itself, you again can convert it to a plain list: in this case it'll be compared by items hash.

LaunchedEffect(itemsList.toList()) {
}

You can achieve the mentioned functionality with SideEffect, remember and with some kind of identificator (listId) of the list items. If this identificator changes, the list will scroll to the top, otherwise not.

I have extended your code. (You can choose any type for listId.)

@Composable
fun <DataType : Any> GenericListView(
    itemsList: SnapshotStateList<DataType>, // this list comes from the search page viewmodel
    modifier: Modifier = Modifier.fillMaxSize(),
    spacing: Dp = 24.dp,
    padding: PaddingValues = PaddingValues(0.dp),
    listId: String? = null,
    item: @Composable (DataType) -> Unit
) {
    var lastListId: String? by remember {
        mutableStateOf(null)
    }

    val listState: LazyListState = rememberLazyListState()
    val coroutineScope = rememberCoroutineScope()

    LazyColumn(
        verticalArrangement = Arrangement.spacedBy(spacing),
        state = listState,
        modifier = modifier.padding(padding)
    ) {
        items(itemsList) {
            item(it)
        }
    }

    SideEffect {
        Log.i("->->->->->->->", "side effect launched")
        coroutineScope.launch {
            if (lastListId != listId) {
                lastListId = listId
                listState.scrollToItem(0)
            }
        }
    }
}
Related