Skip to content

Compose Navigator Tutorials

KaustubhPatange edited this page Sep 14, 2023 · 20 revisions

These are small tutorials that covers some specific implementation detail.

Table of contents

Navigating with arguments

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.

Navigating with animation

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.

Custom animations

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

Implementing Nested Navigation

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

Navigate with single top instance or popUpTo

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

Navigate with goBackUntil or goBackToRoot

Both Controller<T> & ComposeNavigator support this functionality.

  • goBackUntil(dest) - is similar to popUntil 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 becomes A more like goBackUntil(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.

Implementing Dialogs

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 an IllegalStateException is thrown.
  • If a dialog does not exist in the backStack & still tried to close it using controller.closeDialog, then an IllegalStateException is thrown.
  • You should avoid re-using DialogRoute to create multiple dialogs in the same Controller instance.
  • Similar to the navigation backStack you can query the dialog backStack using controller.getAllDialogHistory() which returns an immutable list of all the DialogRoute present in the backStack.

Navigation in Dialogs

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

Extending Dialogs as Bottom Sheet

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

Managing onBackPressed manually

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

Reusing Routes in Navigation

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 Routes 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.

Navigation Scoped ViewModels

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.

Support for Hilt

The library supports creation of ViewModels 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>() // <--
        }
    }
}

Limitation with Reusable Routes

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.

Lifecycle events in Navigation Routes

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