Home > Blockchain >  LaunchedEffect triggering even thought composition should have ended and key changed
LaunchedEffect triggering even thought composition should have ended and key changed

Time:01-28

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

  1. when the Key changes and
  2. 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?

CodePudding user response:

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.

  • Related