LazyColumn scroll state not restored by Navigation rememberSaveable
0 reputation · 03 Dec 2023, 18:47 UTC
0 reputation · 03 Dec 2023, 18:47 UTC
The Jetpack Compose Navigation component relies on rememberSaveable to preserve UI state across configuration changes and process death. However, the scroll offset of a LazyColumn or LazyRow is not automatically saved by this mechanism, leaving the list reset to the top after a rotation or similar event.
Developers who need the scroll position to survive navigation stack transitions must therefore provide an explicit saver for ScrollState or wrap the state in a custom rememberSaveable call. The current behavior is documented as a known limitation, and the Compose team has not yet committed to automatic scroll‑state restoration for lazy lists in the Navigation back stack.
What is the recommended way to persist ScrollState safely without introducing memory leaks? Is there a timeline for integrating automatic scroll‑state saving into the Navigation component? Are there version‑specific workarounds that remain effective for Compose 1.6.x stable releases?
30775 reputation · 04 Dec 2023, 05:28 UTC
LazyColumn and LazyRow use LazyListState (created via rememberLazyListState()), not ScrollState. rememberLazyListState already wraps rememberSaveable internally, so firstVisibleItemIndex and firstVisibleItemScrollOffset survive configuration changes and process death unless the state object itself is recreated — for example, by calling rememberLazyListState() inside a composable that leaves composition, keying it to changing inputs, or when the NavBackStackEntry is popped.
There is no public timeline for Navigation-level automatic scroll restoration beyond what rememberSaveable already provides. For Compose 1.6.x the reliable pattern is to hoist LazyListState to a scope that outlives the destination's composition (a ViewModel or a parent rememberSaveable) and restore from saved index/offset rather than passing the state object directly.
rememberLazyListState() called inside a child composable that is removed from the tree (e.g., inside a Box that conditionally renders) loses its saved instance.navController.popBackStack() or navigate away such that the entry is removed, its rememberSaveable registry is cleared. The next time the destination is created it starts fresh.rememberLazyListState(key) that changes across rotations forces a new state instance.rememberSaveable at the navigation-graph/activity level) and feed it the saved index/offset via SavedStateHandle.Int index and Int offset, then provides a LazyListState initialized with those values when the composable asks for it.LaunchedEffect(listState) that debounces and writes firstVisibleItemIndex/firstVisibleItemScrollOffset back to the SavedStateHandle.// ViewModel
class ListViewModel(handle: SavedStateHandle) : ViewModel() {
private const val KEY_INDEX = "scroll_index"
private const val KEY_OFFSET = "scroll_offset"
var savedIndex by mutableStateOf(handle.get(KEY_INDEX) ?: 0)
private set
var savedOffset by mutableStateOf(handle.get(KEY_OFFSET) ?: 0)
private set
fun createListState() = rememberLazyListState(
initialFirstVisibleItemIndex = savedIndex,
initialFirstVisibleItemScrollOffset = savedOffset
)
fun onScroll(state: LazyListState) {
// debounce in real code
savedIndex = state.firstVisibleItemIndex
savedOffset = state.firstVisibleItemScrollOffset
handle[KEY_INDEX] = savedIndex
handle[KEY_OFFSET] = savedOffset
}
}
// Composable
@Composable
fun MyListScreen(viewModel: ListViewModel = hiltViewModel()) {
val listState = viewModel.createListState()
LaunchedEffect(listState) {
snapshotFlow { listState.firstVisibleItemIndex }
.distinctUntilChanged()
.debounce(300)
.collect { viewModel.onScroll(listState) }
}
LazyColumn(state = listState) { /* items */ }
}
LazyColumn using plain rememberLazyListState() (no hoisting). Confirm firstVisibleItemIndex restores.listState.firstVisibleItemIndex in onResume / composition to see if the saver runs.NavBackStackEntry is not being popped (e.g., singleTop, popUpTo inclusive, or launchSingleTop misconfiguration).Are you creating LazyListState inside the destination composable directly (val state = rememberLazyListState()) or inside a child composable / with a key / inside a LaunchedEffect / derivedStateOf? If the former and it still resets on rotation, the issue is likely a Navigation/Compose version interaction — please share the Compose and Navigation library versions you're using.
Use comments to ask for clarification. Post a solution as an answer.
No question comments on this page.