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鈥檚 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.


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.

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鈥檚 begin by defining possible navigation events.

sealed class ViewModelNavigatorEvent {
  object Default : ViewModelNavigatorEvent()
  object Pop : ViewModelNavigatorEvent()
  object PopToRoot : ViewModelNavigatorEvent()
  class PopUpTo<T : Screen>(val screenClass: KClass<T>) : 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鈥檚 define an interface for that.

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

With the interface defined, let鈥檚 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.

class ViewModelNavigator {
    private val _navigationFlow: MutableStateFlow<ViewModelNavigatorEvent> = 
        MutableStateFlow(ViewModelNavigatorEvent.Default)

    val eventManager: ViewModelNavigatorEventManager = object : ViewModelNavigatorEventManager {
        override val navigationFlow: StateFlow<ViewModelNavigatorEvent> =
            _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 <T : Screen> popUpTo(screen: KClass<T>) = emit(ViewModelNavigatorEvent.PopUpTo(screen))
    fun push(screen: Screen) = emit(ViewModelNavigatorEvent.Push(screen))
}

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

@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鈥檚 use it in our app. First step is to incorporate usage of ViewModelNavigator into the ViewModel. ViewModel implements ViewModelNavigatorEventManager using delegation pattern.

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.

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鈥檚 all, isn鈥檛 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.

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 <reified T : ViewModel> 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.

Thanks for staying till the last.

Interesting? Share the article with your friends!
Avatar photo
J贸zef Piechaczek
Technical Consultant Application Support | Android Developer