Jetpack Compose: react to Slider changed value

Viewed 2943

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.

4 Answers

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:

  1. We remember the state with the initialValue
  2. The channel value is updated, LightView gets recomposed, so does Slider
  3. 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
    )
}
Related