Subscribe for UI recipes

Implementing Overslide interaction in Jetpack Compose

How to add a stretchy animation on a slider

Implementing Overslide interaction in Jetpack Compose

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.

0:00
/0:13

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 new ViewConfiguration with your desired values. This can be done using a CompositionLocalProvider 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.

0:00
/0:13

Try it yourself and build some unique slider interactions.
Full code available here

Thanks for reading and good luck!

Mastodon