Rotating Compose Objects with Taps and Swipes. Part II
Hi! This is second part of the article about rotating Composable objects through taps and swipes. In this part, we will explore how we can make Composable objects follow the user’s finger and, as a bonus, optimize recompositions.
As a reminder, our main goal is to create an object that can be rotated using taps and swipes. When rotating, the object should feel realistic:
- It should follow the user’s finger while dragging and complete the rotation upon release.
- It should rotate in the direction of the tap.
If you haven’t read the first part yet, you can find it here.
Rotation on Swipe
For rotation on swipe, the following requirements exist:
- The object should follow the finger if the swipe is not released (drag).
- If the object is released with a final velocity > N, it should complete the rotation to a full spin.
- If released with a lower velocity, it should return to the previous state.
Let’s introduce mutable state to track whether the user is dragging the card or has already released it:
var dragInProgress by remember { mutableStateOf(false) }
The object should move smoothly with the finger, so we need to adjust the animation during card dragging:
val rotation by animateFloatAsState(
targetValue = targetAngle,
animationSpec = tween(if (dragInProgress) 0 else 1000),
)
There are several ways to track card dragging. In the first solution, I used the pointerInput
modifier. However, it didn't suit our needs because when using it, the Composable to which this modifier was applied captured all interactions. This led to the situation where the object itself handled vertical scrolling, preventing the screen from being scrolled. To fix this, I switched to using the Modifier.draggable
. With this modifier, you can set the orientation, which solves this problem:
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = CardHorizontalPadding)
.draggable(
orientation = Orientation.Horizontal,
onDragStarted = {
dragInProgress = true
},
onDragStopped = {
dragInProgress = false
},
),
rotationAngle = rotation,
interactionSource = cardInteractionSource,
)
In the code above, we set the horizontal orientation because we need to rotate the card along the vertical axis, and the object shouldn’t interfere with other interactions. We also update our dragInProgress
flag when the user starts and releases dragging.
The IDE suggests that we’re missing at least one required field — DraggableState
. With this state, we'll determine how we interact with dragging. Let's define it
state = rememberDraggableState(
onDelta = {},
),
The onDelta
lambda provides the displacement during dragging, which we'll add to our angle.
Here, it’s important to pay attention to the following relationship that needs to be fulfilled: if the user drags the object from the left side to the right side completely, it should rotate by 180 degrees. Let’s formalize this relationship mathematically to determine how many degrees correspond to 1.dp
:
val diff = 180f / cardWidth.value
Based on this knowledge, let’s calculate the rotation angle depending on the displacement (offsetX: Float
) that we obtain in the onDelta
lambda:
private fun calculateAngle(offsetX: Float, density: Density, diff: Float): Float {
val offsetInDp = with(density) { offsetX.toDp() }
return offsetInDp.value * diff
}
This way, we’ll be able to calculate the angle by which we need to rotate the object during dragging. Let’s just enhance the onDelta
lambda with the calculation of the additional angle and add it to our rotation angle:
state = rememberDraggableState(
onDelta = { offsetX ->
val calculatedAngle = calculateAngle(offsetX, density, diff)
targetAngle += calculatedAngle
},
),
Let’s take a look at the result!
As you can see in the video, the card doesn’t rotate to the final angle or ‘settle’ on a specific side.
Let’s fix this.
To achieve this, in onDragStopped
, we will find the nearest angle based on the last recorded angle and the speed with which the card was released:
private fun Float.findNearAngle(velocity: Float): Float {
val velocityAddition = if (abs(velocity) > 1000) {
90f * velocity.sign
} else {
0f
}
val normalizedAngle = this.normalizeAngle() + velocityAddition
val minimalAngle = (this / 360f).toInt() * 360f
return when {
normalizedAngle in -90f..90f -> minimalAngle
abs(normalizedAngle) >= 270f -> minimalAngle + 360f * this.sign
abs(normalizedAngle) >= 90f -> minimalAngle + 180f * this.sign
else -> 0f
}
}
velocityAddition
is an angle that depends on the velocity. It can be either ±90 or 0.
normalizedAngle
is the sum of the normalized angle and the angle that depends on velocity.
minimalAngle
is the angle that will correspond to 0 on the trigonometric circle for any angle. For example, if we have an angle of 1200, the minimal angle will be 1080 degrees. This is the same as having 0 for an angle of 120 degrees, because in both cases, the card will be oriented 'face' towards us. In the image below, minimalAngle is marked with a dot.
Next, it’s simple: if the angle with velocity falls between 90 and -90 degrees, it means it hasn’t crossed the halfway point and should return to the initial angle.
If it crosses and goes past the 90-degree mark, it needs to rotate to π, considering the sign of the incoming angle.
If it crosses and goes past the 270-degree mark, it needs to rotate to 2*π, considering the sign of the incoming angle.
Let’s apply our calculations to calculate and update the angle when the user stops dragging the card:
onDragStopped = { lastVelocity ->
dragInProgress = false
targetAngle = targetAngle.findNearAngle(velocity = lastVelocity)
},
As you might notice, there’s currently a halt after releasing the finger. The dragging speed and rotation speed are not synchronized, which looks not really good. You can manually synchronize the speeds, or you can resort to a trick and simply change the animationSpec
in our rotation animation from tween
to spring
:
val rotation by animateFloatAsState(
targetValue = targetAngle,
animationSpec = spring(
stiffness = if (dragInProgress) Spring.StiffnessHigh else Spring.StiffnessLow,
),
)
And then it will be much better:
Bonus
If you use Android Studio’s Layout Inspector tool and observe recompositions, you’ll notice that the Card
recomposes every time the FlippableCardContainer
recomposes - this can be optimized.
First, let’s wrap rotation
in derivedStateOf
. During card dragging, targetAngle
changes very frequently, and this negatively impacts performance because with every change of targetAngle
, the whole screen recomposes, including the Card
:
val rotationAngleState = remember {
derivedStateOf { rotation.value }
}
Second, let’s wrap frontSideIsShowing
in derivedStateOf
. Otherwise, recompositions will still occur because if you directly listen to rotationAngleState.value
, every change will trigger a recomposition:
val frontSideIsShowing by remember {
derivedStateOf {
abs(rotationAngleState.value.normalizeAngle()) !in 90f..270f
}
}
Thirdly, let’s wrap Modifier.draggable
in remember
, because this modifier is not stable. In onDragStopped
, we read the variable state targetAngle
, and without remember
, this modifier would be recreated each time. The call to Card
from FlippableCardContainer
will look like this:
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = CardHorizontalPadding)
.then(
remember {
Modifier.draggable(
orientation = Orientation.Horizontal,
onDragStarted = {
dragInProgress = true
},
onDragStopped = { lastVelocity ->
dragInProgress = false
targetAngle = targetAngle.findNearAngle(velocity = lastVelocity)
},
state = draggableState,
)
},
),
rotationAngle = rotationAngleState,
interactionSource = cardInteractionSource,
)
Now, Card
will read the angle as a State<Float>
. This will prevent unnecessary recomposition of Card
every time the angle changes (in Card
, the angle is read within a lambda, so Card
won't recompose unnecessarily). It will also calculate needRenderBackSide
more efficiently:
val needRenderBackSide = remember {
derivedStateOf {
val normalizedAngle = abs(rotationAngle.value % 360f)
normalizedAngle in 90f..270f
}
}
Once again, rotationAngle.value
changes very frequently. To prevent recomposition with every change, we wrapped the calculation of needRenderBackSide
in derivedStateOf
.
In the end, we get the following picture in terms of recomposition counts:
As seen from the screenshot, Compose skips all recompositions for Card
- as a result, the user experiences a smoother rotation animation.
Conclusion
That’s it! The card now rotates via tap and swipe, follows the finger, and rotates to the appropriate angle. As mentioned at the beginning, this tutorial can be applied to images, text, or anything else if you’re using Compose.
In our app, this function is used to display bank card details and looks like this:
The code discussed in this article can be found here.
I hope this guide has been helpful, and now you know how to make your app more attractive and enjoyable for users.