Rotating Compose Objects with Taps and Swipes. Part I

Aleksandr Masliaev
Plata card
Published in
8 min readSep 12, 2023

Link to the second part of the article here

Hi! My name is Sasha, and I work as an Android developer at Plata company. I’m responsible for developing the card details screen in our mobile banking app. In this article, I will explain how Compose allows you to tackle the task of enabling users to rotate something using taps and swipes. In our application, we use this function for rotating a bank card and viewing its details. You’ll be able to see this card in the explanatory images and at the end of the second part.

The article will be divided in two parts:

  1. rotation by press.
  2. rotation by swipe and drag.

This guide can be useful not only for working with a bank card but also for any other views developed using Compose.

For example, you can do something like this:

Preparation

To simplify this article, we will rotate a rectangle with round corners. During rotation, the object should feel realistic:

  • Follow the finger while dragging and complete the rotation upon release.
  • Rotate in the direction where tapped.

Basic Object Layout

To begin, we need to prepare our rectangle that we will rotate. It’s a simple box containing 2 additional boxes inside, each responsible for the front and back sides of the card:

private const val CardAspectRatio = 1.5819f

@Composable
internal fun Card(modifier: Modifier = Modifier) {
val sideModifier =
modifier
.widthIn(min = 240.dp)
.aspectRatio(CardAspectRatio)
.clip(shape = RoundedCornerShape(20.dp))

Box {
Box(
modifier = sideModifier
.graphicsLayer {
alpha = 1f
}
.background(Color.Red),
)
Box(
modifier = sideModifier
.graphicsLayer {
alpha = 0f
rotationY = 180f
}
.background(Color.Blue),
)
}
}

Interestingly, the back side of the object has a constant rotationY = 180. This is done so that the object doesn't appear 'inside out' from the back – without a horizontal reflection. You can also notice that showing and hiding the card sides is achieved using alpha, rather than just placing the boxes within if-else conditions. This is done this way because using if conditions would trigger recomposition, which is undesirable.

Let’s create a container. All the logic for angle calculation during rotations will happen inside it, and it will become clearer later:

@Composable
internal fun FlippableCardContainer() {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 40.dp),
)
}
}

Working with Rotation Angles

To teach our card to change its rotation angle relative to the vertical axis, let’s add a rotation angle to the parameters:

@Composable
internal fun Card(
rotationAngle: Float,
modifier: Modifier = Modifier,
) {

To rotate a Composable representation, it’s necessary to set the desired rotation angle in degrees in the rotationY field of Modifier.graphicsLayer. Additionally, we'll set the cameraDistance to move the camera farther away, so that when rotating, the card's angles aren't clipped by the container. This parameter defines the distance along the Z-axis, which is perpendicular to the plane formed by the X and Y axes. Composable representations are drawn on the X and Y plane, while the 'camera' is positioned at a distance along the Z-axis, looking at these representations. You can read more about it here.

Empirically, we’ve discovered with our designers that 12.dp will be enough for this parameter.

val sideModifier =
modifier
.widthIn(min = 240.dp)
.aspectRatio(CardAspectRatio)
.graphicsLayer {
rotationY = rotationAngle
cameraDistance = 12.dp.toPx()
}
.clip(shape = RoundedCornerShape(20.dp))

Now let’s draw the side of the card that is visible to the user based on the rotation angle.

The rotation angle of the card is limited only by the maximum and minimum Float values. Therefore, we need a universal solution that will allow us to bring an angle of any value and any sign to the range from 0 to 360 degrees. To achieve this, we’ll use the % operator to get the remainder of division by 360. The sign of the angle doesn't matter here since what matters is which side we need to display. It doesn't matter whether the user rotates the card by 45 or -45 degrees; in both cases, we need to display the front side. Therefore, we'll take the absolute value of the resulting remainder from the division.

abs(rotationAngle % 360f)

Once we have the normalized angle, we can confidently check whether it falls within the range for rendering the back side of the card (from 90 to 270 degrees) or not.

val normalizedAngle = abs(rotationAngle % 360f)
val needRenderBackSide = normalizedAngle in 90f..270f

The resulting flag will be used to set the alpha for the front and back sides of the object.

Box {
Box(
modifier = sideModifier
.graphicsLayer {
alpha = if (needRenderBackSide) 0f else 1f
}
.background(Color.Red),
)
Box(
modifier = sideModifier
.graphicsLayer {
alpha = if (needRenderBackSide) 1f else 0f
rotationY = 180f
}
.background(Color.Blue),
)
}

Rotation on Press

Let’s declare the rotation angle in FlippableCardContainer.kt – this is what we will be modifying:

var targetAngle by remember { mutableStateOf(0f) }

The rotation angle needs to change based on the user’s interaction with the app. Let’s start with taps. We need to rotate the card in the direction of the tap. For example, tapping on the left side of the card should result in a clockwise rotation, and tapping on the right side should result in an anti-clockwise rotation.

To track where the user tapped, we’ll use MutableInteractionSource. MutableInteractionSource is an entity that allows us to track interactions with a component. Interactions include clicks (press and release), drag-and-drop, focus changes, and more.

Let’s declare a MutableInteractionSource. In our case, we do this in the parent component since we'll be handling the angle manipulation there:

val cardInteractionSource = remember { MutableInteractionSource() }

Provide it into our card and declare by Modifier that our card is now clickable:

.clickable(
interactionSource = interactionSource,
indication = LocalIndication.current,
onClick = {},
)

Since we will handle the clicks in the parent component, we leave the onClick lambda empty.

Now we can track user interactions with the card, and nothing prevents us from capturing them in the parent component (FlippableCardContainer.kt):

LaunchedEffect(Unit) {    
cardInteractionSource.interactions
.filterIsInstance<PressInteraction.Release>()
.map { println("release!") }
.launchIn(this)
}

As mentioned earlier, we need to understand the exact point/coordinate where the press occurred.

For this purpose, for PressInteraction.Release entities, there is a field called press, inside which there is pressPosition. pressPosition is an offset that indicates how far the user pressed from the top-left point (the origin point) of the component. From this offset, we are only interested in the displacement along the x-axis.

Let’s take the offset along the X-axis of our click:

val offsetInDp = with(density) {
it.press.pressPosition.x.toDp()
}

Now, to determine whether the user clicked on the left or right side, it’s simply a matter of comparing the offset obtained from the click with the object’s width. If the offset is less than half of the width, it’s the left side; if it’s more — right side.

Calculating the card’s width is straightforward — we’ll take the side padding of the card and subtract it twice (for left and right) from the screen width.

val screenWidth = LocalConfiguration.current.screenWidthDpval 
cardWidth = screenWidth.dp - CardHorizontalPadding * 2

In fact, this isn’t quite everything, because earlier we rotated the back side of the card by 180 degrees to ensure the content of the back side looked correct. Here, we also need to keep this in mind, as the coordinates will also be rotated, and 0 will not be in the top-left but in the top-right corner.

Let’s introduce a variable that will indicate which side of the card is currently being displayed.

val frontSideIsShowing = abs(targetAngle.normalizeAngle()) !in 90f..270f

Taking this into account, let’s write the code that will tell us whether we need to rotate the card clockwise or anti-clockwise. The entire interaction with clicks currently looks like this:

LaunchedEffect(frontSideIsShowing) {
cardInteractionSource.interactions
.filterIsInstance<PressInteraction.Release>()
.map {
val offsetInDp = with(density) {
it.press.pressPosition.x.toDp()
}
val offsetXForContainer =
if (frontSideIsShowing) {
offsetInDp
} else {
cardWidth - offsetInDp
}
cardWidth / offsetXForContainer > 2
}
}

Now we need to change the angle considering the rotation direction. On tap, we rotate the card by 180 degrees.

This is also straightforward: if clockwise, we add 180 degrees; if counterclockwise — subtract.

internal fun Float.findNextAngle(spinClockwise: Boolean): Float {
return if (spinClockwise) this - 180f else this + 180f
}

And that’s it, now we just need to set the new angle and pass this angle to the card.

LaunchedEffect(frontSideIsShowing) {
cardInteractionSource.interactions
.filterIsInstance<PressInteraction.Release>()
.map {
val offsetInDp = with(density) {
it.press.pressPosition.x.toDp()
}
val offsetXForContainer =
if (frontSideIsShowing) {
offsetInDp
} else {
cardWidth - offsetInDp
}
cardWidth / offsetXForContainer > 2
}
.collect { spinClockwise ->
targetAngle = targetAngle.findNextAngle(spinClockwise)
}
}
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = CardHorizontalPadding),
rotationAngle = targetAngle,
interactionSource = cardInteractionSource,
)

Unfortunately, without animation, we won’t see in which direction the card is rotating. So, let’s add rotation animation.

Thanks to Compose’s Animation API, adding animation is straightforward, and I haven’t even created a separate part for it.

All we need to do is wrap our targetAngle with an animated wrapper and replace all reads of targetAngle with this wrapper. Let's call this wrapper rotation, and here's the final code in FlippableCardContainer:

private val CardHorizontalPadding = 40.dp
@Composable
internal fun FlippableCardContainer() {
val density = LocalDensity.current
val cardInteractionSource = remember { MutableInteractionSource() }
var targetAngle by remember { mutableStateOf(0f) }
val rotation by animateFloatAsState(
targetValue = targetAngle,
animationSpec = tween(1000),
)

val frontSideIsShowing = abs(rotation.normalizeAngle()) !in 90f..270f
val screenWidth = LocalConfiguration.current.screenWidthDp
val cardWidth = screenWidth.dp - CardHorizontalPadding * 2
LaunchedEffect(frontSideIsShowing) {
cardInteractionSource.interactions
.filterIsInstance<PressInteraction.Release>()
.map {
val offsetInDp = with(density) {
it.press.pressPosition.x.toDp()
}
val offsetXForContainer = if (frontSideIsShowing) {
offsetInDp
} else {
cardWidth - offsetInDp
}
cardWidth / offsetXForContainer > 2
}
.collect { spinClockwise ->
targetAngle = targetAngle.findNextAngle(spinClockwise)
}
}
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = CardHorizontalPadding),
rotationAngle = rotation,
interactionSource = cardInteractionSource,
)
}
}

internal fun Float.findNextAngle(spinClockwise: Boolean): Float {
return if (spinClockwise) this - 180f else this + 180f
}

Conclusion

This is only the first part of the article, but already now we can rotate our object in different directions using taps. We achieved this by tracking the coordinates of the point where the user released their finger during the tap. In the second part, we will explore how to make Composables follow the user’s finger, complete the rotation upon release, and optimize recompositions for this scenario.

The code discussed in both parts can be found here.

--

--