Make a simple “Slide to unlock” in Jetpack Compose
If you want to learn how to create your own custom “Slide to Unlock” slider in Jetpack Compose, then this is the right place to be. We will cover a good way to make a slider that can easily be customised and reused. In the IKEA app we use a component like this when the user is claiming a Family Reward, functioning both as a confirmation to claim the reward and a loading state when the network request is being made:
For the impatient developer 👨💻
Now if you’re the kind of developer (like myself) that just want to see a gif of the end result and go straight for the final code, here’s a link to the demo project over on Github
Let’s break it down
The component we’re building consists of three parts
- The track, the container and background
– Transitions between two colors as the thumb moves - The thumb, the button the user slide
– Has an idle and loading state and snaps to either side of the track - A hint, the text shown within the track
– Fades out as the thumb moves
Lets start of with something small, like a…
Thumb 👍
The thumb itself is a rather simple component. Based on a parameter representing loading, it can be in one of two states: Normal or Loading.
Apart from that we only have to expose a Modifier
to allow the parent to position it.
@Composable
fun Thumb(
isLoading: Boolean,
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier
.size(Thumb.Size)
.background(color = Color.White, shape = CircleShape)
.padding(8.dp),
) {
if (isLoading) {
CircularProgressIndicator(
modifier = Modifier.padding(2.dp),
color = Color.Black,
strokeWidth = 2.dp
)
} else {
Image(
painter = painterResource(R.drawable.arrow_right),
contentDescription = null,
)
}
}
}
With that out of the way let’s add something for the thumb to move along.
Track 🛤️
The track functions as a container for the thumb and hint and is responsible for recognising the sliding gesture. As the thumb moves along the track the background color is interpolated from black to yellow.
enum class Anchor { Start, End }
@Composable
fun Track(
swipeState: SwipeableState<Anchor>,
swipeFraction: Float,
enabled: Boolean,
modifier: Modifier = Modifier,
content: @Composable (BoxScope.() -> Unit),
) {
val density = LocalDensity.current
var fullWidth by remember { mutableStateOf(0) }
val startOfTrackPx = 0f
val endOfTrackPx = remember(fullWidth) {
with(density) {
fullWidth - (2 * Track.HorizontalPadding + Thumb.Size).toPx()
}
}
val snapThreshold = 0.8f
val thresholds = { from: Anchor, _: Anchor ->
if (from == Anchor.Start) {
FractionalThreshold(snapThreshold)
} else {
FractionalThreshold(1f - snapThreshold)
}
}
val backgroundColor by remember(swipeFraction) {
derivedStateOf { calculateTrackColor(swipeFraction) }
}
Box(
modifier = modifier
.onSizeChanged { fullWidth = it.width }
.height(56.dp)
.fillMaxWidth()
.swipeable(
enabled = enabled,
state = swipeState,
orientation = Orientation.Horizontal,
anchors = mapOf(
startOfTrackPx to Anchor.Start,
endOfTrackPx to Anchor.End,
),
thresholds = thresholds,
velocityThreshold = Track.VelocityThreshold,
)
.background(
color = backgroundColor,
shape = RoundedCornerShape(percent = 50),
)
.padding(
PaddingValues(
horizontal = Track.HorizontalPadding,
vertical = 8.dp,
)
),
content = content,
)
}
Background color
The color is calculated using linear interpolation based on the swipeFraction
, a float in the range 0f-1f
representing how far the thumb have travelled along the track.
val AlmostBlack = Color(0xFF111111)
val Yellow = Color(0xFFFFDB00)
fun calculateTrackColor(swipeFraction: Float): Color {
val endOfColorChangeFraction = 0.4f
val fraction = (swipeFraction / endOfColorChangeFraction).coerceIn(0f..1f)
return lerp(AlmostBlack, Yellow, fraction)
}
In short this is a standard linear interpolation with the slight change that we want our interpolation to reach it’s end value faster. In this case we want the color to be fully active
(yellow) when the thumb has reached 40% of the track, which is why we use endOfColorChangeFraction = 0.4f
to adjust the input before running it through the color interpolation.
Snapping
We’ll be using the .swipable()
modifier to have the component snap to the start or the end of the track. To get it up and running there are a couple of things that need to be defined:
Anchors
You need to define a kind of object to represent the snapping points together with pixel values describing what point of the track corresponds to which snapping point. In this example we define an enum class
to represent the start and end anchors and then we use the full width of the track to calculate endOfTrackPx
, accounting for any space used up by the thumb or padding.
enum class Anchor { Start, End }
val startOfTrackPx = 0f
val endOfTrackPx = remember(fullWidth) {
with(density) { fullWidth - (2 * Track.HorizontalPadding + Thumb.Size).toPx() }
}
val anchors = mapOf(
startOfTrackPx to Anchor.Start,
endOfTrackPx to Anchor.End,
),
Thresholds
How much of the track must the user swipe before letting go for the component to snap in either of the states. This can just be an arbitrary value in the range[0,1]
but it’s worth mentioning that it’s applied based on the point you are moving from. So in this example we want 0.8f
to be the threshold when going from left to right and 0.2f
when going from right to left
val snapThreshold = 0.8f
val thresholds = { from: Anchor, _: Anchor ->
if (from == Anchor.Start) {
FractionalThreshold(snapThreshold)
} else {
FractionalThreshold(1f - snapThreshold)
}
}
Velocity threshold
Is a value that determines how easy or hard it should be to trigger the component with a small but quick fling. For this you can use a default value provided by SwipeableDefaults.VelocityThreshold
, personally I’ve found that to be a bit to sensitive and have opted to take that value x10 but your milage may vary.
Hint 💬
The hint is just a Text
with a function call to turn the swipeFraction
into a faded text color.
@Composable
fun Hint(
text: String,
swipeFraction: Float,
modifier: Modifier = Modifier,
) {
val hintTextColor by remember(swipeFraction) {
derivedStateOf { calculateHintTextColor(swipeFraction) }
}
Text(
text = text,
color = hintTextColor,
style = MaterialTheme.typography.titleSmall,
modifier = modifier
)
}
fun calculateHintTextColor(swipeFraction: Float): Color {
val endOfFadeFraction = 0.35f
val fraction = (swipeFraction / endOfFadeFraction).coerceIn(0f..1f)
return lerp(Color.White, Color.White.copy(alpha = 0f), fraction)
}
Why fade using text color and not Modifier.alpha()
? Both work fine. I tend to change alpha with color instead of a Component/View out of old habit as it historically have been better for performance and it should still apply in Compose.
Just like with the track color we linearly interpolate the color and make it fully transparent when the thumb has reached 35% of the track.
Assembling the parts
@Composable
fun SlideToUnlock(
isLoading: Boolean,
onUnlockRequested: () -> Unit,
modifier: Modifier = Modifier,
) {
val hapticFeedback = LocalHapticFeedback.current
val swipeState = rememberSwipeableState(
initialValue = if (isLoading) Anchor.End else Anchor.Start,
confirmStateChange = { anchor ->
if (anchor == Anchor.End) {
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
onUnlockRequested()
}
true
}
)
val swipeFraction by remember {
derivedStateOf { calculateSwipeFraction(swipeState.progress) }
}
LaunchedEffect(isLoading) {
swipeState.animateTo(if (isLoading) Anchor.End else Anchor.Start)
}
Track(
swipeState = swipeState,
swipeFraction = swipeFraction,
enabled = !isLoading,
modifier = modifier,
) {
Hint(
text = "Swipe to unlock reward",
swipeFraction = swipeFraction,
modifier = Modifier
.align(Alignment.Center)
.padding(PaddingValues(horizontal = Thumb.Size + 8.dp)),
)
Thumb(
isLoading = isLoading,
modifier = Modifier.offset {
IntOffset(swipeState.offset.value.roundToInt(), 0)
},
)
}
}
Swipe fraction
For the swipeFraction
one already exists in SwipeProgress#fraction
, however it’s always relative to the anchor the interaction started from.
// The thumb is moving...
Anchor Anchor
A ------------ (Thumb) --- B
// if it started from A
fraction = 0.75f
// if it started from B
fraction = -0.25f
But we want a value between [0,1] that’s always relative to the start, so we need a function that check the origin of the interaction and, if necessary, invert the fraction. This means that in the example above the value will always be 0.75f
fun calculateSwipeFraction(progress: SwipeProgress<Anchor>): Float {
val atAnchor = progress.from == progress.to
val fromStart = progress.from == Anchor.Start
return if (atAnchor) {
if (fromStart) 0f else 1f
} else {
if (fromStart) progress.fraction else 1f - progress.fraction
}
}
Haptic feedback
You can add a small vibration when the user successfully triggered the slider by providing a lambda to confirmStateChange
and check for the end state.
val hapticFeedback = LocalHapticFeedback.current
val swipeState = rememberSwipeableState(
initialValue = if (isLoading) Anchor.End else Anchor.Start,
confirmStateChange = { anchor ->
if (anchor == Anchor.End) {
hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress)
onUnlockRequested()
}
true
}
)
With that in place we have everything in place to use the component. If you followed along this far you should now have something looking like this:
Wrap up
That’s all there is to it. Again the demo code can be found on Github:
https://github.com/patrick-elmquist/Demo-SlideToUnlock
If you enjoy this kind of post like once a year or so, considering following me here and/or on Github. Thanks for reading and have a good one! 👋
Behind the scenes
As I was about to wrap up this blog post I noticed that a bunch of the APIs, in true Compose fashion, are being deprecated in alpha versions, where the .swipable(...)
modifier and related classes are being replaced by .anchoredDraggable(...)
. As I wanted the article to be as up to date as possible I rewrote the examples with the new APIs, however when doing so I ran into a number of issues that I suspect is due to bugs in the alpha version. So this is why the article is covering APIs that are about to be phased out. Either way, the new classes may have different names but they are used in a very similar fashion so it should still be useful :)