guia
guia copied to clipboard
Navigation Component for Jetpack Compose with support for screens, dialogs, bottomsheets, transitions and multi module projects
Compose Navigator
Navigator tailored to work nicely with composable screens.
Features | |
---|---|
:tada: | Simple API |
:recycle: | State restoration |
:train: | Nested navigation |
:link: | Deep Linking |
:back: | Multiple back stack strategies |
:twisted_rightwards_arrows: | Support for Enter/Exit compose transitions |
:rocket: | Extensive navigation operations |
:phone: | Result passing between navigation nodes |
Table of Contents
- Installation
-
Navigation Nodes
- Lifecycle
- Reusing NavigatioNode
- Navigator
- NavContainer
- Navigation Operations
-
Animations
- Animating between navigation nodes
- Animating between navigation stacks
- Animating navigation node elements
- Back Stack Management
- State Restoration
- Result passing
- Nested Navigation
- Deeplinks
- ViewModels
- Previews
- UI Tests
Installation
dependencies {
implementation("com.roudikk.compose-navigator:compose-navigator:2.0.0")
}
For proguard rules check consumer-rules.pro
Compose navigator usses Parcelable
interfaces, it's recommended to use Parcelize
in your project. build.gradle
:
plugins {
// groovy
id 'kotlin-parcelize'
// kotlin
id("kotlin-parcelize")
}
Navigation nodes
Screen:
@Parcelize
class MyScreen(val myData: String) : Screen {
@Composable
override fun Content() {
}
}
Dialog:
@Parcelize
class MyDialog(val myData: String) : Dialog {
override val dialogOptions: DialogOptions
get() = DialogOptions(
modifier: Modifier = Modifier.widthIn(max = 300.dp), // The Dialog container Modifier
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
securePolicy: SecureFlagPolicy = SecureFlagPolicy.Inherit // Policy setting for the window
)
@Composable
override fun Content() {
}
}
Bottom Sheet:
@Parcelize
class MyBottomSheet(val myData: String) : BottomSheet {
override val bottomSheetOptions: BottomSheetOptions
get() = BottomSheetOptions(
modifier = Modifier, // Controls the BottomSheet's outside Composable.
confirmStateChange = { true } // When set to { false }, bottom sheet state changes will be ignored and animate back to the last locked state.
)
@Composable
override fun Content() {
}
}
Bottom sheets do not have 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
bottomSheetOptions
inside NavContainer
to provide a common composable parent
to all bottom sheets.
Lifeycle
Each NavigationNode
will have a corresponding BackStackEntry
when added to the backstack.
A BackStackEntry
is a LifecycleOwner
, ViewModelStoreOwner
and a SavedStateRegistryOwner
which means
every navigation node has its own lifecycle and can have its own scoped ViewModels and supporting SavedStateHandle
.
In addition, the library provides LifecycleEffect
to listen to lifecycle events:
/**
* Lifecycle listener for a [NavigationNode]
*
* @param onEnter, called when the node enters composition, this can be called when the node is initially rendered
* or when the node is revisited.
* @param onResume, called when the [NavigationNode] is resumed. This is called right after [onEnter]
* and when the activity is resumed.
* @param onPause, called when the [NavigationNode] is paused. This is called right before [onExit]
* and when the activity is paused.
* @param onExit, called when the node leaves composition. This doesn't mean the node is necessarily
* not going to be revisited.
* @param onDestroy, called the node is completely destroyed, this means the node will never be
* revisited again.
*/
@Composable
fun NavigationNode.LifecycleEffect(
onEnter: () -> Unit = {},
onResume: () -> Unit = {},
onPause: () -> Unit = {},
onExit: () -> Unit = {},
onDestroy: () -> Unit = {}
)
Reusing Navigation Nodes
A NavigationNode
can be a Screen
, Dialog
and a BottomSheet
at the same time!
To decide which type it is when navigation, use NavigationNode.asScreen()/asDialog()/asBottomSheet()
.
Please note that if a NavigationNode
does support multiple types then you must use the asX()
when navigating, otherwise the navigator will not know which type you want and might result in weird behavior.
Navigator
A Navigator is the essential component for navigation and can be used to navigate between navigation nodes.
To initialize a navigator call:
val myNavigator = rememberNavigator(initialNavigationNode = MyScreen())
rememberNavigator
can also take a NavigationConfig
which can either be SingleStack
or MultiStack
.
For multi-stacks navigation with history for each stack (For ex: Bottom navigation),
each stack should have a unique StackKey
To initialize a Navigator with multiple stacks call:
val myNavigator = remmeberNavigator(
navigationConfig = with(
listOf(
NavigationConfig.MultiStack.NavigationStackEntry(
key = AppStackKey.Stack1,
initialNavigationNode = Stack1Screen()
),
NavigationConfig.MultiStack.NavigationStackEntry(
key = AppStackKey.Stack2,
initialNavigationNode = Stack2Screen()
),
NavigationConfig.MultiStack.NavigationStackEntry(
key = AppStackKey.Stack3,
initialNavigationNode = Stack3Screen()
)
)
) {
NavigationConfig.MultiStack(
entries = this,
initialStackKey = this[0].key,
backStackStrategy = BackStackStrategy.Default,
defaultTransition = MaterialSharedAxisTransitionX, // Optional
stackEnterExitTransition = navFadeIn() to navFadeOut() // Optional
)
}
)
NavContainer
To render the current state of a Navigator
, call:
NavContainer(navigator = myNavigator)
For nested navigation, simply nest NavContainer
in the Content
of a parent NavigationNode
:
class BottomNavScreen {
@Composable
override fun Content() {
val bottomTabNavigator = rememberNavigator(navigationConfig = NavigationConfig.MultiStack..)
NavContainer(bottomTabNavigator)
}
}
Navigation operations
// Navigate to a navigation node,
requireNavigator().navigate(navigationNode, transition) // Navigates the new node in the current stack.
// Navigate to a different stack
requireNavigator().navigateToStack(stackKey, transition) // Navigates to a stack with stack key.
// Pop back stack
requireNavigator().popBackStack() // Pops the last node from the current stack.
// Pop to
requireNavigator().popTo<NavigationNode>(inclusive)
requireNavigator().popTo(navigationNodeKey, inclusive) // In case overriding key inside NavigationNode.
// Pop to root
requireNavigator().popToRoot() // This will navigate to the root of the current stack.
// Set root
requireNavigator().setRoot(navigationNode, transition) // Replaces the root of the current stack.
// Replace last
requireNavigator().replaceLast(navigationNode, transition) // Replaces the last node.
// Replace Up To
requireNavigator().replaceUpTo(navigationNode, transition, inclusive, predicate) // Replaces all nodes until the node matching predicate.
requireNavigator().replaceUpTo<NavigationNode>(navigationNode, transition, inclusive) // Replaces all nodes until the node matching navigationNode.key.
// Move To Top
requireNavigator().moveToTop(matchLast, transition, predicate) // Moves the node matching predicate to the top of the stack, returns true if one exists.
requireNavigator().moveToTop<NavigationNode>(matchLast, transition) // Moves the node with a matching key to the top of the stack, returns true if one exists.
// Single instance
requireNavigator().singleInstance(navigatioNode, useExistingInstance, transition) // If useExistingInstance is true, then move the existing node to the top else creates a new instance, if useExistingInstance is false, then always navigate to a new instance, clearing the backstack of any matching keys.
// Single top
requireNavigator().singleTop(navigationNode, transition) // Only navigate if the top most node doesn't have the same key as navigationNode.
// Check if you can navigate back
requireNavigator().canGoBack()
// Any
requireNavigator().any(predicate) // Returns true if any navigation node in the current stack matches predicate condition.
Animations
Compose navigator provides a one to one match of all the EnterTransition
and ExitTransition
defined in compose-animation.
Prepend nav
to compose equivalent function to find the navigation version of it.
For ex: fadeIn()
-> navFadeIn()
EnterTransition
is converted to NavEnterTranstion
and ExitTransition
is converted to NavExitTransition
Animation specs supported currently are: Tween, Snap and Spring.
Prepend nav
to compose equivalent function to find the navigation version of it.
For ex: tween()
-> navTween()
Animating between navigation nodes
Example:
val MaterialSharedAxisTransitionX = NavTransition(
enter = navSlideInHorizontally { (it * 0.2f).toInt() }
+ navFadeIn(animationSpec = navTween(300)),
exit = navSlideOutHorizontally { -(it * 0.1f).toInt() }
+ navFadeOut(animationSpec = navTween(150)),
popEnter = navSlideInHorizontally { -(it * 0.1f).toInt() }
+ navFadeIn(animationSpec = navTween(300)),
popExit = navSlideOutHorizontally { (it * 0.2f).toInt() }
+ navFadeOut(animationSpec = navTween(150))
)
Usage:
requireNavigator().navigate(
navigatioNode = navigationNode,
transition = MaterialSharedAxisTransitionX
)
NavigationConfig
contains defaultTransition
to define a default transition if none was provided in the navigation operation.
Animating between stacks
Animating between stack changes can be done by using the transition
parameter inside navigatToStack
For ex:
findNavigator().navigateToStack(stackKey, navFadeIn() to navFadeOut())
NavigationConfig.MultiStack
contains stackEnterExitTransition
for default stack transition.
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.
To get access the AnimatedVisibilityScope
use LocalNavigationAnimation.current
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 Content() = with(LocalNavigationAnimation.current) {
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 Compose's BackHandler
to override back presses, it's defined before the navigation node's composable 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
- if the current stack is not the initial stack defined in
- CrossStackHistory:
- When navigating between stacks, this strategy will navigate back between stacks based on
navigate/navigateToStack
operations
- When navigating between stacks, this strategy will navigate back between stacks based on
State restoration
NavContainer
uses rememberSaveableStateHolder()
to remember composables ui states.
NavigatorSaver
handles saving/restoring the navigator state upon application state saving/restoration internally.
Using rememberSavable
inside your navigation node composables will remember the values of those fields.
Result passing
Navigator
uses a coroutine Channel
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 or a given string key:
// Navigator.kt
// Listening to results
fun results(key: String) // Return results for a key in case of overriding the default key inside the navigation node
inline fun <reified T : NavigationNode> results() // Convenience 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) // Convenience function that uses the default key for a NavigationNode
// Additionally, navigation node has an extension function on Navigator to make it even easier to listen to results
// NavigationNode
fun Navigator.nodeResults() = results(resultsKey)
Usage ex:
@Parcelize
class Screen1 : Screen {
@Composable
override fun 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 Content() {
val navigator = findNavigator()
Button(onClick = {
navigator.sendResult<Screen1>("Hello!")
navigator.popBackStack()
}) {
Text(text = "Send Result")
}
}
}
Nested Navigation
Compose navigator offers navigator fetching functions:
-
findNavigator()
returns the closest navigator in navigation hierarchy, nullable -
requireNavigator()
returns the closes navigator in navigation hierarchy, throws error if none exist. -
findParentNavigator()
returns the parent navigator of the current navigator, nullable
You can nest navigators by calling NavContainer()
inside a screen that is contained inside a parent NavContainer()
val parentNavigator = rememberNavigator(FirstScreen())
findNavigator() // Returns null
NavContainer(parentNavigator)
// FirstScreen.kt
override fun Content() {
findNavigator() // Returns parentNavigator
findParentNavigator() // Returns null
val nestedNavigator = rememberNavigator(NestedScreen())
NavContainer(nestedNavigator) // Renders NestedScreen
}
// NestedSCreen.kt
override fun Content() {
findNavigator() // Returns nestedNavigator
findParentNavigator() // Returns parentNavigator
}
// 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.
Deeplinks
rememberNavigator
has an initializer
argument which can be used to initialize the state of the navigator, this can be used
to start the navigator with navigation nodes given the initial activity's intent.
For more details on how deeplinking can be implemented check DeepLinkViewModel
ViewModels
Each Navigation node is wrapped around a BackStackEntry
that has its own Lifecycle, viewModelStoreOwner and savedStateRegistry.
This means calling viewModel()
inside a Navigation Node will provide a ViewModel
tied to the node's lifecycle and will be disposed
when the NavigationNode
is no longer used.
To use a singleton ViewModel
across multiple NavigationNode
, it's recommended to define a LocalNavHostViewModelStoreOwner
:
val LocalNavHostViewModelStoreOwner = staticCompositionLocalOf<ViewModelStoreOwner> {
error("Must be provided")
}
Which can then be used in your main activity to provide the activity's ViewModelStoreOwner
:
CompositionLocalProvider(
LocalNavHostViewModelStoreOwner provides requireNotNull(LocalViewModelStoreOwner.current)
) {
NavContainer(navigator = myNavigator)
}
And to retrieve the ViewModel
:
val sharedViewModel = viewModel<SharedViewModel>(viewModelStoreOwner = LocalNavHostViewModelStoreOwner.current)
Previews
It's recommended to separate the navigation logic from the composable previews.
Instead of doing:
@Composable
fun MyComposable() {
val navigator = requireNavigator()
Button(onClick = { navigator.navigator(SomeScreen()) }) {
Text("Navigate")
}
}
You should do:
@Composable
fun MyComposable(onClick: () -> Unit) {
Button(onClick = onClick) {
Text("Navigate")
}
}
And delegate the navigation to the caller instead.
UI tests
For Individual navigation nodes, it's recommended to test that the actions that perform navigation operations to be lambdas rather than use the navigation component.
This will make it easier to preview the composables and easier to assert that actions have been performed, for ex:
@Composable
private fun HomeContent(
onItemSelected: (String) -> Unit = {} // This can be easily tested in unit tests
) {
// Content
}
However, when testing UI flows across multiple navigation nodes, Compose Navigator adds a test tag
using the navigation node key to all navigation nodes in the Compose tree, making it easy to test whether
a navigation node is displayed, using ComposeTestRule.onNodeWithTag(tag).assertIsDisplayed()
, for ex:
@Test
fun details_newRandomItem_addsToStack() {
rule.navigateDetails()
rule.onNodeWithText("New random item").performClick()
rule.onNodeWithTag(key<DetailsScreen>()).assertIsDisplayed()
rule.activity.onBackPressed()
rule.onNodeWithTag(key<DetailsScreen>()).assertIsDisplayed()
rule.activity.onBackPressed()
rule.onNodeWithTag(key<HomeScreen>()).assertIsDisplayed()
}
Check the sample app UI tests for more examples.
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.