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.
Basically I have a launched effect reading the item offset of a lazy column. and based on the offset I change the height/alpha etc..
AS is warning me that it should not be read inside a compose function because it will change a lot, so my question is where should I be reading and what is the best practice here?
Should I make a lambda updating the offset outside of the function and read in the highest one? Or the parent one?
Open to suggestions
You can read it inside a derivedStateOf.
val someData = remember {
derivedStateOf {
val offset = lazyListState.firstVisibleItemScrollOffset
val firstVisibleItem = lazyListState.firstVisibleItemIndex
// Convert to some data here and read this data
}
}
I did similar thing in this library for animating color, scale and alpha of items with
val animationData by remember {
derivedStateOf {
val animationData = getAnimationProgress(
lazyListState = lazyListState,
initialFirstVisibleIndex = initialFirstVisibleIndex,
indexOfSelector = indexOfSelector,
itemScaleRange = itemScaleRange,
showPartialItem = showPartialItem,
globalIndex = globalIndex,
selectedIndex = selectedIndex,
availableSpace = availableSpace,
itemSize = itemSizePx,
spaceBetweenItems = spaceBetweenItems,
visibleItemCount = visibleItemCount,
totalItemCount = totalItemCount,
inactiveScale = inactiveItemScale,
inactiveColor = inactiveColor,
activeColor = activeColor
)
selectedIndex = animationData.globalItemIndex
animationData
}
}
If you read a change you can read LaunchedEffect with snapshotFlow either but as i checked same lazyListState is returned so it's not possible to read any change but you can read lazyListState.firstVisibleItemScrollOffset or any changing value as alternative to derivedStateOf.
For instance
LaunchedEffect(Unit){
snapshotFlow { lazyListState.firstVisibleItemScrollOffset }
.onEach {
// Here we get the change in offset of first visible item
// You might read another value here and do calculation
lazyListState.firstVisibleItemIndex
}.launchIn(this)
}
I think reading lazyListState wouldn't pose any problems either. I use this when i only read one value like firstVisibleItemScrollOffset, or layoutInfo inside snapshotFlow
I have a Composable in which a remembered value (an offset) needs to be updated both by the Composable itself and also from the calling side (using the Composable's arguments) -- how can I achieve this?
In particular, I have the following piece of code. The value I'm talking about is the offset in NavigableBox: I need to both be able to control it by dragging the box and by setting it manually using the value from OffsetInputField which is passed as an argument.
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
Surface {
Box {
var boxOffset by remember { mutableStateOf(Offset.Zero) }
NavigableBox(boxOffset)
OffsetInputField { offset ->
offset.toFloat().let { boxOffset = Offset(it, it) }
}
}
}
}
}
}
#Composable
fun OffsetInputField(onInput: (String) -> Unit) {
var value by remember { mutableStateOf("") }
TextField(
value = value,
onValueChange = { value = it },
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Go),
keyboardActions = KeyboardActions(onGo = { onInput(value) })
)
}
#Composable
fun NavigableBox(initOffset: Offset) {
var offset by remember(initOffset) { mutableStateOf(initOffset) }
Box(
modifier = Modifier
.fillMaxSize()
.pointerInput(Unit) { detectTransformGestures { _, pan, _, _ -> offset += pan } }
) {
Box(modifier = Modifier
.size(100.dp)
.offset { IntOffset(offset.x.roundToInt(), offset.y.roundToInt()) }
.background(Color.Blue)
)
}
}
In the current implementation the dragging works fine until a new value is passed as OffsetInputField's input -- then the box stops responding to dragging. I assume it is because the MutableState object containing the offset changes when gets recalculated and the box doesn't observe it anymore.
I already tried using unidirectional data flow in NavigableBox (passing offset value and onOffsetChange lambda to it, but then dragging doesn't work as expected: the box just jiggles around its initial position, returning back to it when the gesture stops.
In case anyone interested, I'm developing an app where the draggable box is a map, and the text field is used for searching objects on it: the map is moved to be centered on an object entered.
pointerInput captures all the state variables used inside. With new initOffset you're creating a new mutable state, but pointerInput keeps updating the old reference.
You need to restart it by passing the same value(s) to key:
pointerInput(initOffset) { /*...*/ }
I'm trying to update a LazyColumn items using a subscriber to a RxAndroid Flowable. The state variable I'm using for the image list is called simply "list"
This is my LazyColumn code:
LazyColumn(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight()
) {
items(list) { image ->
Text(text = image.title ?: "Title")
}
}
If for example, I run this test coroutine, the list is updated and shows the correct amount of test images:
GlobalContext.run {
val testList = SnapshotStateList<Image>()
for (i in 1..100) {
testList.add(Image(i, null, null, null, null))
}
list = testList
}
But if I try the same method using my subscription to a Flowable, it updates the variable value but the recomposition is not triggered. This is my code:
val observer = remember {
disposable.add(
viewModel.imagesObservable().subscribe(
{ images ->
val snapList = SnapshotStateList<Image>()
images.forEach {
snapList.add(Image(it.id, it.albumId, it.title, it.url, it.thumbnailUrl))
}
list = snapList
},
{ Log.d("dasal", "Error: Can't load images") }
)
)
}
How do I handle a Flowable with a Composable?
Fixed it. I was using this declaration
var list = remember { mutableStateListOf<Image>() }
I changed it to this one instead
val list = remember { mutableStateOf(listOf<Image>()) }
Now I can use the list.value property to update/read the current value.
I want to show a slider that can be either updated by the user using drag/drop, or updated from the server in real-time.
When the user finishes their dragging gesture, I want to send the final value to the server.
My initial attempt is:
#Composable
fun LightView(
channel: UiChannel,
onDimmerChanged: (Float) -> Unit
) {
val sliderValue = channel....
Slider(value = sliderValue, onValueChange = onDimmerChanged)
}
And the onDimmerChanged method comes from my ViewModel, which updates the server value.
It works well, however onValueChange is called for each move, which bombards the server with unneeded requests.
I tried to create a custom slider:
#Composable
fun LightView(
channel: UiChannel,
onDimmerChanged: (Float) -> Unit
) {
val sliderValue = channel....
Slider(initialValue = sliderValue, valueSetter = onDimmerChanged)
}
#Composable
fun Slider(initialValue: Float, valueSetter: (Float) -> Unit) {
var value by remember { mutableStateOf(initialValue) }
Slider(
value = value,
onValueChange = { value = it },
onValueChangeFinished = { valueSetter(value) }
)
}
It works well on the app side, and the value is only sent once, when the user stops dragging.
However, it fails updating the view when there is an update from the server. I'm guessing this has something to do with remember. So I tried without remember:
#Composable
fun Slider(initialValue: Float, valueSetter: (Float) -> Unit) {
var value by mutableStateOf(initialValue)
Slider(
value = value,
onValueChange = { value = it },
onValueChangeFinished = { valueSetter(value) }
)
}
This time the view updates correctly when the value is updated by the server, but does not move anymore when the user drags the slider.
I'm sure I'm missing something with state hoisting and all, but I can't figure out what.
So my final question: how to create a Slider that can be either updated by the ViewModel, or by the user, and notifies the ViewModel of a new value only when the user finishes dragging?
EDIT:
I also tried what #CommonsWare suggested:
#Composable
fun LightView(
channel: UiChannel,
onDimmerChanged: (Float) -> Unit
) {
val sliderValue = channel....
val sliderState = mutableStateOf(sliderValue)
Slider(state = sliderState, valueSet = { onDimmerChanged(sliderState.value) })
}
#Composable
fun Slider(state: MutableState<Float>, valueSet: () -> Unit) {
Slider(
value = state.value,
onValueChange = { state.value = it },
onValueChangeFinished = valueSet
)
}
And it does not work either. When using drag and drop, sliderState is correctly updated, and onDimmerChanged() is called with the correct value. But for some reason, when tapping of the sliding (rather than sliding), valueSet is called and sliderState.value does not contain the correct value. I don't understand where this value comes from.
Regarding the original problem with local & server (or viewmodel) states conflicting with eachother:
I solved it for me by detecting wether or not we are interacting with the slider:
if yes, then show and update the local state value
or if not - then show the viewmodels value.
As you have said, we should never update the viewmodel from onValueChange - as this is only for updating the sliders value locally (documentation). Instead onValueChangeFinished is used for sending the current local state to the viewmodel, as soon as we are done interacting.
Regarding detection of current interaction, we can make use of InteractionSource.
Working example:
#Composable
fun SliderDemo() {
// In this demo, ViewModel updates its progress periodically from 0f..1f
val viewModel by remember { mutableStateOf(SliderDemoViewModel()) }
Column(
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// local slider value state
var sliderValueRaw by remember { mutableStateOf(viewModel.progress) }
// getting current interaction with slider - are we pressing or dragging?
val interactionSource = remember { MutableInteractionSource() }
val isPressed by interactionSource.collectIsPressedAsState()
val isDragged by interactionSource.collectIsDraggedAsState()
val isInteracting = isPressed || isDragged
// calculating actual slider value to display
// depending on wether we are interacting or not
// using either the local value, or the ViewModels / server one
val sliderValue by derivedStateOf {
if (isInteracting) {
sliderValueRaw
} else {
viewModel.progress
}
}
Slider(
value = sliderValue, // using calculated sliderValue here from above
onValueChange = {
sliderValueRaw = it
},
onValueChangeFinished = {
viewModel.updateProgress(sliderValue)
},
interactionSource = interactionSource
)
// Debug interaction info
Text("isPressed: ${isPressed} | isDragged: ${isDragged}")
}
}
Hope that helps.
There are a couple of things going on here, so let's try to break it down.
The initial attempt where onDimmerChanged is called for every value change looks great!
Looking at the second attempt, creating a custom Slider component works, but there are a few issues.
#Composable
fun LightView(
channel: UiChannel,
onDimmerChanged: (Float) -> Unit
) {
val sliderValue = channel....
Slider(initialValue = sliderValue, valueSetter = onDimmerChanged)
}
#Composable
fun Slider(initialValue: Float, valueSetter: (Float) -> Unit) {
var value by remember { mutableStateOf(initialValue) }
Slider(
value = value,
onValueChange = { value = it },
onValueChangeFinished = { valueSetter(value) }
)
}
Let's talk about what happens in the Slider composable here:
We remember the state with the initialValue
The channel value is updated, LightView gets recomposed, so does Slider
Since we remembered the state, it is still set to the previous initialValue
You're right with your thought about remember being the culprit here. When memorizing a value, it won't be updated when recomposing unless we tell Compose to. But without memorization (as seen in your third attempt), a state model will be created with every recomposition (var value by mutableStateOf(initialValue)) using the initialValue. Since a recomposition is triggered every time value changes, we will always pass the initialValue instead of the updated value to the Slider, causing it to never update by changes from within this Composable.
Instead, we want to pass initialValue as a key to remember, telling Compose to recalculate the value whenever the key changes.
#Composable
fun Slider(initialValue: Float, valueSetter: (Float) -> Unit) {
var value by remember { mutableStateOf(initialValue) }
Slider(
value = value,
onValueChange = { value = it },
onValueChangeFinished = { valueSetter(value) }
)
}
You can probably also just pull the Slider into your LightView:
#Composable
fun LightView(
channel: UiChannel,
onDimmerChanged: (Float) -> Unit
) {
var value by remember(channel.sliderValue) { mutableStateOf(channel.sliderValue) }
Slider(
value = value,
onValueChange = { value = it },
onValueChangeFinished = { onDimmerChanged(value) }
)
}
Lastly, about the attempt that #commonsware suggested.
#Composable
fun LightView(
channel: UiChannel,
onDimmerChanged: (Float) -> Unit
) {
val sliderValue = channel....
val sliderState = mutableStateOf(sliderValue)
Slider(state = sliderState, valueSet = { onDimmerChanged(sliderState.value) })
}
#Composable
fun Slider(state: MutableState<Float>, valueSet: () -> Unit) {
Slider(
value = state.value,
onValueChange = { state.value = it },
onValueChangeFinished = valueSet
)
}
This is another way of doing the same thing, but passing around MutableStates is an anti-pattern and should be avoided if possible. This is where state hoisting helps (what you were trying to do in the earlier attempts :))
Finally, about the Slider's onValueChangeFinished using the wrong value.
And it does not work either. When using drag and drop, sliderState is correctly updated, and onDimmerChanged() is called with the correct value. But for some reason, when tapping of the sliding (rather than sliding), valueSet is called and sliderState.value does not contain the correct value. I don't understand where this value comes from.
This is a bug in the Slider component. You can check this by looking at the output of this code:
#Composable
fun Slider(initialValue: Float, valueSetter: (Float) -> Unit) {
var value by remember { mutableStateOf(initialValue) }
val key = Random.nextInt()
Slider(
value = value,
onValueChange = { value = it },
onValueChangeFinished = {
valueSetter(value)
println("Callback invoked. Current key: $key")
}
)
}
Since key should change with every recomposition, you can see that the onValueChangeFinished callback holds a reference to an older composition (hope I put that right). So you weren't going crazy, it's not your fault :)
Hope that helped clear things up a bit!
Yeah, I can reproduce your issue as well. I answered in the bug topic, but I'll copy-paste it here so other people can fix it if they're in a rush. This solution sadly involves copying the whole Slider class.
The problem is that the drag and click modifiers use different Position instances, although they should always be the same (as Position is being created inside of remember).
The issue is with the pointer input modifier.
val press = if (enabled) {
Modifier.pointerInput(Unit) {...}
Change the Modifier.pointerInput() to accept any other subject different than Unit so your Position instance in that Modifier is always up to date and updated when Position gets recreated. For example, you can change it to Modifier.pointerInput(valueRange)
I encountered this problem. As jossiwolf pointed out, using the progress as a key for the remember{} is necessary to ensure that the Slider progress is updated after recomposition.
I had an additional issue though, where, if I updated the progress mid-seek, the Slider would recompose, and the thumb would revert back to its old position.
To work around this, I'm using a temporary slider position, which is only used while a drag is in progress:
#Composable
fun MySlider(
progress: Float,
onSeek: (progress: Float) -> Unit,
) {
val sliderPosition = remember(progress) { mutableStateOf(progress) }
val tempSliderPosition = remember { mutableStateOf(progress) }
val interactionSource = remember { MutableInteractionSource() }
val isDragged = interactionSource.collectIsDraggedAsState()
Slider(
value = if (isDragged.value) tempSliderPosition.value else sliderPosition.value,
onValueChange = { progress ->
sliderPosition.value = progress
tempSliderPosition.value = progress
},
onValueChangeFinished = {
sliderPosition.value = tempSliderPosition.value
onSeek(tempSliderPosition.value)
},
interactionSource = interactionSource
)
}
I've elaborated on Steffen's answer a bit so it is possible to update the "external" "viewModel" value even while the user is currently dragging the slider (as there are many use cases when that is required).
The usage is the same as the compose "native" Slider, with the exception that the onValueChangeFinished parameter now accepts the finishing float value.
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsDraggedAsState
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.material.Slider
import androidx.compose.material.SliderColors
import androidx.compose.material.SliderDefaults
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
/**
* "Wrapper" around the Slider solving the issue of overwriting values during drag. See https://stackoverflow.com/questions/66386039/jetpack-compose-react-to-slider-changed-value.
* Use as normal Slider and feel free to update the underlying value in the onValueChange method.
*/
#Composable
fun ComposeSlider(
value: Float,
onValueChange: (Float) -> Unit,
modifier: Modifier = Modifier,
enabled: Boolean = true,
valueRange: ClosedFloatingPointRange<Float> = 0f..1f,
steps: Int = 0,
onValueChangeFinished: ((Float) -> Unit)? = null,
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
colors: SliderColors = SliderDefaults.colors()
) {
// local slider value state with default value (from the "external" source)
var sliderValueRaw by remember { mutableStateOf(value) }
// getting current interaction with slider - are we pressing or dragging?
val isPressed by interactionSource.collectIsPressedAsState()
val isDragged by interactionSource.collectIsDraggedAsState()
val isInteracting = isPressed || isDragged
// calculating actual slider value to display depending on whether we are interacting or not
// using either the local value, or the provided one
val determinedValueToShow by remember(isInteracting, sliderValueRaw, value) {
derivedStateOf {
if (isInteracting) {
sliderValueRaw
} else {
value
}
}
}
Slider(
value = determinedValueToShow,
onValueChange = {
sliderValueRaw = it
onValueChange.invoke(it)
},
modifier = modifier,
enabled = enabled,
valueRange = valueRange,
steps = steps,
onValueChangeFinished = {
onValueChangeFinished?.invoke(sliderValueRaw)
},
interactionSource = interactionSource,
colors = colors
)
}