Jetpack Compose: Update state from pointer input
Jetpack Compose is on the way to take the UI development world by storm. Not only will it be the de facto default for Android, but support for Desktop/Web/Shell/iOS are all well underway. With so much prospect, it is a good time to dig into it.
Creating a compelling UI and connecting it to data in a robust way is an easy task. However, some aspects require insights due to the composable nature of the UI and its interaction with background work in a coroutine. In this particular instance, the interaction with a touch event on a composable demonstrates the culprit.
Implementing gesture detection
The following snippet shows a PositionSelector
composable, which allows dragging up and down on a text label. Dragging up and down respectively increases and decreases the value. It looks straightforward but does not work as expected. The position is only increased by one drag callback.
1 2 3 4 5 6 7 8 9 10 11 12 13 |
var position by remember { mutableStateOf(0f) } PositionSelector(position) { position = it } @Composable fun PositionSelector(position: Float, onPositionChanged: (Float) -> Unit) { Text( text = "Position $position", modifier = Modifier.pointerInput(Unit) { detectVerticalDragGestures { _, dragAmount -> onPositionChanged(position - dragAmount) } }) } |
State not updating as expected
The issue we are facing resides in the Modifider.pointerInput(..)
. In the detectVerticalDragGesture(..)
we calculate the new position value from the dragAmount
and the previous position
. With the newly created position we modify the state of the position
var outside of the composable
. This in turn recomposes the PositionSelector
and updates the text label.
The problem stems from the recomposition not passing the updated position
value to the pointerInput(..)
. Instead, the one initially passed in position
is used on the second drag event as well. This results in a new position value, which is always recalculated based on the initial value and the current drag offset.
Discovering a solution
The docs of the pointerInput(..)
lead us onto the correct path by stating:
The pointer input handling
block
will be canceled and re-started when pointerInput is recomposed with a different key1.
This would imply that we should change the key from Unit
to the newly passed position
value. Unfortunately, this would cancel and recreate the listening infrastructure, which is not what we want. To get more insights into what is going on, we have to inspect the sources of pointerInput(..)
. In the code, we find that a LaunchedEffect
is used to run our callback block
.
With this insight, we have to recall the behavior of the LaunchedEffect
. Its core feature is to run its effect when entering the composition. This is a one-time operation until key
changes. Therefore our initial position
value is always used in the callback since it is captured during the initial composition.
In order to always use the most recent position
value in our code block, we have to inform compose
to do so. The mechanism for this is to warp the position
via rememberUpdatedState(position)
. Applying it in the snippet above, we get:
1 2 3 4 5 6 7 8 9 10 11 |
@Composable fun PositionSelector(position: Float, onPositionChanged: (Float) -> Unit) { val currentPosition by rememberUpdatedState(position) Text( text = "Position $position", modifier = Modifier.pointerInput(Unit) { detectVerticalDragGestures { _, dragAmount -> onPositionChanged(currentPosition - dragAmount) } }) } |
Wrapping the position
in the currentPosition
allows the internal LaunchedEffect
to always receive the latest value.
Wrap Up
Jetpack Compose is a powerful new UI framework with tons of potential. Using it to create a UI is very simple, but to make the most out of it requires some knowledge of the internals. To get a broader view on the topic, it is advised to read the docs on the side effects api. Hopefully, the Compose team will improve its docs in the future. At this point, it is required to read the sources occasionally.
Feedback is welcome!
Want to join the discussion?Feel free to contribute!
Thank you, this helped a lot! I was wracking by brains for 3 hours before realizing that the offset is not being updated.
Thank you, your article just saved my sanity. I couldn’t understand why my code breaks when I simply extract a method. It seems I need to learn more about state managment in Compose.