In this guide you'll learn how to make a simple User Authentication Flow using Compose Multiplatform
(for an Android
mobile app and a Wasm
web app)
Before we begin
All of the code is in the support repositories. I'll try to only mention the relevant parts related to the navigation, so you can catch the overall idea. Please review the other classes if you have questions about the dependency injection using koin, the screens and ViewModels
Support repositories
- Project we will be doing
- Big complex project with the same principles learned here
Overview of the project
This is the app we will be doing
Navigation graph
Navigation flow
Components
Getting started
The easiest way to start is by creating a new project with the Kotlin Multiplatform Wizard
Show me the code
So let's start by making the navigation graph
NavHost(
navController = navHostController,
startDestination = Init,
) {
composable<Init> {
// This is required so the ViewModel makes the first request to server
val initViewModel = koinViewModel<InitViewModel>()
InitScreen()
}
composable<Login> {
val loginViewModel = koinViewModel<LoginViewModel>()
LoginScreen(
onLogin = { username: String, password: String ->
loginViewModel.login(username, password)
}
)
}
// Nested navigation graph
navigation<Home>(startDestination = UserSettings) {
home()
}
}
And the nested graph
fun NavGraphBuilder.home() {
composable<UserSettings> {
val userSettingsViewModel = koinViewModel<UserSettingsViewModel>()
UserSettingsScreen(
onLogout = { userSettingsViewModel.logout() },
)
}
}
Using the NavController
from the navigation library you can navigate from any composable easily doing something like navHostController.popBackStack()
but because the ViewModels are the ones that do the requests to server to determine if the user is logged or not, how do we navigate from inside a ViewModel?
To do this we would need to implement 3 things:
- NavigationController: This will emit
NavigationEvents
which are an abstraction on top of the navigation library - NavigationControllerObserver: This will listen to the navigation events emitted by the
NavigationController
and use theNavController
to navigate. Basically converting them to the actual navigation - RegisterNavigationControllerObserver: Is an extension function that makes the observer, and connects it to the
NavController
and theNavigationController
NavigationController
/**
* Proxy to send and receive navigation events from the navController inside ViewModels
*/
interface NavigationController {
val navigationEvents: SharedFlow<NavigationEvent>
suspend fun sendNavigationEvent(navigationEvent: NavigationEvent)
sealed interface NavigationEvent {
data class Navigate(
val destinationRoute: Route,
val launchSingleTop: Boolean = false,
val restoreState: Boolean = false,
val popUpTo: PopUpTo? = null,
) : NavigationEvent {
data class PopUpTo(
val startRoute: Route,
val isInclusive: Boolean,
val saveState: Boolean,
)
}
data object PopBackStack : NavigationEvent
}
}
class DefaultNavigationController : NavigationController {
private val _navigationEvents = MutableSharedFlow<NavigationEvent>()
override val navigationEvents: SharedFlow<NavigationEvent> = _navigationEvents
override suspend fun sendNavigationEvent(navigationEvent: NavigationEvent) {
_navigationEvents.emit(navigationEvent)
}
}
NavigationControllerObserver
/**
* Observer that converts NavigationEvents from the [NavigationController] into actual navigation using the NavHost
*/
interface NavigationControllerObserver {
val navigationController: NavigationController
val navHostController: NavHostController
suspend fun subscribe() {
coroutineScope {
launch {
navigationController.navigationEvents.collect(::onNavigationEvent)
}
}
}
fun onNavigationEvent(navigationEvent: NavigationEvent)
}
class DefaultNavigationControllerObserver(
override val navigationController: NavigationController,
override val navHostController: NavHostController,
) : NavigationControllerObserver {
override fun onNavigationEvent(navigationEvent: NavigationEvent) {
when (navigationEvent) {
is Navigate -> {
navHostController.navigate(route = navigationEvent.destinationRoute) {
navigationEvent.popUpTo?.let { popUpTo ->
popUpTo(popUpTo.startRoute) {
inclusive = popUpTo.isInclusive
saveState = popUpTo.saveState
}
}
}
}
is PopBackStack -> navHostController.popBackStack()
}
}
}
@Composable
fun rememberNavigationObserver(
navigationController: NavigationController,
navHostController: NavHostController,
): NavigationControllerObserver =
remember {
DefaultNavigationControllerObserver(navigationController, navHostController)
}
Navigation extensions
fun popUpToInclusive(
startRoute: Route,
saveState: Boolean = false,
): NavigationEvent.Navigate.PopUpTo =
NavigationEvent.Navigate.PopUpTo(
startRoute = startRoute,
isInclusive = true,
saveState = saveState,
)
/**
* Convenience composable that connects the NavHost and the [NavigationController] by a [NavigationControllerObserver]
*/
@Composable
fun RegisterNavigationControllerObserver(
navigationController: NavigationController,
navHostController: NavHostController
) {
val navigationControllerObserver: NavigationControllerObserver =
rememberNavigationObserver(navigationController, navHostController)
LaunchedEffect(navHostController, navigationController, navigationControllerObserver) {
navigationControllerObserver.subscribe()
}
}
Let's see how this works with the LoginViewModel
class LoginViewModel(
// The data source to send requests to server
private val authDataSource: AuthDataSource,
// Some utility class to get the io and ui dispatchers
dispatchersProvider: DispatchersProvider,
// The NavigationController we created to navigate
private val navigationController: NavigationController,
) : ViewModel() {
// Not related to the navigation, but needed for the request
private var loginJob: Job? = null
private val ioScope = CoroutineScope(dispatchersProvider.io())
fun login(username: String, password: String) {
loginJob = ioScope.launch {
// We do the request to the server
val loginResult = authDataSource.login(username = username, password = password)
if (loginResult) {
// If the request to login was successful we navigate to Home
navigationController.login()
} else {
// For simplicity, we are not notifying the user if there was an error with the login
}
}
}
// Convenience extension to navigate to Home
private suspend fun NavigationController.login() {
sendNavigationEvent(
NavigationEvent.Navigate(
destinationRoute = Home,
launchSingleTop = true,
popUpTo = popUpToInclusive(startRoute = Login),
)
)
}
// More code...
}
This is how you navigate from inside a ViewModel
. Remember to call the RegisterNavigationControllerObserver
extension function that glues everything, just over your NavHost
like this
RegisterNavigationControllerObserver(
navigationController = koinInject(),
navHostController = navHostController
)
NavHost(...)
Advantages of this approach
I've found this approach to be useful for the following reasons:
- You have a single global entry-point which is the Init destination. Here you always check if the user is logged or not, then remove the destination, so when the user tries to go back, it exits the app
- You can very easily add and remove screens by using nested navigation graphs
- You have a unified component (the
NavigationController
) to do navigation from your non-compose code which is very handy and can even be mocked for unit tests - All the code is shared between
Android
andWasm
targets so the navigation is actually multiplatform but if you wanted you could adjust theNavigationController
depending on the platform you are on - You can add events (like analytics or logs) on each navigation event if you wanted
Things that can be improved
To make this into a proper production-ready solution, we would need to add a few improvements to the authentication flow:
- A dependencies scope that only exists if the user is logged, so we don't leak logged user data to a non-logged user
- Automatic navigation to Login destination if the user is logged out for any reason
- Persistent session handling, so exiting the app doesn't logs out the user
-
BottomNavigation
orNavigationRail
when the user is logged in and depending of the screen size of the device - Token encryption for platforms that handle token based authentication (like mobile)
- Real data source to make actual requests to server
Top comments (0)