Navigation in Jetpack Compose using Voyager library and ViewModel state

In this article we will use Voyager library to implement navigation in Jetpack Compose. The navigation will be driven from ViewModel and will use StateFlow for handling one-off navigation events.

Why Voyager?

The official Google library for navigation suffers from many problems. The main one is usage of URLs for routes and passing parameters. Because of that, every parameter must be manually encoded into String, making it more error-prone and adding a lot of boilerplate code every time a parameter must be converted. Even though version 2.4.0-alpha10 makes it possible to declare custom navigation types, it still requires declaring additional classes and converting values into JSONs.

The flaws of official library resulted in many custom solutions, created by the Android community. One of them is Voyager library, first multiplatform navigation library for Jetpack Compose, with support for Android and Desktop. Voyager is based on a stack of Screens; it provides support for scoped ViewModels, custom parameter types, transitions, deep links etc. The navigation is handled by the Navigator object, which is accessible in Composables using the CompositionLocal mechanism.

Even though Voyager is a mature library, it still can be a risky choice for a production application, as it has a limited support when compared to the official Google library. The concepts explained in this article can be applied to other navigation libraries (after some adjustments).

Why ViewModel-driven navigation?

Driving navigation from ViewModel makes it possible to control the navigation with a single source of truth. Because of that navigation can be triggered in response to a ViewModel based event, like for example an API call response.

In this example, navigation events are handled using StateFlow. This is the way recommended by Google. Although SharedFlow/Channels may look like a better choice for handling one-off events, they do not guarantee the processing of the event. For more information about this topic, check out this article by one of the Google developers.

Case Study

Let’s think about a simple app, consisting of two screens – a list of tasks and a detailed task view. The screens must inherit from AndroidScreen class, which provides its own LocalViewModelStoreOwner and LocalSavedStateRegistryOwner. The detailed view, for the sake of completeness, will take in a taskId parameter.

Code:

class TaskListScreen : AndroidScreen() {
@Composable
override fun Content() { /* content goes here */ }
}
class TaskDetailScreen(taskId: Int) : AndroidScreen() {
@Composable override fun Content() { /* content goes here */ }
}

The navigation takes place in the activity. Navigator defines the initial screen and the transition/animation that happens during navigation.

Code:

class TaskActivity : ComponentActivity() {
@OptIn(ExperimentalAnimationApi::class)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
AppTheme {
Navigator(
screen = TaskListScreen(),
content = { navigator -> SlideTransition(navigator) }
)
}
}
}
}

This basic setup allows to trigger navigation from Composables, but how to do it from ViewModel? Let’s begin by defining possible navigation events.

Code:

sealed class ViewModelNavigatorEvent {
object Default : ViewModelNavigatorEvent()
object Pop : ViewModelNavigatorEvent()
object PopToRoot : ViewModelNavigatorEvent()
class PopUpTo(val screenClass: KClass) : ViewModelNavigatorEvent()
class Push(val screen: Screen) : ViewModelNavigatorEvent()
}

Event Default is responsible for representing state, in which the event was handled. Voyager is stack-based, so the events correspond to stack operations. Event PopUpTo allow us to pop until a first instance of a given screen class is reached.

ViewModel must expose two objects, to allow to handle the navigation: a StateFlow providing the navigation events and a callback, which notifies that the event was handled. Let’s define an interface for that.

Code:

interface ViewModelNavigatorEventManager {
val navigationFlow: StateFlow
fun onNavigationEventConsumed()
}

With the interface defined, let’s jump to implementation. ViewModelNavigator will hold the StateFlow and will be responsible for emitting the events. It will also provide us with an implementation of ViewModelNavigatorEventManager, which will be used to handle the event in Composable.

Code:

class ViewModelNavigator {
private val _navigationFlow: MutableStateFlow =
MutableStateFlow(ViewModelNavigatorEvent.Default)
val eventManager: ViewModelNavigatorEventManager = object : ViewModelNavigatorEventManager {
override val navigationFlow: StateFlow =
_navigationFlow.asStateFlow()
override fun onNavigationEventConsumed() =
emit(ViewModelNavigatorEvent.Default)
}
private fun emit(event: ViewModelNavigatorEvent) {
_navigationFlow.value = event
}

fun pop() = emit(ViewModelNavigatorEvent.Pop)
fun popToRoot() = emit(ViewModelNavigatorEvent.PopToRoot)
fun popUpTo(screen: KClass) = emit(ViewModelNavigatorEvent.PopUpTo(screen))
fun push(screen: Screen) = emit(ViewModelNavigatorEvent.Push(screen))
}

The last step is to handle the navigation event. Let’s define a Composable which will call Navigator function corresponding to the received event. 

Code:

@Composable
fun ViewModelNavigatorHandler(
navigator: Navigator,
eventManager: ViewModelNavigatorEventManager,
) {
val event = eventManager.navigationFlow.collectAsStateWithLifecycle().value
LaunchedEffect(event) {
when (event) {
is ViewModelNavigatorEvent.Default -> return@LaunchedEffect
is ViewModelNavigatorEvent.Pop -> navigator.pop()
is ViewModelNavigatorEvent.PopToRoot -> navigator.popUntilRoot()
is ViewModelNavigatorEvent.Push -> navigator.push(event.screen)
is ViewModelNavigatorEvent.PopUpTo -> navigator.popUntil { event.screenClass.isInstance(it) }
}
eventManager.onNavigationEventConsumed()
}
}

Now, with the navigation implemented, let’s use it in our app. First step is to incorporate usage of ViewModelNavigator into the ViewModel. ViewModel implements ViewModelNavigatorEventManager using delegation pattern.

Code: 

class TodoViewModel(
private val navigator: ViewModelNavigator = ViewModelNavigator()
) : ViewModel(), ViewModelNavigatorEventManager by navigator.eventManager {

fun onTaskClick(taskId: Int) = navigator.push(TodoDetailScreen(taskId = taskId))
fun onBackClick() = navigator.pop()
}

The navigation event must be handled in the place which contains the original Navigator, so in this case it is TaskActivity.

Code:

class TaskActivity : ComponentActivity() {
private val viewModel: TaskViewModel by viewModels()

@OptIn(ExperimentalAnimationApi::class)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
TaskTheme {
Navigator(
screen = TodoListScreen(),
content = { navigator ->
ViewModelNavigatorHandler(navigator, viewModel)
SlideTransition(navigator)
}
)
}
}
}
}

So, that’s all, isn’t it? Well, there is still one important thing to consider. As previously mentioned, AndroidScreen class provides its own LocalViewModelStoreOwner, so any ViewModel it creates will be scoped to the screen itself by default, resulting in a different instance of ViewModel for any of the screens and the activity.

To handle that, we can create a helper function, which will set up a ViewModel instance scoped to the lifecycle of the activity. To access the activity, we need to iterate over the Context searching for a ComponentActivity instance.

Code: 

fun Context.getActivity(): ComponentActivity =
when (this) {
is ComponentActivity -> this
is ContextWrapper -> this.baseContext.getActivity()
else -> throw IllegalArgumentException("Context should be an instance of ComponentActivity")
}

@Composable
inline fun activityViewModel(): T =
viewModel(viewModelStoreOwner = LocalContext.current.getActivity())

By using the activityViewModel function in TaskListScreen/TaskDetailScreen we get a ViewModel instance scoped to the TaskActivity. The implementation can differ, when using dependency injection frameworks.

The End

In this example we implemented navigation using ViewModel as a single source of truth. I know it may be a long read, but I believe showing a complete example is more useful than abstract implementation.

Author: Józef Piechaczek, Technical Consultant Application Support | Android Developer

 
Follow us! 
www.linkedin.com/company/convistapoland
Blog | Convista

Über ConVista Consulting Sp. z o.o.

Convista is one of the leading consultancies for business transformation. It offers comprehensive and reliable support to its clients in developing and implementing new business processes and crafting end-to-end solutions for SAP as well as IT projects.

In collaboration with its clients, Convista manages complex challenges by combining many years of industry and technology experience and profound expertise. With over two decades of experience, Convista has established itself as a trusted partner for customers operating in the insurance, industry, healthcare, and energy sectors, enabling them to adapt to the demands of an ever-evolving digital world.

IT blog. For IT people. Powered by Convista Poland.

Convista Poland’s blog is the ultimate hub for IT enthusiasts packed with cutting-edge IT articles crafted by Convista´s skilled and experienced employees specializing in Java, iOS, Android, and ABAP, making sure you get top-notch content on these subjects. What makes it truly priceless? It’s the hands-on experience of creators, who actively work on corporate projects every day. They are always pushing the boundaries of their skills, eager to share knowledge, and constantly motivated to explore new frontiers.

Concerning the blog topics and valuable content, one is able to stay up-to-date with the latest IT trends and advancements as well as the opportunity to join a community of like-minded individuals passionate about technology.

Firmenkontakt und Herausgeber der Meldung:

ConVista Consulting Sp. z o.o.
Ul. Powstańców Śląskich 7A
PL53-332 Wrocław
Telefon: +49 (221) 88826-0
Telefax: +49 (221) 88826-199
https://www.convista.pl

Ansprechpartner:
Wioleta Patkowska
HR Manager
E-Mail: wioleta.patkowska@convista.com
Für die oben stehende Pressemitteilung ist allein der jeweils angegebene Herausgeber (siehe Firmenkontakt oben) verantwortlich. Dieser ist in der Regel auch Urheber des Pressetextes, sowie der angehängten Bild-, Ton-, Video-, Medien- und Informationsmaterialien. Die United News Network GmbH übernimmt keine Haftung für die Korrektheit oder Vollständigkeit der dargestellten Meldung. Auch bei Übertragungsfehlern oder anderen Störungen haftet sie nur im Fall von Vorsatz oder grober Fahrlässigkeit. Die Nutzung von hier archivierten Informationen zur Eigeninformation und redaktionellen Weiterverarbeitung ist in der Regel kostenfrei. Bitte klären Sie vor einer Weiterverwendung urheberrechtliche Fragen mit dem angegebenen Herausgeber. Eine systematische Speicherung dieser Daten sowie die Verwendung auch von Teilen dieses Datenbankwerks sind nur mit schriftlicher Genehmigung durch die United News Network GmbH gestattet.

counterpixel