Implementing a circular carousel in Jetpack Compose
Using Jetpack Compose Layout and Animations to create a horizontal carousel
In this article we will learn how to create a circular carousel, a set of items that the user can spin around. The GIF below shows what we want to achieve:
Setting the Scene
When I was thinking about how to implement this, the main issue I faced was how to determine the position of the carousel elements. After a bit of consideration, you can see that the elements are placed over a circle seen from the side, which translates into an ellipse in 2D. So the first thing we need to do is figure out how to place items over an ellipse, in a way that they are all equidistant from one another. The image below shows an ellipse with its 2 axes, main axis w and minor axis h, an angle and a point over the ellipse based on that angle, which is what we want to calculate:
Once we have the major and minor axes, we can get the (x, y) coordinates over the ellipse using these equations:
x = w * cos(a)
y = h * sin(a)
Once we have this, we can place the items along the ellipse by dividing 2π by the number of elements to render and then using that angle as a step for each element.
Drawing the items over an ellipse
Defining the composable signature
The first thing we want to do is draw the carousel items over the ellipse. We will start small, just drawing the items without any drag gestures and, once we have that in place, we can build on top of it step by step until we get our final result.
Because this is a custom layout, there isn’t any out of the box composable in the Jetpack Compose libraries that fits or needs, we will use the Layout composable to measure and place the carousel items.
To make our composable more flexible we will take a factory method that accepts an index, identifying the carousel item at that index, and returns a composable that we will render. And, as usual, we will also accept an optional Modifier to let the user of our carousel specify whatever additional attributes they want to.
For the sake of not making this post overly complex, we will make some assumptions on our carousel, namely we will assume that the carousel is horizontal (i.e., the major axis on the ellipse is the horizontal one), it occupies the whole height, the items to render at at most half the overall height of the carousel (this will actually be enforced), and the items will be set to fit on a square box.
So, with all this said, let’s see how we would define our composable signature:
We can see that
Our composable takes as argument the number of items to render — we need this so that we can calculate the angle between items.
We take an optional Modifier.
We take the size of the items to render, as a fraction of the overall composable height.
This is the factory that will return a composable for each item we want to render.
We do some sanity checks to ensure the values we receive are within bounds.
Now that we have that, we can go ahead and draw the items.
Drawing the items
To place the items, we will first calculate the constraints for each of them. Based on our assumptions, that this is a horizontal carousel, we will use the height of the composable as the base for the items size, and we will apply the itemFraction to it. We constrain the itemFraction to ½ the composable height because the carousel needs to draw 2 sets of items vertically, to offer a sense of perspective, so they can’t be larger than ½ the overall height each.
Once we have the size for the items, we just need to calculate the (x, y) coordinates for each item based on the formula we described above — that’s pretty much all there is to it.
Let’s see the code to achieve this and we will discuss it:
We use the Layout composable as the root element for our carousel.
We pass the Modifier we receive as argument to our root composable.
We now instantiate the content to render.
We loop based on the number of items we need to render.
For each item, we call the factory method with the index and wrap it in a Box — this will allow use to apply transformations to the composable returned by the factory.
Inside the Layout lambda is where we need to measure and place the items to render, and here we are calculating how tall an item can be, based on the overall height of the composable and the fraction we receive as argument.
Once we have this value, we build a Constraints object of fixed dimensions, with the same width and height, so that all items will be constrained in a square box.
Next we measure the composables to render, using the Constraints we just built.
Now we need need to lay out the items, so we use the layout method, passing the width and height from the overall constraints.
We calculate how much horizontal space we have to render the items. We subtract the item width to leave some room on the edges.
Here we calculate the horizontal offset for the items, relative to the center of the ellipse we use to render the items.
Next we calculate how much space we have on the vertical axis, similar to what we just did for the horizontal one.
Here we calculate the separation, in radians, between consecutive items; we will use this to determine the angle for each item.
Now we loop over our placeables so that we can place them.
For each placeable, we calculate its angle, as a multiple of the base angle we calculated in step #13.
Here we get the (x, y) coordinates for each item to place, based on the ellipse parameters (major and minor axes), and the angle.
Once we have this, we can place the item, offset from the center of the ellipse.
And this is our utility method for the calculation of the (x, y) coordinates from the axes size and the angle.
When we run this, we get this result:
The image on the left shows some of the issues we will need to address; for instance, the items are in the incorrect Z order (on the right side the #3 Box should be above the #4 Box), and the items on the back need to be flipped on the Y axis. We will address all of this a bit later.
Adding the horizontal drag
Next we will add the horizontal drag to the carousel. The only parameter that determines where the items are placed is the angle, so that’s the only property we need to expose.
Defining the carousel state
While we could handle the state in the carousel composable itself, it is better to have a dedicated class for this, as this allows us to decouple the animation logic from the composable. We can also make the state an argument of the composable, so that clients can provide their own implementation if they so choose. I have a post that does a deep dive on this subject if you’re interested.
Let’s define our state:
Let’s analyse this:
We define an interface, annotated with @Stable — this means that any change to the properties on the class (actually, its implementation) will be notified to the Compose framework.
The only property we need to handle in the state is the angle that determines where to place the carousel items.
Our interface defines a stop method that we will use to cancel any outstanding animation.
We define a snaptTo method that we will use to update the angle as the user drags their finger over the carousel.
And finally we have the decayTo method that will kick in when the user lifts their finger so that the carousel can animate towards its final angle value.
Implementing the state
Now that we have the state, we need to create its implementation. Let’s see the code and then we’ll walk it:
We use an Animatable object to represent the angle for the carousel items.
The property we need to expose, angle, is just a getter on the current Animatable object’s value.
We define an AnimationSpec that we will use for the decay animation, in this case it’s a Spring animation that works well for our use case, an object starting at a certain speed and coming to rest.
We implement the stop method which simply delegates to the Animatable.
We do the same for the snapTo method.
And finally we do the same with the decayTo method, we call animateTo on the Animatable passing in the initial velocity and the target angle we want to animate to, using the animation spec we instantiated earlier.
This is all we need to do for the state. Next we need to update the composable to observe the angle from the state and use its value to determine the position of the carousel items.
Updating the carousel with the state
Our carousel will accept a new argument, the CarouselState we defined earlier. As is customary on Jetpack Compose, we will also define a function that will provide an instance of the state that is remembered across recompositions, and this will be the default value in our composable, though it can be overridden by users of the carousel.
Our state factory method is defined as shown below:
For our example we’re not taking any arguments, but we could pass an initial angle to initialise the state with. We could also provide a Saver to persist and restore the state, but for simplicity’s sake we’re omitting that here.
Now we need to update our composable to accept this state as argument, and use its angle value for the calculations. The changes for this are fairly minor, let’s see them:
There are only 3 changes needed here:
The composable now takes the CarouselState, defaulting to our implementation.
When we calculate the (x, y) position for each item in the carousel we no longer start at an angle of 0°, we instead use the angle from the state. Note however that our angle is in degrees, while the function that calculates the coordinates expects it in radians, so we do a conversion.
This is our utility method to convert degrees to radians.
If we ran this we won’t see any difference; the state is not changing so neither does the carousel. Let’s fix that next.
Adding the horizontal drag
To add the horizontal drag we will create an extension function on Modifier to keep things out of the main composable. We will use the pointer input utilities provided by Jetpack Compose; this is not dissimilar to how we’ve done dragging in other stories I published earlier, so I won’t go too deep into the details here. Let’s see the code:
As we mentioned, the method is an extension on Modifier.
We define a splieBasedDecay object — this will allow us to calculate what angle we need to decay to based on the current angle and velocity.
Now we wait for the user to initiate a touch event; this method will suspend until there is a touch event on the composable this modifier is applied to.
Once a touch event is detected, we issue a stop command; this will stop any outgoing animation as soon as the user taps the screen.
Next we instantiate a velocity tracker that we will use to calculate the velocity we need to initialise our decay animation with.
This value here will be used to normalise the drag value. When we get a drag event, the value is always in pixels, but in our case we are dealing with degrees, so we need to do a conversion. The carousel spans the whole width of the screen, and a complete drag from edge to edge of the screen corresponds to rotating the carousel by 180°, so we calculate how many degrees each pixel corresponds to — 180 divided by the width in pixels.
Now we have a touch down event, we install a pointer event scope so that we can respond to additional touch events.
The only event we are currently interested in is a horizontal drag, so we use the horizontalDrag method passing in the pointer id; its lambda will be called to update us on drag events.
Inside the lambda we receive a PointerInputChange with details on the drag gesture. We use the x value on this to update the angle; note that the x is a change value, not absolute, so we need to add to the current angle, after we do the pixels to degrees conversion.
Next we update the state with the new computed angle. This will trigger a recomposition of the carousel with the new angle.
We pass the drag event information to the velocity tracker so that it can compute the velocity the user is dragging at.
Once the user lifts their finger we will exit the pointer event scope block — here we calculate the drag velocity, and get the value on the x axis, as that’s the only one we’re interested in.
Once we have the velocity we can calculate what angle to decay to, using the current angle and the initial velocity. Note that we also normalise the velocity as we did with with the change event.
Finally we kick off the decay animation with the target angle and initial velocity we just calculated.
If we run this, we get this result:
This seems to work pretty well, until you try to drag on the top row — the drag events are reversed, when you drag from left to right on the back of the carousel you expect the carousel to rotate clockwise, but it’s actually rotating counter-clockwise. Let’s fix that.
Fixing the rotation
To fix this issue we need to determine if the user is dragging on the top half or the bottom half of the carousel and, if it’s the top half, invert the drag direction.
Doing so is straightforward, inside the pointer scope block we have access to the size of the composable, so we will use that to determine if we’re on the top or bottom half and invert the direction if we’re on the top. Let’s fix our drag method:
Inside the pointer event scope block we check if the touch event happened in the top half of the viewport.
If that’s the case, we set a variable to -1, otherwise to +1.
When we calculate the horizontal offset, we multiple by the variable we just defined — this will effectively invert the direction of drag if it happens on the top half.
We do the same to the velocity when we calculate the target angle.
And likewise when we start the decay animation.
With this our drag is working as expected, dragging left to right on the bottom of the carousel rotates it counter-clockwise, while doing so on the top half rotates it clockwise.
Let’s now fix some of the issues with our carousel to get a better experience.
Polishing up the carousel
Rotating the items
The first thing we want to fix is the items rotation. The carousel items should be face on when they’re in the bottom center of the carousel (at an angle of 0°), complety flipped over at an angle of 180°, and then back to face on at 360°, with smooth transitions in between.
We already have the angle in our state, so all we need to do is calculate how much we need to flip the cards based on that angle. Turns out that the rotation we want for the carousel items is just the angle for that item, as the item moves along the carousel it needs to rotate in sync with that angle.
To rotate the carousel items we will use a graphicsLayer in our factory method — that’s one of the reasons we wrapped the carousel items in a Box, so that we can apply transformations to them.
Let’s add the items rotation, the following snippet shows the changes we need to do to achieve this:
This shows the content block in our Layout composable, as that’s the only place where changes are needed. Here we calculate the angle step for each item, as we did earlier when placing the items.
Next we calculate the angle that corresponds to each carousel item. As the user rotates the carousel the angle value keeps increasing, so we are also normalising the angle to ensure it’s in the [0°..360°] range.
We apply a graphicsLayer to the Box hosting the carousel item.
In this graphicsLayer we set a camera distance to adjust the perspective of the rotation.
Finally we rotate the item on the Y axis, based on the calculated angle.
With these changes we get this result:
Scaling and fading the items
Next we want to change the size of the items when they’re on the back of the carousel — they’re further away, so they would need to appear smaller. We will also apply an alpha factor — right now you can see the number on the box when the items are on the back of the carousel; by adding an alpha to the items on the back it will appear as if we are seeing the numbers through a translucent layer.
Like the earlier tweak, all we need to do is update the content lambda to apply a scale factor and an alpha to the items once they’re on the back of the carousel.
Let’s add those changes:
We calculate the alpha based on whether the item is on the front or back of the carousel. Because we normalised the angle to always be in the [0°..360°] range the item will be in the front if its angle is in the [0°..90°] range or in the [270°..360°] range. If so, its alpha is 1f, otherwise, when it’s on the back, it’s .6f.
Unlike the alpha that is the same value for all the items on the front or back of the carousel, the scale needs to change smoothly from the center front, where the item will be at its default size, to the center back, where it will be at its smallest size, with the values in between interpolated. The formula shown here achieves this, it produces a value that changes from 0f to .2f as we move from 0° to 180°, then then back from .2f to 0f from 180° to 360°; this is the value we want to subtract from 1f to achieve a scale of 80% for items on the center back. We’re also leveraging the fact that we normalized our angle so that these calculations are that much simpler.
Finally we apply this scale factor both on the X and Y axes.
With these updates, we get this result:
Fixing the item overlaps
There is one more thing we need to fix. If you look closely at the carousel edges, the items are not in the correct Z order, the item closest to the the user should be on top of items further back, but that’s not what is happening here. All the items have the same Z index, so they are drawn in the order in which we place them, based on its index, in increasing order.
To fix this we need to set the Z order of the items; the closer they are to the user (i.e., the closer they are to the front of the carousel) the higher its Z index needs to be.
In Jetpack Compose we can specify a Z index as a modifier attribute, and that’s what we will do. We want our Z index to be at its highest for the item at angle 0°, then decrease as we approach 180°, then increase again as we come back to 360°. This is actually the same logic we used for the scale, so we will reuse that, with one minor difference: the Z index accepts a float value, so we don’t need to do any scaling of the value, we can use the angle value directly.
To fix the Z index issue, we just need to add 1 line to our content lambda, as shown here:
We calculate the Z index based on the angle and use the zIndex property on Modifier to adjust each item’s Z index.
With this change, we get a more natural result:
We’re almost there. There is one more thing we can do to make our carousel more engaging.
Adding the eccentricity control
When we discussed the assumptions we were working with, we indicated that the carousel would occupy the full height of the available content. Because we set the ellipse axes based on our dimensions, the ellipse eccentricity is set based on those initial constraints.
What we want to do next is allow the user to update the eccentricity, within the original height constraints. Initially the ellipse will continue to use the full height for its minor axis, but we will allow the user to drag vertically to change this, shrinking the minor axis and then flip it over, so that the front row moves to the top and the back row to the bottom.
Updating the state class
To add this additional control, we will need to update our state to expose a new property that will control the ellipse eccentricity. Unlike the angle, we won’t make this value animatable, it will be updated as the user drags vertically and set at whichever value it had once the finger is released.
We want the minor axis of the ellipse to update from the original value down to 0 (when we have the 2 carousel lines overlapping), and then from there until minus the original value, so it’s flipped. For this we can simply use a float value that is constrained to the [-1f..1f] range and apply this factor to the minor axis.
Let’s update the state and its implementation to allow us to do this:
Our state now exposes a new property for the minor axis scale factor. This value will control the ellipse eccentricity.
We provide a method to update this scale factor.
On the state implementation we have an observable property to host the scale factor.
We expose this value via a getter.
We implement the setter which simply updates the scale factor, ensuring that it is within our acceptable range of [-1f..1f].
Next we need to use this value in our composable to change the ellipse.
Updating the ellipse
In our composable we need to read this new value exposed from the state to adjust how we draw the items on the ellipse. This new value affects the minor axis, so all we need to do is change the logic that determines the size of this minor axis to take into account the scale factor. The only place where we use that value is in the place block in the composable, when calculating the coordinates of each carousel item, so we just need to change 1 line:
When we calculate the coordinates of each item we provide a scaled version of the minor axis.
With these changes there is no change to our composable because the scale factor is initialised to 1 and is not updated. Let’s fix that next.
Handling vertical drag events
To change the ellipses eccentricity we need to handle vertical drag events. Our drag modifier extension function only handles horizontal drag events at present, so we need to update it to handle both.
Let’s see the updates we need to do to our extension method and we will walk them over afterwards:
We update the logic to determine if we need to invert the horizontal drag gesture. With the new eccentricity control the front row may be on top, so we need to check if we’ve flipped the top and bottom rows as well as whether the drag event happens on the top half.
We replace the horizontalDrag method with drag, which will allow us to handle both horizontal and vertical drag events.
Similar to how we handle horizontal drag events, we update the scale factor based on the current vertical offset. As the maximum size for the minor axis on the ellipse is half the total height, we use this value to normalise the pixel offset we get from the drag gesture.
Once we have calculated the next scale factor we update the state, which will trigger a recomposition.
And with this we are done, the final result is shown here. There are a few improvements we could do to the carousel, for instance we could add snapping so that after a drag event there is always a centered item, or we could add an option to have either a horizontal or vertical carousel. These enhancements are left as an exercise to the reader.