In my app I want to send info to server and after receiving successful response I want to pass info to current screen to navigate to another screen.
Here's the flow:
From UI I call viewModel to send request to server. In ViewModel I have a callback:
#HiltViewModel
class CreateAccountViewModel #Inject constructor(
private val cs: CS
) : ViewModel() {
private val _screen = mutableStateOf("")
val screen: State<String> = _screen
fun setScreen(screen: Screen) {
_screen.value = screen.route
}
private val signUpCallback = object : SignUpHandler {
override fun onSuccess(user: User?, signUpResult: SignUpResult?) {
setScreen(Screen.VerifyAccountScreen)
Log.i(Constants.TAG, "sign up success")
}
override fun onFailure(exception: Exception?) {
Log.i(Constants.TAG, "sign up failure ")
}
}
}
As you can see I have also State responsible for Screen so when response is successful I want to update the state so UI layer (Screen) knows that it should navigate to another screen. My question is: how can I observer State in
#Composable
fun CreateAccountScreen(
navController: NavController,
viewModel: CreateAccountViewModel = hiltViewModel()
) {
}
Or is there a better way to achieve that?
I think your view model should know nothing about navigation routes. Simple verificationNeeded flag will be enough in this case:
var verificationNeeded by mutableStateOf(false)
private set
private val signUpCallback = object : SignUpHandler {
override fun onSuccess(user: User?, signUpResult: SignUpResult?) {
verificationNeeded = true
Log.i(Constants.TAG, "sign up success")
}
override fun onFailure(exception: Exception?) {
Log.i(Constants.TAG, "sign up failure ")
}
}
The best practice is not sharing navController outside of the view managing the NavHost, and only pass even handlers. It may be useful when you need to test or preview your screen.
Here's how you can navigate when this flag is changed:
#Composable
fun CreateAccountScreen(
onRequestVerification: () -> Unit,
viewModel: CreateAccountViewModel = hiltViewModel(),
) {
if (viewModel.verificationNeeded) {
LaunchedEffect(Unit) {
onRequestVerification()
}
}
}
in your navigation managing view:
val navController = rememberNavController()
NavHost(
navController = navController,
startDestination = Screen.CreateAccount
) {
composable(Screen.CreateAccount) {
CreateAccountScreen(
onRequestVerification = {
navController.navigate(Screen.VerifyAccountScreen)
}
)
}
}
Related
How to handle ViewModel clear focus event in JetPackCompose?
I have a coroutines channel that sometimes notify my screen to clear the TextField focus
How is the best way to notify my composable to clear focus?
I tried to create a mutableStateFlow, but is there a better way to do it?
#Composable
fun HomeScreen(
viewModel: MainViewModel = hiltViewModel()
) {
val clearFocus by viewModel.clearFocus.collectAsStateWithLifecycle()
AppTheme {
HomeScreenContent(
clearFocus
)
}
}
#HiltViewModel
class MainViewModel #Inject constructor() : ViewModel() {
val clearFocus = MutableStateFlow(false)
init {
viewModelScope.launch {
delay(3000)
clearFocus.value = true
}
}
}
#OptIn(ExperimentalMaterial3Api::class)
#Composable
fun HomeScreenContent(
clearFocus: Boolean
) {
val focusRequester = remember { FocusRequester() }
val focusManager = LocalFocusManager.current
var value by rememberSaveable { mutableStateOf("initial value") }
TextField(
value = value,
onValueChange = {
value = it
}
)
if(clearFocus) {
focusManager.clearFocus()
}
}
When a coroutine channel notifies the ViewModel, I want to clear the TextField focus, how is the best way to achieve that?
Instead of delegating to the HomeScreenContent the duty of clearing the focus you could do it in HomeScreen.
You should not use a stateFlow if you want to do an action that does not affect the compose tree. Instead of using StateFlow use a SharedFlow when you want to trigger an Event.
Using a SharedFlow
#HiltViewModel
class MainViewModel #Inject constructor() : ViewModel() {
val clearFocusEvent = MutableSharedFlow<Unit>()
init {
viewModelScope.launch {
delay(3000)
clearFocusEvent.emit(Unit)
}
}
}
#Composable
fun HomeScreen(
viewModel: MainViewModel = hiltViewModel()
) {
val focusManager = LocalFocusManager.current
LaunchedEffect(Unit) {
viewModel.clearFocusEvent.collectLatest {
focusManager.clearFocus()
}
}
AppTheme {
HomeScreenContent()
}
}
Using a sealed interface as event
If you want to have more events between your VM and Composable or just a cleaner code, you can make a sealed interface that will represent the events
#HiltViewModel
class MainViewModel #Inject constructor() : ViewModel() {
val homeScreenEvent = MutableSharedFlow<HomeScreenEvent>()
init {
viewModelScope.launch {
delay(3000)
homeScreenEvent.emit(HomeScreenEvent.ClearFocus)
}
}
}
sealed interface HomeScreenEvent {
object ClearFocus: HomeScreenEvent
}
#Composable
fun HomeScreen(
viewModel: MainViewModel = hiltViewModel()
) {
val focusManager = LocalFocusManager.current
LaunchedEffect(Unit) {
viewModel.homeScreenEvent.collectLatest {
when(it) {
HomeScreenEvent.ClearFocus -> focusManager.clearFocus()
}
}
}
AppTheme {
HomeScreenContent()
}
}
Now when you'll add an event you just have to handle the new case in the when
I am getting this error when trying to navigate to another screen from the view model,
kotlin.UninitializedPropertyAccessException: lateinit property _navController has not been initialized
This is my activity code,
#AndroidEntryPoint
class MainActivity : ComponentActivity() {
#Inject
lateinit var navigator: Navigator
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
AssessmentAppTheme {
// A surface container using the 'background' color from the theme
Column(modifier = Modifier
.fillMaxWidth(1f)
.padding(vertical = 10.dp, horizontal = 10.dp), horizontalAlignment = Alignment.CenterHorizontally) {
AssessmentApp(modifier = Modifier.padding(bottom = 40.dp))
NavigationGraph(navigator)
}
}
}
}
}
This is my navigation module,
#Module
#InstallIn(ActivityRetainedComponent::class)
class AppModule {
#Provides
fun providesNavigation() = Navigator()
}
This is my navigation class,
#ActivityRetainedScoped
class Navigator {
private lateinit var _navController: NavHostController
fun navigate(destination: NavigationDestination) {
_navController.navigate(destination.route)
}
fun setController(controller: NavHostController) {
_navController = controller
}
}
this is the navigation graph where I am remembering the navController,
#Composable
fun NavigationGraph(
navigator: Navigator
) {
val navController = rememberNavController()
navigator.setController(navController)
NavHost(navController = navController, startDestination = Routes.CLIENTS_ROUTE ) {
composable(Routes.CLIENTS_ROUTE) {
val viewModel = hiltViewModel<ClientViewModel>()
ClientScreen(viewModel = viewModel)
}
composable(Routes.ASSESSMENT_OPTIONS_ROUTE, arguments = listOf(navArgument(RouteArgs.CLIENT_ID) {type = NavType.StringType})) { backStackEntry ->
val viewModel = hiltViewModel<ClientViewModel>()
ClientAssessmentOptionScreen(viewModel = viewModel)
}
}
finally, this is one of view models trying to navigate to different screen,
#HiltViewModel
class ClientViewModel #Inject constructor(
private val repository: IClientRepository,
private val navigator: Navigator,
private val savedStateHandle: SavedStateHandle,
) : ViewModel() {
// Some code here //
fun onEvent(event: ClientEvent) {
viewModelScope.launch {
when(event) {
is ClientEvent.OnClientClicked -> {
event.client.clientName?.let {
navigator.navigate(
NavigationDestination(Routes.generateAssessmentOptionsRoute(clientId = it))
)
}
}
}
}
}
}
What am I doing wrong here? and is the approach to make view models handle navigation the right one for jetpack compose applications?
Just answering it here in case someone else also stumbles upon this. I have modified my navigator class and added a shared flow. Which would be used sort of as an event emitter. Whenever we would want to navigate to another screen we can use the navigate method which would emit the route destination.
#Singleton
class Navigator {
private val _sharedFlow =
MutableSharedFlow<NavigationDestination>(extraBufferCapacity = 1)
val sharedFlow = _sharedFlow.asSharedFlow()
fun navigate(destination: NavigationDestination) {
_sharedFlow.tryEmit(destination)
}
}
Now in the NavigationGraph, I have remembered the NavController and have also added a launchedEffect coroutine, which would be listening to the navigate events from the flow. For each flow event, we will trigger the NavController to navigate to that emitted destination.
#Composable
fun NavigationGraph(
navController: NavHostController = rememberNavController(),
navigator: Navigator
) {
LaunchedEffect("navigation") {
navigator.sharedFlow.onEach {
navController.navigate(it.route)
}.launchIn(this)
}
NavHost(navController = navController, startDestination = Routes.CLIENTS_ROUTE ) {
// some code here... //
}
}
Here is the code that causes the infinite recomposition problem
MainActivity
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
val navController = rememberNavController()
val viewModel : MainViewModel by viewModel()
val state by viewModel.state.observeAsState()
NavHost(navController = navController, startDestination = "firstScreen") {
composable("firstScreen") { FirstScreen(
navigate = {
navController.navigate("secondScreen")
}, updateState = {
viewModel.getState()
},
state
)}
composable("secondScreen") { SecondScreen() }
}
}
}
}
ViewModel
class MainViewModel : ViewModel() {
//var state = MutableStateFlow(0)
private val _state = MutableLiveData(0)
val state: LiveData<Int> = _state
fun getState() {
_state.value = 1
}
}
First Screen
#Composable
fun FirstScreen(
navigate: () -> Unit,
updateState: () -> Unit,
state: Int?
) {
Log.e("state",state.toString())
Button(onClick = {
updateState()
}) {
Text(text = "aaaaaaaa")
}
if(state == 1) {
Log.e("navigate",state.toString())
navigate()
}
}
Second Screen
#Composable
fun SecondScreen() {...}
Pressing the button changes the state in the view model and in reaction if it changes to 1 it triggers navigation to the second screen but the first screen is infinitely recomposed and blocks the whole process
Edit
#Composable
fun FirstScreen(
navigate: () -> Unit,
updateState: () -> Unit,
state: Int?
) {
Log.e("state",state.toString())
Button(onClick = {
updateState()
}) {
Text(text = "aaaaaaaa")
}
LaunchedEffect(state) {
if (state == 1) {
Log.e("navigate", state.toString())
navigate()
}
}
}
this solved the problem
It's because you are navigating based on a conditional property which is part of your FirstScreen composable and changes to that property are outside of the FirstScreen's scope, if that conditional property's value doesn't change, it will always evaluate its block every time the NavHost updates, in your case state remains 1 and will always executes its block.
if(state == 1) {
...
navigate() // navigation
}
What you experience can be summarized by the events broken down below:
Navhost configures FirstScreen and SecondScreen (initial NavHost composition)
FirstScreen observes an integer state with a value of 0
state becomes 1 after you click the button
FirstScreen re-composes, satisfies the condition (state==1), executes navigation for the 1st time
NavHost re-composes
FirstScreen's state remains 1, still satisfies the condition (state==1), executes navigation again for the 2nd time
NavHost re-composes
FirstScreen's state remains 1, satisfies the condition (state==1), executes navigation again for the 3rd time
and the cycle never ends..
Based on the official Docs,
You should only call navigate() as part of a callback and not as part
of your composable itself, to avoid calling navigate() on every
recomposition.
I would advice considering navigation as a one-time event, doing it inside LaunchedEffect and observed from a SharedFlow emission. Below is a short workaround to your problem.
Have a sealed class UiEvent,
sealed class UiEvent {
data class Navigate(val params: Any?): UiEvent()
}
modify your ViewModel like this
class MainViewModel : ViewModel() {
...
private val _oneTimeEvent = MutableSharedFlow<UiEvent>()
val oneTimeEvent = _oneTimeEvent.asSharedFlow()
...
fun navigate() {
if (_state.value == 1) {
viewModelScope.launch {
_oneTimeEvent.emit(UiEvent.Navigate(1))
}
}
}
}
, then observe it via LaunchedEffect in your FirstScreen
#Composable
fun FirstScreen(
navigate: () -> Unit,
..
) {
...
...
LaunchedEffect(Unit) {
mainViewModel.oneTimeEvent.collectLatest { uiEvent ->
when (uiEvent) {
is UiEvent.Navigate -> {
navigate()
}
}
}
}
}
Please see my answer here
I have been using StateFlow + sealed interfaces to represent the various UI states in my Android app. In my ViewModel I have a sealed interface UiState that does this, and the various states are exposed as a StateFlow:
sealed interface UiState {
class LocationFound(val location: CurrentLocation) : UiState
object Loading : UiState
// ...
class Error(val message: String?) : UiState
}
#HiltViewModel
class MyViewModel #Inject constructor(private val getLocationUseCase: GetLocationUseCase): ViewModel() {
private val _uiState = MutableStateFlow<UiState>(UiState.Loading)
val uiState: StateFlow<UiState> = _uiState
// ...
}
Then in a Composable, I observe the events in this manner:
#Composable
fun MyScreen(
viewModel: HomeScreenViewModel,
onLocationFound: (CurrentLocation) -> Unit,
onSnackbarButtonClick: () -> Unit
) {
// ...
LaunchedEffect(true) { viewModel.getLocation() }
when (val state = viewModel.uiState.collectAsState().value) {
is UiState.LocationFound -> {
Log.d(TAG, "MyScreen: LocationFound")
onLocationFound.invoke(state.location)
}
UiState.Loading -> LoadingScreen
// ...
}
}
In my MainActivity.kt, when onLocationFound callback is invoked, I am supposed to navigate to another destination (Screen2) in the NavGraph:
enum class Screens {
Screen1,
Screen2,
// ...
}
#AndroidEntryPoint
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
val navController = rememberNavController()
MyTheme {
MyNavHost(navController = navController)
}
}
}
}
#Composable
fun MyNavHost(navController: NavHostController) {
val context = LocalContext.current
NavHost(navController = navController, startDestination = Screens.Home.name) {
composable(Screens.Screen1.name) {
val viewModel = hiltViewModel<MyViewModel>()
MyScreen(viewModel = viewModel, onLocationFound = {
navController.navigate(
"${Screens.Screen2.name}/${it.locationName}/${it.latitude}/${it.longitude}"
)
}, onSnackbarButtonClick = { // ... }
)
}
// ....
composable("${Screens.Screen2.name}/{location}/{latitude}/{longitude}", arguments = listOf(
navArgument("location") { type = NavType.StringType },
navArgument("latitude") { type = NavType.StringType },
navArgument("longitude") { type = NavType.StringType }
)) {
// ...
}
}
}
But what happens is that the onLocationFound callback seems to be hit multiple times as I can see the logging that I've placed show up multiple times in Logcat, thus I navigate to the same location multiple times resulting in an annoying flickering screen. I checked that in MyViewmodel, I am definitely not setting _uiState.value = LocationFound multiple times. Curiously enough, when I wrap the invocation of the callback with LaunchedEffect(true), LocationFound gets called only two times, which is still weird but at least there's no flicker.
But still, LocationFound should only get called once. I have a feeling that recomposition or some caveat with Compose navigation is in play here but I've researched and can't find the right terminology to look for.
I have a home screen in my application that is basically content with a navigation bar
Each of the three selections of the navigation bar lead to a different screen, so the code looks like this:
#Composable
fun HomeScreen(state: HomeState, event: (HomeEvent) -> Unit) {
val navController = rememberNavController()
Scaffold(
modifier = Modifier.fillMaxSize(),
bottomBar = {
BottomNavigation { .... //add the three bottom navigation menu items
}
},
) {
NavHost(
navController = navController,
startDestination = "news",
) {
composable(route = "news") {
val newsVm: NewsViewModel = hiltViewModel()
NewsScreen(newsVm)
}
composable(route = "tickets") { NewTicketScreen() }
composable(route = "archive") { ArchiveScreen() }
}
}
}
this works correctly
this homescreen is used by the following composeable to actually draw the screen
#Composable
fun HomeScreen(
vm: HomeViewModel = hiltViewModel()
) {
val state = vm.state.value
HomeScreen(state, vm::process )
}
so HomeScreen has its own viewmodel
in this example let us take the NewsScreen which takes as an argument its own viewmodel
What this viewmodel will do is load news articles and show them to the user. But in order to not have to reload data every time the user changes the shown screen, what I would do before compose, is pass the homeViewModel as an argument to the newsViewModel.
Home would contain the data loaded up to now and expose it to its children.
and news would load data and save the loaded data in homeViewmodel
so it would go something like this
class HomeViewModel()..... {
internal val newsArticles = mutableListOf()
}
class NewsViewModel() ..... {
val parent :HomeViewModel = ????
val list = mutableStateOf<List<NewsArticle>>(listOf())
init {
val loaded = parent.newsArticles
loadData(loaded)
}
fun loadData(loaded :List<NewsArticle>) {
if (loaded.isEmpty()) {
list.value = repo.loadNews()
} else {
list.value = loaded
}
}
}
I know that I could do the above in my repository, and have it do the caching, but I also use the homeViewModel for communication between the screens , and if the user has to log in , the app uses the MainActivity's navController to start a new screen where the user will log in.
Is there a way to have a reference to the parent viewmodel from one of the children?
You can either explicitly call the viewmodel that you want to contact by injecting both viewmodel belonging to same nav graph.
Alternatively, you can share a interface among both viewmodels, ensure it is same instance and use it as communication bridge.
interface ViewModelsComBridge<T>{
fun registerCallback(onMessageReceived : (T) -> Unit)
fun onDispatchMessage(message : T)
fun unregister(onMessageReceived : (T) -> Unit)
}
and in your view models:
class ViewModelA #Inject constructor(private val bridge : ViewModelCommunicationBridge<MyData>, ...) : ViewModel(){
init {
bridge.registerCallback { //TODO something with call }
}
}
in second view model:
class ViewModelA #Inject constructor(private val bridge : ViewModelCommunicationBridge<MyData>, ...) : ViewModel(){
fun onClick(){
val myData = processMyData()
bridge.onDispatchMessage(myData)
}
}
On the other end the other viewmodel will receive this call if it is alive.
Ensure your implementation is inject correctly and it is same instance in both viewmodels.
Your can change your NewsViewModel 's viewModelStoreOwner(fragment, activity or HomeScreen's destination), not the lifecycle of news's destination.
so your data will be survive while NewsScreen changes.
#Composable
fun HomeScreen(state: HomeState, event: (HomeEvent) -> Unit) {
val navController = rememberNavController()
val newsVm: NewsViewModel = hiltViewModel() //move to here,
Scaffold(
modifier = Modifier.fillMaxSize(),
bottomBar = {
BottomNavigation { .... //add the three bottom navigation menu items
}
},
) {
NavHost(
navController = navController,
startDestination = "news",
) {
composable(route = "news") {
NewsScreen(newsVm)
}
composable(route = "tickets") { NewTicketScreen() }
composable(route = "archive") { ArchiveScreen() }
}
}
}