Jetpack Compose: how to reset LazyListState?
Asked Answered
B

2

7

I have a LazyColumn with nested LazyRows (similar to Netflix or Spotify home page feed). In short, the issue I'm having is that when the content of the page changes, the scrolling positions of the nested LazyRows are not reset.

For example, user scrolls down the home page, scrolls the 3rd horizontal sections to see the 5th item, refreshes the home page, the new page content is loaded. But the 3rd horizontal section still shows the 5th instead of resetting to the initial position.

In terms of code, here's some snippets:

In HomeScreen composable:

    Scaffold {
                when (val viewState = viewModel.stateFlow.collectAsState().value) {
                    is ViewState.Success -> {
                        Box {
                            with(viewState.data) {
                                LazyColumn {
                                    itemsIndexed(sections) { index, section ->
                                    LazyRow{}
                                    //etc.
                                }
                            }
                      }
               }
    
    }
                    

In the HomeViewModel, when user refreshes home screen this function is called:

private val _stateFlow = MutableStateFlow<ViewState<HomePage>>(ViewState.Loading)
val stateFlow: StateFlow<ViewState<HomePage>>
    get() = _stateFlow

 fun loadHomeScreen() {
        viewModelScope.launch {
            repository.getHomePage()
                .collect {
                    _stateFlow.tryEmit(it)
                }
        }
    }

Now I have managed to add extra code to scrolls the home page's LazyColumn to the top of the screen when user refreshes the home screen. However, I still don't know what's the best way to do that for the nested LazyRows. I do not want to keep a reference to their LazyListState as that solution does not scale well. However, I am confused on why the scrolling position is not being reset when new state is emitted and new LazyRows are displayed. Is that a bug in Compose? Or am I missing something?

Boating answered 22/10, 2022 at 14:29 Comment(2)
Each LazyRow should have exactly the same LaunchedEffect where it resets its own liststateDomesday
Thanks @Domesday for your comment. Yes that's what I mentioned in my question that this is the solution I could think of. But I was hoping there's an easier solution as this one does not scale well.Boating
G
2

How to reset a LazyListState

I'm afraid there's no API that can "reset" a composable back to its genesis state (if that's what you meant by "reset"), in this case you want these Lazy components to scroll to a specific position when an element is added to one of them, you then have to perform a lazy state scroll action.

For example, user scrolls down the home page, scrolls the 3rd horizontal sections to see the 5th item, refreshes the home page, the new page content is loaded. But the 3rd horizontal section still shows the 5th instead of resetting to the initial position.

I'd assume here that your'e always inserting new items in the first index of a LazyRow, and you wanted to scroll to the start (0 index) every time a new element is added.

I do not want to keep a reference to their LazyListState as that solution does not scale well

But I was hoping there's an easier solution as this one does not scale well.

I don't know what you mean by "doesn't scale well", but as mentioned by @vitidev you can utilize LaunchedEffect, you also don't need to hold a reference of each LazyList state instance if you want to scroll to 0 index when a new element is added.

So I'd assume there are no issues in your ViewModel or anything related to flows or coroutines.

And based on what I understand you can simply adjust your design to avoid creating and holding too many LazyListState references. Consider the sample below, though its quite a very long code, but all of them are copy-and-paste-able so you can compile them with no issues.

data class NestedItem(
    val display: String
)

val row1 = mutableStateListOf(
    NestedItem("Row 1 Item 1"),
    NestedItem("Row 1 Item 2"),
    NestedItem("Row 1 Item 3"),
    NestedItem("Row 1 Item 4"),
    NestedItem("Row 1 Item 5"),
)

val row2 = mutableStateListOf(
    NestedItem("Row 2 Item 1"),
    NestedItem("Row 2 Item 2"),
    NestedItem("Row 2 Item 3"),
    NestedItem("Row 2 Item 4"),
    NestedItem("Row 2 Item 5"),
)

val row3 = mutableStateListOf(
    NestedItem("Row 3 Item 1"),
    NestedItem("Row 3 Item 2"),
    NestedItem("Row 3 Item 3"),
    NestedItem("Row 3 Item 4"),
    NestedItem("Row 3 Item 5"),
)

val row4 = mutableStateListOf(
    NestedItem("Row 4 Item 1"),
    NestedItem("Row 4 Item 2"),
    NestedItem("Row 4 Item 3"),
    NestedItem("Row 4 Item 4"),
    NestedItem("Row 4 Item 5"),
)

var nest = listOf(
    row1, row2, row3, row4
)

@Composable
fun MyScreen(nestedList: List<List<NestedItem>>) {
    Column {
        Row(
            modifier = Modifier
                .fillMaxWidth()
                .wrapContentHeight(),
            horizontalArrangement = Arrangement.SpaceEvenly
        ) {
            repeat(4) { idx ->
                Button(onClick = {
                    when (idx) {
                        0 -> {
                            row1.add(0, NestedItem("New Item At Row ${idx + 1}"))
                        }
                        1 -> {
                            row2.add(0, NestedItem("New Item At Row ${idx + 1}"))
                        }
                        2 -> {
                            row3.add(0, NestedItem("New Item At Row ${idx + 1}"))
                        }
                        3 -> {
                            row4.add(0, NestedItem("New Item At Row ${idx + 1}"))
                        }
                    }
                }) {
                    Text(text = "R${idx + 1} Add")
                }
            }
        }

        MyScreen(nestedList = nest)
    }
}


@Composable
fun MyContent(nestedList: List<List<NestedItem>>) {
    LazyColumn(
        modifier = Modifier.fillMaxWidth()
    ) {
        items(nestedList) {
            MyEveryRow(rowItems = it)
        }
    }
}

// we leave all the scrolling to LaunchedEffect here everytime the size changes
@Composable
fun MyEveryRow(rowItems: List<NestedItem> ) {

    val lazyRowState = rememberLazyListState()
    
    LaunchedEffect(rowItems.size) { 
        lazyRowState.animateScrollToItem(0)
    }

    LazyRow(
        state = lazyRowState
    ) {
        items(rowItems) { item ->
            MyCell(item = item)
        }
    }
}

@Composable
fun MyCell(item: NestedItem) {

    Box(
        modifier = Modifier
            .size(width = 150.dp, height = 150.dp)
            .border(BorderStroke(Dp.Hairline, Color.DarkGray)),
        contentAlignment = Alignment.Center
    ) {
        Text(text = item.display)
    }
}

The code above will produce this output, adding new items to the first index and scrolling to it without the need of having too many LazyListState references.

enter image description here

Here we used the size of the item as the key for this LaunchedEffect so when a new element is added, this side-effect will execute.

LaunchedEffect(rowItems.size) { 
     lazyRowState.animateScrollToItem(0)
}
Gut answered 13/11, 2022 at 7:14 Comment(1)
Thank you for your detailed answer! This is one way of solving the issue under the assumptions that you outlined. However, I solved my real problem by simply adding keys to my list composables so that whenever the homepage refreshes, the ids change and lists go back to their initial state. I rewarded you the bounty for the effort you put into a working solution.Boating
Y
1

You could create lazyListState for each of inner LazeRows and add them to the launchedEffect:

LaunchedEffect(
    key1 = scrollToTopState,
) {
    if (scrollToTopState) {
        listState.scrollToItem(0)

        //Added these lines
        lazyRowStateOne.scrollToItem(0)
        lazyRowStateTwo.scrollToItem(0)
        
        viewModel.updateScrollState(false)
    }
}

But personally, I prefer creating a lambda instead of using LaunchedEffect for that case:

val lazyColumnState = rememberLazyListState()
val lazyRowOneState = rememberLazyListState()
val lazyRowTwoState = rememberLazyListState()

val coroutineScope = rememberCoroutineScope()

val resetLazyLists: () -> Unit = {
    coroutineScope.launch {
        lazyColumnState.scrollToItem(0)
        lazyRowOneState.scrollToItem(0)
        lazyRowTwoState.scrollToItem(0)
    }
}

LazyColumn(state = lazyColumnState) {
    //...
    item{ LazyRow(state = lazyRowOneState) {} }
    //...
    item{ LazyRow(state = lazyRowTwoState) {} }
    //...
}

Button(onClick = resetLazyLists) {
    Text(text = "Reset scroll")
}
Youngs answered 23/10, 2022 at 12:12 Comment(1)
Thanks @merkost for your answer. I mentioned this solution in my question. It doesn't scale well when you have numerous nested lists. I'm afraid there's a simple solution that we're not considering. If you are recreating Facebook homepage for example and user swipe-to-refresh the home screen, the code wouldn't call scrollToItem(0) to all lists, agree? Wouldn't be better to recreate them instead somehow?Boating

© 2022 - 2024 — McMap. All rights reserved.