I see inconsistent behavior with Android Compose and Mutable objects (newbie?) - android

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)
}
}
}
}

Related

How to communicate between adapter vs jetpack compose (lazy column)?

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)

Android common ViewModel for two Compose screens

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?

Button onClick keep replacing values in mutableStateList

I'm trying to display a 4x4 grid with values that change depending on user input. To achieve that, I created mutableStateListOf that I use in a ViewModel to survive configuration changes. However, when I try to replace a value in that particular list using button onClick, it keeps doing that until app crashes. I can't understand why is onReplaceGridContent looping after clicking the button once. Currently, my code looks like this:
ViewModel:
class GameViewModel : ViewModel(){
var gameGridContent = mutableStateListOf<Int>()
private set // Restrict writes to this state object to private setter only inside view model
fun replaceGridContent(int: Int, index: Int){
gameGridContent[index] = int
}
fun removeGridContent(index: Int){
gameGridContent[index] = -1
}
fun initialize(){
for(i in 0..15){
gameGridContent.add(-1)
}
val firstEmptyGridTile = GameUtils.getRandomTilePosition(gameGridContent)
val firstGridNumber = GameUtils.getRandomTileNumber()
gameGridContent[firstEmptyGridTile] = firstGridNumber
}
}
Button:
Button(
onClick = {
onReplaceGridContent(GameUtils.getRandomTileNumber(),GameUtils.getRandomTilePosition(gameGridContent))},
colors = Color.DarkGray
){
Text(text = "Add number to tile")
}
Activity Composable:
#Composable
fun gameScreen(gameViewModel: GameViewModel){
gameViewModel.initialize()
MainStage(
gameGridContent = gameViewModel.gameGridContent,
onReplaceGridContent = gameViewModel::replaceGridContent,
onRemoveGridContent = gameViewModel::removeGridContent
)
}
Your initialize will actually run on every recomposition of gameScreen:
You click on a tile - state changes causing recomposition.
initializa is called and changes the state again causing recomposition.
Step 2 happens again and again.
You should initialize your view model in its constructor instead (or use boolean flag to force one tim initialization) to make it inly once.
Simply change it to constructor:
class GameViewModel : ViewModel(){
var gameGridContent = mutableStateListOf<Int>()
private set // Restrict writes to this state object to private setter only inside view model
fun replaceGridContent(int: Int, index: Int){
gameGridContent[index] = int
}
fun removeGridContent(index: Int){
gameGridContent[index] = -1
}
init {
for(i in 0..15){
gameGridContent.add(-1)
}
val firstEmptyGridTile = GameUtils.getRandomTilePosition(gameGridContent)
val firstGridNumber = GameUtils.getRandomTileNumber()
gameGridContent[firstEmptyGridTile] = firstGridNumber
}
}
Now you don't need to call initialize in the composable:
#Composable
fun gameScreen(gameViewModel: GameViewModel){
MainStage(
gameGridContent = gameViewModel.gameGridContent,
onReplaceGridContent = gameViewModel::replaceGridContent,
onRemoveGridContent = gameViewModel::removeGridContent
)
}

When using List as State, how to update UI when item`attribute change in Jetpack Compose?

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())

Why this function is called multiple times in Jetpack Compose?

I'm currently trying out Android Compose. I have a Text that shows price of a crypto coin. If a price goes up the color of a text should be green, but if a price goes down it should be red. The function is called when a user clicks a button. The problem is that the function showPrice() is called multiple times (sometimes just once, sometimes 2-4 times). And because of that the user can see the wrong color. What can I do to ensure that it's only called once?
MainActivity:
#Composable
fun MainScreen() {
val priceLiveData by viewModel.trackLiveData.observeAsState()
val price = priceLiveData ?: return
when (price) {
is ViewState.Response -> showPrice(price = price.data)
is ViewState.Error -> showError(price.text)
}
Button(onClick = {viewModel.post()} )
}
#Composable
private fun showPrice(price: Double) {
lastPrice = sharedPref.getFloat("eth", 0f).toDouble()
val color by animateColorAsState(if (price >= (lastPrice)) Color.Green else
Color.Red)
Log.v("TAG", "last=$lastPrice new = $price")
editor.putFloat("eth", price.toFloat()).apply()
Text(
text = price.toString(),
color = color,
fontSize = 28.sp,
fontFamily = fontFamily,
fontWeight = FontWeight.Bold
)
}
ViewModel:
#HiltViewModel
class MyViewModel #Inject constructor(
private val repository: Repository
): ViewModel() {
private val _trackLiveData: MutableLiveData<ViewState<Double>> = MutableLiveData()
val trackLiveData: LiveData<ViewState<Double>>
get() = _trackLiveData
fun post(
) = viewModelScope.launch(Dispatchers.Default) {
try {
val response = repository.post()
_trackLiveData.postValue(ViewState.Response(response.rate.round(7)))
} catch (e: Exception) {
_trackLiveData.postValue(ViewState.Error())
Log.v("TAG: viewmodelPost", e.message.toString())
}
}
}
ViewState:
sealed class ViewState<out T : Any> {
class Response<out T : Any>(val data: T): ViewState<T>()
class Error(val text:String = "Unknown error"): ViewState<Nothing>()
}
So when I press Button to call showPrice(). I can see these lines on Log:
2021-06-10 16:39:18.407 16781-16781/com.myapp.myapp V/TAG: last=2532.375732421875 new = 2532.7403716
2021-06-10 16:39:18.438 16781-16781/com.myapp.myapp V/TAG: last=2532.740478515625 new = 2532.7403716
2021-06-10 16:39:18.520 16781-16781/com.myapp.myapp V/TAG: last=2532.740478515625 new = 2532.7403716
What can I do to ensure that it's only called once?
Nothing, that's how it's meant to work. In the View system you would not ask "Why is my view invalidated 3 times?". The framework invalidates (recomposes) the view as it needs, you should not need to know or care when that happens.
The issue with your code is that your Composable is reading the old value from preferences, that is not how it should work, that value should be provided by the viewmodel as part of the state. Instead of providing just the new price, expose a Data Class that has both the new and old price and then use those 2 values in your composable to determine what color to show, or expose the price and the color to use.

Categories

Resources