Made in Compose: Airbnb Month Picker Dial
In a past update, Airbnb added a month dial picker in their app that let users choose a month duration in the most delightful way. Let's learn how to recreate that using my ChromaDial library.
The Dial
For the initial setup, let's create a dial with a range that matches up with Airbnb's version. They have 12 segments for a total of 12 months, so we will have an interval of 30º (360÷12).
But it has a lower bound of 1, so that a user can't pick 0 months. Because of this, we will set startDegrees to 30º, and the total sweep to be 330º (360º - 30º).
That way, the starting point will be 1 month, at the 1 o'clock position, and 12 months will be at 12 o'clock. We'll just keep in mind we will need to add this 30º, for the active section.
Finally valueRange will give us automatic mapping for our 12 month range.
var degree by remember { mutableStateOf(90f) }
val animatedDegree by animateFloatAsState(targetValue = degree)
Dial(
degree = animatedDegree,
onDegreeChange = { degree = it },
sweepDegrees = 330f,
startDegrees = 30f,
interval = 30f,
modifier = Modifier.size(280.dp),
valueRange = 1f..12f,
)
Wrapping degree with animateFloatAsState will make the thumb glide to a month interval, instead of snapping.
0:00
/0:03
1×
Thumb and Inactive Track
Let's start our customization with the thumb, which we want to look like a physical knob. We will create a thumb of size 56.dp, and add shadows, gradients and a border to create a 3D effect.
thumb = {
Box(
Modifier
.size(56.dp)
.padding(6.dp)
.graphicsLayer {
rotationZ = -it.absoluteDegree
}
.dropShadow(shape = CircleShape) {
radius = 10f
alpha = .4f
}
.border(
width = 3.dp,
shape = CircleShape,
brush = Brush.verticalGradient(
colors = listOf(Zinc50, Zinc400)
)
)
.background(Zinc200, CircleShape)
)
}
rotationZ = -it.absoluteDegree cancels the thumb's rotation as it moves around the dial. Without this, the gradient border would rotate with the thumb.
For the inactive track, let's render it using basic canvas functions inside a drawBehind Modifier.
val ringStroke = 56.dp.toPx()
val ringRadius = center.x - (ringStroke / 2)
drawCircle(
color = Zinc300,
radius = ringRadius,
style = Stroke(width = ringStroke),
)
drawEveryInterval(
startDegrees = 0f,
sweepDegrees = 330f,
radius = ringRadius,
spacing = 30f,
) { data ->
drawCircle(
color = Neutral500,
radius = 3.dp.toPx(),
center = data.position,
)
}
First, we calculate the radius we will need, which is the radius of the entire dial, minus the thumb's radius. For the stroke, we set it to the same size as the thumb.
The track is rendered as just a circle with a stroke style. But the dots are positioned using ChromaDial's drawEveryInterval, which takes in a range, radius and an interval, and lets us draw anything along this circumference.
The active track will need to sit on top of the ring, so we also need to wrap everything in a Box for stacking. While we're at it, let's also add a drop shadow with an upward offset, giving the ring a subtle lift from the surface.
track = { dialState ->
Box {
Box(
modifier = Modifier
.fillMaxSize()
.dropShadow(shape = CircleShape) {
radius = 7f
alpha = .15f
offset = Offset(0f, -8f)
spread = 4f
}
.background(shape = CircleShape, color = Zinc100)
.drawBehind {
val ringStroke = 56.dp.toPx()
val ringRadius = center.x - (ringStroke / 2)
drawCircle(
color = Zinc300,
radius = ringRadius,
style = Stroke(width = ringStroke),
)
drawEveryInterval(
startDegrees = 0f,
sweepDegrees = 330f,
radius = ringRadius,
spacing = 30f,
) { data ->
drawCircle(
color = Neutral500,
radius = 3.dp.toPx(),
center = data.position,
)
}
}
)
// Active track will go here
}
}
0:00
/0:03
1×
Tube Shape & Active Track
Compose doesn't have a built-in shape for an arc stroke with rounded caps. A regular arc gives you as a line that we can add a stroke and rounded caps. But this is only for drawing, and we can't easily use this shape for other purposes, like clipping or adding a stroke to its shape.
Specifically for this Airbnb Dial, we need to set the rounded corner radius for each end individually. So we can set the starting cap to a small radius, and the ending cap to be rounded, to accommodate the thumb.
ChromaDial provides this with TubeShape. It takes a tube radius, corner radii for each cap, a start angle, and a sweep angle.
TubeShape extends Shape, so you pass it directly to .clip(), .background(), or .border().
0:00
/0:04
1×
Compared to a plain arc, TubeShape fills the full tube thickness. tubeRadius is half the thumb width, so the shape exactly occupies the ring. The corner radii control the cap at each end. Setting topEnd/bottomEnd to tubeRadius makes the tip a perfect semicircle. The starting cap (topStart & bottomStart) uses 20f for a slightly softer look.
val tubeRadius = with(LocalDensity.current) { 56.dp.toPx() / 2 }
val tubeCornerRadius = RoundedCornerShape(
topStart = 20f,
bottomStart = 20f,
topEnd = tubeRadius,
bottomEnd = tubeRadius,
)
val activeShape = remember(dialState.degree, dialState.overshootDegrees) {
TubeShape(
startAngleDegrees = 0f,
sweepAngleDegrees = 30f + dialState.degree + dialState.overshootDegrees,
tubeRadius = tubeRadius,
cornerRadius = tubeCornerRadius,
)
}
val donutShape = remember {
TubeShape(
startAngleDegrees = 0f,
sweepAngleDegrees = 360f,
tubeRadius = tubeRadius,
cornerRadius = tubeCornerRadius,
)
}
In the code above, we have defined the two shapes we'll need. activeShape tracks the filled region, keyed on degree values so it only rebuilds when the thumb moves. donutShape covers the full ring and never changes, so it's created once.
startAngleDegrees = 0f anchors the fill at 12 o'clock. We add 30f to the sweep to account for the dial's startDegrees offset.
Box(
modifier = Modifier
.fillMaxSize()
.background(
color = Red500,
shape = activeShape,
)
)
As you can see above, drawing the tube shapes is as easy as passing it into a background Modifier, with our desired color.
The center display lives inside the track composable. ChromaDial gives us dialState.mappedValue, a float in our valueRange that tracks the thumb in real time. Let's convert it to an integer, and use it for our display.
val monthIndex = dialState.mappedValue.roundToInt()
Column(
modifier = Modifier
.fillMaxSize()
.padding(56.dp)
.background(color = Zinc200, shape = CircleShape)
.innerShadow(shape = CircleShape) {
radius = 6f
offset = Offset(0f, -6f)
alpha = .2f
spread = 5f
},
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(0.dp, Alignment.CenterVertically),
) {
Text(
text = "$monthIndex",
modifier = Modifier.fillMaxWidth(),
textAlign = TextAlign.Center,
color = Neutral950,
fontSize = 72.sp,
fontFamily = FontFamily.Monospace,
fontWeight = FontWeight.Bold,
)
Text(
text = if (monthIndex == 1) "month" else "months",
color = Zinc600,
fontSize = 12.sp,
fontFamily = FontFamily.Monospace,
)
}
The padding(56.dp) matches the thumb size, carving out the inner circle from the track area. The innerShadow pointing upward simulates the hole being recessed below the tube surface.
0:00
/0:03
1×
Adding Detail
The flat fill is a good start, but Airbnb's dial went the extra mile and made it look 3D. Let's do the same by adding some layers to simulate a physical tube. This is achieved with a series of strategic gradients and blurs.
The first layer draws a thin stroke along the full ring edge, clipped and blurred. This creates the dark groove running along the inner and outer tube walls.
Box(
modifier = Modifier
.fillMaxSize()
.clip(donutShape)
.blur(
radius = 12.dp,
edgeTreatment = BlurredEdgeTreatment.Unbounded
)
.border(
width = 5.dp,
color = Neutral400.copy(alpha = .6f),
shape = donutShape,
)
)
Next, we add the fill color with 30.dp blur. This is the ambient glow that makes the color seem to radiate from inside the tube rather than sitting on top of it.
Box(
modifier = Modifier
.fillMaxSize()
.clip(donutShape)
.blur(radius = 30.dp, edgeTreatment = BlurredEdgeTreatment.Unbounded)
.background(
color = Red500,
shape = activeShape,
)
)
The solid fill goes in next. On top of it, a radial gradient that brightens the center of the path and fades outward. This simulates the way a cylindrical surface is brightest where it faces the viewer straight on.
Box(
modifier = Modifier
.fillMaxSize()
.background(
color = Red500,
shape = activeShape,
)
.background(
brush = Brush.radialGradient(
.7f to Orange600,
1f to Transparent,
),
shape = activeShape,
)
)
A softer inner glow adds the impression of light transmitting through the tube material. It uses a lighter tint at 20.dp blur, clipped to the filled region.
Box(
modifier = Modifier
.fillMaxSize()
.clip(activeShape)
.blur(radius = 20.dp, edgeTreatment = BlurredEdgeTreatment.Unbounded)
.background(
color = Red300.copy(alpha = .2f),
shape = activeShape,
)
)
Finally, a thin stroke offset 3.dp up and left, blurred at 4.dp, with a vertical gradient running transparent-to-Zinc100. This is the overhead light catching the top edge of the tube.
Box(
modifier = Modifier
.fillMaxSize()
.clip(activeShape)
.blur(radius = 4.dp, edgeTreatment = BlurredEdgeTreatment.Unbounded)
.offset(x = (-3).dp, y = (-3).dp)
.border(
width = 3.dp,
brush = Brush.verticalGradient(colors = listOf(Transparent, Zinc100)),
shape = activeShape,
)
)
0:00
/0:03
1×
Each of these layers looks like nothing in isolation. The glow on its own is just a smear, the rim stroke is barely visible, the highlight is a thin sliver. Stack them and the flat tube gets a form. You can play around with these layers and colors to get various unique looks.
Thanks for reading and good luck!
Discussion in the ATmosphere