Jetpack Compose model list becomes jumbled when adding new items - android

I have an issue with Jetpack compose displaying a model containing a ModelList of items. When new items are added, the order of the UI elements becomes incorrect.
Here's a very simple CounterModel containing a ModelList of ItemModels:
#Model
data class CounterModel(
var counter: Int = 0,
var name: String = "",
val items: ModelList<ItemModel> = ModelList()
)
#Model
data class ItemModel(
var name: String
)
The screen shows two card rows for each ItemModel: RowA and RowB.
When I create this screen initialised with the following CounterModel:
val model = CounterModel()
model.name="hi"
model.items.add(ItemModel("Item 1"))
model.items.add(ItemModel("Item 2"))
CounterModelScreen(model)
...it displays as expected like this:
Item 1
Row A
Item 1
Row B
Item 2
Row A
Item 2
Row B
When I click my 'add' button, to insert a new ItemModel, I simply expect to see
Item 3
Row A
Item 3
Row B
At the bottom. But instead, the order is jumbled, and I see two rowAs then two rowBs:
Item 1
Row A
Item 1
Row B
Item 2
Row A
Item 3
Row A
Item 3
Row B
Item 2
Row B
I don't really understand how this is possible. The UI code is extremely simple: loop through the items and emit RowA and RowB for each one:
for (i in counterModel.items.indices) {
RowA(counterModel, i)
RowB(counterModel, i)
}
Using Android Studio 4.0C6
Here's the complete code:
#Composable
fun CounterModelScreen(counterModel: CounterModel) {
Column {
TopAppBar(title = {
Text(
text = "Counter Model"
)
})
CounterHeader(counterModel)
for (i in counterModel.items.indices) {
RowA(counterModel, i)
RowB(counterModel, i)
}
Button(
text = "Add",
onClick = {
counterModel.items.add(ItemModel("Item " + (counterModel.items.size + 1)))
})
}
}
#Composable
fun CounterHeader(counterModel: CounterModel) {
Text(text = counterModel.name)
}
#Composable
fun RowA(counterModel: CounterModel, index: Int) {
Padding(padding = 8.dp) {
Card(color = Color.White, shape = RoundedCornerShape(4.dp)) {
Column(crossAxisSize = LayoutSize.Expand) {
Text(
text = counterModel.items[index].name
)
Text(text = "Row A")
}
}
}
}
#Composable
fun RowB(counterModel: CounterModel, index: Int) {
Padding(padding = 8.dp) {
Card(color = Color.Gray, shape = RoundedCornerShape(4.dp)) {
Column(crossAxisSize = LayoutSize.Expand) {
Text(
text = counterModel.items[index].name
)
Text(text = "Row B")
}
}
}
}

I have tested it using compose-1.0.0-alpha07 and making some changes to adapt the code to the changed APIs. Everything works flawlessly so my guess is that something was broken in an older version of compose as the code looks correct and works in more recent versions with the mentioned changes.
I have also modified your code to use states as recommended in the docs and added a ViewModel that will help you decouple the Views from the data management:
ViewModel
class CounterModelViewModel : ViewModel() {
private val myBaseModel = CounterModel().apply {
name = "hi"
items.add(ItemModel("Item 1"))
items.add(ItemModel("Item 2"))
}
private val _modelLiveData = MutableLiveData(myBaseModel)
val modelLiveData: LiveData<CounterModel> = _modelLiveData
fun addNewItem() {
val oldCounterModel = modelLiveData.value ?: CounterModel()
// Items is casted to a new MutableList because the new state won't be notified if the new
// counter model content is the same one as the old one. You can also change any other
// properties instead like the name or the counter
val newItemsList = oldCounterModel.items.toMutableList()
newItemsList.add(ItemModel("Item " + (newItemsList.size + 1)))
// Pass a new instance of CounterModel to the LiveData
val newCounterModel = oldCounterModel.copy(items = newItemsList)
_modelLiveData.value = newCounterModel
}
}
Composable Views updated:
#Composable
fun CounterModelScreen(counterModel: CounterModel, onAddNewItem: () -> Unit) {
ScrollableColumn {
TopAppBar(title = {
Text(
text = "Counter Model"
)
})
CounterHeader(counterModel)
counterModel.items.forEachIndexed { index, item ->
RowA(counterModel, index)
RowB(counterModel, index)
}
Button(
onClick = onAddNewItem
) {
Text(text = "Add")
}
}
}
#Composable
fun CounterHeader(counterModel: CounterModel) {
Text(text = counterModel.name)
}
#Composable
fun RowA(counterModel: CounterModel, index: Int) {
Card(
backgroundColor = Color.White,
shape = RoundedCornerShape(4.dp),
modifier = Modifier.padding(8.dp)
) {
Column(modifier = Modifier.fillMaxWidth()) {
Text(
text = counterModel.items[index].name
)
Text(text = "Row A")
}
}
}
#Composable
fun RowB(counterModel: CounterModel, index: Int) {
Card(
backgroundColor = Color.Gray,
shape = RoundedCornerShape(4.dp),
modifier = Modifier.padding(8.dp)
) {
Column(modifier = Modifier.fillMaxWidth()) {
Text(
text = counterModel.items[index].name
)
Text(text = "Row B")
}
}
}
This previous code is called from another composable function that contains the instance of the ViewModel, however you can change this to an activity or a fragment with an instance of the mentioned ViewModel, it's up to your preference.
#Composable
fun MyCustomScreen(viewModel: CounterModelViewModel = viewModel()) {
val modelState: CounterModel by viewModel.modelLiveData.observeAsState(CounterModel())
CounterModelScreen(
counterModel = modelState,
onAddNewItem = {
viewModel.addNewItem()
}
)
}

Related

Jetpack Compose - No possibilit to create a nested LazyColumns

Following problem: I created a Compose View which should display a item list (it also should display more things in future development).
I created following view:
data class ItemHolder(
val header: String,
val subItems: List<String>,
val footer: String
)
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Create items
val items = (1..20).map { itemIndex ->
ItemHolder(
header = "Header of $itemIndex",
subItems = (1..30).map { subItemIndex ->
"Sub item $subItemIndex of $itemIndex"
},
footer = "Footer of $itemIndex"
)
}
setContent {
Column(
modifier = Modifier.verticalScroll(rememberScrollState())
) {
Text(text = "Items:")
ItemList(items = items)
}
}
}
}
// Displays the list of items
#Composable
fun ItemList(items: List<ItemHolder>) {
LazyColumn {
items(items = items) {
Item(item = it)
}
}
}
// Displays a single item
#Composable
fun Item(item: ItemHolder) {
var subItemsVisible by remember { mutableStateOf(false) }
// Displays the header of the item
Row {
Text(text = item.header)
Button(
onClick = { subItemsVisible = !subItemsVisible },
content = {
Text(text = if (subItemsVisible) "Hide" else "Show")
}
)
}
// Displays the sub items of the item
AnimatedVisibility(visible = subItemsVisible) {
Column {
for (subItem in item.subItems) {
Text(text = subItem)
}
}
}
// Displays the footer of the item
Text(text = item.footer)
}
I found out that the problem is, that the outer Column (which is scrollable) contains the LazyColumn which contains the actual items.
I get following error:
java.lang.IllegalStateException: Vertically scrollable component was measured with an infinity maximum height constraints, which is disallowed.
I was searching around for hours, but didn't find any suitable solution for my problem.
How can I fix this?
I think you have to remove modifier = Modifier.verticalScroll(rememberScrollState()) it will not work with nested lazy column
refer this link may be help you :https://proandroiddev.com/nested-scroll-with-jetpack-compose-9c3b054d2e12
I edit your code I hope it will help you
data class ItemHolder(
val header: String,
val subItems: List<String>,
val footer: String
)
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val items = (1..4).map { itemIndex ->
ItemHolder(
header = "Header of $itemIndex",
subItems = (1..30).map { subItemIndex ->
"Sub item $subItemIndex of $itemIndex"
},
footer = "Footer of $itemIndex"
)
}
setContent {
LazyColumn(
modifier = Modifier.padding(10.dp)
) {
item {
Text(text = "Items: Header", color = Color.Red, fontSize = 20.sp)
Spacer(modifier = Modifier.height(20.dp))
}
items(items = items) {
Item(item = it)
}
item {
Spacer(modifier = Modifier.height(20.dp))
Text(text = "Items: Footer", color = Color.Red, fontSize = 20.sp)
Spacer(modifier = Modifier.height(20.dp))
}
items(items = items) {
Item(item = it)
}
}
}
}
}
// Displays a single item
#Composable
fun Item(item: ItemHolder) {
var subItemsVisible by remember { mutableStateOf(false) }
// Displays the header of the item
Row {
Text(text = item.header)
Button(
onClick = { subItemsVisible = !subItemsVisible },
content = {
Text(text = if (subItemsVisible) "Hide" else "Show")
}
)
}
// Displays the sub items of the item
AnimatedVisibility(visible = subItemsVisible) {
Column {
for (subItem in item.subItems) {
Text(text = subItem)
}
}
}
// Displays the footer of the item
Text(text = item.footer)
}

Single Selection - DeSelection in Lazy Column

PROBLEM ::: I want to create a lazy column where I can select or deselect only one option at a time. Right now, whenever I click on row component inside lazy column, all the rows get selected.
CODE :::
#Composable
fun LazyColumnWithSelection() {
var isSelected by remember {
mutableStateOf(false)
}
var selectedIndex by remember { mutableStateOf(0) }
val onItemClick = { index: Int -> selectedIndex = index }
LazyColumn(
modifier = Modifier.fillMaxSize(),
) {
items(100) { index ->
Row(modifier = Modifier
.fillMaxWidth()
.clickable {
onItemClick.invoke(index)
if (selectedIndex == index) {
isSelected = !isSelected
}
}
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically) {
Text(text = "Item $index", modifier = Modifier.padding(12.dp), color = Color.White)
if (isSelected) {
Icon(imageVector = Icons.Default.Check,
contentDescription = "Selected",
tint = Color.Green,
modifier = Modifier.size(20.dp))
}
}
}
}
}
CURRENT RESULT :::
Before Clicking ->
After Clicking ->
You can see all the items are getting selected but I should be able to select or deselect one item at a time not all.
I tried to use remember state for selection but I think I'm doing wrong something in the index selection or maybe if statement.
This should probably give you a head start.
So we have 4 components here:
Data Class
Class state holder
Item Composable
ItemList Composable
ItemData
data class ItemData(
val id : Int,
val display: String,
val isSelected: Boolean = false
)
State holder
class ItemDataState {
val itemDataList = mutableStateListOf(
ItemData(1, "Item 1"),
ItemData(2, "Item 2"),
ItemData(3, "Item 3"),
ItemData(4, "Item 4"),
ItemData(5, "Item 5")
)
// were updating the entire list in a single pass using its iterator
fun onItemSelected(selectedItemData: ItemData) {
val iterator = itemDataList.listIterator()
while (iterator.hasNext()) {
val listItem = iterator.next()
iterator.set(
if (listItem.id == selectedItemData.id) {
selectedItemData
} else {
listItem.copy(isSelected = false)
}
)
}
}
}
Item Composable
#Composable
fun ItemDisplay(
itemData: ItemData,
onCheckChanged: (ItemData) -> Unit
) {
Row(
modifier = Modifier
.fillMaxWidth()
.height(80.dp)
.border(BorderStroke(Dp.Hairline, Color.Gray)),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(text = if (itemData.isSelected) "I'm selected!" else itemData.display)
Checkbox(
checked = itemData.isSelected,
onCheckedChange = {
onCheckChanged(itemData.copy(isSelected = !itemData.isSelected))
}
)
}
}
Finally the ItemList (LazyColumn)
#Composable
fun ItemList() {
val itemDataState = remember { ItemDataState() }
LazyColumn {
items(itemDataState.itemDataList, key = { it.id } ) { item ->
ItemDisplay(
itemData = item,
onCheckChanged = itemDataState::onItemSelected
)
}
}
}
All of these are copy-and-pasteable so you can run it quickly. The codes should be simple enough for you to dissect them easily and use them as a reference for your own use-case.
Notice that we use a data class here which has an id property to be unique and we're using it as a key parameter for LazyColumn's item.
I usually implement my UI collection components with a unique identifier to save me from potential headaches such as UI showing/removing/recycling wrong items.
Remember index instead of Boolean (isSelected).

Dynamic Text Input does not trigger recomposition

I'm trying to build a custom form using jetpack compose.
What I did so far in the Screen :
#Composable
fun FormContent(
viewModel: FormViewModel,
customFieldList: List<String>,
valuesCustomFieldsList: List<String>
) {
Column(Modifier.fillMaxWidth()) {
Column(
modifier = Modifier
.verticalScroll(rememberScrollState())
.weight(1f)
.padding(top = 8.dp, bottom = 8.dp)
) {
if (customFieldList.isNotEmpty()) {
customFieldList.forEachIndexed { index, item ->
TextRow(
title = item,
placeholder = "Insert $item",
value = valuesCustomFieldsList[index],
onValueChange = {
viewModel.onCustomFieldChange(index, it)
},
isError = false
)
}
}
}
}
Where customFieldList is the list of textFields i want and valuesCustomFieldsList is
val valuesCustomFields by viewModel.values.collectAsState()
In my viewModel the code is as it follows:
private val _values = MutableStateFlow(emptyList<String>())
val values : StateFlow<List<String>>
get() = _values
private var customFieldValues = mutableListOf<String>()
init {
viewModelScope.launch {
//get all custom fields
customFields = gmeRepository.getCustomField()
if (customFields.isNotEmpty()) {
//init the customFieldsValues
for (i in 0..customFields.size) {
customFieldValues.add("")
}
_values.value = customFieldValues
}
}
}
fun onCustomFieldChange(index : Int, value: String) {
customFieldValues[index] = value
_values.value = customFieldValues
}
What happens here is that if I try to change the value inside the inputText, onCustomFieldChange is correctly triggered with the first letter I wrote inside, but then no change is visible in the UI.
If I try to change another static field inside the form, then only the last char i wrote inside my custom fields are shown cause a recomposition is triggered.
Is there something I can do to achieve my goal?

Android Compose LazyList - keep scrolling position when new items are added to the top of the list

Here's the original list before I add any new item
When I add a new item to the end of the list, without scrolling, the first item still remains in that position
But when I add new items to the start of the list, all the old items is moved down. Notice that New item 0 is not at the very top of the list anymore
How can I make it so that if I add a new item to the start of the list, the old item still remain in the exact position. And these new items will only be visible when the user scrolls up, not pushing everything down. If I remember correctly, it's the default behavior with RecyclerView
Here's my code:
MainActivity.kt
class MainActivity : ComponentActivity() {
private val list = MutableStateFlow<List<String>>(emptyList())
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
EmptyComposeTheme {
val items = list.collectAsState()
Box(Modifier.fillMaxSize()) {
LazyColumn(
Modifier.fillMaxSize(),
) {
itemsIndexed(items.value) { index: Int, item: String ->
Text(item, color = MaterialTheme.colors.onSurface)
}
}
FloatingActionButton(onClick = {
val oldList = list.value.toMutableList()
oldList.add("New item ${oldList.size}")
list.value = oldList
}, Modifier.align(Alignment.BottomStart)) {
Text("Click me!")
}
FloatingActionButton(onClick = {
val oldList = list.value.toMutableList()
oldList.add(0, "New item reversed ${oldList.size}")
list.value = oldList
}, Modifier.align(Alignment.BottomEnd)) {
Text("Click other me!")
}
}
}
}
}
}
I'm using Compose version 1.0.1
LazyComun with itemsIndexedand index in the key param solved my problem.
LazyColumn(
state = listState
) {
itemsIndexed(list, key = {index, item->
index + item.hashcode() // Including index in the key param solved my problem
}) {
....
}
}
Basically you aim at controlling the scroll position as described in some more detail here
While taking your code as a basis, you could hence do the following:
val items = list.collectAsState()
val listState = rememberLazyListState()
val coroutineScope = rememberCoroutineScope()
Box(Modifier.fillMaxSize()) {
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
) {
...
}
...
FloatingActionButton(onClick = {
val currentIndex = listState.firstVisibleItemIndex + 1
val oldList = list.value.toMutableList()
oldList.add(0, "New item reversed ${oldList.size}")
list.value = oldList
coroutineScope.launch {
listState.scrollToItem(currentIndex)
}
}, Modifier.align(Alignment.BottomEnd)) {
Text("Click other me!")
}
}
u can add key param to LazyColumn like this, but if ur firstVisibleIndex = 0, then problem not solved, i do not know why its:
LazyColumn(
state = listState,
modifier = Modifier.fillMaxSize(),
key = { item -> item.id} // or any unique value
) {
...
}

Navigate to AlertDialog from LazyColumn

I've been experimenting a bit with the new Jetpack Compose for the last few days and it's been great but now I'm stuck on something that should be quite simple. All I want to do is from a LazyColumn (RecyclerView) show an AlertDialog when the user clicks (or long presses) an item in the list AND pass the clicked item as an argument to the AlertDialog. I've managed to do it without passing any arguments and just showing an AlertDialog with preset info. It also works fine to show a Toast message with the clicked item. Here is my code (it is basically the same as the Rally app from the compose-samples on GitHub):
#ExperimentalFoundationApi
#Composable
fun AccountsBody(navController: NavController, viewModel: AccountsViewModel) {
val accountsFromVM = viewModel.accounts.observeAsState()
accountsFromVM.value?.let { accounts ->
StatementBody(
items = accounts, // this is important for the question
// NOT IMPORTANT
amounts = { account -> account.balance },
colors = { account -> HexToColor.getColor(account.colorHEX) },
amountsTotal = accounts.map { account -> account.balance }.sum(),
circleLabel = stringResource(R.string.total),
buttonLabel = DialogScreen.NewAccount.route,
navController = navController,
onLongPress = { } // show alert
) { account, _ -> // this is important for the question
AccountRow(
name = account.name,
bank = account.bank,
amount = account.balance,
color = HexToColor.getColor(account.colorHEX),
account = account,
onClick = { },
onlongPress = { clickedItem ->
// Show alert dialog here and pass in clickedItem
}
)
}
}
}
#Composable
fun <T> StatementBody(
items: List<T>,
// NOT IMPORTANT
colors: (T) -> Color,
amounts: (T) -> Float,
amountsTotal: Float,
circleLabel: String,
buttonLabel: String,
navController: NavController,
onLongPress: (T) -> Unit,
rows: #Composable (T, (T) -> Unit) -> Unit
) {
Column {
// Animating circle and balance box
// NOT IMPORTANT - (see last few rows for the important part)
Box(Modifier.padding(16.dp)) {
val accountsProportion = items.extractProportions { amounts(it).absoluteValue }
val circleColors = items.map { colors(it) }
AnimatedCircle(
accountsProportion,
circleColors,
Modifier
.height(300.dp)
.align(Alignment.Center)
.fillMaxWidth()
)
Column(modifier = Modifier.align(Alignment.Center)) {
Text(
text = circleLabel,
style = MaterialTheme.typography.body1,
modifier = Modifier.align(Alignment.CenterHorizontally)
)
Text(
text = formatAmount(amountsTotal),
style = MaterialTheme.typography.h2,
modifier = Modifier.align(Alignment.CenterHorizontally)
)
Button(onClick = { navController.navigate("${buttonLabel}/Nytt sparkonto") }) {
Text(text = buttonLabel)
}
}
}
Spacer(Modifier.height(10.dp))
// Recycler view
// THIS IS THE IMPORTANT PART
Card {
LazyColumn(modifier = Modifier.padding(12.dp)) {
itemsIndexed(items) { idx, item ->
rows(item, onLongPress) // rows is the Composable you pass in
}
}
}
}
}
#ExperimentalFoundationApi
#Composable
fun AccountRow(
name: String,
bank: String,
amount: Float,
color: Color,
account: AccountData,
onClick: () -> Unit,
onlongPress: (AccountData) -> Unit
) {
BaseRow(
color = color,
title = name,
subtitle = bank,
amount = amount,
rowType = account,
onClick = onClick,
onLongPress = onlongPress
)
}
#ExperimentalFoundationApi
#Composable
private fun <T> BaseRow(
color: Color,
title: String,
subtitle: String,
amount: Float,
rowType: T,
onClick: () -> Unit,
onLongPress: (T) -> Unit
) {
val formattedAmount = formatAmount(amount)
Row(
modifier = Modifier
.height(68.dp)
.combinedClickable(
onClick = onClick,
onLongClick = { onLongPress(rowType) } //HERE IS THE IMPORTANT PART
),
verticalAlignment = Alignment.CenterVertically
) {
val typography = MaterialTheme.typography
AccountIndicator(color = color, modifier = Modifier)
Spacer(Modifier.width(12.dp))
Column(Modifier) {
Text(text = title, style = typography.body1)
CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) {
Text(text = subtitle, style = typography.subtitle1)
}
}
Spacer(Modifier.weight(1f))
Row(horizontalArrangement = Arrangement.SpaceBetween) {
Text(
text = "$formattedAmount kr",
style = typography.h6,
modifier = Modifier.align(Alignment.CenterVertically)
)
}
Spacer(Modifier.width(16.dp))
}
RallyDivider()
}
Any help on how to accomplish this would be appreciated. I'm kind of new to android development and programming in general so I have probably made this more complex than it has to be heh.
This can be easily done by saving the selected item as a MutableState and whether to show the dialog or not is another MutableState.
Here is simplified example that could work as a starting point:
val items = emptyList<String>()
val currentSelectedItem = remember { mutableStateOf(items[0]) }
val showDialog = remember { mutableStateOf(false) }
if (showDialog.value) ShowDialog(currentSelectedItem.value)
Card {
LazyColumn(modifier = Modifier.padding(12.dp)) {
itemsIndexed(items) { idx, item ->
Row() {
Text(
text = item,
Modifier.clickable {
currentSelectedItem.value = item
showDialog.value=true
}
)
} // rows is the Composable you pass in
}
}
}

Categories

Resources