Subscribe for UI recipes
Implementing Overslide interaction in Jetpack Compose
How to add a stretchy animation on a slider
In a recent article, I wrote about how to create Overscroll animations, which we used to playfully communicate the bounds of a lazy list in Jetpack Compose.
After messing around with sliders recently, I thought this might be useful to create for sliders as well.
In that article, I utilized nested scrolling to achieve the effect. At first, I thought I could do the same for the slider and quickly implement this animation.
Turned out to be not that simple. Let's see what we can do instead.
Intercepting touch events
We will have to intercept the user's drag events, before they reach the slider. We can do this using the pointerInput
modifier. This modifier is quite broad and can be used to capture various touch events from any Composable. So, for our purposes let's go over the main flow for capturing the events necessary for our interaction.
.pointerInput(Unit) {
awaitEachGesture {
val down = awaitFirstDown()
// First touch
awaitHorizontalTouchSlopOrCancellation(down.id) { _, _ -> }
// Wait for the touch slop
horizontalDrag(down.id) { change ->
change.positionChange().x
// Use this ^^ to know how far the user has dragged
}
// end of touch event
}
}
Inside the pointerInput
modifier, we can call suspend functions to capture the touch events we require. We start off with awaitEachGesture
function, which will be called... on each gesture.
Inside it, we will wait until the user touches the screen using awaitFirstDown()
.
To avoid any unintentional dragging while the user intended for a click, we will call awaitHorizontalTouchSlopOrCancellation()
which will ignore drag events that are lower than the pre-defined minimum.
Note: This touch slop is used in multiple other dragging interactions in Compose, eg. swipe to dismiss. If you would like to edit this value, you can supply a newViewConfiguration
with your desired values. This can be done using aCompositionLocalProvider
like so:
val config = LocalViewConfiguration.current
val newConfig = remember {
object : ViewConfiguration {
override val doubleTapMinTimeMillis: Long = config.doubleTapMinTimeMillis
override val doubleTapTimeoutMillis: Long = config.doubleTapTimeoutMillis
override val longPressTimeoutMillis: Long = config.longPressTimeoutMillis
// Modify touch slop here
// For example, increase the threshold by x2
override val touchSlop: Float = config.touchSlop * 2f
}
}
CompositionLocalProvider(
LocalViewConfiguration provides newConfig
) {
// Your content here
}
After the touch slop threshold has been reached, we can then listen to horizontal drag events. Inside the horizontalDrag
, we can listen to drag changes on the x-axis.
Track Overslide
Now that we know how to use pointerInput
, let's wrap it with our own custom modifier that will track the overslide amount.
@Composable
fun Modifier.trackOverslide(
value: Float,
onNewOverslideAmount: (Float) -> Unit,
): Modifier {
// Implementation goes here
}
Our modifier will take in the current value of the slider, normalized to 0f..1f
, and a lambda function to send back the new overslide amount, to be used for an animation.
val valueState = rememberUpdatedState(value)
val scope = rememberCoroutineScope()
val overslideAmountAnimatable = remember { Animatable(0f, .0001f) }
var length by remember { mutableFloatStateOf(1f) }
LaunchedEffect(Unit) {
snapshotFlow { overslideAmountAnimatable.value }.collect {
onNewOverslideAmount(CustomEasing.transform(it / length))
}
}
val isLtr = LocalLayoutDirection.current == LayoutDirection.Ltr
return onSizeChanged { length = it.width.toFloat() }
Inside trackOverslide()
, we first set up some useful variables that we shall use in a bit. First, we need the value
as a state object. This is to ensure that when we use it inside pointerInput
, it will be able to listen to it when it changes.
We also create an animatable, overslideAmountAnimatable
, that we will use to smoothly animate the overslide amount. We shall return this through the lambda we passed in earlier in a LaunchedEffect
.
But to calculate the overslide amount as a fraction of the slider's size, we shall need the length
of the slider. We can obtain this by through the onSizeChanged
modifier.
We will also need isLtr
to make some special calculations when the device is set to a RTL language. This is because our slider values would be flipped, but our coordinate space would still have the same origin.
Now that everything is set up, we can add the pointerInput
modifier.
return onSizeChanged { length = it.width.toFloat() }
.pointerInput(Unit) {
awaitEachGesture {
val down = awaitFirstDown()
// User has touched the screen
awaitHorizontalTouchSlopOrCancellation(down.id) { _, _ -> }
// User has moved the minimum horizontal amount to recognize a drag
var overslideAmount = 0f
// Start tracking horizontal drag amount
horizontalDrag(down.id) {
// Negate the change in X when Rtl language is used
val deltaX = it.positionChange().x * if (isLtr) 1f else -1f
// Clamp overslide amount
overslideAmount = when (valueState.value) {
0f, 1f -> (overslideAmount + deltaX)
else -> 0f
}
// Animate to new overslide amount
scope.launch {
overslideAmountAnimatable.animateTo(overslideAmount)
}
}
// User has lifted finger off the screen
// Drag has stopped
// Animate overslide to 0, with a bounce
scope.launch {
overslideAmountAnimatable.animateTo(
targetValue = 0f,
animationSpec = spring(
dampingRatio = .45f,
stiffness = Spring.StiffnessLow
)
)
}
}
}
As described earlier, we start by recognizing the touch gesture and verifying that it is a horizontal drag. Once we verified that, we can start tracking the overslide amount using horizontalDrag
function.
To account for RTL layout, we first multiply the change in x by -1, when appropriate.
Next, we add deltaX
to overslideAmount
, only when our slider's value is 0f or 1f, meaning we are at the extreme ends of the slider. Otherwise, the overslide amount should be 0f.
Finally, after the horizontalDrag
function, we can animate the overslideAmountAnimatable
to 0f. This is when the user has finally lifted their finger off the screen. Here, I added a bounce animation to simulate the "elasticity" of the slider. But you can play around with the values to create your own effect.
Applying Overslide
Now that our custom modifier is ready, we can apply it onto our slider.
Since we need our pointerInput
to grab the drag values before the slider can, the best place to apply our modifier would be inside the track of the slider.
Before we do that, let's set up some variables that we shall use to create the stretchy animation.
var scaleX by remember { mutableFloatStateOf(1f) }
var scaleY by remember { mutableFloatStateOf(1f) }
var translateX by remember { mutableFloatStateOf(0f) }
var transformOrigin by remember { mutableStateOf(TransformOrigin.Center) }
Slider(
value = value,
onValueChange = { value = it },
modifier = modifier
.graphicsLayer {
this.transformOrigin = transformOrigin
this.scaleX = scaleX
this.scaleY = scaleY
this.translationX = translateX
},
thumb = {},
track = { sliderState ->
// trackOverslide modifer will be added here
}
We will need to scale the slider, in both the x and y axes. We will also apply a little translation along the x axis and also modify the transform origin based on where the user is dragging from.
After creating these variables, we can apply them in the graphicsLayer
modifier.
Inside the track Composable lambda, we can apply the trackOverslide
modifier.
track = { sliderState ->
val sliderFraction by remember {
derivedStateOf {
(animatedValue - sliderState.valueRange.start) / (sliderState.valueRange.endInclusive - sliderState.valueRange.start)
}
}
val density = LocalDensity.current
val isLtr = LocalLayoutDirection.current == LayoutDirection.Ltr
...
}
As mentioned earlier, trackOverslide
relies on a normalized value of the slider. So let's calculate that, just in case your slider does not go from 0f..1f
. We can also grab the density and check if the device is set to LTR layout.
Box(
modifier = Modifier
.trackOverslide(value = sliderFraction) { overslide ->
transformOrigin = TransformOrigin(
pivotFractionX = when (isLtr) {
true -> if (sliderFraction < .5f) 2f else -1f
false -> if (sliderFraction < .5f) -1f else 2f
},
pivotFractionY = .5f,
)
when (sliderFraction) {
in 0f..(.5f) -> {
scaleY = 1f + (overslide * .2f)
scaleX = 1f - (overslide * .2f)
}
else -> {
scaleY = 1f - (overslide * .2f)
scaleX = 1f + (overslide * .2f)
}
}
translateX = overslide * with(density) { 24.dp.toPx() }
}
// Rest of the Track implementation goes here
)
Inside the lambda, we shall first set the transform origin. To amplify the stretch effect, we want this origin point to be on the other side of where the user is touching at the moment. That way, it will feel like the slider is anchored on the other side, causing some tension as the user pulls it.
Since we are dealing with left and right here, we will need to account for isLtr
in this calculation.
Next, we will simulate a "stretchy" effect. This is simply accomplished by scaling down along the y-axis, and scaling up along the x-axis.
To preserve the overslide momentum while bouncing, we will animate the scale separately, depending on if we are in the first half or last half of the slider.
Finally, we will move the slider horizontally, just to add some dynamic movement as the user is pulling it.
All this will give us this stretchy and delightful slider interaction.
Try it yourself and build some unique slider interactions.
Full code available here
Thanks for reading and good luck!