Creating a circular list in Jetpack Compose
Background
I recently came across a question on StackOverflow on 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 this:
In this article we’ll find out how to create this list.
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:
- - 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, afraction
that will indicate how circular our path is (with1f
meaning perfectly circular), and finally a Composable lambda for the content. - First we do some sanity check to validate the input arguments and throw if those are not valid.
- Next we use the
Layout
composable to place the items, passing theModifier
and the content lambda we received as argument. TheLayout
composable also takes aMeasurePolicy
lambda that tellsLayout
how to measure and position its children. - 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.
- 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. - Once we have these
Constraints
we use them to measure the children; this returns a list ofPlaceable
s that we will later lay out. - Now that we have all the pieces we need, it’s time to lay the items out by calling
layout
. - 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.
- Now we iterate over our
Placeable
s in order to place them. - The vertical offset for each
Placeable
is the offset calculated in step 8, plus the number of items preceding it, times the item height. - 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:
1. 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.
2. 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.
3. 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 theAnimatable
'ssnapTo
, 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.
4.- 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:
- We create an extension on
Modifier
calleddrag
and we callpointerInput
so that we can handle touch events on our Composable. - We loop so that once a drag event has completed we await the next one.
- We wait for a pointer event to trigger. This is a
suspend
function that will resume once the user has interacted with the Composable. - First we stop any outgoing animations on our Composable.
- Next we start monitoring vertical drags using the
verticalDrag
method, anothersuspend
function that calls our lambda whenever the user has dragged vertically. - Whenever the lambda is called we update our vertical offset.
- And then we snap the list to the new offset.
- 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:
- We update our signature to take a State, and we default to our implementation.
- We provide the information needed to the state.
- We limit the
Placeable
s we place to those that will be visible. - 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:
- We instantiate a
splineBasedDecay
object that we will use to compute the final offset we need to animate to. - We instantiate a
VelocityTracker
when a pointer gesture is detected that we will use to keep track of the drag velocity. - Whenever we get a drag update we pass that drag information to the
VelocityTracker
. - When the drag gesture is complete we calculate the vertical velocity the user was dragging at.
- With that velocity we then calculate how far we should be scrolling to, based on the current scroll position.
- 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:
- 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 thedampingRatio
(how much it bounces when it stops), and thestiffness
, 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. - 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 Pitagoras 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:
- First we calculate how much an item may scroll vertically, this will determine when the item will be at the 0 horizontal coordinate.
- Next we calculate how far the current item is from the center of the screen, vertically.
- Next we define the radius of our imaginary circle, the circle is as tall as our container, so the radius is half that.
- 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.
- Finally we calculate the offset on the horizontal axis based on the radius and the vertical offset.
- 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:
- We update our state to take an
overshoot
parameter, in number of items. - We calculate the minimum scroll above the first item, based on how many items we can overshoot by.
- 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 shown below:
And an example usage is shown 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, or to reduce their alpha the further they are from the center. These additional tweaks are left as an exercise to the reader.
I hope you found this useful, and I’ll see you on the next one.