Motivation
After writing about Glovo-animation, I was still curious about one type of movement in the app known as fling animation. I realized that learning this could help me get better at designing user interfaces. Then, I saw the same animation in another app called ELSA Speak, and it seemed like a hint that I should dive deeper into it. I decided to learn all about it and share my findings with others who enjoy making apps look nice.
Check the comments for extra tips and explanations!
En avante!
Main Component
Let’s start with the basics: drawing large and small circles. In my previous article, I did it using Canvas, and now I want to try something new — Custom Layout. With a custom layout, we will have less amount of drawings, which, in my view, will be easier to support, although it always depends on your preferences.
First, let’s create a Composable.
@Composable | |
fun TimeCircleComponent( | |
items: List<TimeItem>, | |
itemSize: Dp, | |
onItemPicked: (TimeItem) -> Unit, | |
size: Dp, | |
content: @Composable (TimeItem) -> Unit, | |
itemPadding: Dp = 30.dp, | |
modifier: Modifier = Modifier, | |
) |
In this setup, items
represent our inner circles. itemSize
and size
define the dimensions of the items and the main circle, respectively. onItemPicked
is a callback function triggered upon choosing an item. The content
lambda allows customization of item composable. itemPadding
provides spacing between items and the main circle, while modifier
offers additional customization options for our layout.
data class TimeItem( | |
val time: String, | |
val isDayTime: Boolean, | |
) |
TimeItem is a simple data class that contains a time string and information about the period of a day (we will need it later for the Sun and Moon animation).
When we have all the important components, it’s time to draw everything.
Layout( | |
modifier = modifier, | |
content = { | |
// Draw each item with appropriate information | |
repeat(items.size) { index -> | |
Box(modifier = Modifier.size(itemSize)) { | |
content(items[index]) | |
} | |
} | |
} | |
) { measurables, constraints -> | |
val paddingInPx = itemPadding.toPx() | |
val placeables = measurables.map { measurable -> measurable.measure(constraints) } | |
val sizeInPx = size.toPx().toInt() | |
// We need to remove the itemSize because items will be positioned not in a circle but at the edge | |
val availableSpace = sizeInPx - itemSize.toPx() | |
val radius = (availableSpace / 2.0).roundToInt() | |
// Calculate the step between each item | |
val angleStep = (360 / items.size.toDouble()).degreesToRadians() | |
layout( | |
width = sizeInPx, | |
height = sizeInPx, | |
) { | |
placeables.forEachIndexed { index, placeable -> | |
// Calculate the angle of each item | |
val itemAngle = angleStep * index.toDouble() | |
// Get coordinates relative to the circle center with paddings | |
val offset = getCoordinates( | |
radius = radius.toDouble(), | |
angle = itemAngle, | |
paddings = paddingInPx | |
) | |
placeable.placeRelative( | |
x = offset.x.roundToInt(), | |
y = offset.y.roundToInt(), | |
) | |
} | |
} | |
} | |
} | |
private fun getCoordinates(angle: Double, radius: Double, paddings: Float): Offset { | |
val radiusWithPaddings = radius - paddings | |
val x = radiusWithPaddings * sin(angle) | |
val y = radiusWithPaddings * cos(angle) | |
// Adding radius is necessary to shift the origin from the center of the circle | |
return Offset( | |
x = x.toFloat() + radius.toFloat(), | |
y = y.toFloat() + radius.toFloat(), | |
) | |
} |
To summarize our process, inside the custom layout, we measure each child with the given constraints, resulting in a list of placeables that are ready to be placed in the layout. We then perform calculations to determine the available space, the radius of our circle, and the necessary distances for optimal item positioning. Next, we processed all placeables to calculate their angles in radians. Using the trigonometry function we calculate the position inside the global circle and place our items according to the coordinates.
Let’s review the entire composable.
val itemSize = 50.dp | |
val size = 1100.dp | |
Box( | |
modifier = Modifier | |
.fillMaxSize() | |
.background(BackgroundColor) | |
) { | |
TimeCircleComponent( | |
items = getTimes(), | |
itemSize = itemSize, | |
onItemPicked = {}, | |
size = size, | |
content = { item -> | |
Box( | |
modifier = Modifier | |
.size(itemSize) | |
.background(CircleBackgroundColor) | |
.clip(CircleShape) | |
.border(1.dp, Color.White, CircleShape) | |
) { | |
Text( | |
text = item.time, | |
modifier = Modifier.align(Alignment.Center), | |
color = Color.White, | |
fontSize = 12.sp, | |
) | |
} | |
}, | |
modifier = Modifier | |
.size(size) | |
.offset(y = 750.dp) | |
.align(Alignment.BottomCenter) | |
.clip(CircleShape) | |
.background(CircleBackgroundColor) | |
) | |
} |
There isn’t much more to explain. In this section, we simply configure the colors and sizes of our main and item circles. Although I’m not generally a fan of using offsets, they offer the quickest solution in this scenario. To simplify this explanation, I’ve left out the getTimes()
function and colors. You can find these details in the attached GitHub repository.
Draggability
Now, we approach the most challenging yet engaging part of our animation: implementing fling actions and draggability. Our goal is to enable users to interact with the main circle by dragging it smoothly or flinging it with force, resulting in a spinning motion.
To begin, we should create a state to track the current angle of our circle and the selected item
class SelectedItem( | |
// Distance to the closest/selected item | |
val angle: Float = 361f, | |
// Index of the selected item | |
val index: Int = 0, | |
) | |
class TimePickerState { | |
// Angle changes | |
private val _angle = Animatable(0f) | |
val angle: Float | |
get() = _angle.value | |
// Angle state after the end of the animation | |
var oldAngle: Float = 0f | |
// Currently selected item | |
var selectedItem = SelectedItem() | |
// Animation that we will use for spins | |
private val decayAnimationSpec = FloatSpringSpec( | |
dampingRatio = Spring.DampingRatioNoBouncy, | |
stiffness = Spring.StiffnessVeryLow, | |
) | |
suspend fun stop() { | |
_angle.stop() | |
} | |
suspend fun snapTo(angle: Float) { | |
_angle.snapTo(angle) | |
} | |
suspend fun animateTo(angle: Float) { | |
// Save the new old angle as this is the last step of the animation | |
oldAngle = angle | |
_angle.animateTo(angle, decayAnimationSpec) | |
} | |
suspend fun decayTo(angle: Float, velocity: Float) { | |
_angle.animateTo( | |
targetValue = angle, | |
initialVelocity = velocity, | |
animationSpec = decayAnimationSpec, | |
) | |
} | |
} |
While comments will provide immediate guidance, it’s better to return to them later, when the whole picture is clearer.
The next step will be to create a drag modifier.
fun Modifier.drag( | |
state: TimePickerState, | |
onItemPicked: (TimeItem) -> Unit, | |
magnitudeFactor: Float = 0.25f | |
) = pointerInput(Unit) { | |
val center = Offset(x = this.size.width / 2f, this.size.height / 2f) | |
val decay = splineBasedDecay<Float>(this) | |
coroutineScope { | |
while (true) { | |
var startedAngle = 0f | |
val pointerInput = awaitPointerEventScope { | |
// Catch the down event | |
val pointer = awaitFirstDown() | |
// Calculate the angle where user started dragging and convert to degrees | |
startedAngle = -atan2( | |
center.x - pointer.position.x, | |
center.y - pointer.position.y, | |
) * (180f / PI.toFloat()).mod(360f) | |
pointer | |
} | |
// Stop previous animation | |
state.stop() | |
val tracker = VelocityTracker() | |
var changeAngle = 0f | |
awaitPointerEventScope { | |
// Catch drag event | |
drag(pointerInput.id) { change -> | |
// Calculate the angle after user drag event and convert to degrees | |
changeAngle = -atan2( | |
center.x - change.position.x, | |
center.y - change.position.y, | |
) * (180f / PI.toFloat()).mod(360f) | |
launch { | |
// Change the current angle (later will be added to each item angle) | |
state.snapTo((state.oldAngle + (startedAngle - changeAngle).mod(360f))) | |
} | |
// Pass the info about changes to the VelocityTracker for later calculations | |
tracker.addPosition(change.uptimeMillis, change.position) | |
if (change.positionChange() != Offset.Zero) change.consume() | |
} | |
// Get magnitude of velocity and multiply by factor (to decrease the speed) | |
var velocity = tracker.calculateVelocity().getMagnitudeOfLinearVelocity() * magnitudeFactor | |
// Calculate the fling side (left or right) | |
val difference = startedAngle - changeAngle | |
velocity = if (difference > 0) | |
velocity else -velocity | |
// Calculate new angle according to the velocity | |
val targetAngle = decay.calculateTargetValue( | |
state.angle, | |
velocity, | |
) | |
launch { | |
// Animate items to the new angle with velocity | |
state.decayTo( | |
angle = targetAngle, | |
velocity = velocity, | |
) | |
} | |
} | |
// In the end save the old angle for further calculations | |
state.oldAngle = state.angle.mod(360f) | |
} | |
} | |
} | |
// This is used to determine the speed of the user's drag gesture | |
private fun Velocity.getMagnitudeOfLinearVelocity(): Float { | |
return sqrt(this.x.pow(2) + this.y.pow(2)) | |
} |
Job Offers
The implementation depends on two key parameters: state
, as mentioned before, and magnitudeFactor.
The latter is crucial for adjusting the decay animation’s speed to prevent the circle from spinning too quickly, an optimization realized through testing.
We start by identifying the center of our component. Next, we initialize a DecayAnimationSpec to manage the dynamic changes in our circle’s angle, ensuring smooth transitions.
A coroutineScope is then set up to handle user input events. Here we can start to catch different user events. The initial event we capture is the DownEvent, which allows us to determine the initial angle of the drag. For this calculation, I employ the atan2 function, a choice explained in more detail in my previous article.
On detecting a drag gesture, we calculate the angle changes based on the user’s touch position relative to the center of the component and continuously update the angle, keeping the interaction responsive.
A VelocityTracker captures the speed of these gestures. When the user releases their touch, the tracker’s data, adjusted by the magnitudeFactor
calculates the animation’s velocity. This calculated speed helps us determine the target angle, marking the animation’s endpoint. We also calculate the side of a fling by subtracting the changeAngle
from the startedAngle
. Then circle smoothly transitions to the new angle
We keep track of the last selected angle by saving it to the oldAngle
variable to ensure continuity between drag gestures.
The last step in this part is to add this state to the drag modifier.
@Composable | |
private fun rememberTimePickerState(): TimePickerState = remember { | |
TimePickerState() | |
} |
Layout( | |
modifier = modifier.drag(state, onItemPicked = onItemPicked), |
And here is the beautiful result of our efforts.
Item Picker
In this section, we’ll refine the item selection process. Our goal is to ensure that the selectedItem
is always centered at the top of the global circle and trigger onItemPicked
callback. This requires a slight adjustment in our drawing logic — specifically, subtracting 180 degrees from the starting position of our items.
// Adjust degrees to start item drawing from the top | |
private const val SELECTED_ANGLE_DEGREES = 180f | |
//... | |
val changeAngle = state.angle.toDouble() - SELECTED_ANGLE_DEGREES | |
//... | |
val itemAngle = changeAngle.degreesToRadians() + angleStep * index.toDouble() |
Next, we focus on identifying the closest item to the top of the circle. By calculating the distance of each item to the circle’s apex, we can dynamically update the selectedItem
.
// Convert angles to degrees | |
val itemAngleDegrees = (itemAngle * (180f / PI.toFloat())).toFloat() | |
// Get the distance from the top position to the current item | |
val distanceToSelectedItem = itemAngleDegrees.mod(360f) - SELECTED_ANGLE_DEGREES | |
// Find the closest item | |
if (abs(distanceToSelectedItem) < abs(state.selectedItem.angle)) { | |
state.selectedItem = SelectedItem(distanceToSelectedItem, items[index]) | |
} |
After the decay animation, we simply need to drag the main circle the remaining distance to the nearest item.
// Drag to the closest item | |
state.animateTo(state.angle - state.selectedItem.angle) | |
// Trigger pick listener | |
state.selectedItem.item?.let(onItemPicked) |
Also, we need to add different colors when an item is selected or deselected. For this, we add a few items that will control it — specifically, the currently selected item and color animation inside content lambda that triggers when currentItem
changes.
var currentItem by remember { | |
mutableStateOf(times.first()) | |
} | |
content = { item -> | |
val colorAnimation by animateColorAsState( | |
targetValue = if (item == currentItem) CircleAccentColor else CircleBackgroundColor, | |
animationSpec = spring( | |
dampingRatio = Spring.DampingRatioNoBouncy, | |
stiffness = Spring.StiffnessLow, | |
) | |
) |
Pass this item to the box modifier.
.background(colorAnimation)
Animating the Sun and Moon
To add a finishing touch, we introduce a Sun and Moon animation to reflect day or night time based on the selectedItem
. This involves a component with three icons (a house, the Sun, and the Moon, which can be found here) that rotates using a rotate modifier.
@Composable | |
fun SunMoonComponent( | |
rotation: Float, | |
modifier: Modifier = Modifier, | |
) { | |
Box( | |
modifier = modifier | |
) { | |
Icon( | |
painter = painterResource(id = R.drawable.icon_house), | |
contentDescription = "House", | |
Modifier | |
.align(Alignment.Center) | |
.size(60.dp), | |
tint = Color.Gray | |
) | |
Box( | |
modifier = Modifier | |
.fillMaxSize() | |
.rotate(rotation) | |
) { | |
Icon( | |
painter = painterResource(id = R.drawable.icon_sun), | |
contentDescription = "Sun", | |
Modifier.align(Alignment.TopStart), | |
tint = Color.Yellow | |
) | |
Icon( | |
painter = painterResource(id = R.drawable.icon_moon), | |
contentDescription = "Moon", | |
Modifier | |
.align(Alignment.BottomStart) | |
.rotate(180f), | |
tint = Color.White | |
) | |
} | |
} | |
} |
We add new objects to our main Box: AnimationSpec
for the animation’s behavior, a rotation value, and a coroutine scope to execute the animation. (Also with an offset).
val defaultSpringSpec = remember { | |
FloatSpringSpec( | |
dampingRatio = Spring.DampingRatioNoBouncy, | |
stiffness = Spring.StiffnessVeryLow, | |
) | |
} | |
val rotationAnimation = remember { | |
Animatable(180f) | |
} | |
val scope = rememberCoroutineScope() | |
SunMoonComponent( | |
rotation = rotationAnimation.value, | |
modifier = Modifier | |
.size(height = 150.dp, width = 100.dp) | |
.offset(y = 165.dp) | |
.align(Alignment.Center) | |
) |
This animation is triggered by the onItemPicked
callback.
onItemPicked = { | |
scope.launch { | |
rotationAnimation.animateTo( | |
if (it.isDayTime) 0f else 180f, | |
defaultSpringSpec | |
) | |
} | |
}, |
Finally, seeing the complete animation in action, I’m deeply touched…
Conclusion
This article showed you how to add fling animation to your apps, using the ELSA Speak time picker as an example. We covered how to combine this with other features such as Custom Layouts, VelocityTracker, and DecayAnimation, detailing each step to enhance your app’s interactivity and visual appeal.
Links
If you want to see the whole implementation, you can find it in the link below.
https://github.com/AndreVero/ElsaSpeakTimePicker?source=post_page—–de931876acac——————————–
Many useful insights I’ve got from this article.
Icon by Fasil on freeicons.io
This article is previously published on proandroiddev.com