Voyager: Compose on Warp Speed
Voyager is a pragmatic navigation library built for, and seamlessly integrated with, Jetpack Compose.
Turn on the Warp Drive and enjoy the trek
Features
- Create scalable Single-Activity apps powered by a pragmatic API
- State-aware Stack API
- Tab navigation like Youtube app
- Nested navigation if you need to manage multiple stacks
- State restoration after Activity recreation
- Lifecycle callbacks
- Back press handling
- Deep linking support
- Compose for Desktop support (soonโข)
Setup
Add the desired dependencies to your module's build.gradle:
dependencies {
implementation "cafe.adriel.voyager:voyager-navigator:$currentVersion"
implementation "cafe.adriel.voyager:voyager-tab-navigator:$currentVersion"
}
Samples
Stack API | Basic nav. | Tab nav. | Nested nav. |
---|---|---|---|
Usage
Let's start by creating the screens: you should implement the Screen interface and override the Content()
composable function. Screens can be data class
(if you need to send params), class
(if no param is required) or even object
(useful for tabs).
object HomeScreen : Screen {
@Composable
override fun Content() {
// ...
}
}
class PostListScreen : Screen {
@Composable
override fun Content() {
// ...
}
}
data class PostDetailsScreen(val postId: Long) : Screen {
@Composable
override fun Content() {
// ...
}
}
Now, start the Navigator
with the root screen.
class SingleActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
Navigator(HomeScreen)
}
}
}
Use the LocalNavigator
to navigate to other screens. Take a look at the Stack API for the available operations.
class PostListScreen : Screen {
@Composable
override fun Content() {
// ...
}
@Composable
private fun PostCard(post: Post) {
val navigator = LocalNavigator.currentOrThrow
Card(
modifier = Modifier.clickable {
navigator.push(PostDetailsScreen(post.id))
// Also works:
// navigator push PostDetailsScreen(post.id)
// navigator += PostDetailsScreen(post.id)
}
) {
// ...
}
}
}
Stack API
Voyager is backed by a SnapshotStateStack:
- Implementation of Stack that can be observed and snapshot
- Internally uses a SnapshotStateList
- State-aware: content change triggers a recomposition
You will use it to navigate forward (push
, replace
, replaceAll
) and backwards (pop
, popAll
, popUntil
), but the SnapshotStateStack
can also be used as a regular collection.
val stack = mutableStateStackOf("๐", "๐", "๐", "๐", "๐ฅ", "๐")
// ๐, ๐, ๐, ๐, ๐ฅ, ๐
stack.lastOrNull
// ๐
stack.push("๐")
// ๐, ๐, ๐, ๐, ๐ฅ, ๐, ๐
stack.pop()
// ๐, ๐, ๐, ๐, ๐ฅ, ๐
stack.popUntil { it == "๐" }
// ๐, ๐, ๐, ๐
stack.replace("๐")
// ๐, ๐, ๐, ๐
stack.replaceAll("๐")
// ๐
You can also create a SnapshotStateStack
through rememberStateStack()
, it will restore the values after Activity recreation.
State restoration
The Screen
interface is Serializable
. Every param of your screens will be saved and restored automatically. Because of that, it's important to known what can be passed as param: anything that can be stored inside a Bundle.
// โ๏ธ DO
@Parcelize
data class Post(/*...*/) : Parcelable
data class ValidScreen(
val userId: UUID, // Built-in serializable types
val post: Post // Your own parcelable and serializable types
) : Screen {
// ...
}
// ๐ซ DON'T
class Post(/*...*/)
data class InvalidScreen(
val context: Context, // Built-in non-serializable types
val post: Post // Your own non-parcelable and non-serializable types
) : Screen {
// ...
}
Not only the params, but the properties will also be restored, so the same rule applies.
// โ๏ธ DO
class ValidScreen : Screen {
// Serializable properties
val tag = "ValidScreen"
// Lazily initialized serializable types
val randomId by lazy { UUID.randomUUID() }
// Lambdas
val callback = { /*...*/ }
}
// ๐ซ DON'T
class InvalidScreen : Screen {
// Non-serializable properties
val postService = PostService()
}
If you want to inject dependencies through a DI framework, make sure it supports Compose, like Koin and Kodein.
// โ๏ธ DO
class ValidScreen : Screen {
@Composable
override fun Content() {
// Inject your dependencies inside composables
val postService = get<PostService>()
}
}
// ๐ซ DON'T
class InvalidScreen : Screen {
// Using DI to inject non-serializable types as properties
val postService by inject<PostService>()
}
Lifecycle
Inside a Screen
, you can call LifecycleEffect
to listen for some events:
onStarted
: called when the screen enters the compositiononDisposed
: called when the screen is disposed
class PostListScreen : Screen {
@Composable
override fun Content() {
LifecycleEffect(
onStarted = { /*...*/ },
onDisposed = { /*...*/ }
)
// ...
}
}
Back press
By default, Voyager will handle back presses but you can override its behavior. Use the onBackPressed
to manually handle it: return true
to pop the current screen, or false otherwise. To disable, just set to null
.
setContent {
Navigator(
initialScreen = HomeScreen,
onBackPressed = { currentScreen ->
false // won't pop the current screen
// true will pop, default behavior
}
// To disable:
// onBackPressed = null
)
}
Deep links
You can initialize the Navigator
with multiple screens, that way, the first visible screen will be the last one and will be possible to return (pop()
) to the previous screens.
val postId = getPostIdFromIntent()
setContent {
Navigator(
HomeScreen,
PostListScreen(),
PostDetailsScreen(postId)
)
}
Transitions
It's simple to add transition between screens: when initializing the Navigator
you can override the default content. You can use, for example, the built-in Crossfade animation.
setContent {
Navigator(HomeScreen) { navigator ->
Crossfade(navigator.last) { screen ->
screen.Content()
}
}
}
Want to use a custom animation? No problem, just follow the same principle.
setContent {
Navigator(HomeScreen) { navigator ->
MyCustomTransition {
CurrentScreen()
}
}
}
Tab navigation
Voyager provides a handy abstraction over the Navigator
and Screen
: the TabNavigator
and Tab
.
The Tab
interface, like the Screen
, has a Content()
function, but also a title
and an optional icon
. Since tabs aren't usually reused, its OK to create them as object
.
object HomeTab : Tab {
override val title: String
@Composable get() = stringResource(R.string.home)
override val icon: Painter
@Composable get() = rememberVectorPainter(Icons.Default.Home)
@Composable
override fun Content() {
// ...
}
}
The TabNavigator
unlike the Navigator
:
- Don't handle back presses, because the tabs are siblings
- Don't exposes the Stack API, just a
current
property
You can use it with a Scaffold to easily create the UI for your tabs.
setContent {
TabNavigator(HomeTab) {
Scaffold(
content = {
CurrentTab()
},
bottomBar = {
BottomNavigation {
TabNavigationItem(HomeTab)
TabNavigationItem(FavoritesTab)
TabNavigationItem(ProfileTab)
}
}
)
}
}
Use the LocalTabNavigator
to get the current TabNavigator
, and current
to get and set the current tab.
@Composable
private fun RowScope.TabNavigationItem(tab: Tab) {
val tabNavigator = LocalTabNavigator.current
BottomNavigationItem(
selected = tabNavigator.current == tab,
onClick = { tabNavigator.current = tab },
icon = { Icon(painter = tab.icon, contentDescription = tab.title) }
)
}
Nested navigation
For more complex use cases, when each tab should have its own independent navigation, like the Youtube app, you can combine the TabNavigator
with multiple Navigator
s.
Let's go back to the previous example.
setContent {
TabNavigator(HomeTab) {
// ...
}
}
But now, the HomeTab
will have it's own Navigator
.
object HomeTab : Screen {
@Composable
override fun Content() {
Navigator(PostListScreen())
}
}
That way, we can use the LocalNavigator
to navigate deeper into HomeTab
, or the LocalTabNavigator
to switch between tabs.
class PostListScreen : Screen {
@Composable
private fun GoToPostDetailsScreenButton(post: Post) {
val navigator = LocalNavigator.currentOrThrow
Button(
onClick = { navigator.push(PostDetailsScreen(post.id)) }
)
}
@Composable
private fun GoToProfileTabButton() {
val tabNavigator = LocalTabNavigator.current
Button(
onClick = { tabNavigator.current = ProfileTab }
)
}
}
Going a little further, it's possible to have nested navigators. The Navigator
has a level
property (so you can check how deeper your are) and can have a parent
navigator.
setContent {
Navigator(ScreenA) { navigator0 ->
println(navigator.level)
// 0
println(navigator.parent == null)
// true
Navigator(ScreenB) { navigator1 ->
println(navigator.level)
// 1
println(navigator.parent == navigator0)
// true
Navigator(ScreenC) { navigator2 ->
println(navigator.level)
// 2
println(navigator.parent == navigator1)
// true
}
}
}
}
Another operation is the popUntilRoot()
, it will recursively pop all screens starting from the leaf navigator until the root one.
Credits
- Logo by Icons8