-
Notifications
You must be signed in to change notification settings - Fork 5
Compose Navigator Tutorials
These are small tutorials that covers some specific implementation detail.
- Navigating with arguments
- Navigating with animation
- Implementing Nested Navigation
- Navigate with single top instance or
popUpTo
- Navigate with
goBackUntil
orgoBackToRoot
- Implementing Dialogs
- Navigation in Dialogs
- Extending Dialogs as Bottom Sheet
- Managing
onBackPressed
manually - Reusing Routes in Navigation
- Navigation Scoped ViewModels
- Lifecycle events in Navigation
Route
s
When you declare a Route say a sealed
class (shown in Quick setup), the constructor parameters of the individual destination becomes the argument for that destination.
// Go to Quick setup guide to see the full example.
navigator.Setup(...) { controller, dest ->
when(dest) {
is MainRoute.First -> FirstScreen(dest.data, onChanged)
}
}
Kotlin's is
keyword will smartly cast the dest
to the type we have specified so that we can easily access the arguments.
Navigator allows you to set transitions for target
and current
destination.
Consider the following example, where we are navigating from current destination A
to target B
with animation.
// current --> target
controller.navigateTo(dest) {
withAnimation {
target = SlideRight
current = Fade
}
}
SlideRight
& Fade
are few of the built-in transition (see custom animations to built your own) you can apply to a destination.
Each transition defines a forward & backward transition methods which will be choose by the navigator based on navigation. In the above example SlideRight
transition is associated with target
& Fade
transition is associated with current
.
Since we are moving from current -> target
, SlideRight
's forward transition will run on target
& Fade
's backward transition will run on current
. This will result in slide-in-right for target
& fade out for current
.
During a back navigation (i.e on back press) these transition will be reversed i.e from moving target -> current
, Fade
's forward transition will run on current
& SlideRight
's backward transition will run on target
. This will result in slide-out-right for target
& fade in for current
.
Each transition extends from com.kpstv.navigation.compose.NavigatorTransition
interface which contains two important properties forwardTransition
& backwardTransition
(which you have to implement) that returns Modifier
object.
Optionally you can override animationSpec
property to provide a different FiniteAnimationSpec<T>
.
// Create & expose custom transition
public val Custom get() = CustomTransition.key
private val CustomTransition = object : NavigatorTransition() {
override val key: TransitionKey = TransitionKey("a_unique_name")
override val forwardTransition: ComposeTransition = ComposeTransition { modifier, width, height, progress ->
// "progress" goes from 0f -> 1f
modifier.then(Modifier /*...*/)
}
override val backwardTransition: ComposeTransition = ComposeTransition { modifier, width, height, progress ->
// "progress" goes from 0f -> 1f
modifier.then(Modifier /*...*/)
}
}
// register transition when initializing ComposeNavigator
class MainActivity : ComponentActivity() {
private lateinit var navigator: ComposeNavigator
override fun onCreate(savedInstanceState: Bundle?) {
...
navigator = ComposeNavigator.with(this, savedInstanceState)
.registerTransitions(CustomTransition) // <--
.initialize()
}
}
// Use the Custom transition
controller.navigateTo(dest) {
withAnimation {
target = Custom // our custom transition
current = Fade // built-in fade transition
}
}
When you call navigator.Setup
, it binds the current ComposeNavigator
to the CompositionLocal
which can be retrived using findComposeNavigator()
. It also binds the Controller<T>
associated with the destination T
for all the child composables which can be retrieved using findController<T>()
.
All you have to do is implement navigator.Setup
for another screen where you want nested-navigation.
Check out the Basic Sample for a more clear example.
@Composable
fun SecondScreen() { // nested to MainScreen
val mainController = findController<MainRoute>() // controller associated with MainRoute.
val navigator = findComposeNavigator()
navigator.Setup(key = SecondRoute.key, initial = SecondRoute.First()) { controller, dest ->
when(dest) {
...
}
}
}
controller.navigateTo(dest) {
singleTop = true // makes sure that there is only one instance of this destination in backstack.
}
controller.navigateTo(dest) {
popUpTo(Route::class) { // recursivly pops the backstack till the destination.
inclusive = true // inclusive
all = false // last added one will be chosen.
}
}
Both Controller<T>
& ComposeNavigator
support this functionality.
-
goBackUntil(dest)
- is similar topopUntil
feature where a destination is recursively popped until the specified destination is met. -
goBackToRoot()
- provides a jump to root functionality, for eg:A -> B -> C -> D
becomesA
more likegoBackUntil(A)
.
Note: These features may not be stable for ComposeNavigator
as determining parent routes from nested navigation is tricky & sometimes produce incorrect/unexpected results. For now you need to optin @UnstableNavigatorApi
to use them.
A -> B -> C -> D
controller.goBackUntil(B, inclusive = false) // produces: A -> B
controller.goBackUntil(B, inclusive = true) // produces: A
controller.goBackToRoot() // produces: A
Examples:
1. [s = {1,2,3} , n = {1,2} , t = {1,2}] -> target is "n:2" (inclusive)
=> navigator.goBackUntil(n:2, inclusive = true) // produces [s = {1,2,3} , n = {1}]
2. [s = {1,2,3} , n = {1,2} , t = {1,2}] -> target is "n:2" (not inclusive)
=> navigator.goBackUntil(n:2, inclusive = false) // produces [s = {1,2,3} , n = {1,2} , t = {1}]
Reason: n:2 serves as nested navigation for t which means it cannot exist unless t is present
which is why not inclusive keeps t:1 which is the intial route for that nested navigation.
3. [s = {1,2,3} , n = {1,2} , t = {1,2}] -> target is "n:1" (inclusive)
=> navigator.goBackUntil(n:1, inclusive = true) // produces [s = {1,2}]
Reason: s:3 serves as nested navigation for n which means it cannot exist unless n is present
which is why inclusive also removes s:3 which serves nothing more than just a route for
nested navigation for n.
4. [s = {1} , n = {1} , t = {1,2}] -> target is t:1 (inclusive)
=> navigator.goBackUntil(t:1, inclusive = true) // produces [s = {1} , n = {1} , t = {1}]
Reason: Only t:2 was removed, the reason is same both s:1 & n:1 serves as nested navigation for
their consecutive route hence they cannot exist one way the other.
- Refer to the tests for more examples.
Dialogs in navigator are similar to DialogFragment
from the View system. Here in Compose androidx.compose.ui.window.Dialog
are composables shown in android.view.Window
i.e above the actual setContent
& are considered to be unique dialog destination in navigator-compose
that are added or removed from the backStack.
When you setup navigation with navigator.Setup
, the controller that is used to manage the navigation for that Route
is used create, show & close dialogs.
Each dialog must extend from com.kpstv.navigation.compose.DialogRoute
, they can be a data
class where the constructor parameters becomes the argument for the dialog destination or object
for no argument dialog destination.
Check out the Basic Sample for a more clear example.
@Parcelize
data class MainDialog(val arg: String) : DialogRoute // arg dialog
@Parcelize
object CloseDialog : DialogRoute // no arg dialog
fun MainScreen() {
navigator.Setup(key = ...) { controller, dest
when(dest) {
is ... -> {
/* destination content */
// Button to show main dialog
Button(onClick = {
controller.showDialog(MainDialog(arg = "Test")) // <- Navigate to Main dialog.
}) {
Text("Show dialog")
}
}
...
}
// setup main dialog
controller.CreateDialog(key = MainDialog::class) {
/* You are in DialogScope */
/* your content */
Text(dialogRoute.arg) // <- /*this.dialogRoute*/ Use of argument
// `dismiss` is a function in DialogScope which can be invoked to
// close this dialog.
Button(onClick = { dismiss() }) {
Text("Close")
}
}
// setup close dialog
controller.CreateDialog(key = CloseDialog::class) {
...
}
}
}
Note:
- If dialog is not created using
controller.CreateDialog
&controller.showDialog
is called to show the dialog, then anIllegalStateException
is thrown. - If a dialog does not exist in the backStack & still tried to close it using
controller.closeDialog
, then anIllegalStateException
is thrown. - You should avoid re-using
DialogRoute
to create multiple dialogs in the sameController
instance. - Similar to the navigation backStack you can query the dialog backStack using
controller.getAllDialogHistory()
which returns an immutable list of all theDialogRoute
present in the backStack.
When you create a dialog using controller.CreateDialog
you are immediately in a DialogScope
which contains some helpful functions. One of them is dialogNavigator
.
dialogNavigator
is itself a ComposeNavigator
which you can use to create navigation inside this DialogScope
whether be nested, etc. This ComposeNavigator
is separate from activity's ComposeNavigator
i.e through findComposeNavigator()
.
Check out the Basic Sample for a more clear example on navigation dialogs.
@Composable
fun MainScreen() {
val controller = findController<MainRoute>()
controller.CreateDialog(key = SomeDialog::class) {
// inside DialogScope
// Setup navigation using `dialogNavigator`
dialogNavigator.Setup(key = ...) { controller, dest
when(dest) {
...
}
}
}
}
There is no official implementation of Bottom Sheets in ComposeNavigator
, but there is an example of it in the sample app based on Dialog
composable.
If you think about the View
world, bottom sheets are nothing but a Dialog whose content shifts from bottom hence the name BottomSheetDialog
or BottomSheetDialogFragment
. However with Compose we were introduced to ModelBottomSheetLayout
composable which wraps the content to slide from bottom.
The problem with this approach is the constraint that is imposed on sheet's height or width are determined by the parent composable of ModelBottomSheetLayout
. So to create a full width / height bottom sheet you would need to wrap your root with ModelBottomSheetLayout
and use some ways to trigger the sheet (maybe using CompositionLocal
, etc.).
For eg: In the sample app MainScreen.kt
file,
// reference: https://github.com/KaustubhPatange/navigator/blob/acd5269885b4fcc7dd38fb9e53f30f005bab0035/navigator-compose/samples/basic-sample/src/main/java/com/kpstv/navigation/compose/sample/ui/screens/MainScreen.kt
fun MainScreen() {
navigator.Setup(key = ...) { controller, dest
...
// setup close dialog
controller.CreateDialog(key = CloseDialog::class) {
BottomSheet(this) {
// your content
...
}
}
}
}
The current implementation of ComposeNavigator
registers a BackPressDispatcher
to automatically handle back press logic i.e back navigation for you. However there might be a case where you need full control over backpress logic.
In such case ComposeNavigator
exposes 2 methods canGoBack() : Boolean
& goBack()
which does what is says.
But first you need to disable the built-in backpress logic.
class MainActivity : ComponentActivity() {
private lateinit var navigator: ComposeNavigator
override fun onCreate(savedInstanceState: Bundle?) {
...
navigator = ComposeNavigator.with(...)
.disableDefaultBackPressLogic() // <--
.initialize()
}
override fun onBackPressed() {
if (navigator.canGoBack()) {
navigator.goBack()
} else {
super.onBackPressed()
}
}
}
There might be a case where you wan't to reuse a Route
in a nested navigation.
Quoting from PR #19, Consider a bottom navigation implementation with two tabs A & B both of them set nested navigation of C which has a child destination D. Now suppose we are in A -> C and there is a requirement to go to D. The question is how should we navigate to D because the system needs to know whether it is in A or B to then go to A -> C -> D or B -> C -> D.
If your requirement is a similar case to above, then you may need to reuse existing Route
. In an ideal case you must avoid reusing Route
s for a navigation.
Typically when you declare a route you write a sealed class something like,
sealed class MyRoute : Route {
@Immutable @Parcelize
data class ChildRoute1(...) : MyRoute()
@Immutable @Parcelize
data class ChildRoute2(...) : MyRoute()
companion object Key : RouteKey<MyRoute>
}
// To use it,
navigator.Setup(key = MyRoute.key, ...) {
...
}
Here, as you can see we are declaring a navigation with key MyRoute.key
of type MyRoute
. You cannot reuse the same key again as navigation backstack depends on a unique key but what you can do is declare a different key for this type as,
sealed class ReusableRouteKey {
companion object : RouteKey<MyRoute> // different key but of same type
}
// To use it,
navigator.Setup(key = ReusableRouteKey.key, ...) {
...
}
When you set a Controller<T>
to this navigation, you can easily navigate to any destination of type MyRoute
so in a sense we've declared a new key for this navigation ReusableRouteKey.key
thus reusing existing Route.
Quoting from PR #20, each destination Route
has a LifecycleController
which manages the SavedStateRegistry
& ViewModelStore
through their owner classes.
So each ViewModel
(created using AbstractSavedStateViewModelFactory
for SavedStateHandle
) will be scoped to the destination.
sealed class MyRoute {
@Parcelize
data class First(...) : MyRoute()
@Parcelize
data class Second(...) : MyRoute()
companion object : RouteKey<MyRoute>
}
class MyViewModel : ViewModel()
navigator.Setup(key = MyRoute.key, ...) { dest ->
when(dest) {
is First -> {
val viewModel = viewModel<MyViewModel>(factory = ...) // <- Scoped to MyRoute.First
}
is Second -> {
val viewModel = viewModel<MyViewModel>(factory = ...) // <- Scoped to MyRoute.Second
}
}
}
If your navigation setup is a nested navigation, then you can scope your ViewModel
to parent navigation by instantiating with parent destination through Controller<T>.parentRoute
. This is effectively what is called as Navigation Scoped ViewModel.
The library supports creation of ViewModel
s that are injected using Hilt DI through an additional dependency io.github.kaustubhpatange:navigator-compose-hilt:<version>
.
The sample shows the usage of the library.
@HiltViewModel
class MyViewModel @Inject constructor(...): ViewModel()
navigator.Setup(...) { dest ->
when(dest) {
... -> {
val viewModel = hiltViewModel<MyViewModel>() // <--
}
}
}
If you look at the implementation on how this feature is implemented (PR #20), you'll notice that LifecycleController
is tied to the Route
destination & not the scope of the navigation which means it will live as long as destination is in the backstack. This is good because now you have a defined entry when the destination is active & when it's not.
Though this seems helpful it comes with a drawback. If you read Reusing Routes in Navigation, the whole point of reusing routes is to implement a complex navigation where you don't know your current navigation position to move forward or backward (although this can be easily solved with CompositionLocal
& other various approaches). Practically you should avoid it to implement an ideal & predictable navigation but sometimes this is not the case.
In those cases, if you reuse routes you will be presented with the existing instance of LifecycleController
, this is because (as mentioned above) they are tied to the destination & not the navigation scope. So suppose if you are using a ViewModel
in that destination, chances are it will provide you an existing instance from the ViewModelStore
.
To avoid such ambiguity when reusing routes it is necessary that you specify a key
when constructing a ViewModel
.
navigator.Setup(...) {
...
val viewModel = viewModel<MyViewModel>(key = "a-key", ...) // <--
}
This will make sure that a fresh instance is provided to you associated with the given key. You can change the logic to provide a dynamic key when reusing routes based on your destination position in the navigation backstack.
Each destination you declare by extending the Route
or DialogRoute
class contains a LifecycleController
property which you can access using route.lifecycleController
.
This LifecycleController
is a SavedStateRegistryOwner
& a ViewModelStoreOwner
which means this class manages your ViewModel
instances as well as SavedStateHandle
& any rememberSaveables
.
When you navigate to a destination this LifecycleController
's values are bind to LocalSavedStateRegistryOwner
& LocalViewModelStoreOwner
of CompositionLocalProvider
respectively which you can access using the .current
property on them. This is the reason why you can create a ViewModel
scoped to a destination so that things get saved & restored at appropriate lifecycle events.
Now that all peices are together, let's see how this affects during navigation.
navigator.navigateTo(A)
// A -> onCreate -> onStart -> onResume
// backstack = [A]
navigator.navigateTo(B)
// A -> onPause -> onStop
// B -> onCreate -> onStart -> onResume
// backstack = [A, B]
navigator.goBack()
// B -> onPause -> onStop -> onDestroy
// A -> onStart -> onResume
// backstack = [A]
Apart from navigation, they also respond to Activity
's lifecycle as well. You can observe this event changes by adding a LifecycleEventObserver
to your Route
class when they are initialized.
class MyObserver(private val route: Route) : LifecycleEventObserver {
override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) {
Log.d("LifecycleEvent", "${route::class.qualifiedName} -> $event")
}
}
sealed class MyRoute : Route {
data class First(...) : MyRoute() {
init {
lifecycleController.lifecycle.addObserver(MyObserver(this))
}
}
data class Second(...) : MyRoute() {
init {
lifecycleController.lifecycle.addObserver(MyObserver(this))
}
}
companion object Key : Route.Key<MyRoute>
}