Compose Navigator

Navigator tailored to work nicely with composable screens.

Note: This is currently a WIP and experimental and API is very likely to change.

Features
🎉 Simple API
♻️ State restoration
🚋 Nested navigation
🔙 Multiple back stack strategies
🔀 Support for Enter/Exit compose transitions
🚀 Different launch modes
☎️ Result passing between navigation nodes

Table of Contents

  1. Installation
  2. Navigation Nodes
  3. NavHost and NavContainer
  4. Navigation Operations
  5. Launch Modes
  6. Animations
    1. Animating between navigation nodes
    2. Animating between navigation stacks
    3. Animating navigation node elements
  7. Back Stack Management
  8. State Restoration
  9. Result passing
  10. Nested Navigation
  11. Working with ViewModels

Installation

dependencies {
    implementation("com.roudikk.compose-navigator:compose-navigator:1.0.0")
}

For proguard rules check consumer-rules.pro

Recommended to use Kotlin Parcelize, build.gradle:

plugins {
    // groovy
    id 'kotlin-parcelize'
    
    // kotlin
    id("kotlin-parcelize")
}

OPTIONAL: Module OptIn for experimental navigator api:

tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().configureEach {
    kotlinOptions.freeCompilerArgs += "-opt-in=com.roudikk.navigator.ExperimentalNavigatorApi"
}

Navigation nodes

Screen:

@Parcelize
class MyScreen(val myData: String) : Screen {
    
    @Composable
    override fun AnimatedVisibilityScope.Content() {
        
    }
}

Dialog:

@Parcelize
class MyDialog(val myData: String) : Dialog {

    override val dialogOptions: DialogOptions
        get() = DialogOptions(
            dismissOnBackPress = true, // When set to false, back press will not cancel this dialog
            dismissOnClickOutside = true // When set to false, clicking outside the dialog doesn't dismiss it
        )

    @Composable
    override fun AnimatedVisibilityScope.Content() {

    }
}

Bottom Sheet:

@Parcelize
class MyBottomSheet(val myData: String) : BottomSheet {

    override val bottomSheetOptions: BottomSheetOptions
        get() = BottomSheetOptions(
            dismissOnHidden = true // When set to false, swiping down the bottom sheet will not dismiss it. 
        )
    
    @Composable
    override fun AnimatedVisibilityScope.Content() {

    }
}

Bottom sheets do not get a default surface as a background. This is to let developers choose which composable is the parent of a bottom sheet (For ex: Surface2 or Surface3) inside their own implementation.

However, to make it easier to have a consistent bottom sheet design across all bottom sheets (if that’s the case), you can override bottomSheetSetup inside NavContainer to provide a common composable parent to all bottom sheets.

NavHost and NavContainer

A Navhost holds all the navigators defined in the application.

For a single stack navigation:

NavHost(
        Navigator.defaultKey to NavigationConfig.SingleStack(DefaultScreen()),
        "nested-nav-key" to NavigationConfig.SingleStack(NestedScreen())
    ) {
    // Inside this scope you have access to the navigators using findNavigator()
    
    findNavigator() // returns closest navigator in navigation hierarchy
    findNavigator(key) // returns navigator for given key
    findParentNavigator() // returns parent navigator in navigation hierarchy
    findDefaultNavigator() // returns navigator with key == Navigator.defaultKey
}

A NavHost doesn’t immediately render the initial navigation nodes, it’s used to cache the navigators and save/restore them.

To render the state of a navigator, use NavContainer:

NavContainer() // Renders the navigator's state that's using Navigator.defaultKey
NavContainer(key = "some-other-navigator-key") // Renders the navigator's state for given key

For multi-stacks navigation with history for each stack (For ex: Bottom navigation)

Each stack should have a unique NavigationKey:

sealed class AppNavigationKey : NavigationKey() {

    @Parcelize
    object Home : AppNavigationKey()

    @Parcelize
    object Profile : AppNavigationKey()

    @Parcelize
    object Settings : AppNavigationKey()
}

Then define your NavHost:

val stackEntries = listOf(
    NavigationConfig.MultiStack.NavigationStackEntry(
        key = AppNavigationKey.Home,
        initialNavigationNode = HomeScreen()
    ),
    NavigationConfig.MultiStack.NavigationStackEntry(
        key = AppNavigationKey.Profile,
        initialNavigationNode = ProfileScreen()
    ),
    NavigationConfig.MultiStack.NavigationStackEntry(
        key = AppNavigationKey.Settings,
        initialNavigationNode = SettingsScreen()
    )
)

NavHost(
      Navigator.defaultKey to NavigationConfig.MultiStack(
          entries = stackEntries,
          initialStackKey = stackEntries[0].key,
          backStackStrategy = BackStackStrategy.BackToInitialStack()
      ),
      // You can have multi stack and single stack navigators within the same app with each handling its own backstack
      "nested-nav-key" to NavigationConfig.SingleStack(NestedScreen())
) {

    NavContainer() // This will draw the initial stack's initial screen immediately
    
    val navigator = findNavigator()
    val currentStackKey by navigator.currentKeyFlow.collectAsState()
    
    // Use currentStackKey to change which tab is selected in case of a bottom navigation
}

Navigation operations

// Note: enter/exit/popEnter/popExit animations can be defined in NavOptions along with SingleTop flag.

// Navigate to a navigation node, 
findNavigator().navigate(navigationNode, navOptions) 

// Navigate to a different stack
findNavigator().navigateToStack(stackKey, transitions, addKeyToHistory)

// Pop back stack
findNavigator().popBackStack() 

// Pop to
findNavigator().popTo<Screen>(inclusive) 
findNavigator().popTo(navigationNodeKey, inclusive) // In case overriding key inside NavigationNode

// Pop to root
findNavigator().popToRoot() // This will navigate to the root of the current stack
findNavigator().setRoot(navigationNode, navOptions)

// Check if you can navigate back
findNavigator().canGoBack()

Launch Modes

Launch mode can be specified using the navOptions.launchMode parameter of navigate function. Available Launch modes are:

  • Single Top: If the current top most navigation node has the same key, no additional navigation happens.
  • Single instance: Clears the entire backstack of navigation nodes matching same key and launches a new instance on top.

Note: Currently the launch modes don’t provide newIntent equivalent behaviour so the content will not restore the state of an existing navigation node.

Animations

EnterTransition and ExitTransition are not savable in a bundle and cannot be saved/restored when the state of the app is saved/restored.
They are sealed and final so there is no easy way to extend them and make them savable.

Compose navigator provides a one to one match of all the EnterTransition and ExitTransition defined.
Prepend navigation to the compose equivalent function to find the navigation version of it.

For ex: fadeIn() -> navigationFadeIn()

EnterTransition is converted to NavigationEnterTransition
ExitTransition is converted to NavigationExitTransition

Animation specs supported currently are: Tween, Snap and Spring, prepend navigation to compose equivalent.

For ex: tween() -> navigationTween()

Animating between navigation nodes

Example:

val MaterialSharedAxisTransitionX = NavTransition(
    enter = navigationSlideInHorizontally { (it * 0.2f).toInt() }
            + navigationFadeIn(animationSpec = navigationTween(300)),

    exit = navigationSlideOutHorizontally { -(it * 0.1f).toInt() }
            + navigationFadeOut(animationSpec = navigationTween(150)),

    popEnter = navigationSlideInHorizontally { -(it * 0.1f).toInt() }
            + navigationFadeIn(animationSpec = navigationTween(300)),

    popExit = navigationSlideOutHorizontally { (it * 0.2f).toInt() }
            + navigationFadeOut(animationSpec = navigationTween(150))
)

Usage:

findNavigator().navigate(
  navigatioNode = navigationNode, 
  navOptions = navOptions(
      navTransition = MaterialSharedAxisTransitionX,
  )
)

Animating between stacks

Animating between stack changes can be done by using the transitions paramter inside navigatToStack

For ex:

findNavigator().navigateToStack(stackKey, navigationFadeIn() to NavigationFadeOut())

Animating navigation node elements with screen transitions

Content function inside a NavigatioNode has reference to the animatedVisibilityScope used by the AnimatedContent that handles all transitions between navigation nodes.

This means composables inside navigation nodes can have enter/exit transitions based on the node’s enter/exit state, using the animateEnterExit modifier.

For ex:

@Parcelize
class MyScreen : Screen {

    @Composable
    override fun AnimatedVisibilityScope.Content() {
        Text(
            modifier = Modifier
                .animateEnterExit(
                    enter = slideInVertically { it },
                    exit = slideOutVertically { it }
                ),
            text = "I animate with this screen's enter/exit transitions!"
        )
    }
}

Back stack management

NavContainer uses composes’s BackHandler to override back presses, it’s defined before the navigation node composables so navigation nodes can override back press handling by providing their own BackHandler

For Multi stack navigation, NavigationConfig.MultiStack provides 3 possible back stack strategies:

When the stack reaches its initial node then pressing the back button:

  • Default: back press will no longer be handled by the navigator.
  • BackToInitialStack:
    • if the current stack is not the initial stack defined in NavigationConfig.MultiStack then the navigator will navigate back to the initial stack
    • If the current stack is the initial stack, then back press will no longer be handled by navigator
  • CrossStackHistory:
    • When navigating between stacks, this strategy will navigate back between stacks based on navigate/navigateToStack operations

State restoration

NavContainer uses rememberSaveableStateHolder() to remember composables ui states.

NavigatorCacheSaver handles saving/restoring the navigator state upon application state saving/restoration.

Using rememberSavable inside your navigation node composables will remember the values of those fields.

Result passing

Navigator uses coroutine flows to pass results between navigation nodes.

A Result can be of any type.

Sending/receiving results are done by the key of the navigation node:

    // Navigator.kt
    // Listening to results
    fun results(key: String) // Returns results for a key in case of overriding the default key inside the navigation node
    inline fun <reified T : NavigationNode> results() // Covenience function that uses the default key for a NavigationNode
    
    // Sending results
    fun sendResult(result: Pair<String, Any>) // Sends result for a given navigation node key
    inline fun <reified T : NavigationNode> sendResult(result: Any) // Covenience function that uses the default key for a NavigationNode
    
    // Additionally, navigation node has an extension function on Navigator to make it even easir to listen to results
    
   // NavigationNode
   fun Navigator.nodeResults() = results(resultsKey)

Usage ex:

@Parcelize
class Screen1 : Screen {

    @Composable
    override fun AnimatedVisibilityScope.Content() {
        val context = LocalContext.current
        val navigator = findNavigator()

        Button(onClick = { navigator.navigate(Screen2()) }) {
            Text(text = "Navigate")
        }

        LaunchedEffect(Unit) {
            navigator.nodeResults()
                .onEach {
                    Toast.makeText(context, "$it", Toast.LENGTH_SHORT).show()
                }
                .launchIn(this)
        }
    }
}

@Parcelize
class Screen2 : Screen {

    @Composable
    override fun AnimatedVisibilityScope.Content() {
        val navigator = findNavigator()

        Button(onClick = {
            navigator.sendResult<Screen1>("Hello!")
            navigator.popBackStack()
        }) {
            Text(text = "Send Result")
        }
    }
}

Nested Navigation

Compose navigator offers 3 navigator fetching functions:

  • findNavigator(optonalKey) returns the closest navigator in navigation hierarchy or one matching optionalKey
  • findParentNavigator() returns the parent navigator of the current navigator, nullable
  • findDefaultNavigator() returns the default navigator using Navigator.defaultKey

You can nest navigators by calling NavContainer(key) inside a screen that is contained inside a parent NavContainer

The first NavContainer should usually use the default key (Navigator.defaultKey)

All nested NavContainer must provide a unique key to differentiate between them.

// NavHost1
NavHost(
    Navigator.defaultKey to NavigationConfig.SingleStack(FirstScreen()),
    "nested-navigator" to NavigationConfig.SingleStack(NestedSCreen())
) {

    NavContainer() // Renders FirstScreen
}
    
// FirstScreen.kt
 override fun AnimatedVisibilityScope.Content() {
    findNavigator() // Returns navigator for Navigator.defaultKey
    findParentNavigator() // Returns null
    findDefaultNavigator() // Returns navigator for Navigator.defaultKey
        
    NavContainer("nested-navigator") // Renders NestedScreen
}

// NestedSCreen.kt
override fun AnimatedVisibilityScope.Content() {
    findNavigator() // Returns navigator for "nested-navigator"
    findParentNavigator() // Returns navigator for Navigator.defaultKey
    findDefaultNavigator() // Returns navigator for Navigator.defaultKey
}
            
// NavContainer in NestedScreen will override the back press of NavContainer in FirstScreen until it can no longer go back
// Then NavContainer in FirstScreen will take over back press handling.
// Both navigators can use any navigation node defined anywhere.

ViewModels


For example usage with a view model, check Home Screen Sample

License

Copyright 2022 Roudi Korkis Kanaan

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.

GitHub

View Github