Skip to content

Compose Navigator Tutorials

Kaustubh Patange edited this page Oct 8, 2021 · 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 ->
        modifier.then(Modifier /*...*/)
    }
    override val backwardTransition: ComposeTransition = ComposeTransition { modifier, width, height, progress ->
        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) {
                ...
            }
        }
    }
}

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()
        }
    }
}
Clone this wiki locally