Why does Jetpack Compose LazyColumn MutableLiveData only recompose on first button click? - kotlin

Learning Kotlin and Jetpack Compose in Android Studio. Trying to understand recomposition.
The following code only recomposes on the first button click - why?
Output is shown below my code. Basically, the first time I click the Add New Item button the screen recomposes perfectly, but then it does not recompose again. However, if you look at the LogCat, you can see that it's still working, just not updating the screen. What am I missing?
View Model
class MainViewModel : ViewModel() {
val listLiveData: LiveData<List<Int>>
get() = newListLiveData
private val newList = ArrayList<Int>()
private val newListLiveData = MutableLiveData<List<Int>>()
fun addNewListItem(listItem: Int) {
newList.add(listItem)
newListLiveData.value = newList
println("**** addNewListItem($listItem)")
}
}
Home Screen Scaffolding and Content
#Composable
fun HomeScreen(
navController: NavHostController,
model: MainViewModel
) {
val stuff by model.listLiveData.observeAsState(emptyList())
Scaffold(
content = { Content(model, stuff) }
)
}
#Composable
fun Content(
model: MainViewModel = MainViewModel(),
stuff: List<Int>
) {
LazyColumn() {
items(items = stuff){ index ->
Text("$index")
}
}
Button(
onClick = { model.addNewListItem((0..10).random()) },
) {
Text(text = "Add Random Item to List")
}
}
LogCat Output
I/System.out: **** addNewListItem(0) <-- Recomposes fine here
I/System.out: **** addNewListItem(2) <-- Does not recompose
I/System.out: **** addNewListItem(5) <-- Does not recompose
I/System.out: **** addNewListItem(6) <-- Does not recompose
I/System.out: **** addNewListItem(4) <-- Does not recompose
Screenshot
Thank you

Honestly speaking, as long as you do not have to do with interoperability with the View system, AVOID using anything other than the built-for-Compose MutableState<T> objetcs. The ultra-boilerplate code of yours can simply be replaced by
ViewModel{
val cleanList by mutableStateListOf<Item>() // Initialized as an Empty list of 'Item's
fun addItem(item: Item){
cleanList.add
}
}
Within Composables,
#Composable
MyComposable(
list: List<Item>,
onAddItem: (Item) -> Unit // Receive Item, return Unit
) {
Button { // This is on-Click, assume
onAddItem(/*Add Item Here, per logic*/)
}
}
Call it anywhere like so
MyComposable(list = viewModel.cleanList, onAddItem = viewModel::addItem)
This is some fine code here. Easy to read, free of boilerplate, bafflingly beautiful and clean as a crystal.
Leverage the beauty of Compose, and ditch the ugly legacy crap.

Related

Weird function behaviour inside composable function

Whenever I tell my NavGraph to start on this composable screen:
#Composable
fun LiveTrainingScreen(viewModel: LiveTrainingViewModel = viewModel(), navController: NavController) {
viewModel.context = LocalContext.current
viewModel.scope = rememberCoroutineScope()
viewModel.navController = navController
val largeVideoPlayerHandler = viewModel.InitLargeVideoDisplay(CameraLink.cockPitRight) //exoplayer saved within this handler and refrences is made within the viewModel
val smallVideoPlayerHandler = viewModel.InitSmallVideoDisplay(CameraLink.navigationAndAltitudeDisplay) //exoplayer saved within this handler and refrences is made within the viewModel
//lots of code
}
and when I want to switch the mediaType/video that is being displayed of the Exoplayer this function will work:
class LiveTrainingViewModel(application: Application) : AndroidViewModel(application) {
fun SwitchLargeVideoDisplay(cameraLinkObject: CameraLinkObject) {
UpdateLargeVideoDisplayCameraLinkObject(cameraLinkObject)
largeVideoDisplay.videoPlayer.setMediaItem(MediaItem.fromUri(cameraLinkObject.url))
largeVideoDisplay.videoPlayer.prepare()
}
}
But whenever I load this Screen from another screen this large screen update function won't work for some reason?
Does anybody know why?

Flow working incorrectly. Called again when it shouldn't, but liveData is working correct

I use Jetpack Compose and have 2 screens. When I open second screen and back to the fisrt, flow variable calling again and ui updated again. But, I don't understand why... When I use liveData was working perfect.
My code with LiveData:
class MainViewModel(private val roomRepository: Repository, private val sPref:SharedPreferences) : ViewModel() {
val words: LiveData<List<WordModel>> by lazy {
roomRepository.getAllWords()
}
...
}
MainScreen.kt:
#ExperimentalMaterialApi
#Composable
fun MainScreen(viewModel: MainViewModel) {
...
val words: List<WordModel> by viewModel
.words
.observeAsState(listOf())
...
WordList(
words = words,
onNoticeClick = { viewModel.onWordClick(it) },
state = textState,
lazyState = viewModel.listState!!
)
...
}
#Composable
private fun WordList(
words: List<WordModel>,
onNoticeClick: (WordModel) -> Unit,
state: MutableState<TextFieldValue>,
lazyState: LazyListState
) {
var filteredCountries: List<WordModel>
LazyColumn(state = lazyState) {
val searchedText = state.value.text
filteredCountries = if (searchedText.isEmpty()) {
words
} else {
words.filter {
it.word.lowercase().contains(searchedText) || it.translation.lowercase()
.contains(searchedText)
}
}
items(count = filteredCountries.size) { noteIndex ->
val note = filteredCountries[noteIndex]
Word(
word = note,
onWordClick = onNoticeClick
)
}
}
}
WordDao.kt:
#Dao
interface WordDao {
#Query("SELECT * FROM WordDbModel")
fun getAll(): LiveData<List<WordDbModel>>
}
RoomRepositoryImpl.kt:
class RoomRepositoryImpl(
private val wordDao: WordDao,
private val noticeDao: NoticeDao,
private val dbMapper: DbMapper
) : Repository {
override fun getAllWords(): LiveData<List<WordModel>> =
Transformations.map(wordDao.getAll()) {dbMapper.mapWords(it)}
...
}
DbMapperImpl.kt:
class DbMapperImpl: DbMapper {
...
override fun mapWords(words: List<WordDbModel>): List<WordModel> =
words.map { word -> mapWord(word, listOf<NoticeModel>()) }
}
My code with Flow, which calling every time when open the first screen:
class MainViewModel(private val roomRepository: Repository, private val sPref:SharedPreferences) : ViewModel() {
val words: Flow<List<WordModel>> = flow {
emitAll(repository.getAllWords())
}
}
MainScreen.kt:
#ExperimentalMaterialApi
#Composable
fun MainScreen(viewModel: MainViewModel) {
...
val words: List<WordModel> by viewModel
.words
.collectAsState(initial = listOf())
...
}
WordDao.kt:
#Dao
interface WordDao {
#Query("SELECT * FROM WordDbModel")
fun getAll(): Flow<List<WordDbModel>>
}
RoomRepositoryImpl.kt:
class RoomRepositoryImpl(
private val wordDao: WordDao,
private val noticeDao: NoticeDao,
private val dbMapper: DbMapper
) : Repository {
override fun getWords(): Flow<List<WordModel>> = wordDao.getAll().map { dbMapper.mapWords(it) }
}
And my router from MainRouting.kt:
sealed class Screen {
object Main : Screen()
object Notice : Screen()
object Your : Screen()
object Favorites : Screen()
}
object MainRouter {
var currentScreen: Screen by mutableStateOf(Screen.Main)
var beforeScreen: Screen? = null
fun navigateTo(destination: Screen) {
beforeScreen = currentScreen
currentScreen = destination
}
}
And MainActivity.kt:
class MainActivity : ComponentActivity() {
...
#Composable
#ExperimentalMaterialApi
private fun MainActivityScreen(viewModel: MainViewModel) {
Surface {
when (MainRouter.currentScreen) {
is Screen.Main -> MainScreen(viewModel)
is Screen.Your -> MainScreen(viewModel)
is Screen.Favorites -> MainScreen(viewModel)
is Screen.Notice -> NoticeScreen(viewModel = viewModel)
}
}
}
...
}
Perhaps someone knows why a new drawing does not occur with liveData (or, it is performed so quickly that it is not noticeable that it is), but with Flow the drawing of the list is visible.
You're passing the viewModel around, which is a terrible practice in a framework like Compose. The Model is like a waiter. It hangs around you, serves you water, does its job while you make the order. As you get distracted talking, it leaves. When it comes back, it is not the same waiter you had earlier. It wears the same uniform, with the same characteristics, but is still essentially a different object. When you pass the model around, it gets destroyed in the process of navigation. In case of flow, you are getting biased. Notice how you manually do a lazy initialization for the LiveData, but a standard proc. for Flow? Seems like the only logical reason for your observed inconsistency. If you want to use Flow in your calls instead of LiveData, just convert it at the site of initialization in the ViewModel. Your symptoms should go away.

Jetpack compose collectAsState() is not collecting a hot flow when the list is modified

When I use collectAsState(), the collect {} is triggered only when a new list is passed, not when it is modified and emitted.
View Model
#HiltViewModel
class MyViewModel #Inject constructor() : ViewModel() {
val items = MutableSharedFlow<List<DataItem>>()
private val _items = mutableListOf<DataItem>()
suspend fun getItems() {
_items.clear()
viewModelScope.launch {
repeat(5) {
_items.add(DataItem(it.toString(), "Title $it"))
items.emit(_items)
}
}
viewModelScope.launch {
delay(3000)
val newItem = DataItem("999", "For testing!!!!")
_items[2] = newItem
items.emit(_items)
Log.e("ViewModel", "Updated list")
}
}
}
data class DataItem(val id: String, val title: String)
Composable
#Composable
fun TestScreen(myViewModel: MyViewModel) {
val myItems by myViewModel.items.collectAsState(listOf())
LaunchedEffect(key1 = true) {
myViewModel.getItems()
}
LazyColumn(
modifier = Modifier.padding(vertical = 20.dp, horizontal = 10.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(myItems) { myItem ->
Log.e("TestScreen", "Got $myItem") // <-- won't show updated list with "999"
}
}
}
I want the collect {} to receive the updated list but it is not. SharedFlow or StateFlow does not matter, both behave the same. The only way I can make it work is by creating a new list and emit that. When I use SharedFlow it should not matter whether equals() returns true or false.
viewModelScope.launch {
delay(3000)
val newList = _items.toMutableList()
newList[2] = DataItem("999", "For testing!!!!")
items.emit(newList)
Log.e("ViewModel", "Updated list")
}
I should not have to create a new list. Any idea what I am doing wrong?
You emit the same object every time. Flow doesn't care about equality and emits it - you can try to collect it manually to check it, but Compose tries to reduce the number of recompositions as much as possible, so it checks to see if the state value has actually been changed.
And since you're emitting a mutable list, the same object is stored in the mutable state value. It can't keep track of changes to that object, and when you emit it again, it compares and sees that the array object is the same, so no recomposition is needed. You can add a breakpoint at this line to see what's going on.
The solution is to convert your mutable list to an immutable one: it's gonna be a new object each on each emit.
items.emit(_items.toImmutableList())
An other option to consider is using mutableStateListOf:
private val _items = mutableStateListOf<DataItem>()
val items: List<DataItem> = _items
suspend fun getItems() {
_items.clear()
viewModelScope.launch {
repeat(5) {
_items.add(DataItem(it.toString(), "Title $it"))
}
}
viewModelScope.launch {
delay(3000)
val newItem = DataItem("999", "For testing!!!!")
_items[2] = newItem
Log.e("ViewModel", "Updated list")
}
}
This is the expected behavior of state and jetpack compose. Jetpack compose only recomposes if the value of the state changes. Since a list operation changes only the contents of the object, but not the object reference itself, the composition will not be recomposed.

Lazy Column is blinking when navigating with compose navigation

I set up navigation, pagination and use flow to connect ui with model. If simplify, my screen code looks like this:
#Composable
MainScreen() {
val listState = rememberLazyListState()
val lazyItems = Pager(PagingConfig(...)) { ... }
.flow
.cachedIn(viewModelScope)
.collectAsLazyPagingItems()
LazyColumn(state = listState) {
items(lazyItems, key = { it.id }) { ... }
}
}
And here is my NavHost code:
NavHost(navController, startDestination = "mainScreen") {
composable("mainScreen") {
MainScreen()
}
}
But when i navigate back to MainScreen from another screen or just opening the drawer, data is loaded from DataSource again and i see noticeable blink of LazyColumn.
How to avoid reloading data?
Your code gives me the following error for cachedIn:
Flow operator functions should not be invoked within composition
You shouldn't ignore such warnings.
During transition Compose Navigation recomposes both disappearing and appearing views many times. This is the expected behavior.
And your code creates a new Pager with a new flow on each recomposition, which is causing the problem.
The easiest way to solve it is using remember: it'll cache the pager flow between recompositions:
val lazyItems = remember {
Pager(PagingConfig(/* ... */)) { /* ... */ }
.flow
.cachedIn(viewModelScope)
.collectAsLazyPagingItems()
}
But it'll still be reset during configuration change, e.g. device rotation. The best way to prevent this is moving this logic into a view model:
class MainScreenViewModel : ViewModel() {
val pagingFlow = Pager(PagingConfig(/* ... */)) { /* ... */ }
.flow
.cachedIn(viewModelScope)
}
#Composable
fun MainScreen(
viewModel = viewModel<MainScreenViewModel>()
) {
val lazyItems = viewModel.pagingFlow.collectAsLazyPagingItems()
}

Can't add a suspended function to a setOnClickListener in my AlertDialog

The add_button in the songToAddDialog() method won't accept my suspended method positiveButtonClick() into its setOnClickListener. I have been looking to this for hours and I do not know what to do.
// Check the GenreKey of the current playlist to use later to create the Song
suspend fun playlistGenreCheck(id: Long): Long {
val playlist = dataSource.getPlaylist(id)
val playlistGenre = playlist.playlistGenre
return playlistGenre
}
// When you press the add button in the AlertDialog it will add the song to the database and closes the AlertDialog afterwards
suspend fun positiveButtonClick(builder: AlertDialog){
val song = Song(title = builder.dialogTitleEt.toString(), artist = builder.dialogArtistEt.toString(), playlist = arguments.playlistKey, key = builder.dialogKeyEt.toString(), genre = playlistGenreCheck(arguments.playlistKey))
songsModel.addSong(song)
builder.dismiss()
}
// When you press the cancel button the AlertDialog will bet dismissed
fun negativeButtonClick(builder: AlertDialog){
builder.dismiss()
}
// This creates the AlertDialog and adds the two functions above to the buttons
fun songToAddDialog(){
val mDialogView = LayoutInflater.from(requireContext()).inflate(R.layout.add_song_dialog, null)
val mBuilder = AlertDialog.Builder(requireContext()).setView(mDialogView).setTitle("Add a Song")
val mAlertDialog = mBuilder.show()
mDialogView.add_button.setOnClickListener{positiveButtonClick(mAlertDialog)}
mDialogView.cancel_button.setOnClickListener{negativeButtonClick(mAlertDialog)}
}
// Makes the add-button inside the songslistview observable
songsModel.addButton.observe(viewLifecycleOwner, androidx.lifecycle.Observer{
if (it) {
songToAddDialog()
}
})
The Suspend function can only be called from a CoroutineScope. If you have the lifecycle dependency then use:
mDialogView.add_button.setOnClickListener{
lifecyclescope.launch{
positiveButtonClick(mAlertDialog)
}
}
If you don't have lifecycle dependencies then calling CoroutineScope like this should also work:
mDialogView.add_button.setOnClickListener{
CoroutineScope(Dispatchers.IO).launch{
positiveButtonClick(mAlertDialog)
}
}
Do tell if you still have some problem regarding this :)