Voyager Navigation

Michał Konkel
Michał Konkel
August 28, 2024 | Software development
compose multiplatform voyager

This is a Kotlin Multiplatform project targeting Android and iOS where we will showcase the Voyager as the app navigation.

  • Application should allow us to navigate from one screen to another.
  • Application should allow to pass some parameters from first to second screen.
  • Application should handle the screen rotation without loosing data.
  • Application should handle the Tab Navigation.
  • Application should handle the async operations with coroutines.

In other posts, I’ve covered also the ecompose, Apyx, and Composer navigation libraries.

The project:

The base project setup as always is made with Kotlin Multiplatform Wizard, we also need to add some Voyager as it is the core thing that we would like to examine.

[versions]
voyager = "1.1.0-alpha04"
[libraries]
voyager - navigator = { module = "cafe.adriel.voyager:voyager-navigator", version.ref = "voyager" }
voyager - screenmodel = { module = "cafe.adriel.voyager:voyager-screenmodel", version.ref = "voyager" }
voyager - transitions = { module = "cafe.adriel.voyager:voyager-transitions", version.ref = "voyager" }
commonMain.dependencies {
    ...

    implementation(libs.voyager.navigator)
    implementation(libs.voyager.screenmodel)
    implementation(libs.voyager.transitions)
}

Screens

The basic structure of the Voyager is Screen interface. Every screen in our application is just a class with @Composable function responsible for providing content. Every screen related class should implement the interface. With quick examination, we can see that the Screen interface is a simple contract with only one method to implement. We can use an object or class for screens without input parameters, or a data class in case we need to pass some entry params.

Let’s add the screens with basic UI to the project

class FirstScreen : Screen {

    @Composable
    override fun Content() {
        Column(
            modifier = Modifier.fillMaxSize(),
            horizontalAlignment = Alignment.CenterHorizontally,
            verticalArrangement = Arrangement.Center
        ) {
            Text("First screen")
            Button(onClick = { /*TODO*/ }) {
                Text("Second Screen")
            }
        }
    }
}
data class SecondScreen(val greetings: String) : Screen {

    @Composable
    override fun Content() {
        Column(
            modifier = Modifier.fillMaxSize(),
            horizontalAlignment = Alignment.CenterHorizontally,
            verticalArrangement = Arrangement.Center
        ) {
            Text("First screen")
            Spacer(modifier = Modifier.height(16.dp))
            Text("Greetings: $greetings}")
            Button(onClick = { /*TODO*/ }) {
                Text("Go Back")
            }
        }
    }
}

Navigation

The second most important thing in the Voyager is the Navigator a compose function built upon the compose internals. The navigator manages lifecyclebackPressStateRestoration , and the navigation itself. To obtain a navigator on any Screen, we should use the LocalNavigator in the local composition. As all things where we use in the CompositionLocal it has to be provided in the root of the composition – the App() function.

Let’s obtain the navigator and push the SecondScreen from the FirstScreen – as we can see we need to instantiate the second screen in the function call. A cool thing to notice is that the push is also a infix function, so we can use it in a more readable way.

@Composable
private fun FirstScreenButton() {
    val navigator = LocalNavigator.currentOrThrow

    Button(
        onClick = {
            navigator.push(SecondScreen("Hello from First Screen"))
            // infix call:
            // navigator push SecondScreen("Hello from First Screen")
        }
    ) {
        Text("Second Screen")
    }
}

We can do same with the SecondScreen to navigate back to the FirstScreen with the pop function.

@Composable
private fun SecondScreenButton() {
    val navigator = LocalNavigator.currentOrThrow

    Button(
        onClick = {
            navigator.pop()
        }
    ) {
        Text("Go Back")
    }
}

The last thing to do is to take care of the navigator creation to avoid null-pointers in the composition. The flexible Voyagers approach allow us to choose between various default screen transitions, or even create our own. For the sake of this post we will use the SlideTransition().

@Composable
fun App() {
    MaterialTheme {
        Navigator(FirstScreen()) { navigator ->
            SlideTransition(navigator)
        }
    }
}

The initial configuration is up and running, we can navigate between the screens and the data is preserved during the screen rotation. Let’s run the project on both Android and iOS to see the results.

Comparing to the Decompose Voyager also has its own ViewModel equivalent, called the ScreenModel but with some late changes in the compose multiplatform, the ViewModels were moved to the common code. In such a situation you are flexible with the approach and can use the one that fits you the best. For the sake of this post, I will use ScreenModel from Voyager lib. The ScreenModel is designed to store and manage UI-related data with lifecycle awareness and survives configuration changes. Unlike the ViewModel, the ScreenModel is just an interface. You can create a ScreenModel only from the Screen component.

class FirstScreenModel : ScreenModel {
    val screenTitle = "First screen"
    val buttonText = "Second Screen"
    val greetings = "Hello from First Screen"
}
class FirstScreen : Screen {

    @Composable
    override fun Content() {
        val screenModel = rememberScreenModel { FirstScreenModel() }
        ...
        Text(screenModel.screenTitle)
    }
}

Now if we would have a text input and want to store the value we should use state inside the ScreenModel. So it can be recreated after screen rotation.

Tab Navigation

To use the Tab navigation we need to add another library to our project voyager-tab-navigator.

[libraries]
voyager - tabs = { module = "cafe.adriel.voyager:voyager-tab-navigator", version.ref = "voyager" }
commonMain.dependencies {
    ...
    implementation(libs.voyager.tabs)
}

When we want to create Tab we need to use the Tab interface instead of the previously seen Screen interface. The idea behind tabs is the same. Nevertheless, TabNavigator doesn’t support the backPress and Stack API. With the Tab interface, we need to implement 2 methods:

  • Content() – the composable content of the tab
  • Options() – the informations about current tab icontitle and index.

Let’s create the FirstTab and implement all the methods, with some default values.

object FirstTab : Tab {
    @Composable
    override fun Content() {
        Column {
            Text("First Tab")
        }
    }

    override val options: TabOptions
        @Composable
        get() {
            val title = remember { "First" }
            val icon = rememberVectorPainter(Icons.Default.Home)

            return remember {
                TabOptions(
                    index = 0u,
                    title = title,
                    icon = icon
                )
            }
        }
}

The SecondTab should look exactly the same but with different content.

object SecondTab : Tab {
    @Composable
    override fun Content() {
        Column {
            Text("Second Tab")
        }
    }

    override val options: TabOptions
        @Composable
        get() {
            val title = remember { "Second" }
            val icon = rememberVectorPainter(Icons.Default.AccountBox)

            return remember {
                TabOptions(
                    index = 1u,
                    title = title,
                    icon = icon
                )
            }
        }
}

The last thing to do is to create a container for the tabs, we can follow the steps from the beginning of the post and create TabScreen. Inside the screen, we will use a Scaffold function with bottomBar with a component called BottomNavigation from material lib. The whole content should be wrapped with the TabNavigator function.

class TabScreen : Screen {
    @Composable
    override fun Content() {
        TabNavigator(FirstTab) {
            Scaffold(
                bottomBar = {
                    BottomNavigation {
                       // items
                    }
                }
            ) {
               // content
            }
        }
    }
}

We can create the helper function TabNavigationItem() that will be using the LocalTabNavigator to navigate between the tabs and BottomNavigationItem for creating the items.

@Composable
private fun RowScope.TabNavigationItem(tab: Tab) {
    val tabNavigator = LocalTabNavigator.current

    BottomNavigationItem(
        selected = tabNavigator.current == tab,
        onClick = { tabNavigator.current = tab },
        icon = {
            tab.options.icon?.let {
                Icon(painter = it, contentDescription = tab.options.title)
            }
        }
    )
}

Now we can use the helper function.

BottomNavigation {
    TabNavigationItem(FirstTab)
    TabNavigationItem(SecondTab)
}

The content of our Scaffold function should display the current tab, to do so we need to just use Voyagersfunction CurrentTab().

@Composable
override fun Content() {
    Scaffold(
        bottomBar = {
            ...
        }
    ) {
        CurrentTab()
    }
}

For the convenience we should add the entrypoint to any existing screen.

@Composable
private fun TabScreenButton() {
    val navigator = LocalNavigator.currentOrThrow

    Button(onClick = { navigator.push(TabScreen()) }) {
        Text("Tabs")
    }
}

Coroutines

The ScreenModel provides a simple way to handle the async operations with coroutines. Following the documentation we can implement a countdown timer to check how it works. The Screen provides a screenModelScope it is cancelled automatically when the screen is disposed.

class FirstScreenModel : ScreenModel {
    ...
    val countDownText = mutableStateOf<String>("0")

    init {
        screenModelScope.launch {
            for (i in 10 downTo 0) {
                countDownText.value = i.toString()
                delay(1000)
            }
        }
    }
}

Summary

The Voyager is a great library for navigation in the Compose Multiplatform projects. It is easy to use and provides various ways to navigate between the screens. The library is tightly coupled with the Jetpack Compose and can use ScreenModel or a ViewModel for handling the business logic that’s flexible and can speed up the process if you used such an approach in the past.

Comparing to the Decompose I mentioned in the previous post it is simpler to configure and use out of the box. It requires less effort to start with, but on the other hand, it is more tied to Compose itself. Another library for composing multiplatform navigation is Appyx which has a lot of similarities to the Voyager. It also is closely connected to compose and provides a lot of flexible features that can be used in the application. But I find myself more comfortable with the Voyager, it is more intuitive.

If you are looking for a navigation lib for your compose multiplatform project you definitely should give it a try!

If you want to meet us in person, click here and we’ll get in touch!