Items do not always recompose correctly when using LazyColumn - android

I am having some difficulty using lazycolumn in Jetpack Compose to display a list of users contained in a viewModel.
When the app loads, the data for the first few users is displayed correctly, however, as the user scrolls down, and more users are made visable, the data does not always display in the UI.
Furthermore, is the user scrolls back up, previously visable data is no longer displayed.
I assume this has something to do with the data being lost during recomposition, however I do not know how to solve this issue
MainActivity
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
ListPracticeTheme {
val viewModel : MainViewModel by viewModels()
Surface(color = MaterialTheme.colors.background) {
UsersList(viewModel = viewModel)
}
}
}
}
}
UsersList
#Composable
fun UsersList(viewModel: MainViewModel) {
LazyColumn(
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
items(viewModel.users.value) { user ->
Card(
modifier = Modifier.fillMaxWidth()
) {
Column(Modifier.fillMaxWidth()) {
Text(text = "USER ID = ${user.userID}")
Text(text = "USERNAME = ${user.userName}")
Text(text = "DESCRIPTION = ${user.description}")
}
}
}
}
}
MainViewModel
class MainViewModel : ViewModel() {
var users : MutableState<List<User>> = mutableStateOf(ArrayList())
init{
for(i in 1..20){
users.value += randomPost()
}
}
}
As the user scrolls, the UI is not updated correctly, as seen in this video:
https://youtu.be/ETEuMGStIIk
Instead, as the user scrolls, more users data should be displayed, and this data should be made visible again when the user scrolls back up.

You can try to only pass the value of viewModel.users into your UsersList.

This was apparently a confirmed bug, which is fixed in Compose beta-08. Just upgrade the Compose version to that and the Kotlin Version to 1.5.10 and it should be fine

Related

When I change ViewModel var, Composable Doesn't Update in Kotlin + Compose

When I change ViewModel variable, Composable Doesn't Update the View and I'm not sure what to do.
This is my MainActivity:
class MainActivity : ComponentActivity() {
companion object {
val TAG: String = MainActivity::class.java.simpleName
}
private val auth by lazy {
Firebase.auth
}
var isAuthorised: MutableState<Boolean> = mutableStateOf(FirebaseAuth.getInstance().currentUser != null)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val user = FirebaseAuth.getInstance().currentUser
setContent {
HeroTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colors.background
) {
if (user != null) {
Menu(user)
} else {
AuthTools(auth, isAuthorised)
}
}
}
}
}
}
I have a a View Model:
class ProfileViewModel: ViewModel() {
val firestore = FirebaseFirestore.getInstance()
var profile: Profile? = null
val user = Firebase.auth.currentUser
init {
fetchProfile()
}
fun fetchProfile() {
GlobalScope.async {
getProfile()
}
}
suspend fun getProfile() {
user?.let {
val docRef = firestore.collection("Profiles")
.document(user.uid)
return suspendCoroutine { continuation ->
docRef.get()
.addOnSuccessListener { document ->
if (document != null) {
this.profile = getProfileFromDoc(document)
}
}
.addOnFailureListener { exception ->
continuation.resumeWithException(exception)
}
}
}
}
}
And a Composable View upon user autentication:
#Composable
fun Menu(user: FirebaseUser) {
val context = LocalContext.current
val ProfileVModel = ProfileViewModel()
Column(
modifier = Modifier
.background(color = Color.White)
.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Text("Signed in!");
ProfileVModel.profile?.let {
Text(it.username);
}
Row(
horizontalArrangement = Arrangement.Center,
modifier = Modifier.fillMaxWidth()
) {
TextButton(onClick = {
FirebaseAuth.getInstance().signOut()
context.startActivity(Intent(context, MainActivity::class.java))
}) {
Text(
color = Color.Black,
text = "Sign out?",
modifier = Modifier.padding(all = 8.dp)
)
}
}
}
}
When my Firestore method returns, I update the profile var, and "expect" it to be updated in the composable, here:
ProfileVModel.profile?.let {
Text(it.username);
}
However, nothing is changing?
When I was adding firebase functions from inside composable, I could just do:
context.startActivity(Intent(context, MainActivity::class.java))
And it would update the view. However, I'm not quite sure how to do this from inside a ViewModel, since "context" is a Composable-specific feature?
I've tried to look up Live Data, but every tutorial is either too confusing or differs from my code. I'm coming from SwiftUI MVVM so when I update something in a ViewModel, any view that's using the value updates. It doesn't seem to be the case here, any help is appreciated.
Thank you.
Part 1: Obtaining a ViewModel correctly
On the marked line below you are setting your view model to a new ProfileViewModel instance on every recomposition of your Menu composable, which means your view model (and any state tracked by it) will reset on every recomposition. That prevents your view model to act as a view state holder.
#Composable
fun Menu(user: FirebaseUser) {
val context = LocalContext.current
val ProfileVModel = ProfileViewModel() // <-- view model resets on every recomposition
// ...
}
You can fix this by always obtaining your ViewModels from the ViewModelStore. In that way the ViewModel will have the correct owner (correct lifecycle owner) and thus the correct lifecycle.
Compose has a helper for obtaining ViewModels with the viewModel() call.
This is how you would use the call in your code
#Composable
fun Menu(user: FirebaseUser) {
val context = LocalContext.current
val ProfileVModel: ProfileViewModel = viewModel()
// or this way, if you prefer
// val ProfileVModel = viewModel<ProfileViewModel>()
// ...
}
See also ViewModels in Compose that outlines the fundamentals related to ViewModels in Compose.
Note: if you are using a DI (dependency injection) library (such as Hilt, Koin...) then you would use the helpers provided by the DI library to obtain ViewModels.
Part 2: Avoid GlobalScope (unless you know exactly why you need it) and watch out for exceptions
As described in Avoid Global Scope you should avoid using GlobalScope whenever possible. Android ViewModels come with their own coroutine scope accessible through viewModelScope. You should also watch out for exceptions.
Example for your code
class ProfileViewModel: ViewModel() {
// ...
fun fetchProfile() {
// Use .launch instead of .async if you are not using
// the returned Deferred result anyway
viewModelScope.launch {
// handle exceptions
try {
getProfile()
} catch (error: Throwable) {
// TODO: Log the failed attempt and/or notify the user
}
}
}
// make it private, in most cases you want to expose
// non-suspending functions from VMs that then call other
// suspend factions inside the viewModelScope like fetchProfile does
private suspend fun getProfile() {
// ...
}
// ...
}
More coroutine best practices are covered in Best practices for coroutines in Android.
Part 3: Managing state in Compose
Compose tracks state through State<T>. If you want to manage state you can create MutableState<T> instances with mutableStateOf<T>(value: T), where the value parameter is the value you want to initialize the state with.
You could keep the state in your view model like this
// This VM now depends on androidx.compose.runtime.*
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
class ProfileViewModel: ViewModel() {
var profile: Profile? by mutableStateOf(null)
private set
// ...
}
then every time you would change the profile variable, composables that use it in some way (i.e. read it) would recompose.
However, if you don't want your view model ProfileViewModel to depend on the Compose runtime then there are other options to track state changes while not depending on the Compose runtime. From the documentation section Compose and other libraries
Compose comes with extensions for Android's most popular stream-based
solutions. Each of these extensions is provided by a different
artifact:
Flow.collectAsState() doesn't require extra dependencies. (because it is part of kotlinx-coroutines-core)
LiveData.observeAsState() included in the androidx.compose.runtime:runtime-livedata:$composeVersion artifact.
Observable.subscribeAsState() included in the androidx.compose.runtime:runtime-rxjava2:$composeVersion or
> androidx.compose.runtime:runtime-rxjava3:$composeVersion artifact.
These artifacts register as a listener and represent the values as a
State. Whenever a new value is emitted, Compose recomposes those parts
of the UI where that state.value is used.
This means that you could also use a MutableStateFlow<T> to track changes inside the ViewModel and expose it outside your view model as a StateFlow<T>.
// This VM does not depend on androidx.compose.runtime.* anymore
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
class ProfileViewModel : ViewModel() {
private val _profileFlow = MutableStateFlow<Profile?>(null)
val profileFlow = _profileFlow.asStateFlow()
private suspend fun getProfile() {
_profileFlow.value = getProfileFromDoc(document)
}
}
And then use StateFlow<T>.collectAsState() inside your composable to get the State<T> that is needed by Compose.
A general Flow<T> can also be collected as State<T> with Flow<T : R>.collectAsState(initial: R), where the initial value has to be provided.
#Composable
fun Menu(user: FirebaseUser) {
val context = LocalContext.current
val ProfileVModel: ProfileViewModel = viewModel()
val profile by ProfileVModel.profileFlow.collectAsState()
Column(
// ...
) {
// ...
profile?.let {
Text(it.username);
}
// ...
}
}
To learn more about working with state in Compose see the documentation section on Managing State. This is fundamental information to be able to work with state in Compose and trigger recompositions efficiently. It also covers the fundamentals of state hoisting. If you prefer a coding tutorial here is the code lab for State in Jetpack Compose.
An introduction to handling the state as the complexity increases is in the video from Google about Using Jetpack Compose's automatic state observation.
Profile in view model should be State<*>
private val _viewState: MutableState<Profile?> = mutableStateOf(null)
val viewState: State<Profile?> = _viewState
In composable
ProfileVModel.profile.value?.let {
Text(it.username);
}
I recommend using MutableStateFlow.
a simple sample is described in this Medium article :
https://farhan-tanvir.medium.com/stateflow-with-jetpack-compose-7d9c9711c286

Jetpack Compose view doesn't observes state updates

I have a state class
object SomeState {
data class State(
val mainPhotos: List<S3Photo>? = emptyList(),
)
}
VM load data via init and updates state
class SomeViewModel() {
var viewState by mutableStateOf(SomeState.State())
private set
init {
val photos = someSource.load()
viewState = viewState.cope(mainPhotos = photos)
}
}
Composable takes data from state
#Composable
fun SomeViewFun(
state = SomeState.State
) {
HorizontalPager(
count = state .mainPhotos?.size ?: 0,
) {
//view items
}
}
The problem is that count in HorizontalPager always == 0, but in logcat and debugger i see that list.size() == 57
I have a lot of screen with arch like this and they works normaly. But on this screen view state doesn't updates and i can't understand why.
UPDATE
VM passes to Composable like this
#Composable
fun SomeDistanation() {
val viewModel: SomeViewModel = hiltViewModel()
SomeViewFun(
state = viewModel.state
)
}
Also Composable take Flow<ViewEffect> and etc, but in this question it doesn't matter, because there is no user input or side effects
UPDATE 2
The problem was in data source. All code in question work correctly. Problem closed.
object wrapping is completely redundant (no fields, no functions), you can remove it (also, change the name so it won't confuse with compose's State):
data class MyState(
val mainPhotos: List<S3Photo>? = emptyList(),
)
According to Android Developers, you need to create the state in the view model, and observe the state in the composable function - your code is a bit unclear for me so I'll just show you how I do it in my apps.
create the state in the view model:
class SomeViewModel() {
private val viewState = mutableStateOf(MyState())
// Expose as immutable so it won't be edited
fun getState(): State<MyState> = viewState
init {
val photos = someSource.load()
viewState.value = viewState.value.copy(mainPhotos = photos)
}
}
observe the state in the composable function:
#Composable
fun SomeDistanation() {
val viewModel: SomeViewModel = hiltViewModel()
val state: MyState by remember { viewModel.getState() }
SomeViewFun(state)
}
Now you'll get automatic recomposition in case the state changes.

Updating state in ViewModel of one screen after event in the second screen - Jetpack Compose Navigation

I am building an application for solving queastionnaires. The application has three screens: MainMenuView, QuestionnaireView and SettingsView. From the MainMenu a user can enter QuestionnaireView and SettingsView, however they cannot enter QuestionnaireView unless the userId has not been submitted in the SettingsView (I am saving the userId in SharedPreferences).
In my MainMenuViewModel I created isUserIdFilled mutableState which indicates (by checking through checkUserIdFilled()) whether the userId has been submitted.
class MainMenuViewModel constructor(val getUserId: GetUserIdUseCase) : ViewModel() {
var isUserIdFilled by mutableStateOf(false)
private set
init {
checkUserIdFilled()
}
fun checkUserIdFilled() {
if (getUserId().isNotEmpty()) {
isUserIdFilled = true
}
}
}
In the NavigationHost I am checking whether userId has been submitted and navigating to SettingsView or QuestionnaireView. The if on onQuestionnaireClick works and navigates to SettingsView if the userId has not been submitted. However, the isUserIdFilled state was not updating (the init in MainMenuViewModel is not executed again as the ViewModel is still in the backstack and is not recreated) and I was redirected to SettingsView all the time (unless I restarted the app). So I added the call to checkUserIdFilled() in the NavHost. However, I don't like this approach as it changes the state from the Composable function.
#Composable
fun MyNavHost(navController: NavHostController, modifier: Modifier = Modifier) {
NavHost(
navController = navController,
startDestination = Screen.MainMenu.name,
modifier = modifier
) {
composable(Screen.MainMenu.name) {
val mainMenuViewModel = hiltViewModel<MainMenuViewModel>()
MainMenuView(
onQuestionnaireClick = {
// Is there a better way to do this and avoid the below call?
mainMenuViewModel.checkUserIdFilled()
if (mainMenuViewModel.isUserIdFilled)
navController.navigate(Screen.Questionnaire.name)
else {
navController.navigate(Screen.Settings.name)
}
},
onSettingsClick = { navController.navigate(Screen.Settings.name) },
mainMenuViewModel = mainMenuViewModel
)
}
composable(Screen.Questionnaire.name) {
val questionnaireViewModel = hiltViewModel<QuestionnaireViewModel>()
QuestionnaireView(questionnaireViewModel)
}
composable(Screen.Settings.name) {
val settingsViewModel = hiltViewModel<SettingsViewModel>()
// userId is submitted and saved to SharedPreferences here
SettingsView(settingsViewModel::onSubmitUserId, settingsViewModel)
}
}
}
Is there a better way to do this? How to update isUserIdFilled from the SettingsViewModel once the userId is submitted in a clean way?

Changes in a MutableState are reverted after calling popBackStack()

I have two #Composable screens which are connected by a NavHostController. Let's call them Screen 1 and Screen 2.
They're both sharing a ViewModel that is injected by hiltViewModel(). This ViewModel contains a state value (true by default) wrapped in a data class UiState and exposes method to change that state (to false).
data class UiState(
var state: Boolean
)
#HiltViewModel
class StateViewModel : ViewModel() {
val uiState: MutableState<UiState> = mutableStateOf(UiState(true))
fun setStateToFalse() {
uiState.value = uiState.value.copy(state = false)
}
}
Screen 1 is based on the UiState and displays data based on it. You can also navigate to the Screen 2 by clicking the button on the Screen 1:
#Composable
fun Screen1(
navController: NavHostController,
stateViewModel: StateViewModel = hiltViewModel()
) {
val uiState = remember { stateViewModel.uiState }
Button(
onClick = { navController.navigate("Screen2") }
) {
Text(
text = "State value: " + if (uiState.value.state) "true" else "false"
)
}
}
After navigating to Screen 2 we can change the state to false and immediately after that call popBackStack() to navigate back to the Screen 1:
#Composable
fun Screen2(
navController: NavHostController,
stateViewModel: StateViewModel = hiltViewModel()
) {
Button(
onClick = {
stateViewModel.setStateToFalse()
CoroutineScope(Dispatchers.Main).launch {
navController.popBackStack()
}
}
) {
Text(text = "Change state to false")
}
}
Now, after the calls to setStateToFalse() and popBackStack() I end up at the Screen 1 that tells me that the state is still true while it should be false:
And this is how I expected the Screen 1 to look like:
I have debugged the application and the state is changed to false in the Screen 2 but later I could see that on the Screen 1 it remains true. I'm still pretty new to Jetpack Compose and Navigation Components so I might be missing something obvious. Even if so, please help me :)

Using viewModel as the single source of truth in jetpack compose

let's say we have a viewModel that has a value called apiKey inside. Contents of this value is received from DataStore in form of a Flow and then, it is exposed as LiveData.
On the other hand we have a Fragment called SettingsFragment, and we are trying to display that apiKey inside a TextField, let the user modify it and save it in DataStore right away.
The solution that I'm currently using is down below, but the issue is that the UI gets very laggy and slow when changes are being made to the text.
My question is that what is the best way to implement this and still have a single source of truth for our apiKey?
class SettingsViewModel() : ViewModel() {
val apiKey = readOutFromDataStore.asLiveData()
fun saveApiKey(apiKey: String) {
viewModelScope.launch(Dispatchers.IO) {
saveToDataStore("KEY", apiKey)
}
}
}
/** SettingsFragment **/
...
#Composable
fun ContentView() {
var text = mViewModel.apiKey.observeAsState().value?.apiKey ?: ""
Column() {
OutlinedTextField(
label = { Text(text = "API Key") },
value = text,
onValueChange = {
text = it
mViewModel.saveApiKey(it)
})
}
}
Don't save the TextField's value in the onValueChange event to the data store on every key press - which is almost certainly slowing you down - especially if you are using the same thread. Use a local state variable and only update the data store when the user either moves the focus elsewhere or they save what's on the screen through some button press. You also need to avoid mixing UI threading with data storage threading which should be on the IO thread. Here is one possible solution:
#Composable
fun ContentViewHandler() {
ContentView(
initialText = viewmodel.getApiKey(),
onTextChange = { text ->
viewmodel.updateApiKey(text)
}
)
}
#Composable
fun ContentView(
initialText: String,
onTextChange: (text: String) -> Unit
) {
var text by remember { mutableStateOf(initialText) }
Column() {
OutlinedTextField(
label = { Text(text = "API Key") },
value = text,
onValueChange = {
text = it
},
modifier = Modifier.onFocusChanged {
onTextChange(text)
}
)
// Or add a button and save the text when clicked.
}
}

Categories

Resources