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?
Related
I have debugged the app and I saw that the data in UIState changes when I try to add or remove the item, especially the isAdded field. However, even though the isAdded changes, the AddableItem does not recompose. Additionally, when I try to sort items, or try to write a query THAT WILL NOT SEND ANY API REQUEST, JUST CHANGES THE STRING IN TEXTFIELD, the UI recomposes. So UI reacts to changes in UIState. I have searched for similar issues but cannot find anything. I believe that the framework must recompose when the pointer of the filed changes, however, it does not. Any idea why this happens or solve that?
This is the viewModel:
#HiltViewModel
class AddableItemScreenViewModel#Inject constructor(
val getAddableItemsUseCase: GetItems,
val getItemsFromRoomUseCase: GetRoomItems,
val updateItemCase: UpdateItem,
savedStateHandle: SavedStateHandle) : ViewModel() {
private val _uiState = mutableStateOf(UIState())
val uiState: State<UIState> = _uiState
private val _title = mutableStateOf("")
val title: State<String> = _title
private var getItemsJob: Job? = null
init {
savedStateHandle.get<String>(NavigationConstants.TITLE)?.let { title ->
_title.value = title
}
savedStateHandle.get<Int>(NavigationConstants.ID)?.let { id ->
getItems(id = id.toString())
}
}
fun onEvent(event: ItemEvent) {
when(event) {
is ItemEvent.UpdateEvent -> {
val modelToUpdate = UpdateModel(
id = event.source.id,
isAdded = event.source.isAdded,
name = event.source.name,
index = event.source.index
)
updateUseCase(modelToUpdate).launchIn(viewModelScope)
}
is ItemEvent.QueryChangeEvent -> {
_uiState.value = _uiState.value.copy(
searchQuery = event.newQuery
)
}
is ItemEvent.SortEvent -> {
val curSortType = _uiState.value.sortType
_uiState.value = _uiState.value.copy(
sortType = if(curSortType == SortType.AS_IT_IS)
SortType.ALPHA_NUMERIC
else
SortType.AS_IT_IS
)
}
}
}
private fun getItems(id: String) {
getItemsJob?.cancel()
getItemsJob = getItemsUseCase(id)
.combine(
getItemsFromRoomUseCase()
){ itemsApiResult, roomData ->
when (itemsApiResult) {
is Resource.Success -> {
val data = itemsApiResult.data.toMutableList()
// Look the api result, if the item is added on room, make it added, else make it not added. This ensures API call is done once and every state change happens because of room.
for(i in data.indices) {
val source = data[i]
val itemInRoomData = roomData.find { it.id == source.id }
data[i] = data[i].copy(
isAdded = itemInRoomData != null
)
}
_uiState.value = _uiState.value.copy(
data = data,
isLoading = false,
error = "",
)
}
is Resource.Error -> {
_uiState.value = UIState(
data = emptyList(),
isLoading = false,
error = itemsApiResult.message,
)
}
is Resource.Loading -> {
_uiState.value = UIState(
data = emptyList(),
isLoading = true,
error = "",
)
}
}
}.launchIn(viewModelScope)
}
}
This it the composable:
#OptIn(ExperimentalComposeUiApi::class)
#Composable
fun AddableItemsScreen(
itemsViewModel: AddableItemScreenViewModel = hiltViewModel()
) {
val state = itemsViewModel.uiState.value
val controller = LocalNavigationManager.current
val focusManager = LocalFocusManager.current
val keyboardController = LocalSoftwareKeyboardController.current
val mainScrollState = rememberLazyListState()
val focusRequester = remember { FocusRequester() }
// Screen UI
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colors.BackgroundColor)
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() }
) {
focusManager.clearFocus()
},
) {
LazyColumn(
modifier = Modifier
.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
state = mainScrollState,
) {
item {
WhiteSpacer(
whiteSpacePx = 200,
direction = SpacerDirections.VERTICAL
)
}
if (state.isLoading) {
item {
ProgressIndicator()
}
}
if (state.error.isNotEmpty() && state.error.isNotBlank()) {
item {
ErrorText()
}
}
if (state.data.isNotEmpty()) {
val data = if (state.sortType == SortType.ALPHA_NUMERIC)
state.data.sortedBy { it.name }
else
state.data
data.forEach { source ->
if((state.searchQuery.isEmpty() && state.searchQuery.isBlank()) ||
(source.name != null && source.name.contains(state.searchQuery, ignoreCase = true))) {
item {
AddableItem(
modifier = Modifier
.padding(
vertical = dimManager.heightPxToDp(20)
),
text = source.name ?: "",
isAdded = source.isAdded ?: false,
onItemPressed = {
controller.navigate(
Screens.ItemPreviewScreen.route +
"?title=${source.name}" +
"&id=${source.categoryId}" +
"&isAdded=${source.isAdded}"
)
},
onAddPressed = {
itemsViewModel.onEvent(ItemEvent.UpdateEvent(source))
}
)
}
}
}
}
}
Column(
modifier = Modifier
.align(Alignment.TopStart)
.background(
MaterialTheme.colors.BackgroundColor
),
) {
ItemsScreenAppBar(
title = itemsViewModel.title.value,
onSortPressed = {
itemsViewModel.onEvent(ItemEvent.SortEvent)
}
) {
controller.popBackStack()
}
SearchBar(
query = state.searchQuery,
focusRequester = focusRequester,
placeholder = itemsViewModel.title.value,
onDeletePressed = {
itemsViewModel.onEvent(ItemEvent.QueryChangeEvent(""))
},
onValueChanged = {
itemsViewModel.onEvent(ItemEvent.QueryChangeEvent(it))
},
onSearch = {
keyboardController!!.hide()
}
)
WhiteSpacer(
whiteSpacePx = 4,
direction = SpacerDirections.VERTICAL
)
}
}
}
And finally this is the UIState:
data class UIState(
val data: List<ItemModel> = emptyList(),
val isLoading: Boolean = false,
val error: String = "",
val searchQuery: String = "",
val sortType: SortType = SortType.AS_IT_IS,
)
#Parcelize
data class ItemModel (
val id: Int? = null,
var isAdded: Boolean? = null,
val name: String? = null,
val index: Int? = null,
#SerializedName("someSerializedNameForApi")
var id: Int? = null
): Parcelable
Finally, I have a similar issue with almost the same viewModel with the same UI structure. The UI contains an Add All button and when everything is added, it turns to Remove All. I also hold the state of the button in UIState for that screen. When I try to add all items or remove all items, the UI recomposes. But when I try to add or remove a single item, the recomposition does not happen as same as the published code above. Additionally, when I remove one item when everything is added on that screen, the state of the button does change but stops to react when I try to add more. I can also share that code if you people want. I still do not understand why the UI recomposes when I try to sort or try to add-remove all on both screens but does not recompose when the data changes, even though I change the pointer address of the list.
Thanks for any help.
I could not believe that the answer can be so simple but here are the solutions:
For the posted screen, I just changed _uiState.value = _uiState.value.copy(...) to _uiState.value = UIState(...copy and replace everything with old value...) as
_uiState.value = UIState(
data = data,
isLoading = false,
error = "",
searchQuery = _uiState.value.searchQuery,
sortType = _uiState.value.sortType
)
For the second screen, I was just double changing the isAdded value by sending the data directly without copying. As the api call changes the isAdded value again, and the read from room flow changes it again, the state were changed twice.
However, I still wonder why compose didn't recompose when I changed the memory location of data in UIState.
I have a TextField component that I want to set the initial content to. The initial content will be fetched from a database using a Flow.
I have this TextField code (loosely following this codelabs tutorial):
#Composable
private fun EntryText(placeholder: String, initialText: String = "", ) {
val (text, setText) = remember { mutableStateOf(initialText) }
EntryTextField(
text = text,
placeholder = placeholder,
onTextChanged = setText
)
}
and
#Composable
private fun EntryTextField(text: String, placeholder: String, onTextChanged: (String) -> Unit) {
TextField(
modifier = Modifier.fillMaxWidth(),
value = text,
onValueChange = {
onTextChanged(it)
},
placeholder = { Text(text = placeholder) }
)
}
I want use it like so to set both a Text and the content of the EntryText:
val entry by viewModel.getEntryContent().collectAsState(initial = "initial")
val hint = "hint"
Column {
Text(text = entry)
EntryText(placeholder = hint, initialText = entry)
}
When the ViewModel getEntryContent flow emits the result from the database only the Text is being updated with the new String and not the EntryText (it stays with the initial state of "initial").
How can I have my TextField get updated when my ViewModel emits the string?
Because your text is handle by ViewModel, I think you can store it state in ViewModel like
class MainViewModel : ViewModel() {
var entry = MutableStateFlow("initial")
fun getEntryContent(): Flow<String> {
// get from database
}
}
In your Activity
val entry by viewModel.entry.collectAsState()
Column {
Text(text = entry)
EntryText(placeholder = hint, text = entry, onTextChanged = {
viewModel.entry.value = it
})
}
lifecycleScope.launch {
viewModel.getEntryContent().flowWithLifecycle(lifecycle).collect {
// observe value from db then set to TextField
viewModel.entry.value = it
}
}
In my app I have list of items shown with LazyColumn. My list can update fields and sometime reorder items. Whenever I have reorder of items I need to show list with first item at the top. I came up with setup like the one below. List scrolls to the top only on the first reorder event. On consecutive list update with reorder = true, my list not scrolling to the top. What is the better approach in Compose to reset list scroll position (or even force rebuilding compose list) by sending state from ViewModel?
class ViewItemsState(val items: List<ViewItem>, val scrollToTop: Boolean)
class ExampleViewModel() : ViewModel() {
var itemsLiveData = MutableLiveData<ViewItemsState>()
}
#Composable
fun ExampleScreen(viewModel: ExampleViewModel = viewModel()) {
val itemState by viewModel.itemsLiveData.observeAsState()
val coroutineScope = rememberCoroutineScope()
val listState = rememberLazyListState()
if (itemState.scrollToTop) {
LaunchedEffect(coroutineScope) {
Log.e("TAG", "TopCoinsScreen: scrollToTop" )
listState.scrollToItem(0)
}
}
LazyColumn(state = listState) {
items(itemState.items) { item ->
ItemCompose(
item.name,
item.value
)
}
}
}
Recomposition is triggered only when a state changes.
Two things to fix in this,
Reset scrollToTop to false once scrolling completes.
Store scrollToTop in the view model as a MutableState, LiveData, Flow, or any other reactive element.
Currently scrollToTop is stored as a boolean in a data class object. Resetting the value will not trigger a Composable recomposition.
Example code with sample data,
class ViewItemsState(
val items: List<String>,
)
class ExampleViewModel : ViewModel() {
private var _itemsLiveData =
MutableLiveData(
ViewItemsState(
items = Array(20) {
it.toString()
}.toList(),
)
)
val itemsLiveData: LiveData<ViewItemsState>
get() = _itemsLiveData
private var _scrollToTop = MutableLiveData(false)
val scrollToTop: LiveData<Boolean>
get() = _scrollToTop
fun updateScrollToTop(scroll: Boolean) {
_scrollToTop.postValue(scroll)
}
}
#Composable
fun ExampleScreen(
viewModel: ExampleViewModel = ExampleViewModel(),
) {
val itemState by viewModel.itemsLiveData.observeAsState()
val scrollToTop by viewModel.scrollToTop.observeAsState()
val listState = rememberLazyListState()
LaunchedEffect(
key1 = scrollToTop,
) {
if (scrollToTop == true) {
listState.scrollToItem(0)
viewModel.updateScrollToTop(false)
}
}
Column {
LazyColumn(
state = listState,
modifier = Modifier.weight(1f),
) {
items(itemState?.items.orEmpty()) { item ->
Text(
text = item,
modifier = Modifier.padding(16.dp),
)
}
}
Button(
onClick = {
viewModel.updateScrollToTop(true)
},
) {
Text(text = "Scroll")
}
}
}
In this app, I have a screen where you can enter a title and content for a Note.
The screen has two composables DetailScreen() and DetailScreenContent.
Detailscreen has the scaffold and appbars and calls DetailScreenContents() which has two TextFields and a button.
I'm expecting the user to enter text in these fields and then press the button which will package the text into a NOTE object. My question is, how to pass the NOTE to the upper composable which is DETAILSCREEN() with a callback like=
onclick: -> Note or any other efficient way?
#Composable
fun DetailScreen(navCtl : NavController, mviewmodel: NoteViewModel){
Scaffold(bottomBar = { TidyBottomBar()},
topBar = { TidyAppBarnavIcon(
mtitle = "",
onBackPressed = {navCtl.popBackStack()},
)
}) {
DetailScreenContent()
}
}
#Composable
fun DetailScreenContent() {
val titleValue = remember { mutableStateOf("")}
val contentValue = remember { mutableStateOf("")}
val endnote by remember{ mutableStateOf(Note(
Title = titleValue.value,
Content = contentValue.value))}
Column(modifier = Modifier.fillMaxSize()) {
OutlinedTextField(value = titleValue.value,
onValueChange = {titleValue.value = it},
singleLine = true,
label = {Text("")}
,modifier = Modifier
.fillMaxWidth()
.padding(start = 3.dp, end = 3.dp),
shape = cardShapes.small
)
OutlinedTextField(value = contentValue.value, onValueChange = {
contentValue.value = it
},
label = {Text("Content")}
,modifier = Modifier
.fillMaxWidth()
.padding(start = 3.dp, end = 3.dp, top = 3.dp)
.height(200.dp),
shape = cardShapes.small,
)
Row(horizontalArrangement = Arrangement.End,
modifier = Modifier.fillMaxWidth()){
Button(onClick = {
/**return the object to the upper composable**/
}, shape = cardShapes.small) {
Text(text = stringResource(R.string.Finish))
}
}
}
You could use state hoisting. Using lambdas is the most common way of hoisting state here.
Ok so here's DetailScreenContent(), say
fun DetailScreenContent(
processNote: (Note) -> Unit
){
Button( onClick = { processNote(/*Object to be "returned"*/) }
}
We are not literally returning anything, but we are hoisting the state up the hierarchy. Now, in DetailsScreen
fun DetailScreen(navCtl : NavController, mviewmodel: NoteViewModel){
Scaffold(bottomBar = { TidyBottomBar()},
topBar = { TidyAppBarnavIcon(
mtitle = "",
onBackPressed = {navCtl.popBackStack()},
)
}) {
DetailScreenContent(
processNote = {note -> //This is the passed object
/*Perform operations*/
}
)
//You could also extract the processNote as a variable, like so
/*
val processNote = (Note) {
Reference the note as "it" here
}
*/
}
}
This assumes that there is a type Note (something like a data class or so, the object of which type is being passed up, get it?)
That's how we hoist our state and hoist it up to the viewmodel. Remember, compose renders state based on variables here, making it crucial to preserve the variables, making sure they are not modified willy nilly and read from random places. There should be, at a time, only one instance of the variables, which should be modified as and when necessary, and should be read from a common place. This is where viewmodels are helpful. You store all the variables (state) inside the viewmodel, and hoist the reads and modifications to there. It must act as a single source of truth for the app.
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()
}
)
}