Blog Infos
Author
Published
Topics
, , ,
Published
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!

The ELSA Speak Time Picker
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.

The result of the first chapter

 

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

Job Offers

There are currently no vacancies.

OUR VIDEO RECOMMENDATION

, ,

Migrating to Jetpack Compose – an interop love story

Most of you are familiar with Jetpack Compose and its benefits. If you’re able to start anew and create a Compose-only app, you’re on the right track. But this talk might not be for you…
Watch Video

Migrating to Jetpack Compose - an interop love story

Simona Milanovic
Android DevRel Engineer for Jetpack Compose
Google

Migrating to Jetpack Compose - an interop love story

Simona Milanovic
Android DevRel Engin ...
Google

Migrating to Jetpack Compose - an interop love story

Simona Milanovic
Android DevRel Engineer f ...
Google

Jobs

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)
Now we can pick items!

 

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…

Final result
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.

https://fvilarino.medium.com/implementing-a-circular-carousel-in-jetpack-compose-cc46f2733ca7?source=post_page—–de931876acac——————————–

Icon by Fasil on freeicons.io

This article is previously published on proandroiddev.com

YOU MAY BE INTERESTED IN

YOU MAY BE INTERESTED IN

blog
It’s one of the common UX across apps to provide swipe to dismiss so…
READ MORE
blog
In this part of our series on introducing Jetpack Compose into an existing project,…
READ MORE
blog
In the world of Jetpack Compose, where designing reusable and customizable UI components is…
READ MORE
blog

How to animate BottomSheet content using Jetpack Compose

Early this year I started a new pet project for listening to random radio…
READ MORE

Leave a Reply

Your email address will not be published. Required fields are marked *

Fill out this field
Fill out this field
Please enter a valid email address.

Menu