Search code examples
android-jetpack-composeandroid-viewmodelandroid-jetpack-navigation

How to save data in a composable function with view model constructor


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.


Solution

  • 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.