I have a problem with navigation in my Compose app. So starting from the beginning I want to have two navigation graphs: one for authentication related things and second for main functionalities, which should be accessible only after login. So typical case.
I want to use Splash Screen API from Android 12, so in my main activity I could do something like this:
class AppMainActivity : ComponentActivity() {
private val viewModel by viewModels<SplashViewModel>()
private var userAuthState: UserAuthState = UserAuthState.UNKNOWN
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
viewModel.onEvent(SplashEvent.CheckAuthentication)
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.isAuthenticated.collect {
userAuthState = it
}
}
}
setContent {
CarsLocalizerTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colors.background
) {
val scaffoldState = rememberScaffoldState()
val navController = rememberNavController()
Scaffold(
modifier = Modifier.fillMaxSize(),
scaffoldState = scaffoldState
) {
val splash = installSplashScreen()
splash.setKeepOnScreenCondition {
userAuthState != UserAuthState.UNKNOWN
}
when (userAuthState) {
UserAuthState.UNAUTHENTICATED -> {
AuthNavigation(
navController = navController,
scaffoldState = scaffoldState
)
}
UserAuthState.AUTHENTICATED -> {
HomeScreen(
viewModel = hiltViewModel(),
onLogout = { navController.popBackStack() }
)
}
UserAuthState.UNKNOWN -> {}
}
}
}
}
}
}
}
Here I am collecting StateFlow from view model, which describes if user is authenticated already or no. If authenticated successfully then go to HomeScreen, which has HomeNavigation inside. If not authenticated, go to Authentication nav graph. Problem is that this approach would not work, since activity is created only once, so if I user will login, this when
when (userAuthState) {
UserAuthState.UNAUTHENTICATED -> {
AuthNavigation(
navController = navController,
scaffoldState = scaffoldState
)
}
UserAuthState.AUTHENTICATED -> {
HomeScreen(
viewModel = hiltViewModel(),
onLogout = { navController.popBackStack() }
)
}
UserAuthState.UNKNOWN -> {}
}
Won’t be called again.
I was trying to find some solution for my problem but, can’t find anything helpful. Maybe somebody had such issue before, or saw something useful? Will be very glad for any help.
Rest of my code:
SplashViewModel
#HiltViewModel
class SplashViewModel #Inject constructor(
private val authenticateUseCase: AuthenticateUseCase
): ViewModel() {
private val _isAuthenticated = MutableStateFlow<UserAuthState>(value = UserAuthState.UNKNOWN)
val isAuthenticated: StateFlow<UserAuthState> = _isAuthenticated.asStateFlow()
fun onEvent(event: SplashEvent) {
when (event) {
is SplashEvent.CheckAuthentication -> {
viewModelScope.launch {
val result = authenticateUseCase()
when (result) {
true -> {
_isAuthenticated.emit(UserAuthState.AUTHENTICATED)
}
false -> {
_isAuthenticated.emit(UserAuthState.UNAUTHENTICATED)
}
}
}
}
}
}
}
AuthNavigation
#Composable
fun AuthNavigation(
navController: NavHostController,
scaffoldState: ScaffoldState
) {
NavHost(
navController = navController,
startDestination = Screen.Login.route,
modifier = Modifier.fillMaxSize()
) {
composable(Screen.Login.route) {
LoginScreen(
onNavigate = { navController.navigate(it) } ,
onLogin = {
navController.popBackStack()
},
scaffoldState = scaffoldState,
viewModel = hiltViewModel()
)
}
composable(Screen.Register.route) {
RegisterScreen(
onPopBackstack = { navController.popBackStack() },
scaffoldState = scaffoldState,
viewModel = hiltViewModel()
)
}
composable(Screen.Onboarding.route) {
OnboardingScreen(
onCompleted = { navController.popBackStack() },
viewModel = hiltViewModel()
)
}
}
}
HomeScreen
#Composable
fun HomeScreen(
viewModel: HomeViewModel,
onLogout: () -> Unit
) {
val navController = rememberNavController()
val scaffoldState = rememberScaffoldState()
var appBarTitle by remember {
mutableStateOf("")
}
LaunchedEffect(key1 = true) {
viewModel.userName.collectLatest {
appBarTitle = "Hello $it"
}
}
Scaffold(
scaffoldState = scaffoldState,
topBar = {
if (appBarTitle.isEmpty()) {
AppBarWithName("Hello")
} else {
AppBarWithName(appBarTitle)
}
},
bottomBar = { BottomNavigationBar(navController) },
content = { padding ->
Box(modifier = Modifier.padding(padding)) {
HomeNavigation(
navController = navController,
scaffoldState = scaffoldState,
onLogout = { onLogout() }
)
}
}
)
}
HomeNavigation
#Composable
fun HomeNavigation(
navController: NavHostController,
scaffoldState: ScaffoldState,
onLogout: () -> Unit
) {
NavHost(
navController = navController,
startDestination = Screen.Map.route
) {
composable(Screen.Map.route) {
MapScreen(viewModel = hiltViewModel())
}
composable(Screen.ManageCars.route) {
ManageCarsScreen(
viewModel = hiltViewModel(),
scaffoldState = scaffoldState,
onAddCar = {
navController.navigate(Screen.AddCar.route)
}
)
}
composable(Screen.AddCar.route) {
AddCarScreen(
viewModel = hiltViewModel(),
onPopBackstack = {
navController.popBackStack()
}
)
}
composable(Screen.Logout.route) {
LogoutDialogScreen(
viewModel = hiltViewModel(),
onLogout = {
navController.popBackStack()
onLogout()
},
onCancel = {
navController.popBackStack()
}
)
}
}
}
You need to store the UserAuthState as MutableState, this way, when the value is updated, setContent will automatically re-compose:
private var userAuthState = mutableStateOf(UserAuthState.UNKNOWN)
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
viewModel.isAuthenticated.collect { userAuthState.value = it }
}
}
setContent {
when (userAuthState.value) {
etc....
}
}
From the docs:
mutableStateOf creates an observable MutableState, which is an
observable type integrated with the compose runtime.
Any changes to value schedules recomposition of any composable
functions that read value.
Related
I just learned Jetpack Compose and building a simple login screen with retrofit to connect with the API.
I'm able to navigate from login screen to home screen. But I'm wondering if I'm doing it right.
Here is my login screen composable
#OptIn(ExperimentalMaterialApi::class)
#Composable
fun InsertNumberScreen(
modifier: Modifier = Modifier,
navHostController: NavHostController,
viewModel: LoginViewModel = viewModel(factory = LoginViewModel.provideFactory(
navHostController = navHostController,
owner = LocalSavedStateRegistryOwner.current
)),
) {
var phoneNumber by remember {
mutableStateOf("")
}
var isActive by remember {
mutableStateOf(false)
}
val modalBottomSheetState =
rememberModalBottomSheetState(initialValue = ModalBottomSheetValue.Hidden)
val coroutine = rememberCoroutineScope()
ModalBottomSheetLayout(
sheetState = modalBottomSheetState,
sheetContent = {
BottomSheetLoginContent(phoneNumber){
//Here I call login function inside viewModel
viewModel.login(phoneNumber)
}
},
sheetShape = RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp)
) {
Column {
TopAppBarCustom(text = "")
LoginText(modifier = modifier.padding(16.dp))
Row(modifier = modifier.padding(16.dp)) {
Prefix()
PhoneNumber(
shape = RoundedCornerShape(topEnd = 16.dp, bottomEnd = 16.dp),
value = phoneNumber,
onValueChange = {
isActive = it.length >= 10
phoneNumber = it
})
}
Spacer(
modifier = Modifier
.fillMaxHeight()
.weight(1f)
)
BottomContainer(isEnabled = isActive) {
coroutine.launch {
if (modalBottomSheetState.isVisible) {
modalBottomSheetState.animateTo(ModalBottomSheetValue.Hidden)
} else {
modalBottomSheetState.animateTo(ModalBottomSheetValue.Expanded)
}
}
}
}
}
}
Here is my ViewModel
class LoginViewModel(val navHostController: NavHostController) : ViewModel() {
var result by mutableStateOf(Data(0, "", Message("", "")))
fun login(phone: String) {
val call: Call<Data> = Network.NetworkInterface.login(phone)
call.enqueue(
object : Callback<Data> {
override fun onResponse(call: Call<Data>, response: Response<Data>) {
if (response.code() == 400) {
val error =
Gson().fromJson(response.errorBody()!!.charStream(), Data::class.java)
result = error
navHostController.navigate("login")
} else {
result = response.body()!!
navHostController.navigate("home")
}
}
override fun onFailure(call: Call<Data>, t: Throwable) {
Log.d("Data Login", t.message.toString())
}
}
)
}
companion object {
fun provideFactory(
navHostController: NavHostController,
owner: SavedStateRegistryOwner,
defaultArgs: Bundle? = null,
): AbstractSavedStateViewModelFactory =
object : AbstractSavedStateViewModelFactory(owner, defaultArgs) {
#Suppress("UNCHECKED_CAST")
override fun <T : ViewModel> create(
key: String,
modelClass: Class<T>,
handle: SavedStateHandle
): T {
return LoginViewModel(navHostController) as T
}
}
}
}
In my viewModel class, it has a constructor NavHostController. And then, in the login method, I call navHostController.navigate() to navigate to home screen if the login is success.
The question is, is it okay to call navHostController.navigate() directly inside the viewModel? Because I follow codelabs from Google and the navigation is handled in the sort of NavHostBootstrap composable (Something like this)
#Composable
fun RallyNavHost(
navController: NavHostController,
modifier: Modifier = Modifier
){
NavHost(navController = navController, startDestination = Overview.route, modifier = modifier){
composable(Overview.route){
OverviewScreen(
onClickSeeAllAccounts = {
navController.navigateSingleTopTo(Accounts.route)
},
onClickSeeAllBills = {
navController.navigateSingleTopTo(Bills.route)
},
onAccountClick = {
Log.d("Account Clicked", it)
navController.navigateToSingleAccount(it)
}
)
}
}
I am using compose navigation with single activity and no fragments.
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MobileComposePlaygroundTheme {
Surface(color = MaterialTheme.colors.background) {
val navController = rememberNavController()
NavHost(navController, startDestination = "main") {
composable("main") { MainScreen(navController) }
composable("helloScreen/{data}") { HelloScreen() }
}
}
}
}
}
}
#Composable
private fun MainScreen(navController: NavHostController) {
val viewModel = viewModel()
val loginState by viewModel.loginState
LaunchedEffect(loginState) {
if(loginState is State.Success){
navController.navigate("helloScreen/data")
}
}
Column {
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = { viewModel.login() }, //viewmodel will change loginState
modifier = Modifier.padding(8.dp)
) {
Text(text = "Go To HelloScreen")
}
}
}
#Composable
fun HelloScreen() {
Log.d("TAG", "HelloScreen")
Text("Hello Screen")
}
This post is hidden. You deleted this post 7 mins ago.
i have some problem
MainScreen(loginState)-> LaunchedEffect(loginState) -> HelloScreen -> back button -> MainScreen
when i pop HelloScreen
MainScreen will be recompose for
loginState, LaunchedEffect(netWorkState) will navigate(HelloScreen) again
how can i change code right of navigate
Use Disposable effect
DisposableEffect(loginState) {
if(loginState is State.Success){
navController.navigate("helloScreen/data")
}
onDispose {
loginState = State.Idle // Reset back your state here
}
}
I have a splash screen that I show when the app launch. I'm using SplashScreenAPI for the splash screen. I'm also using accompanist navigation library to navigate with animations. After I updated the accompanist version from 0.24.7-alpha to 0.24.8-beta I've encountered an issue. The issue is :
As you can see after the splash screen is shown there is a blank screen for a sec, then it navigates to the start destination.
Here is also the behavior for version 0.24.7-alpha:
In case of need here is also the code:
#OptIn(ExperimentalMaterial3WindowSizeClassApi::class)
#AndroidEntryPoint
class AuthActivity : ComponentActivity() {
private val splashViewModel: SplashViewModel by viewModels()
private lateinit var splashScreen: SplashScreen
override fun onCreate(savedInstanceState: Bundle?) {
splashScreen = installSplashScreen()
super.onCreate(savedInstanceState)
splashScreen.setKeepOnScreenCondition {
splashViewModel.isLoading.value
}
splashScreen.setOnExitAnimationListener {
val startDestination = splashViewModel.navDestination.value.route
if (startDestination == AUTH_GRAPH) {
it.remove()
} else {
val intent = Intent(this#AuthActivity, MainActivity::class.java)
startActivity(intent)
finish()
}
}
setContent {
val errorMessage by splashViewModel.errorFlow.collectAsState()
val widthSizeClass = calculateWindowSizeClass(this).widthSizeClass
ProvideLocalWindowWidthSizeClass(widthSizeClass = widthSizeClass) {
RubiBrandsTheme {
RubiBrandsBackground {
errorMessage?.asString(this#AuthActivity)?.let {
ErrorDialog(message = it)
}
AuthNavGraph(
navController = rememberAnimatedNavController(),
activity = this#AuthActivity
)
}
}
}
}
}
}
I just check the authentication and then navigate the user based on success or not.
Here is my MainActivity :
#[AndroidEntryPoint OptIn(ExperimentalMaterial3WindowSizeClassApi::class)]
class MainActivity : ComponentActivity(), AuthenticationManager {
private val mViewModel by viewModels<MainActivityViewModel>()
#Inject
lateinit var authenticationMediator: AuthenticationMediator
private lateinit var navController: NavHostController
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
WindowCompat.setDecorFitsSystemWindows(window, false)
authenticationMediator.registerAuthenticationManager(this)
setContent {
val widthSizeClass = calculateWindowSizeClass(this).widthSizeClass
val showUnauthorizedDialog = mViewModel.showUnauthorizedDialog
ProvideLocalWindowWidthSizeClass(widthSizeClass = widthSizeClass) {
RubiBrandsTheme {
RubiBrandsBackground {
navController = rememberAnimatedNavController()
val bottomSheetNavigator =
rememberBottomSheetNavigator()
val scaffoldState = rememberScaffoldState()
navController.navigatorProvider += bottomSheetNavigator
val navBackStackEntry by navController.currentBackStackEntryAsState()
val currentDestination = navBackStackEntry?.destination
val rubiBrandsTopLevelNavigation = remember(navController) {
RubiBrandsTopLevelNavigation(navController)
}
val topLevelDestinations = listOf(
TopLevelDestination.DashboardDestination,
TopLevelDestination.OrdersDestination,
TopLevelDestination.ProductsDestination,
TopLevelDestination.AccountDestination
)
val topLevelDestinationRoutes =
topLevelDestinations.map(TopLevelDestination::route)
val isNavBarVisible =
navBackStackEntry?.destination?.route in topLevelDestinationRoutes
ModalBottomSheetLayout(
modifier = Modifier
.systemBarsPadding()
.imePadding(),
bottomSheetNavigator = bottomSheetNavigator,
sheetElevation = dimensionResource(id = R.dimen.dimen_16),
sheetShape = RoundedCornerShape(
topStart = dimensionResource(id = R.dimen.dimen_16),
topEnd = dimensionResource(
id = R.dimen.dimen_16
)
),
sheetBackgroundColor = RubiBrandsTheme.colors.filterBottomSheetBackgroundColor
) {
RubiBrandsScaffold(
modifier = Modifier
.systemBarsPadding()
.imePadding(),
bottomBar = {
if (isNavBarVisible) {
RubiBrandsBottomNavigationView(
onNavigateToTopLevelDestination = rubiBrandsTopLevelNavigation::navigateTo,
currentDestination = currentDestination,
topLevelDestinations = topLevelDestinations
)
}
},
scaffoldState = scaffoldState
) {
//navigation graph
Box(
modifier = Modifier
.padding(it)
) {
MainNavGraph(
navController = navController,
mainActivity = this#MainActivity,
mainActivityViewModel = mViewModel,
)
showUnauthorizedDialog?.getContentIfNotHandled()?.let { show ->
if (show) {
RubiBrandsUnauthorizedScreen {
logClickEvent(ItemNames.UNAUTHORIZED_DIALOG_OKAY_BUTTON)
logout()
}
}
}
}
}
}
}
}
}
}
}
#Composable
fun MainNavGraph(
navController: NavHostController,
modifier: Modifier = Modifier,
) {
AnimatedNavHost(
navController = navController,
startDestination = MAIN_GRAPH,
) {
//main nav graph
mainNavGraph(navController)
}
fun NavGraphBuilder.mainNavGraph(
navController: NavController
) {
navigation(
startDestination = TopLevelDestination.DashboardDestination.route,
route = MAIN_GRAPH,
) {
addDashboardScreen(navController)
}
}
Here is also compose dependencies and the versions :
object Compose : Library {
object Version {
const val COMPOSE_VERSION = "1.2.0"
const val COMPOSE_ACTIVITY_VERSION = "1.5.1"
const val COMPOSE_CONSTRAINT_LAYOUT_VERSION = "1.0.1"
const val HILT_NAVIGATION_COMPOSE_VERSION = "1.0.0"
}
const val COMPOSE_UI = "androidx.compose.ui:ui:$COMPOSE_VERSION"
const val COMPOSE_MATERIAL = "androidx.compose.material:material:$COMPOSE_VERSION"
const val COMPOSE_UI_TOOLING_PREV =
"androidx.compose.ui:ui-tooling-preview:$COMPOSE_VERSION"
const val COMPOSE_ACTIVITY = "androidx.activity:activity-compose:$COMPOSE_ACTIVITY_VERSION"
const val COMPOSE_UI_TOOLING = "androidx.compose.ui:ui-tooling:$COMPOSE_VERSION"
const val COMPOSE_UI_TEST_MANIFEST = "androidx.compose.ui:ui-test-manifest:$COMPOSE_VERSION"
const val COMPOSE_CONSTRAINT_LAYOUT =
"androidx.constraintlayout:constraintlayout-compose:$COMPOSE_CONSTRAINT_LAYOUT_VERSION"
const val HILT_NAVIGATION_COMPOSE =
"androidx.hilt:hilt-navigation-compose:$HILT_NAVIGATION_COMPOSE_VERSION"
const val COMPOSE_LIVE_DATA = "androidx.compose.runtime:runtime-livedata:$COMPOSE_VERSION"
override val components: List<String>
get() = listOf(
COMPOSE_UI,
COMPOSE_MATERIAL,
COMPOSE_UI_TOOLING_PREV,
COMPOSE_ACTIVITY,
)
}
I know that my compose version should be compatible with the accompanist version. As per this I should set the accompanist version to 0.25.1 but because of this issue, I don't update the animation version.
My app has a main screen with a Scaffold and a BottomNavigation bar:
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
val navController = rememberNavController()
MyApplicationTheme {
Scaffold(
bottomBar = {
BottomBar(navController = navController)
}
) {
NavigationGraph(navController = navController)
}
}
}
}
...
#Composable
fun NavigationGraph(navController: NavHostController){
NavHost(navController = navController, startDestination = BottomMenuOption.Home.route) {
composable(route = BottomMenuOption.Home.route) {
HomeScreen(navController = navController)
}
composable(route = BottomMenuOption.Settings.settings) {
SettingsScreen()
}
composable(route = BottomMenuOption.Profile.route) {
ProfileScreen()
}
composable(route = "feature") {
FeatureScreen()
}
}
}
FeatureScreen has it's own Scaffold with a topBar instead of a bottomBar and when I navigate to it from HomeScreen, I want to replace the previous one from the main screen and just see a topBar but instead, I'm seeing the two bars in the screen.
#Composable
fun FeatureScreen() {
Scaffold (
topBar = {
TopBar(" Feature Screen")
}
) {
}
}
Is it possible to accomplish this? I'm thinking it could be done by just using a new Activity but ideally, I would like to keep the Single Activity approach.
I would suggest creating a new function like this:
#Composable
fun MainScaffold(
topBar: #Composable (() -> Unit) = {},
bottomBar: #Composable (() -> Unit) = {},
content: #Composable (PaddingValues) -> Unit){
Scaffold(
bottomBar = bottomBar,
topBar = topBar,
content = content
)
}
then, use this main scaffold in your screens:
#Composable
fun HomeScreen(navController: NavHostController) {
MainScaffold(
bottomBar = { BottomBar(navController = navController) },
content = {
// content
})
}
and in your feature screen:
#Composable
fun FeatureScreen() {
MainScaffold (
topBar = {
TopBar(" Feature Screen")
}
) {
//content
}
}
and in setContent
setContent {
val navController = rememberNavController()
VoiceAssistantJetpackComposeTheme {
NavigationGraph(navController = navController)
}}
I'm doing my test project using ViewModel and ComposeView.
My architecture include: one Activity and multi ComposeView, using navigation like this:
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
MainView()
}
}
}
#Composable
fun MainView() {
TestComposeTheme {
val navController = rememberNavController()
Surface(color = MaterialTheme.colors.background) {
NavHost(
navController = navController,
startDestination = KYCScreen.getName(),
) {
navigation(startDestination = KYCScreen.Preload.name, route = KYCScreen.getName()) {
composable(KYCScreen.Preload.name) { PreloadView(navHostController = navController) }
composable(KYCScreen.StartKYC.name) { StartView(navHostController = navController) }
composable(KYCScreen.Login.name) { LoginView(navHostController = navController) }
}
navigation(startDestination = HomeScreen.Home.name, route = HomeScreen.getName()) {
composable(HomeScreen.Home.name) { HomeView(navHostController = navController) }
}
}
}
}
}
The problem happens when I include logic that executes when a Composable function is executed, my logic code was looped many times. Here my code:
#Composable
fun PreloadView(navHostController: NavHostController) {
val preloadViewModel: PreloadViewModel = viewModel()
preloadViewModel.getSyncData()
val syncState by preloadViewModel.uiStateSync.observeAsState()
syncState?.let { PreloadContent(navHostController = navHostController, it) }
}
#Composable
fun PreloadContent(navHostController: NavHostController, uiState: UiState<SyncResponse>) {
when (uiState.state) {
RequestState.SUCCESS -> {
navHostController.navigate(KYCScreen.StartKYC.name)
}
RequestState.FAIL -> {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.fillMaxHeight()
.fillMaxWidth()
) {
Text(text = "Error", color = Color.Black)
}
}
RequestState.NON -> {
}
}
}
Anyone have a solution to help me with that architecture?
Your composable function should be side-effects free.
If you want to run something just once. You can do something like the following
LaunchedEffect(Unit){
preloadViewModel.getSyncData()
}
or in your case, if it is mandatory to Sync Data when ViewModel initializes you can call this function in the init block inside of your ViewModel.
Check the official doc https://developer.android.com/jetpack/compose/side-effects