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.
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
}
)
}
}
}
}