-
Notifications
You must be signed in to change notification settings - Fork 82
Refactor conditional navigation recipe to make it saveable. #163
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -22,45 +22,64 @@ import androidx.activity.compose.setContent | |
| import androidx.compose.foundation.layout.Column | ||
| import androidx.compose.material3.Button | ||
| import androidx.compose.material3.Text | ||
| import androidx.compose.runtime.getValue | ||
| import androidx.compose.runtime.mutableStateListOf | ||
| import androidx.compose.runtime.Composable | ||
| import androidx.compose.runtime.mutableStateOf | ||
| import androidx.compose.runtime.remember | ||
| import androidx.compose.runtime.setValue | ||
| import androidx.compose.runtime.saveable.rememberSaveable | ||
| import androidx.compose.runtime.saveable.rememberSerializable | ||
| import androidx.navigation3.runtime.NavBackStack | ||
| import androidx.navigation3.runtime.NavKey | ||
| import androidx.navigation3.runtime.entryProvider | ||
| import androidx.navigation3.runtime.serialization.NavBackStackSerializer | ||
| import androidx.navigation3.runtime.serialization.NavKeySerializer | ||
| import androidx.navigation3.ui.NavDisplay | ||
| import com.example.nav3recipes.content.ContentBlue | ||
| import com.example.nav3recipes.content.ContentGreen | ||
| import com.example.nav3recipes.content.ContentYellow | ||
| import kotlinx.serialization.Serializable | ||
|
|
||
| private data object Home | ||
|
|
||
| // A marker interface is used to mark any routes that require login | ||
| private data object Profile : AppBackStack.RequiresLogin | ||
| private data object Login | ||
| // We use a sealed class for the route supertype because KotlinX Serialization handles polymorphic | ||
| // serialization of sealed classes automatically. | ||
| @Serializable | ||
| sealed class Route(val requiresLogin: Boolean = false) : NavKey | ||
|
|
||
| @Serializable | ||
| private data object Home : Route() | ||
|
|
||
| @Serializable | ||
| private data object Profile : Route(requiresLogin = true) | ||
|
|
||
| @Serializable | ||
| private data object Login : Route() | ||
|
|
||
| class ConditionalActivity : ComponentActivity() { | ||
|
|
||
| override fun onCreate(savedInstanceState: Bundle?) { | ||
| super.onCreate(savedInstanceState) | ||
| setContent { | ||
|
|
||
|
|
||
| val appBackStack = remember { | ||
| AppBackStack(startRoute = Home, loginRoute = Login) | ||
| val backStack = rememberNavBackStack<Route>(Home) | ||
| val isLoggedInState = rememberSaveable { | ||
| mutableStateOf(false) | ||
| } | ||
|
|
||
| val navigator = Navigator( | ||
| backStack = backStack, | ||
| loginRoute = Login, | ||
| isLoggedInState = isLoggedInState | ||
| ) | ||
|
Comment on lines
+66
to
+70
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The val navigator = remember(backStack, isLoggedInState) {
Navigator(
backStack = backStack,
loginRoute = Login,
isLoggedInState = isLoggedInState
)
} |
||
|
|
||
| NavDisplay( | ||
| backStack = appBackStack.backStack, | ||
| onBack = { appBackStack.remove() }, | ||
| backStack = backStack, | ||
| onBack = { navigator.goBack() }, | ||
| entryProvider = entryProvider { | ||
| entry<Home> { | ||
| ContentGreen("Welcome to Nav3. Logged in? ${appBackStack.isLoggedIn}") { | ||
| ContentGreen("Welcome to Nav3. Logged in? ${isLoggedInState.value}") { | ||
| Column { | ||
| Button(onClick = { appBackStack.add(Profile) }) { | ||
| Button(onClick = { navigator.navigate(Profile) }) { | ||
| Text("Profile") | ||
| } | ||
| Button(onClick = { appBackStack.add(Login) }) { | ||
| Button(onClick = { navigator.navigate(Login) }) { | ||
| Text("Login") | ||
| } | ||
| } | ||
|
|
@@ -69,16 +88,16 @@ class ConditionalActivity : ComponentActivity() { | |
| entry<Profile> { | ||
| ContentBlue("Profile screen (only accessible once logged in)") { | ||
| Button(onClick = { | ||
| appBackStack.logout() | ||
| navigator.logout() | ||
| }) { | ||
| Text("Logout") | ||
| } | ||
| } | ||
| } | ||
| entry<Login> { | ||
| ContentYellow("Login screen. Logged in? ${appBackStack.isLoggedIn}") { | ||
| ContentYellow("Login screen. Logged in? ${isLoggedInState.value}") { | ||
| Button(onClick = { | ||
| appBackStack.login() | ||
| navigator.login() | ||
| }) { | ||
| Text("Login") | ||
| } | ||
|
|
@@ -90,52 +109,16 @@ class ConditionalActivity : ComponentActivity() { | |
| } | ||
| } | ||
|
|
||
| /** | ||
| * A back stack that can handle routes that require login. | ||
| * | ||
| * @param startRoute The starting route | ||
| * @param loginRoute The route that users should be taken to when they attempt to access a route | ||
| * that requires login | ||
| */ | ||
| class AppBackStack<T : Any>(startRoute: T, private val loginRoute: T) { | ||
|
|
||
| interface RequiresLogin | ||
|
|
||
| private var onLoginSuccessRoute: T? = null | ||
|
|
||
| var isLoggedIn by mutableStateOf(false) | ||
| private set | ||
|
|
||
| val backStack = mutableStateListOf(startRoute) | ||
|
|
||
| fun add(route: T) { | ||
| if (route is RequiresLogin && !isLoggedIn) { | ||
| // Store the intended destination and redirect to login | ||
| onLoginSuccessRoute = route | ||
| backStack.add(loginRoute) | ||
| } else { | ||
| backStack.add(route) | ||
| } | ||
|
|
||
| // If the user explicitly requested the login route, don't redirect them after login | ||
| if (route == loginRoute) { | ||
| onLoginSuccessRoute = null | ||
| } | ||
| } | ||
|
|
||
| fun remove() = backStack.removeLastOrNull() | ||
|
|
||
| fun login() { | ||
| isLoggedIn = true | ||
|
|
||
| onLoginSuccessRoute?.let { | ||
| backStack.add(it) | ||
| backStack.remove(loginRoute) | ||
| } | ||
| } | ||
|
|
||
| fun logout() { | ||
| isLoggedIn = false | ||
| backStack.removeAll { it is RequiresLogin } | ||
| // An overload of `rememberNavBackStack` that returns a subtype of `NavKey`. | ||
| // If you would like to see this included in the Nav3 library please upvote the following issue: | ||
| // https://issuetracker.google.com/issues/463382671 | ||
| @Composable | ||
| fun <T : NavKey> rememberNavBackStack(vararg elements: T): NavBackStack<T> { | ||
| return rememberSerializable( | ||
| serializer = NavBackStackSerializer(elementSerializer = NavKeySerializer()) | ||
| ) { | ||
| NavBackStack(*elements) | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,78 @@ | ||
| /* | ||
| * Copyright 2025 The Android Open Source Project | ||
| * | ||
| * Licensed under the Apache License, Version 2.0 (the "License"); | ||
| * you may not use this file except in compliance with the License. | ||
| * You may obtain a copy of the License at | ||
| * | ||
| * http://www.apache.org/licenses/LICENSE-2.0 | ||
| * | ||
| * Unless required by applicable law or agreed to in writing, software | ||
| * distributed under the License is distributed on an "AS IS" BASIS, | ||
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| * See the License for the specific language governing permissions and | ||
| * limitations under the License. | ||
| */ | ||
|
|
||
| package com.example.nav3recipes.conditional | ||
|
|
||
| import androidx.compose.runtime.MutableState | ||
| import androidx.compose.runtime.getValue | ||
| import androidx.compose.runtime.setValue | ||
| import androidx.navigation3.runtime.NavBackStack | ||
|
|
||
| /** | ||
| * Manages application navigation with built-in support for conditional access. | ||
| * | ||
| * This class wraps a [NavBackStack] to intercept navigation events. If a user attempts to navigate | ||
| * to a [Route] that requires login (via [Route.requiresLogin]) but is not currently authenticated, | ||
| * this Navigator will: | ||
| * 1. Save the intended destination. | ||
| * 2. Redirect the user to the [loginRoute]. | ||
| * | ||
| * @property backStack The underlying Navigation 3 back stack that holds the history of [Route]s. | ||
| * @property loginRoute The specific [Route] representing the Login screen. | ||
| * @property isLoggedInState A [MutableState] representing the source of truth for the user's | ||
| * authentication status. | ||
| */ | ||
| class Navigator( | ||
| private val backStack: NavBackStack<Route>, | ||
| private val loginRoute: Route, | ||
| isLoggedInState: MutableState<Boolean>, | ||
| ) { | ||
|
|
||
| private var isLoggedIn by isLoggedInState | ||
|
|
||
| // The route that we will navigate to after successful login. | ||
| private var onLoginSuccessRoute: Route? = null | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The To fix this, this state should be hoisted to In val onLoginSuccessRoute = rememberSaveable { mutableStateOf<Route?>(null) }
val navigator = remember { // ...
Navigator(
// ...
onLoginSuccessRouteState = onLoginSuccessRoute
)
}In class Navigator(
// ...
onLoginSuccessRouteState: MutableState<Route?>,
) {
// ...
private var onLoginSuccessRoute by onLoginSuccessRouteState
// ...
}Since this change spans multiple files, I'm providing the explanation and examples here. Please apply these changes to make the navigation state fully saveable. |
||
|
|
||
| fun navigate(route: Route) { | ||
| if (route.requiresLogin && !isLoggedIn) { | ||
| // Store the intended destination and redirect to login | ||
| onLoginSuccessRoute = route | ||
| backStack.add(loginRoute) | ||
| } else { | ||
| backStack.add(route) | ||
| } | ||
|
|
||
| // If the user explicitly requested the login route, don't redirect them after login | ||
| if (route == loginRoute) { | ||
| onLoginSuccessRoute = null | ||
| } | ||
| } | ||
|
|
||
| fun goBack() = backStack.removeLastOrNull() | ||
|
|
||
| fun login() { | ||
| isLoggedIn = true | ||
| onLoginSuccessRoute?.let { | ||
| backStack.add(it) | ||
| backStack.remove(loginRoute) | ||
| } | ||
| } | ||
|
Comment on lines
+66
to
+72
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The
fun login() {
isLoggedIn = true
onLoginSuccessRoute?.let { destination ->
backStack.add(destination)
backStack.remove(loginRoute)
onLoginSuccessRoute = null
} ?: run {
// When login is successful, we should at least pop the login screen.
if (backStack.lastOrNull() == loginRoute) {
backStack.removeLast()
}
}
} |
||
|
|
||
| fun logout() { | ||
| isLoggedIn = false | ||
| backStack.removeAll { it.requiresLogin } | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Personally, I'm not really a fan of using "Route" in the context of nav3 because we don't use that term at all in nav3 library (kdocs or naming). I think sticking to "Key" helps clarify its role and where its used in the APIs.