Creating a circular list in Jetpack Compose

Learn how to implement a list that curves along a path

Background

In this article we will learn how to implement a Circular List in Jetpack Compose. This is basically a list where the items shift horizontally based on their vertical position, as if they were following a circular path . The GIF below will help in explaining what we want to achieve:

The basic list

The first step in creating this circular list is creating a Composable that will draw just a regular list of items, once we have that we can start tweaking the Composable step by step so that we can achieve our desired outcome.

When creating custom Composables a powerful tool is the Layout Composable, which is the equivalent of a custom ViewGroup in the View system. Using a Layout gives us total control on the position of the items in our Composable, so that’s what we will start with.

Let’s start by creating a simple Composable that renders its children vertically, basically we are creating a simple Column as our starting point. The code to achieve this is below:

Let’s have a look:

  1. We create our Circular List and define 4 arguments, the number of visible items to render (i.e., how many items will be visible at once in our container), a Modifier so that the client can customize the attributes of our Composable, a fraction that will indicate how circular our path is (with 1f meaning perfectly circular), and finally a Composable lambda for the content.

  2. First we do some sanity check to validate the input arguments and throw if those are not valid.

  3. Next we use the Layout composable to place the items, passing the Modifier and the content lambda we received as argument. The Layout composable also takes a MeasurePolicy lambda that tells Layout how to measure and position its children.

  4. In our lambda first we calculate how tall each item needs to be, based on our incoming constraints and the number of items to be visible at once.

  5. Next we create a Constraints object with fixed width and height, those being the width of our Composable, and the item height we just calculated.

  6. Once we have these Constraints we use them to measure the children; this returns a list of Placeables that we will later lay out.

  7. Now that we have all the pieces we need, it’s time to lay the items out by calling layout.

  8. First we calculate the vertical offset for the elements, as we want the first element to be centered in the container when we first display the items. To achieve that, the offset has to be the container height minus the child height, divided by 2.

  9. Now we iterate over our Placeables in order to place them.

  10. The vertical offset for each Placeable is the offset calculated in step 8, plus the number of items preceding it, times the item height.

  11. Once we have our vertical offset we can position the elements, for now we just place them at 0 on the horizontal axis, we’ll fix that later.

This gives us a very basic layout that displays our content vertically, with the first item centered. This first solution is not optimized yet, while there may be only a small number of visible items on the screen, we are placing them all, event those that fall off the screen. We will fix that in later steps.

With this, we get this result:

Pretty boring result so far, let’s keep going.

Adding the drag gesture

The previous step created a static list, we can see that there are more items in the list, but currently we can’t scroll them. Let’s fix that as the next step in our journey.

First we will create a State for our Composable, as that will make it easier to handle its logic. As I described in an earlier article, it is a good idea to provide a State interface and an implementation that will be used by default, that way clients can customize the Composable if they so choose, or default to the provided implementation otherwise.

Let’s define our State and the default implementation:

State Config

Our state will need a few parameters for the computations it needs to do; instead of passing these individually we create a data class that we can use to wrap them.

State interface

Next we define our interface. The interface exposes:

  • The current vertical offset for the items to display. We are shifting responsibility for this calculation from the Composable to the State, so that the Composable is only responsible for rendering the items, and the State will do all the calculations.

  • The first item that is visible in our layout.

  • The last item that is visible in our layout. We will use this and the previous property to know which items to place and which to ignore.

  • A suspend function to snap the list to a certain position, provided as a vertical offset in pixels.

  • A decayTo method that we will later use to animate the list when the user drags the items.

  • A stop method to stop any outgoing animations; we will trigger this method when the user taps on the list.

  • A method to return the offset (x and y) for a given element in our list, identified by its index in the list.

  • Finally a method to provide the config we just defined to our state.

State implementation

Next we have the implementation for our state, the default behaviour if no custom implementation is provided by users of the Circular List. A few things worth of note in this implementation are:

  • We use an Animatable to keep track of the current offset in the list.

  • The snapTo method delegates to the Animatable's snapTo, but we coerce the items so that we don’t let the user scroll above or below the 1st and last item.

  • The offsetFor method has the same calculation we had before in the Composable, it also only provides a vertical offset for now.

  • We provide a Saver for our state so that it can be persisted and restored if the app is restored after being killed.

State factory

Finally we have a function to remember our state and return the instance.

Now we are ready to add the drag functionality to the Circular List. To enable drag we will create Modifier extension that we will call drag. Let’s check this Modifier first and then we’ll see it in action:

  1. We create an extension on Modifier called drag and we call pointerInput so that we can handle touch events on our Composable.

  2. We loop so that once a drag event has completed we await the next one.

  3. We wait for a pointer event to trigger. This is a suspend function that will resume once the user has interacted with the Composable.

  4. First we stop any outgoing animations on our Composable.

  5. Next we start monitoring vertical drags using the verticalDrag method, another suspend function that calls our lambda whenever the user has dragged vertically.

  6. Whenever the lambda is called we update our vertical offset.

  7. And then we snap the list to the new offset.

  8. Finally we consume the event.

Now we just need to update our Circular List to use the drag Modifier and to leverage our state classes:

The changes are fairly small:

  1. We update our signature to take a State, and we default to our implementation.

  2. We provide the information needed to the state.

  3. We limit the Placeables we place to those that will be visible.

  4. We use the state’s offsetFor to position the children.

This is the result:

We have basically duplicated what a scrollable Column offers, but not much more.While the content scrolls, it stops as soon as we lift the finger. Let’s add a decay animation so that the list comes to a rest in a more organic fashion.

Adding decay animation

To add decay animation to our list we need to calculate the velocity the user is dragging the list at, and then calculate the final position we need to continue animating to, while decreasing the velocity. Most of this is handled by the animation APIs from Jetpack Compose, our responsibility is to calculate the initial velocity, the final offset and then trigger the decay animation.

Let’s first update our drag Modifier extension to compute the velocity and provide it to our state:

To add the decay we need these changes:

  1. We instantiate a splineBasedDecay object that we will use to compute the final offset we need to animate to.

  2. We instantiate a VelocityTracker when a pointer gesture is detected that we will use to keep track of the drag velocity.

  3. Whenever we get a drag update we pass that drag information to the VelocityTracker.

  4. When the drag gesture is complete we calculate the vertical velocity the user was dragging at.

  5. With that velocity we then calculate how far we should be scrolling to, based on the current scroll position.

  6. Finally we provide the velocity and the offset to scroll to to our state.

Let’s now see how the state handles these parameters to decay the drag animation:

We have added the following to our state:

  1. We instantiate a decayAnimationSpec that we will use to animate the list coming to a rest after the user lifts the finger. Here you can specify the dampingRatio (how much it bounces when it stops), and the stiffness, how easy or hard it is to drag the list. A list with high stiffness will come to a rest sooner, while low stiffness will scroll further.

  2. We implement the decayTo method. Here we ensure the final target value is within valid bounds, then we ensure that we end with an item centered in the middle of the list. Basically what we do is calculate the index of the item at the scroll we are given, then if that items is less than half visible we scroll to the next one (as it will be more than half visible, so closer to the center).

Now we have a draggable list that comes to a rest when we release our finger:

Adding the circular path

Next we will add the circular path for the items. To calculate where to place the items on the horizontal axis we will calculate the radius of an imaginary circle that is embedded in our container (as tall as our container), and then we will use the well known Pythagoras formula to determine the horizontal offset. This is easier to show than to explain, so let’s see how we can modify our offsetFor method to calculate the correct offset:

  1. First we calculate how much an item may scroll vertically, this will determine when the item will be at the 0 horizontal coordinate.

  2. Next we calculate how far the current item is from the center of the screen, vertically.

  3. Next we define the radius of our imaginary circle, the circle is as tall as our container, so the radius is half that.

  4. The vertical delta we calculated earlier needs to be adjusted, the delta is constrained to half the container height, but items can scroll a bit further as they are rendered as they scroll off the screen, so here we adjust the delta accordingly.

  5. Finally we calculate the offset on the horizontal axis based on the radius and the vertical offset.

  6. We apply the circle fraction to the x coordinate and return it.

We can add a few other tweaks to our Composable, while we’re at it. We can also add overshoot, so that the user can drag past the first or last item, and when they release their finger, it will animate back to the first or last item.

We can provide this information (how much to overshoot by, in number of items) to our state, then adjust our snapTo method to allow this much overshoot:

  1. We update our state to take an overshoot parameter, in number of items.

  2. We calculate the minimum scroll above the first item, based on how many items we can overshoot by.

  3. We calculate the maximum scroll below the last item, again based on the number of overshoot items.

With this our list is complete, the full code is available in this gist and an example preview is available here.

There are a few more tweaks we could do, for instance we could change the size of the items so that they are larger as they approach the center of the container, orincrease their transparency the further they are from the center. These additional tweaks are left as an exercise to the reader.