I have an issue with refreshing Compose Lazy List, based on changes in persistence.
The business case - I have a screen (Fragment) with MyObject list contains all objects, there is another screen with only favorites MyObjects. Both use the same Composable as a list element with name, description and "heart" icon to set/unset favorite flag.
On "all" list setting and unsetting favorite flag works well - click on IconToggleButton sets boolean in DB and then switching to Favorite screen shows new item. Unset favorite on "all" screen sets flag to false as expected and when navigates to Favorite screen removes item.
But toggling favorite icon on Favorite screen change boolean in DB - BUT does not refresh and recompose LazyList content. I have to manually switch few times between screens, then eventually both are, let's say, synchronized with DB.
Unset favorite on the last element on Favorite list does not refresh it at all - I have to "like" another object on "all" list, then the Favorite list content is recompose with replacing items.
Moreover - there are some cases, that items on both lists disappears, while they are still in DB. I need to dig it deeper and debug this case, but maybe is related.
Some code's details:
There is a simple Entity
#Entity(tableName = "my_objects")
data class MyObject(
#PrimaryKey(autoGenerate = true)
var id: Long = 0,
#ColumnInfo(name = "name")
val name: String,
#ColumnInfo(name = "favorite")
val favorite: Boolean = false
)
Then there are also DAO, Provider and Repository with Domain Model. In DAO there are methods:
#Query("SELECT * FROM my_objects")
fun getAll(): List<MyObject>
#Query("SELECT * FROM my_objects WHERE favorite = 1")
fun getFavorites(): List<MyObject>
called in Provider and then in Repository.
In MyObjectListViewModel (with mapping from DB model do domain model):
#HiltViewModel
class MyObjectListViewModel #Inject constructor(
private val updateMyObject: UpdateMyObject,
private val getOrderedMyObjectList: GetOrderedMyObjectList,
private val dispatchers: CoroutineDispatcherProvider
) : ViewModel() {
private val mutableMyObjects = MutableLiveData<List<ItemMyObjectModel>>()
val myObjects: LiveData<List<ItemMyObjectModel>> = mutableMyObjects
fun loadMyObjects() {
viewModelScope.launch(dispatchers.io) {
val myObjectListResult = getOrderedMyObjectList()
withContext(dispatchers.main) {
when (myObjectListResult) {
is MyObjectListResult.Success -> {
val viewModelList = myObjectListResult.list.map {
ItemMyObjectModel(it)
}
mutableMyObjects.postValue(viewModelList)
}
}
}
}
}
fun switchFavoriteFlag(itemMyObjectModel: ItemMyObjectModel) {
val myObject = itemMyObjectModel.itemMyObject
myObject.favorite = !myObject.favorite
viewModelScope.launch(dispatchers.io) {
val updatedObject = updateMyObject(myObject) //save via DAO
}
}
}
MyObjectFavoriteListViewModel looks exactly the same, except that load function calls loadFavoriteMyObjects() and it uses GetOrderedFavoriteMyObjectList Repository. BTW - maybe it could be aggregate to one ViewModel, but with pair of LiveData and load function - one pair for all item and one for favorites?
Last but not least - Composables:
#OptIn(ExperimentalMaterial3Api::class)
#Composable
fun MyObjectFavoriteListScreen(
viewModel: MyObjectFavoriteListViewModel,
navigator: MyObjectNavigator
) {
val list by viewModel.myObjects.observeAsState()
val lazyListState = rememberLazyListState()
Scaffold(
floatingActionButton = {
MyObjectListFloatingActionButton(
extended = lazyListState.isScrollingUp() //local extension
) { navigator.openNewMyObjectFromObjectList() }
}
) { padding ->
if (list != null) {
LazyColumn(
contentPadding = PaddingValues(
horizontal = dimensionResource(id = R.dimen.margin_normal),
vertical = dimensionResource(id = R.dimen.margin_normal)
),
state = lazyListState,
modifier = Modifier.padding(padding)
) {
items(list!!) { item ->
MyObjectListItem( // with Card() includes Text() and IconToggleButton()
item = item,
onCardClick = { myObjectId -> navigator.openMyObjectDetailsFromFavouriteList(myObjectId) },
onFavoriteClick = { itemMyObjectModel -> viewModel.switchFavoriteFlag(itemMyObjectModel) }
)
}
}
} else {
Box(
modifier = Modifier
.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Text(text = stringResource(id = "No objects available"))
}
}
}
}
I think that one issue could be related with if (list != null) {} (list is observed as State<T?>).
But for sure there is something wrong with states, I am pretty sure that the list should be triggered to recompose, but there is no(?) state to do so.
Any ideas?
Related
In classic Android programming (before composites) to show list on recyclerview I need adapter and if I want to give any action like clickable element, where logic will be provide in fragment/viewmodel I had to through argument create such parameter like
(Int) -> Unit
how does it look in jetpack compose when I create lazy column? Should it look the same and just create paramaters in Screens/UI then configure logic in the same way in fragment/viewmodel or there is some other approachs?
Getting rid of all the bloat of recyclerview is one of my favorite advantages of compose. Let's assume you'd like to include the following list in your screen:
#Composable
private fun TestList(
myItems: List<String>,
onClick: (String) -> Unit,
modifier: Modifier = Modifier,
) {
LazyColumn(modifier.fillMaxSize()) {
items(myItems) { item ->
TestItemView(
text = item,
onClick = onClick
)
}
}
}
#Composable
private fun TestItemView(
text: String,
onClick: (String) -> Unit,
modifier: Modifier = Modifier,
) {
Surface(modifier.fillMaxWidth()) {
Button(
onClick = { onClick(text) },
content = { Text(text) }
)
}
}
Option A: keep the state in the composable.
#Composable
private fun TestScreenA() {
val myItems = remember { mutableStateOf(listOf("A", "B", "C", "D")) }
TestList(
myItems = myItems.value,
onClick = { clickedItem ->
// for demonstration purposes we remove item on click
myItems.value = myItems.value.filterNot { it == clickedItem }
}
)
}
Option B: Keep the state in the viewmodel (like before)
class TestViewModelB: ViewModel() {
private val _myItems = MutableStateFlow(listOf("A", "B", "C", "D"))
val myItems = _myItems.asStateFlow()
fun onItemClicked(clickedItem: String){
// for demonstration purposes we remove item on click
_myItems.update { items -> items.filterNot { it == clickedItem } }
}
}
#Composable
private fun TestScreenB(
// inject viewModel here using your favorite DI-Framework
viewModel: TestViewModelB
) {
TestList(
// you might want to use collectAsStateWithLifecycle in the future
// see https://medium.com/androiddevelopers/consuming-flows-safely-in-jetpack-compose-cde014d0d5a3
myItems = viewModel.myItems.collectAsState().value,
onClick = viewModel::onItemClicked
)
}
Both options are viable (especially when using rememberSavable).
However, i suggest to use optionB for people just migrating to compose, as its more similar to what they're used. I personally use OptionA for simple states, and OptionB for more complex ones (like a list)
I'm struggling with the Jetpack Compose navigation. I'm following the NowInAndroid architecture, but I don't have the correct behaviour. I have 4 destinations in my bottomBar, one of them is a Feed-type one. In this one, it makes a call to Firebase Database to get multiple products. The problem is that everytime I change the destination (e.j -> SearchScreen) and go back to the Feed one, this (Feed) does not get restored and load all the data again. Someone know what is going on?
This is my navigateTo function ->
fun navigateToBottomBarRoute(route: String) {
val topLevelOptions = navOptions {
popUpTo(navController.graph.findStartDestination().id) {
saveState = true
}
launchSingleTop = true
restoreState = true
}
when (route) {
HomeSections.FEED.route -> navController.navigateToFeed(navOptions = topLevelOptions)
HomeSections.SEARCH.route -> navController.navigateToSearch(navOptions = topLevelOptions)
else -> {}
}
}
My Feed Screen ->
#OptIn(ExperimentalLifecycleComposeApi::class)
#Composable
fun Feed(
onProductClick: (Long, String) -> Unit,
onNavigateTo: (String) -> Unit,
modifier: Modifier = Modifier,
viewModel: FeedViewModel = hiltViewModel()
) {
val feedUiState: FeedScreenUiState by viewModel.uiState.collectAsStateWithLifecycle()
val productsCollections = getProductsCollections(feedUiState)
Feed(
feedUiState = feedUiState,
productCollections = productsCollections,
onProductClick = onProductClick,
onNavigateTo = onNavigateTo,
onProductCreate = onProductCreate,
modifier = modifier,
)
}
The getProductsCollections function returns a list of ProductCollections
fun getProductsCollections(feedUiState: FeedScreenUiState): List<ProductCollection> {
val productCollection = ArrayList<ProductCollection>()
if (feedUiState.processors is FeedUiState.Success) productCollection.add(feedUiState.processors.productCollection)
if (feedUiState.motherboards is FeedUiState.Success) productCollection.add(feedUiState.motherboards.productCollection)
/** ........ **/
return productCollection
}
I already tried to make rememberable the val productsCollections = getProductsCollections(feedUiState) but does not even load the items.
I hope you guys can help me, thanks!!
I already tried to make rememberable the val productsCollections = getProductsCollections(feedUiState) but does not even load the items.
I want to restore the previous state of the FeedScreen and not reload everytime I change the destination in my bottomBar.
I need help with composables and mutableStates!
In the example below, I have a list of classes.
I want to try to manipulate the list to
a. swap the position of two items
b. change the "data" in one of the classes
c. get a copy of the classes "flattened"
I have in the main composable a text field displaying the items
a. a copy of the list from the viewModel
b. a button to move the 1st item to the 3rd position
c. a button to get the "flattened" data
d. a button to modify the data in the 3rd item.
I expected each of these actions to modify the visible contents
of the items.
When I run this I see the items on the screen.
When I click "move 2nd to 4th", the list of items does not update on the screen.
But, clicking to get the flattened layout shows the flattened data correctly.
Now, I click "change data" and the data is changed in the items on the screen!
If I click on get flat layout, the original list reappears and the
flattened list shows the original rlattened list again!
What is happening and how can I fix it? I don't expect coroutine calls are
needed here.
I also have the problem of losing data when I rotate the screen (newbie here.)
Pertinent code of MainActivity:
#Composable
fun Screen() {
val vm = MyViewModel()
val items = vm.items.collectAsState()
var flat by remember{ mutableStateOf ("")}
var newItem by remember { mutableStateOf(MyClass("",""))}
Column {
Text(text = items.toString().split("MyC").joinToString("\nMyC"))
Spacer(Modifier.height(8.dp))
Button(
onClick = {vm.moveList(1,3)} // swap 2nd and 4th items on list
) { Text( text = "move 2nd to 4th")}
Spacer(Modifier.height(8.dp))
Button(
onClick = {flat = vm.getLayout()}
) { Text( text = "get flat layout")}
Spacer(Modifier.height(8.dp))
Text(text = flat)
Spacer(Modifier.height(8.dp))
Button(
onClick = { vm.changeData(2,"NEW")
newItem = vm.items.value[2]}
) { Text( text = "get change heading on 3rd")}
Spacer(Modifier.height(8.dp))
Text(text = newItem.toString())
}
}
And here is code from the viewModel:
data class MyClass(
val key: String,
val data: String,
)
class MyViewModel: ViewModel(){
companion object {
private const val DEFAULT_LAYOUT = "0|A,1|B,2|C,3|D,4|E"
}
private val _items: MutableStateFlow<List<MyClass>> =
MutableStateFlow(
DEFAULT_LAYOUT.split(",").map{ MyClass(it.split("|").first(),it.split("|").last())})
val items: StateFlow<List<MyClass>> = _items
fun getLayout(): String {
return items.value.map { it.key + "|" + it.data }.joinToString(",")
}
fun changeData(
index: Int,
newData: String
) {
val mC : MyClass = items.value[index].copy(data = newData)
_items.update {
it.toMutableList().apply {
set(index,mC)
}
}
}
fun moveList(from: Int, to: Int) {
_items.update {
it.toMutableList().apply {
add(to, removeAt(from))
}
}
}
fun removeElement(idx: Int) {
_items.update {
it.toMutableList().apply {
removeAt(idx)
}
}
}
}
I'm have a LazyColumn that renders a list of items. However, I now want to fetch more items to add to my lazy list. I don't want to re-render items that have already been rendered in the LazyColumn, I just want to add the new items.
How do I do this with a StateFlow? I need to pass a page String to fetch the next group of items, but how do I pass a page into the repository.getContent() method?
class FeedViewModel(
private val resources: Resources,
private val repository: FeedRepository
) : ViewModel() {
// I need to pass a parameter to `repository.getContent()` to get the next block of items
private val _uiState: StateFlow<UiState> = repository.getContent()
.map { content ->
UiState.Ready(content)
}.catch { cause ->
UiState.Error(cause.message ?: resources.getString(R.string.error_generic))
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(stopTimeoutMillis = SUBSCRIBE_TIMEOUT_FOR_CONFIG_CHANGE),
initialValue = UiState.Loading
)
val uiState: StateFlow<UiState>
get() = _uiState
And in my UI, I have this code to observe the flow and render the LazyColumn:
val lifecycleAwareUiStateFlow: Flow<UiState> = remember(viewModel.uiState, lifecycleOwner) {
viewModel.uiState.flowWithLifecycle(lifecycleOwner.lifecycle, Lifecycle.State.STARTED)
}
val uiState: UiState by lifecycleAwareUiStateFlow.collectAsState(initial = UiState.Loading)
#Composable
fun FeedLazyColumn(
posts: List<Post> = listOf(),
scrollState: LazyListState
) {
LazyColumn(
modifier = Modifier.padding(vertical = 4.dp),
state = scrollState
) {
// how to add more posts???
items(items = posts) { post ->
Card(post)
}
}
}
I do realize there is a Paging library for Compose, but I'm trying to implement something similar, except the user is in charge of whether or not to load next items.
This is the desired behavior:
I was able to solve this by adding the new posts to the old posts before emitting it. See comments below for the relevant lines.
private val _content = MutableStateFlow<Content>(Content())
private val _uiState: StateFlow<UiState> = repository.getContent()
.mapLatest { content ->
_content.value = _content.value + content // ADDED THIS
UiState.Ready(_content.value)
}.catch { cause ->
UiState.Error(cause.message ?: resources.getString(R.string.error_generic))
}.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(stopTimeoutMillis = SUBSCRIBE_TIMEOUT_FOR_CONFIG_CHANGE),
initialValue = UiState.Loading
)
private operator fun Content.plus(content: Content): Content = Content(
posts = this.posts + content.posts,
youTubeNextPageToken = content.youTubeNextPageToken
)
class YouTubeDataSource(private val apiService: YouTubeApiService) :
RemoteDataSource<YouTubeResponse> {
private val nextPageToken = MutableStateFlow<String?>(null)
fun setNextPageToken(nextPageToken: String) {
this.nextPageToken.value = nextPageToken
}
override fun getContent(): Flow<YouTubeResponse> = flow {
// retrigger emit when nextPageToken changes
nextPageToken.collect {
emit(apiService.getYouTubeSnippets(it))
}
}
}
For example, I load data into a List, it`s wrapped by MutableStateFlow, and I collect these as State in UI Component.
The trouble is, when I change an item in the MutableStateFlow<List>, such as modifying attribute, but don`t add or delete, the UI will not change.
So how can I change the UI when I modify an item of the MutableStateFlow?
These are codes:
ViewModel:
data class TestBean(val id: Int, var name: String)
class VM: ViewModel() {
val testList = MutableStateFlow<List<TestBean>>(emptyList())
fun createTestData() {
val result = mutableListOf<TestBean>()
(0 .. 10).forEach {
result.add(TestBean(it, it.toString()))
}
testList.value = result
}
fun changeTestData(index: Int) {
// first way to change data
testList.value[index].name = System.currentTimeMillis().toString()
// second way to change data
val p = testList.value[index]
p.name = System.currentTimeMillis().toString()
val tmplist = testList.value.toMutableList()
tmplist[index].name = p.name
testList.update { tmplist }
}
}
UI:
setContent {
LaunchedEffect(key1 = Unit) {
vm.createTestData()
}
Column {
vm.testList.collectAsState().value.forEachIndexed { index, it ->
Text(text = it.name, modifier = Modifier.padding(16.dp).clickable {
vm.changeTestData(index)
Log.d("TAG", "click: ${index}")
})
}
}
}
Both Flow and Compose mutable state cannot track changes made inside of containing objects.
But you can replace an object with an updated object. data class is a nice tool to be used, which will provide you all copy out of the box, but you should emit using var and only use val for your fields to avoid mistakes.
Check out Why is immutability important in functional programming?
testList.value[index] = testList.value[index].copy(name = System.currentTimeMillis().toString())