androidkotlinscrollandroid-jetpack-compose

Remember scroll position when navigating screens of the app


I'm trying that the Android app remembers in which position of the screen the user left it off, so they can carry on when they move back and forth in the app.

I'm looking for the simplest way (ideally, avoiding to have the viewModel involved). I thought by using rememberSaveable it would work as I expect, but it does not...

Here I show a simple and reproducible example of what I tried:

object WelcomeDestination : NavigationDestination() {
    override val route = "welcome"
    override val title = "welcome"
}


object ContentDestination : NavigationDestination() {
    override val route = "content"
    override val title = "content"
}

@Composable
fun WelcomeScreen(onNavigationButtonClick: (NavigationDestination) -> Unit, modifier: Modifier) {
    Column(modifier = modifier) {
        Button(
            onClick = { onNavigationButtonClick(ContentDestination) },
        ) {
            Text("Go to Content")
        }

    }
}

@Composable
fun ContentScreen(modifier: Modifier) {

    val scrollState = rememberSaveable(saver = ScrollState.Saver) {
        ScrollState(initial = 0) // Initialize a new ScrollState
    }

    Box(modifier = modifier.verticalScroll(scrollState)) {
        Text(LONG_TEXT)
    }

}

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MainScreen(
    navController: NavHostController = rememberNavController(),
) {

    Scaffold(

    ) { innerPadding ->
        NavHost(
            navController = navController,
            startDestination = WelcomeDestination.route,
        ) {
            composable(route = WelcomeDestination.route) {
                WelcomeScreen(
                    onNavigationButtonClick = { navigationDestination: NavigationDestination ->
                        navController.navigate(navigationDestination.route)
                    },
                    modifier = Modifier
                        .padding(innerPadding)
                        .fillMaxSize()
                        .wrapContentSize(Alignment.Center)
                )
            }
            composable(route = ContentDestination.route) {
                ContentScreen(
                    modifier = Modifier
                        .padding(innerPadding)
                        .fillMaxSize()
                        .wrapContentSize(Alignment.Center)
                )
            }
        }

    }
} 

Try to enter in ContentScreen, scroll down, and go back to WelcomeScreen, and enter again in ContentScreen. I would expect to be back down in the screen, but it comes back to top.


Solution

  • I presumed that you are trying to remember the state of the text visible when scrolling. The problem you are facing is because the ScrollState is part of the Content screen and when you navigate away from that the Content Screen is destroyed along with the ScrollState.

    The reason rememberSavable is not working in your case is because the rememberSavable works something like a temp storage managed by compose/android when the activity/fragment is recreated in case of configuration change, very similar to the onSaveInstanceState in activity.

    The best/recommended solution is to use a viewModel in and save the state in that to remember that when you navigate to Content screen again, but as you are trying to achieve that without a viewModel, you can make the ScrollState part of the MainScreen and send it to content screen instead and get the desired result.

    Code with the modified:

    object WelcomeDestination : NavigationDestination() {
        override val route = "welcome"
        override val title = "welcome"
    }
    
    
    object ContentDestination : NavigationDestination() {
        override val route = "content"
        override val title = "content"
    }
    
    @Composable
    fun WelcomeScreen(onNavigationButtonClick: (NavigationDestination) -> Unit, modifier: Modifier) {
        Column(modifier = modifier) {
            Button(
                onClick = { onNavigationButtonClick(ContentDestination) },
            ) {
                Text("Go to Content")
            }
    
        }
    }
    
    @Composable
    fun ContentScreen(modifier: Modifier, scrollState: ScrollState) {
    
        Box(modifier = modifier.verticalScroll(scrollState)) {
            Text(LONG_TEXT)
        }
    
    }
    
    @OptIn(ExperimentalMaterial3Api::class)
    @Composable
    fun MainScreen(
        navController: NavHostController = rememberNavController(),
    ) {
        val scrollState = rememberSaveable(saver = ScrollState.Saver) {
            ScrollState(initial = 0) // Initialize a new ScrollState
        }
    
        Scaffold(
    
        ) { innerPadding ->
            NavHost(
                navController = navController,
                startDestination = WelcomeDestination.route,
            ) {
                composable(route = WelcomeDestination.route) {
                    WelcomeScreen(
                        onNavigationButtonClick = { navigationDestination: NavigationDestination ->
                            navController.navigate(navigationDestination.route)
                        },
                        modifier = Modifier
                            .padding(innerPadding)
                            .fillMaxSize()
                            .wrapContentSize(Alignment.Center)
                    )
                }
                composable(route = ContentDestination.route) {
                    ContentScreen(
                        modifier = Modifier
                            .padding(innerPadding)
                            .fillMaxSize()
                            .wrapContentSize(Alignment.Center), scrollState
                    )
                }
            }
    
        }
    }
    

    The recommended way to store state is by using viewModel to especially when the case is to save the state when the user navigates away. Personally I find it clean way to store states at one place and as viewModel survives the configuration changes we can store multiple states in it.

    Now for the implementation we will essentially do the same thing we did before but the part of saving and retrieving the scrollState will be done by using viewModel.

    Here is the modified code with some comments also some code is commented which is kinda look how when to save the state even when the app is closed.

    open class NavigationDestination() {
        open val route = "route"
        open val title = "welcome"
    }
    
    class MainViewModel() : ViewModel() {
        var scrollStateViewModel: Int = 0
    
    }
    
    object WelcomeDestination : NavigationDestination() {
        override val route = "welcome"
        override val title = "welcome"
    }
    
    
    object ContentDestination : NavigationDestination() {
        override val route = "content"
        override val title = "content"
    }
    
    @Composable
    fun WelcomeScreen(onNavigationButtonClick: (NavigationDestination) -> Unit, modifier: Modifier) {
        Column(modifier = modifier) {
            Button(
                onClick = { onNavigationButtonClick(ContentDestination) },
            ) {
                Text("Go to Content")
            }
    
        }
    }
    
    @Composable
    fun ContentScreen(
        modifier: Modifier,
        viewModel: MainViewModel,
        updateScrollWatch: (Int) -> Unit
    ) {
    
        val scrollState = rememberScrollState(initial = viewModel.scrollStateViewModel)
    
        updateScrollWatch(viewModel.scrollStateViewModel)
    
        // updating the value of scroll position when the user stops scrolling for 300ms
        LaunchedEffect(scrollState) {
            snapshotFlow {
                scrollState.value
            }.debounce { 300 }
                .collectLatest { position ->
                    // here we can also save the value to other place like database
                    viewModel.scrollStateViewModel = position
                    updateScrollWatch(position)
    
                }
        }
    
        Box(modifier = modifier.verticalScroll(scrollState)) {
            Text(LONG_TEXT)
        }
    }
    
    @OptIn(ExperimentalMaterial3Api::class)
    @Composable
    fun MainScreen(
        navController: NavHostController = rememberNavController(),
    ) {
    
        val viewModel: MainViewModel = viewModel()
    
        // helper view to see when the value of the scroll state is saved 
        val scrollStateChangeWatcher = remember {
            mutableIntStateOf(0)
        }
    
        Scaffold(
            topBar = {
                Text(
                    modifier = Modifier
                        .fillMaxWidth()
                        .padding(top = 60.dp),
                    text = "scrollState - ${scrollStateChangeWatcher.value} ",
                    textAlign = TextAlign.Center
                )
            }
        ) { innerPadding ->
            NavHost(
                navController = navController,
                startDestination = WelcomeDestination.route,
            ) {
                composable(route = WelcomeDestination.route) {
                    WelcomeScreen(
                        onNavigationButtonClick = { navigationDestination: NavigationDestination ->
                            navController.navigate(navigationDestination.route)
                        },
                        modifier = Modifier
                            .padding(innerPadding)
                            .fillMaxSize()
                            .wrapContentSize(Alignment.Center)
                    )
                }
                composable(route = ContentDestination.route) {
                    ContentScreen(
                        modifier = Modifier
                            .padding(innerPadding)
                            .fillMaxSize()
                            .wrapContentSize(Alignment.Center), viewModel,
                        updateScrollWatch = { value: Int ->
                            scrollStateChangeWatcher.intValue = value
                        }
                    )
                }
            }
    
        }
    }