Folks I am stuck engineering a proper solution to access a viewModel scoped to a nav graph , from a button that exists in the TopAppBar in a compose application
Scaffold{
TopAppBar-> Contains the Save Button
Body->
BioDataGraph() -> Contains 5 screens to gather biodata information , and a viewmodel scoped to the graph
}
}
My BioDataViewModel looks like this
class BioDataViewModel{
fun gatherPersonalInformation()
fun gatherPhotos()
...
fun onSaveEverything()
}
The issue i came across is as i described above , how should i go about access the BioDataViewModel , such that i can invoke onSaveEverything when save is clicked in the TopAppBar.
What I have tried
private val performSave by mutableStateOf(false)
Scaffold(
topBar = {
TopAppBar(currentDestination){
//save is clicked.
performSave = true
}
})
{
NavHost(
navController = navController,
startDestination = homeNavigationRoute,
modifier = Modifier
.padding(padding)
.consumedWindowInsets(padding),
) {
composable(route = bioDataRoute) {
val viewModel = hiltViewModel<BioDataViewModel>()
if (performSave){
viewModel.onSaveEverything()
}
BioDataScreen(
viewModel
)
}
}
}
The problem with the approach above is that how and when should i reset the state of performSave ? . Because if i do not set it to false; on every recomposition onSaveEverything would get called.
What would be the best way to engineer a solution for this ? . I checked to see if a similar situation was tackled in jetpack samples , but i found nothing there .
I'm not sure if I understand you correctly, but you can define the BioDataViewModel in activity level, and you can access it in the TopAppBar like this
class MyActivity: ComponentActivity() {
// BioDataViewModel definition here
private val viewModel: BioDataViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
Scaffold(
topBar = {
TopAppBar(currentDestination) {
//save is clicked.
viewModel.onSaveEverything() // call onSaveEverything here
}
})
{
...
...
}
...
...
Edit:
If you want to have the same instance of ViewModel from activity and NavGraph level, you can consider this, a reference from my other answer.
You can define the ViewModelStoreOwner in the navigation graph level.
NavHost(
navController = navController,
startDestination = homeNavigationRoute,
modifier = Modifier
.padding(padding)
.consumedWindowInsets(padding),
) {
val viewModelStoreOwner = checkNotNull(LocalViewModelStoreOwner.current) {
"LocalViewModelStoreOwner not available"
}
composable(route = bioDataRoute) {
val viewModel = hiltViewModel<BioDataViewModel>(viewModelStoreOwner)
if (performSave){
viewModel.onSaveEverything()
}
BioDataScreen(
viewModel
)
}
}
Related
I have a composable function for the home screen, whenever I navigate to a new composable and come back to this function everything is re created. Do I have to move the view model somewhere higher?
home screen
#Composable
fun HomeScreen(viewModel: NFTViewModel = viewModel()) {
val feedState by viewModel.nftResponse.observeAsState()
if (feedState?.status == Result.Status.SUCCESS) {
val nfts = feedState?.data?.content
LazyColumn {
itemsIndexed(nfts!!) { index, item ->
Card(
modifier = Modifier
.fillMaxWidth()
.padding(15.dp)
.clickable { },
elevation = 8.dp
) {
Column(
modifier = Modifier.padding(15.dp)
) {
Image(
painter = rememberAsyncImagePainter(
item.firebaseUri
),
contentDescription = null,
contentScale = ContentScale.FillWidth,
modifier = Modifier.height(250.dp)
)
Text(
buildAnnotatedString {
append("Contract Address: ")
withStyle(
style = SpanStyle(fontWeight = FontWeight.W900, color = Color(0xFF4552B8))
) {
append(item.contractAddress)
}
}
)
Text(
buildAnnotatedString {
append("Chain: ")
withStyle(style = SpanStyle(fontWeight = FontWeight.W900)) {
append(item.chain.displayName)
}
append("")
}
)
Text(
buildAnnotatedString {
append("Price: ")
withStyle(style = SpanStyle(fontWeight = FontWeight.W900)) {
append(item.price)
}
append("")
}
)
}
}
}
}
} else {
Column(
modifier = Modifier
.fillMaxSize()
.background(colorResource(id = R.color.white))
.wrapContentSize(Alignment.Center)
) {
Text(
text = "Feed",
fontWeight = FontWeight.Bold,
color = Color.DarkGray,
modifier = Modifier.align(Alignment.CenterHorizontally),
textAlign = TextAlign.Center,
fontSize = 25.sp
)
}
}
}
Navigation
#Composable
fun Navigation(navController: NavHostController) {
NavHost(navController, startDestination = NavigationItem.Home.route) {
composable(NavigationItem.Home.route) {
val vm: NFTViewModel = viewModel()
HomeScreen(vm)
}
composable(NavigationItem.Add.route) {
AddScreen()
}
composable(NavigationItem.Wallets.route) {
WalletsScreen()
}
composable(NavigationItem.Popular.route) {
PopularScreen()
}
composable(NavigationItem.Profile.route) {
ProfileScreen()
}
}
}
It seems like I need to save the view model as a state or something? I seem to be missing something. Basically I dont want to trigger getFeed() everytime the composable function is called, where do I save the data in the compose function, in the view model?
EDIT now calling getFeed from init in the view model
class NFTViewModel : ViewModel() {
private val nftRepository = NFTRepository()
private var successResult: Result<NftResponse>? = null
private var _nftResponse = MutableLiveData<Result<NftResponse>>()
val nftResponse: LiveData<Result<NftResponse>>
get() = _nftResponse
init {
successResult?.let {
_nftResponse.postValue(successResult!!)
} ?: kotlin.run {
getFeed() //this is always called still successResult is always null
}
}
private fun getFeed() {
viewModelScope.launch {
_nftResponse.postValue(Result(Result.Status.LOADING, null, null))
val data = nftRepository.getFeed()
if (data.status == Result.Status.SUCCESS) {
successResult = data
_nftResponse.postValue(Result(Result.Status.SUCCESS, data.data, data.message))
} else {
_nftResponse.postValue(Result(Result.Status.ERROR, null, data.message))
}
}
}
override fun onCleared() {
Timber.tag(Constants.TIMBER).d("onCleared")
super.onCleared()
}
}
However it still seems like a new view model is being created.
It seems like I need to save the view model as a state or something?
You don't have to. ViewModels are already preserved as part of their owner scope. The same ViewModel instance will be returned to you if you retrieve the ViewModels correctly.
I seem to be missing something.
You are initializing a new instance of your NFTViewModel every time the navigation composable recomposes (gets called) instead of retrieving the NFTViewModel from its ViewModelStoreOwner.
You should retrieve ViewModels by calling viewModel() or if you are using Hilt and #HiltViewModel then call hiltViewModel() instead.
No Hilt
val vm: NFTViewModel = viewModel()
Returns an existing ViewModel or creates a new one in the given owner (usually, a fragment or an activity), defaulting to the owner provided by LocalViewModelStoreOwner.
The created ViewModel is associated with the given viewModelStoreOwner and will be retained as long as the owner is alive (e.g. if it is an activity, until it is finished or process is killed).
If using Hilt (i.e. your ViewModel has the #HiltViewModel annotation)
val vm: NFTViewModel = hiltViewModel()
Returns an existing #HiltViewModel-annotated ViewModel or creates a new one scoped to the current navigation graph present on the NavController back stack.
If no navigation graph is currently present then the current scope will be used, usually, a fragment or an activity.
The above will preserve your view model state, however you are still resetting the state inside your composable if your composable exits the composition and then re-enters it at a later time, which happens every time you navigate to a different screen (to a different "screen" composable, if it is just a dialog then the previous composable won't leave the composition, because it will be still displayed in the background).
Due to this part of the code
#Composable
fun HomeScreen(viewModel: NFTViewModel) {
val feedState by viewModel.nftResponse.observeAsState()
// this resets to false when HomeScreen leaves and later
// re-enters the composition
val fetched = remember { mutableStateOf(false) }
if (!fetched.value) {
fetched.value = true
viewModel.getFeed()
}
fetched will always be false when you navigate to (and back to) HomeScreen and thus getFeed() will be called.
If you don't want to call getFeed() when you navigate back to HomeScreen you have to store the fetched value somewhere else, probably inside your NFTViewModel and only reset it to false when you want that getFeed() is called again.
I have a composable function named 'Page' as a basic composable to hold NavHost for my app, please see architecture below:
#Composable
fun Page(viewModel: LdvToolViewModel = hiltViewModel(), scaffoldState: ScaffoldState, navController: NavHostController){
val statusBarMode = viewModel.statusBarUiState
val uiController = rememberSystemUiController()
LaunchedEffect(statusBarMode){
uiController.run {
if(statusBarMode.isDarkContent){
setStatusBarColor(color = Color.White, darkIcons = true)
}else{
setStatusBarColor(color = LdvOrange, darkIcons = false)
}
}
}
val navBuilder: NavGraphBuilder.() -> Unit = {
composable(LdvPages.SEARCHING.name) { SearchUi(viewModel, scaffoldState = scaffoldState) }
composable(LdvPages.ERROR.name) { ErrorUi(viewModel,scaffoldState = scaffoldState) }
composable(LdvPages.PANEL.name) { PanelUi(scaffoldState,viewModel, mBaseViewModel) }
composable(LdvPages.PrivacyPolicy.name){ PrivacyPolicy(scaffoldState)}
composable(LdvPages.TermsOfUse.name){ TermsOfUse(scaffoldState)}
composable(LdvPages.OpenSourceLicense.name){ OpenSourceLicense(scaffoldState)}
composable(LdvPages.DebugPage.name){ DebugPage(viewModel)}
}
val start by derivedStateOf {
if (...){
LdvPages.PANEL.name }else if(...){
LdvPages.ERROR.name
}else{LdvPages.SEARCHING.name}
}
NavHost(navController = navController, startDestination = start, builder = navBuilder)
if(!isNfcEnable){
viewModel.setNfcDisableContent()
ErrorDialog(viewModel = viewModel){
startActivity(Intent(Settings.ACTION_NFC_SETTINGS));
}
}
}
As you can see that 'LdvToolViewModel' has been injected to 'Page' as hiltViewModel. To keep 'LdvToolViewModel' as one instance among lifecycles of nested-composable functions in navBuilder, I have to pass it as parameter to those functions. Is there any better way like I can somehow inject 'LdvToolViewModel' in those functions as hiltViewModel and meanwhile I can still have the injected hiltViewModel as a same instance?
Imagine you have a "HomeGraph", with "Home" as a parent destination, and few destination screens that should share the same ViewModel instance.
First get a NavBackStackEntry, by passing your parent route
val parentEntry: NavBackStackEntry = remember(navBackStackEntry) {
navController.getBackStackEntry(Destination.HomeGraph.route)
}
Then get an instance of a ViewModel by passing the parent NavBackStackEntry
val userViewModel = hiltViewModel<HomeViewModel>(parentEntry)
Also, remember that if you navigate to Destination.HomeGraph.route either from nested navigation or from a different graph a new instance of ViewModel will be created, so if you navigate within a single graph, navigate to startDestination e.g Destination.Home.route - this way you will keep the same ViewModel instance.
I don't thing we have a well-defined ViewModel sharing in compose as we had with a view system e.g by activityViewModels(), but keeping ViewModel state in graphs while user is not accessing them is a bad practice.
You can always pass the ViewModel in one of the graph extension function if necessary.
fun NavGraphBuilder.homeGraph(navController: NavHostController) {
navigation(
startDestination = Destination.Home.route,
route = Destination.HomeGraph.route
) {
composable(Destination.Home.route) { navBackStackEntry ->
val parentEntry = remember(navBackStackEntry) {
navController.getBackStackEntry(Destination.HomeGraph.route)
}
val homeViewModel = hiltViewModel<HomeViewModel>(parentEntry)
HomeRoute(
viewModel = homeViewModel,
onNavigate = { dest ->
navController.navigate(dest.route)
})
}
composable(Destination.Search.route) { navBackStackEntry ->
val parentEntry = remember(navBackStackEntry) {
navController.getBackStackEntry(Destination.HomeGraph.route)
}
val homeViewModel = hiltViewModel<HomeViewModel>(parentEntry)
UserSupportRoute(
viewModel = userViewModel,
onNavigate = { dest ->
navController.navigate(dest.route) {
popUpTo(Destination.Search.route) {
inclusive = true
}
}
})
}
}
I'm having a real strange issue here. I have a ViewModel that has a StateFlow. That ViewModel is recreated in specific circumstances and sets it's StateFlow value to 0.
I also have Compose view that reads value of this StateFlow and displays text according to it.
Then I change that state to 2, for example. And then recreate the whole Compose view and ViewModel.
But, when I recreate the whole view, and the ViewModel, for brief moment, StateFlow keeps it's old state (even though ViewModel is recreated altogether with the View and state is set to 0), and then switches to the new one, which is zero (this only works if you make a change mentioned below).
This can cause tha crash if we have lists that have different amout of items, and we pass them when view is recreated, because then we will read index value that does not exist and our app will crash.
Changing the list ViewModelTwo(mutableListOf("text4")) to the ViewModelTwo(mutableListOf("text4", "text5", "text6")) will stop the crash. But look at the logs, and you'll see what's going on. First it goes to 2, then to 0, which is default.
I have github repo setup for Compose-Jb. You can open it in Android Studio: https://github.com/bnovakovic/composableIssue
Sorry for using android compose tags, but I could not find Compose-JB tag.
And for convinience, here are the code snippets.
Any help is appreciated
Main.kt
#Composable
#Preview
fun App(viewModelOne: ViewModelOne) {
val showComposable by viewModelOne.stateOne.collectAsState()
MaterialTheme {
// Depending on the state we decide to create different ViewModel
val viewModelTwo: ViewModelTwo = when (showComposable) {
0 -> ViewModelTwo(mutableListOf("text1", "text2", "text3"))
1 -> ViewModelTwo(mutableListOf("text4"))
else -> ViewModelTwo(mutableListOf("blah1", "blah2", "blah3"))
}
// New composable is always created with new ViewModelTwo that has default index of 0, yet the app still crashes
TestComposableTwo(viewModelTwo)
Row {
Button(onClick = {
viewModelOne.changeState()
}) {
Text("Click button below, than me")
}
}
}
}
fun main() = application {
Window(onCloseRequest = ::exitApplication) {
val viewModelOne = ViewModelOne();
App(viewModelOne)
}
}
TestComposableView
#Composable
fun TestComposableTwo(viewModelTwo: ViewModelTwo) {
val currentIndex by viewModelTwo.currentListItem.collectAsState()
println("Index is: $currentIndex")
Column(
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight()
) {
// At the point where we recreate this composable view, currentIndex keeps it's old value, and then changes it
// to the new one causing the app to crash since new list does not have index of 1
Text(text = viewModelTwo.stringList[currentIndex])
Button(onClick = {
viewModelTwo.changeIndex()
}) {
Text("Click me before clicking button above")
}
}
}
ViewModel1
class ViewModelOne {
private val viewModelScope = CoroutineScope(Dispatchers.IO)
private val _stateOne = MutableStateFlow(0)
val stateOne = _stateOne.asStateFlow()
fun changeState() {
viewModelScope.launch {
val currentValue = stateOne.value + 1
_stateOne.emit(currentValue)
}
}
}
ViewModel2
class ViewModelTwo(val stringList: List<String>) {
private val viewModelScope = CoroutineScope(Dispatchers.IO)
private val _currentListItem = MutableStateFlow(0)
val currentListItem = _currentListItem.asStateFlow()
fun changeIndex() {
viewModelScope.launch {
_currentListItem.emit(2)
}
}
}
It seems overly complex to make a new ViewModelTwo in the Composable based on a value in the first ViewModel. Try to make your ViewModel take care of the logic and your Composables just show the data.
I would suggest using a single ViewModel with a single MutableStateFlow.
And you don't need the emit or the viewModelScope in this case.
Something like this should work:
class MainViewModel {
private val _state = MutableStateFlow(State(listOf("text1", "text2", "text3"), 0))
val state: StateFlow<State> = _state
fun onChangeStateClick() {
_state.value = State(listOf("text4"), 0) // Or other logic here if needed
}
fun onChangeIndexClick() {
_state.update {
if (it.currentIndex < it.stringList.count() - 1) {
it.copy(currentIndex = it.currentIndex + 1)
} else it.copy(currentIndex = 0)
}
}
data class State(
val stringList: List<String>,
val currentIndex: Int
)
}
#Composable
#Preview
fun App(viewModel: MainViewModel) {
val state by viewModel.state.collectAsState()
MaterialTheme {
TestComposableTwo(
state.stringList[state.currentIndex],
onButtonClick = { viewModel.onChangeIndexClick() }
)
Row {
Button(onClick = {
viewModel.onChangeStateClick()
}) {
Text("Click button below, than me")
}
}
}
}
#Composable
fun TestComposableTwo(text: String, onButtonClick: () -> Unit) {
Column(
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight()
) {
Text(text = text)
Button(onClick = onButtonClick) {
Text("Click me before clicking button above")
}
}
}
I am quite new in Android Flow and JetPack compose,
I am trying to update my UI when mutable state is being changed , but this is not calling our composable , here is my code
#Composable
fun Grid() {
val mainViewModel by viewModels<DashBoardViewModel>()
mainViewModel.getData()
when (val result = mainViewModel.mutableState.value) {
is Resource.Success -> {
LazyVerticalGrid(
cells = GridCells.Adaptive(100.dp)
) {
items(result.device.items.first().devices.count()) {
EachItem(it)
}
}
}
is Resource.Error -> { Text(text = result.message) }
Resource.Loading -> { CircularProgressIndicator() }
Resource.Empty -> {}
else -> { CircularProgressIndicator() }
}
}
ViewModel:
#HiltViewModel
class DashBoardViewModel #Inject constructor(
private val dashBoardRepository: DashBoardRepository
) : ViewModel() {
val mutableState = MutableLiveData<Resource>()
fun getData() = viewModelScope.launch {
flow {
emit(Resource.Loading)
try {
val mResponse = dashBoardRepository.getDevice()
emit(Resource.Success(mResponse))
} catch (e: Exception) {
e.printStackTrace()
emit(Resource.Error("Error"))
}
}.flowOn(Dispatchers.IO).collect {
mutableState.value = it
}
}
}
There are two problems in your code:
mainViewModel.mutableState.value gets only the current value from your mutable state. Your composable will not be notified when this value changes and thus it cannot reflect the change. If you want to use LiveData in your viewmodel, you have to use observeAsState() extension function which converts LivaData to State that can be automatically observed by composable function. Other option is to have (Mutable)State directly in your viewmodel. See this state explanation.
Your mainViewModel.getData() function will be called every time your Grid() function recomposes, which will be every time your mainViewModel.mutableState changes (once you observe it correctly). You definitely don't want that. Better solution would be to call getData() from your viewModel's init block, or, if you really need to call it from your composable function, use LaunchedEffect.
And, as a side note, the way you are creating flow and then collecting it into LiveData is really odd and unnecessary. You can do something like this instead:
fun getData() = viewModelScope.launch {
mutableState.value = Resource.Loading
try {
val mResponse = dashBoardRepository.getDevice()
mutableState.value = Resource.Success(mResponse)
} catch (e: Exception) {
e.printStackTrace()
mutableState.value = Resource.Error("Error")
}
}
Consider this example.
For authentication, we'll be using 2 screens - one screen to enter phone number and the other to enter OTP.
Both these screens were made in Jetpack Compose and the for the NavGraph, we are using compose navigation.
Also I have to mention that DI is being handled by Koin.
val navController = rememberNavController()
NavHost(navController) {
navigation(
startDestination = "phone_number_screen",
route = "auth"
) {
composable(route = "phone_number_screen") {
// Get's a new instance of AuthViewModel
PhoneNumberScreen(viewModel = getViewModel<AuthViewModel>())
}
composable(route = "otp_screen") {
// Get's a new instance of AuthViewModel
OTPScreen(viewModel = getViewModel<AuthViewModel>())
}
}
}
So how can we share the same viewmodel among two or more composables in a Jetpack compose NavGraph?
You can to pass your top viewModelStoreOwner to each destination
directly passing to .viewModel() call, composable("first") in my example
overriding LocalViewModelStoreOwner for the whole content, so each composable inside CompositionLocalProvider will have access to the same view models, composable("second") in my example
val viewModelStoreOwner = checkNotNull(LocalViewModelStoreOwner.current) {
"No ViewModelStoreOwner was provided via LocalViewModelStoreOwner"
}
val navController = rememberNavController()
NavHost(navController = navController, startDestination = "first") {
composable("first") {
val model = viewModel<SharedModel>(viewModelStoreOwner = viewModelStoreOwner)
}
composable("second") {
CompositionLocalProvider(
LocalViewModelStoreOwner provides viewModelStoreOwner
) {
SecondScreen()
}
}
}
In the second case, you can get your model at any level of the composition tree, which is inside the CompositionLocalProvider:
#Composable
fun SecondScreen() {
val model = viewModel<SharedModel>()
SomeView()
}
#Composable
fun SomeView() {
val model = viewModel<SharedModel>()
}
Using Hilt you could do something like the below. But since you are using Koin I don't know the way of Koin yet.
#Composable
fun MyApp() {
NavHost(navController, startDestination = startRoute) {
navigation(startDestination = innerStartRoute, route = "Parent") {
// ...
composable("exampleWithRoute") { backStackEntry ->
val parentEntry = remember {
navController.getBackStackEntry("Parent")
}
val parentViewModel = hiltViewModel<ParentViewModel>(
parentEntry
)
ExampleWithRouteScreen(parentViewModel)
}
}
}
}
Official doc: https://developer.android.com/jetpack/compose/libraries#hilt
Here is an other way with Koin.
It strictly do the same than the validated answer but simpler to write. It will have exactly the same viewModelStoreOwner without having to write it explicitly. Please tell me if i'm wrong.
val navController = rememberNavController()
val sharedViewModel = getViewModel()
NavHost(navController = navController, startDestination = "first") {
composable("first") {
// You can use sharedViewModel
}
composable("second") {
// You can use sharedViewModel
}
}