/voyager

πŸ›Έ A pragmatic navigation library for Jetpack Compose

Primary LanguageKotlinMIT LicenseMIT

Maven metadata URL Android API kotlin ktlint License MIT


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

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

Current version: Maven metadata URL

Samples

Stack API Basic nav. Tab nav. Nested nav.
navigation-stack navigation-basic navigation-tab navigation-nested

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:

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 composition
  • onDisposed: 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 Navigators.

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