I have a screen with Jetpack Compose in which I have a TextField for the user to write a text.
With this text I will make a query to obtain data. I want this query to be made when the user finishes typing.
Is there a way to know if the user takes 2 seconds without writing (for example) to launch this query?
To query after 2 seconds after user stop typing, I think you can use debounce operator (similar idea to the answer here Jetpack Compose and Room DB: Performance overhead of auto-saving user input?)
Here is an example to handle text change on TextField, then query to database and return the result to dbText
class VM : ViewModel() {
val text = MutableStateFlow("")
val dbText = text.debounce(2000)
.distinctUntilChanged()
.flatMapLatest {
queryFromDb(it)
}
private fun queryFromDb(query: String): Flow<String> {
Log.i("TAG", "query from db: " + query)
if (query.isEmpty()) {
return flowOf("Empty Result")
}
// TODO, do query from DB and return result
}
}
In Composable
Column {
val text by viewModel.text.collectAsState()
val dbText by viewModel.dbText.collectAsState("Empty Result")
TextField(value = text, onValueChange = { viewModel.text.value = it })
Text(text = dbText)
}
Another way is to avoid the viewmodel completely. Utilise the LaunchedEffect that will cancel/restart itself on every key (text) change. I find this to be way cleaner than to couple your debounce code to your viewmodel.
#Composable
private fun TextInput(
dispatch: (ViewModelEvent) : Unit,
modifier: Modifier = Modifier
) {
var someInputText by remember { mutableStateOf(TextFieldValue("")) }
TextField(
value = someInputText,
onValueChange = {
someInputText = it
},
)
LaunchedEffect(key1 = someInputText) {
// this check is optional if you want the value to emit from the start
if (someInputText.text.isBlank()) return#LaunchedEffect
delay(2000)
// print or emit to your viewmodel
dispatch(SomeViewModelEvent(someInputText.text))
}
}
Related
I'm using Compose to build my Android UI.
I have a screen where I want to be able to search for stocks and show them in a LazyColumn. For triggering the API call I'm using a LaunchedEffect like this.
val stocks = remember { mutableStateListOf<Stock>() }
var searchText by remember { mutableStateOf("") }
val hasSearchEnoughChars = searchText.length >= 3
...
if(hasSearchEnoughChars) {
LaunchedEffect(key1 = searchText) {
delay(500)
searchStocksForText(searchText) {
isSearching = false
wereStocksFound = it.isNotEmpty()
stocks.clear()
stocks.addAll(it)
}
}
} else {
stocks.clear()
}
...
SearchField(
onValueChanged = {
searchText = it
}
)
...
private fun SearchField(
onValueChanged: (String) -> Unit,
modifier: Modifier = Modifier,
isError: Boolean = false
) {
var inputText by remember { mutableStateOf("") }
OutlinedTextField(
value = inputText,
onValueChange = {
inputText = it
onValueChanged(it)
},
...
)
}
This is how searchText is updated.
fun searchStocksForText(searchText: String, onDataReceived: (List<Stock>) -> Unit) {
StockApiConnection().getStocksViaSearch(
query = searchText,
onSuccess = { onDataReceived(it) },
onFailure = { onDataReceived(emptyList()) }
)
}
This is the async function which is build on top of a retrofit callback.
So far so good, but I'm experiencing a weird behavior of LaunchedEffect in an edgecase.
When having typed 4 Chars into the Textfield (represented by searchText) and erasing 2 of them with a slight delay (probably the delay(500) from LaunchedEffect) the stocks will still be fetched for the 3-char-sized searchText and therefore shown in the LazyColumn.
I also already tried using a CoroutineScope, having the if(hasSearchEnoughChars) statement inside of the LaunchedEffect and also aborting the LaunchedEffect / Scope in the else Branch but nothing seems to work. Curiously the API is not called when typing fast, except the last one after 500ms, as intended.
For my understanding LaunchedEffect should cancel the current Coroutine
when the Key changes and
when the Composable leaves the composition
which should booth be the case but the callback is still triggered.
Is there something I'm missing when handling async callbacks in LaunchedEffect or is my understanding of LaunchedEffect wrong?
searchStocksForText() is an asynchronous function with callback instead of a suspend function, so if the coroutine is cancelled after it has already been fired, it cannot be cancelled and it's callback will still be run. You need to convert it into a suspend function:
suspend fun searchStocksForText(searchText: String): List<Stock> = suspendCancellableCoroutine { cont ->
StockApiConnection().getStocksViaSearch(
query = searchText,
onSuccess = { cont.resume(it) },
onFailure = { cont.resume(emptyList()) }
)
}
Then you can call the code synchronously in your coroutine, and it will be cancellable appropriately:
if(hasSearchEnoughChars) {
LaunchedEffect(key1 = searchText) {
delay(500)
val stocks = searchStocksForText(searchText)
isSearching = false
wereStocksFound = it.isNotEmpty()
stocks.clear()
stocks.addAll(it)
}
} else {
stocks.clear()
}
However, I think using a launched effect for this is kind of convoluted. You might try doing it with a Flow and using debounce(). I didn't test this, so beware. Still a newbie to Compose myself, and I'm not sure if the cold flow needs to be stored in a remember parameter before you call collectAsStateWithLifecycle() on it.
val searchText = remember { MutableStateFlow("") }
val stocks: State<List<Stock>> = searchText
.debounce(500)
.onEach { isSearching = true }
.map { if (it.length >= 3) searchStocksForText(searchText) else emptyList() }
.onEach { isSearching = false }
.collectAsStateWithLifecycle()
val wereStocksFound = stocks.isNotEmpty()
Side note, beware of using length >= 3 on your search string. That is completely ignoring code point size.
#Composable
fun getData() {
var wordData = arrayListOf<Word>()
db.get().addOnSuccessListener { documents ->
for (document in documents) {
wordData.add(document.toObject(Word::class.java))
}
}
LazyColumn {
items(wordData) { word ->
WordCard(word = word)
}
}
}
I wanted to use Lazy Column to show all of my words data, these are my WordCard codes.
#Composable
fun WordCard(word: Word) {
Text(text = word.word, color = Color.Black, modifier = Modifier.background(Color.Gray).padding(12.dp))
}
Not sure if this is a firebase issue, but I notice this.
var wordData = arrayListOf<Word>()
You are just adding elements to a standard collection structure, not something that compose can observe state changes for it to update your LazyColumn.
So please change it to this using SnapshotStateList and wrap it to a remember{…} so the list won't re-initialize on succeeding re-compositions
val wordData = remember { mutableStateListOf<Word>() }
I've been checking out the Performance best practices for Jetpack Compose Google I/O, in there it's stated that this code should only re-execute the Text() function, since only this function reads a value that changes.
private class NameHolder(var name: String)
#Composable
private fun LittleText(nameHolder: NameHolder) {
Box {
Text(text = "Nombre: ${nameHolder.name}")
println("compose 2")
}
println("compose 1")
}
however when I run it I can see that for every change both prints execute as well.
I also tested with something like this:
#Composable
private fun LittleText(name: String) {
Box {
Text(text = "Nombre: $name")
println("compose 2")
}
println("compose 1")
}
With the same result, I'm changing the text with a TextField, like this:
var name by remember { mutableStateOf("name") }
TextField(
value = name,
onValueChange = {
name = it
}
)
LittleText(name)
What I'm I doing wrong? How can I achieve this behaviour and have only the Text re-executing the composition?
I found an answer that cover this:
#Composable
fun TestingCompose() {
Column {
TestView()
println("compose 1")
}
}
#Composable
fun TestView() {
val textFieldValue = remember { mutableStateOf(TextFieldValue("")) }
TextField(textFieldValue)
println("compose 2")
}
#Composable
fun TextField(textFieldValue: MutableState<TextFieldValue>) {
TextField(
value = textFieldValue.value,
onValueChange = { textFieldValue.value = it }
)
println("compose 3")
}
I'm still trying to fully understand it, so any insight would be greatly appreciated, but checking the log while testing this shows that only the composable containing the TextField gets re-executed with every character.
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())
The following Code A is from the project.
uiState is created by the delegate produceState, can I use mutableStateOf instead of produceState? If so, how can I write code?
Why can't I use Code B in the project?
Code A
#Composable
fun DetailsScreen(
onErrorLoading: () -> Unit,
modifier: Modifier = Modifier,
viewModel: DetailsViewModel = viewModel()
) {
val uiState by produceState(initialValue = DetailsUiState(isLoading = true)) {
val cityDetailsResult = viewModel.cityDetails
value = if (cityDetailsResult is Result.Success<ExploreModel>) {
DetailsUiState(cityDetailsResult.data)
} else {
DetailsUiState(throwError = true)
}
}
when {
uiState.cityDetails != null -> {
...
}
#HiltViewModel
class DetailsViewModel #Inject constructor(
private val destinationsRepository: DestinationsRepository,
savedStateHandle: SavedStateHandle
) : ViewModel() {
private val cityName = savedStateHandle.get<String>(KEY_ARG_DETAILS_CITY_NAME)!!
val cityDetails: Result<ExploreModel>
get() {
val destination = destinationsRepository.getDestination(cityName)
return if (destination != null) {
Result.Success(destination)
} else {
Result.Error(IllegalArgumentException("City doesn't exist"))
}
}
}
data class DetailsUiState(
val cityDetails: ExploreModel? = null,
val isLoading: Boolean = false,
val throwError: Boolean = false
)
Code B
#Composable
fun DetailsScreen(
onErrorLoading: () -> Unit,
modifier: Modifier = Modifier,
viewModel: DetailsViewModel = viewModel()
) {
val cityDetailsResult = viewModel.cityDetails
val uiState=if (cityDetailsResult is Result.Success<ExploreModel>) {
DetailsUiState(cityDetailsResult.data)
} else {
DetailsUiState(throwError = true)
}
...
uiState is created by the delegate produceState, can I use mutableStateOf instead of produceState? If so, how can I write code?
No, you can't write it using the mutableStateOf (direct initialization not possible). In order to understand why it not possible we need to understand the use of produceState
According to documentation available here
produceState launches a coroutine scoped to the Composition that can
push values into a returned State. Use it to convert non-Compose state
into Compose state, for example bringing external subscription-driven
state such as Flow, LiveData, or RxJava into the Composition.
So basically it is compose way of converting non-Compose state to compose the state.
if you still want to use mutableStateOf you can do something like this
var uiState = remember { mutableStateOf(DetailsUIState())}
LaunchedEffect(key1 = someKey, block = {
uiState = if (cityDetailsResult is Result.Success<ExploreModel>) {
DetailsUiState(cityDetailsResult.data)
} else {
DetailsUiState(throwError = true)
}
})
Note: here someKey might be another variable which handles the recomposition of the state
What is wrong with this approach?
As you can see it's taking another variable someKey to recomposition. and handling it is quite tough compared to produceState
Why can't I use Code B in the project?
The problem with code B is you don't know whether the data is loaded or not while displaying the result. It's not observing the viewModel's data but its just getting the currently available data and based on that it gives the composition.
Imagine if the viewModel is getting data now you will be having UiState with isLoading = true but after some time you get data after a successful API call or error if it fails, at that time the composable function in this case DetailsScreen doesn't know about it at all unless you are observing the Ui state somewhere above this composition and causing this composition to recompose based on newState available.
But in produceState the state of the ui will automatically changed once the suspended network call completes ...