- Регистрация
- 1 Мар 2015
- Сообщения
- 1,481
- Баллы
- 155
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) with it's Ktor Server
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
This is a 3 parts guide
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
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:
/**
* 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:
To make this into a proper production-ready solution, we would add a few improvements to the authentication flow:
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
This is a 3 parts guide
- Part 1: Adding the navigation graph in the app (this guide)
- Part 2: Handling user sessions and tokens in the app (TBD)
- Part 3: Creating Ktor server to handle the authentication (TBD)
- Branch for only
- Complete
- Big with the same principles learned here
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
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 the NavController 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 the 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 and Wasm targets so the navigation is actually multiplatform but if you wanted you could adjust the NavigationController depending on the platform you are on
- You can add events (like analytics or logs) on each navigation event if you wanted
To make this into a proper production-ready solution, we would add a few improvements to the authentication flow:
- A dependencies scope that only exists if the user is logged
- 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 or NavigationRail when the user is logged in and depending of the screen size of the device
- Token encryption for platforms that handle token based authentication