Creating an animated selector in Jetpack Compose

Francesc Vilarino Guell
11 min readApr 11, 2022

--

In today’s story we will learn how to create a custom selector that lets users choose one option among several, with a background that animates to highlight the selected option. As usual, seeing an animation of what we want to achieve will make this clearer:

Let’s list what we want to achieve with this composable: we want the selected background to animate to the selected option, we want the corners of the background to animate based on the position on the main container, and we want the text color of each option to change when the background overlaps with that option. Let see how we can achieve all this.

Laying out the selector options

We will start by laying out the options users can choose. For this particular composable we want to spread the width evenly between the children, so all are of equal widths. For this we could use a Row with weights, but to give us a bit more flexibility we will instead use a Layout — this is the equivalent of a ViewGroup in the view system, but these are much easier to implement than an actual ViewGroup and give us total control on the layout of the composable, so we will go with that option.

Our composable will accept as input parameters a list of strings representing the options to display, the currently selected option and, as is usual, a Modifier that offers customization options.

In Jetpack Compose is usually preferable to have stateless composables and hoist the state to a higher composable, and we will do the same here — our composable will be told which option is selected, and when the user taps on a new option, we will forward that click up the chain and, if a change needs to take place, our composable will be called again with a new selected option. This is exactly how, for instance, a CheckBox works — the state (whether it is checked or not) is not held in the composable itself, but higher up in the chain.

Now that we have described the first step in creating our composable, let’s see the code and analyze it:

  1. We define our composable accepting the arguments we described, a list of options, the selected option, a callback for when an option is clicked on, and an optional Modifier.
  2. For our implementation we require that there are at least 2 options, and that the selected option is one of the available options. You could make the selected option nullable to have a control with no selected option by default, but for my use case there will always be a selected option.
  3. Next we use the Layout composable to render the content. This takes a composable lambda that emits the content, and a MeasurePolicy that will measure and lay the content.
  4. We use a background with Surface color and rounded corners for our main composable.
  5. For the content, we will build a set of Boxes each containing a text composable with the text being the option, centered in the container.
  6. In the MeasurePolicy lambda, we receive the measurables (our content), and the constraints to apply to the composable. As we want our content to be of equal widths, we divide the total width by the number of options we have — this is how wide each item will be.
  7. Once we know how wide each element will be, we generate the Constraints to measure them with, using that calculated width as the fixed width, and the max height as our incoming height.
  8. Next we measure all the measuables using the constraints we just generated. Because we used Constraints.fixed, our Boxes will fill all the available space given to them. The result of this operation is a list of placeables.
  9. Now we can lay out the content, by calling the layout method.
  10. We iterable over our placeables and place them on a row. As we forced each one to be of exactly the same width, we just need to offset them by the item width.

With this, we achieve this result:

Adding the selected item background

Next we are going to add the background. For now it will be fixed, we will animate it later.

To add the background, we need to update the content lambda of our composable to include a new item for the background. It is worth of note that, once we are in the MeasurePolicy lambda, all the measurables are the same, so we need a means to identify the background from the other composables. We could rely on the order in which we add the content, but there is a better solution: Modifier offers a layoutId method that allows use to assign anything to a composable (it’s like a marker, it has no effect on the measuring or display of the composable), and later we can retrieve that id from the measurables so that we can identify them.

As we need to differentiate between 2 types of objects, the background and the options, we will define an enum to represent these 2 types,

Let’s see what changes we need to make to add the background:

  1. We define an enum to represent our 2 object types.
  2. When we define the composables for the options, we specify a layoutId and set its value to indicate it’s an option.
  3. We add a Box for the background, setting its color to primary and, like on the options, we set the layoutId so that we can identify this object later.
  4. When we get our measurables, we filter the list and retrieve only those whose layout ID identifies them as options, and measure them.
  5. We do the same with the background — we know there is only 1, so we can use the first operator on the list
  6. We lay the background, presently on the left side of the container — before the options, so that it is behind them.

With this, we get this result:

Animating the background

Defining the state

Next we are going to animate the background. To keep things more manageable, we will define a state class that hosts the composable state as it relates to the animation. We will follow the guidelines described in this story, so we will define an interface for the state and a concrete implementation, with a remember method to get an instance of the implementation. For this story we will skip the Saveable part of the state for brevity sake, you can refer to the linked story above for details on how to implement the Saveable for the state.

Presently we only have 1 element to animate, the background position, so we will represent this as the index of the selected item, and this index will animate from the current selection to the new one. For instance, if we have 3 items and the currently selected item is the leftmost and we click on the rightmost, the index will animate from 0f to 2f.

Let’s define the state interface and its implementation:

  1. We define a Stable interface for our state.
  2. The state exposes the currently selected item index, as a Float.
  3. We expose a method to tell the state that an item has been selected. This takes two arguments, a coroutineScope so that we can animate the index transition in a coroutine, and the index of the selected item.
  4. Our state implementation constructor takes 2 arguments, the list of options and the selected option.
  5. In our implementation we override the index, by taking a snapshot of the current value in the animation.
  6. Our index is internally represented as an Animatable object, which we initialize to the index of the currently selected item.
  7. Next we define an animation spec to describe how the background will animate. For the animation, we want the background to initially accelerate as it starts moving, and then easy off and come to a stop as it reaches its final value; FastOutSlowInEasing is what we need.
  8. Finally we implement the selectOption method, where we trigger the animation, animating towards the selected index.
  9. We also provide a utility method to instantiate and remember our state.

Now that we have the state defined it’s time to animate the background.

Animating the background

To animate the background we need to do a couple of things, we need to update the coordinates where we render the background, based on the animated index in the state, and we need to trigger the animation whenever a selection takes place.

Let’s tackle the 2nd first. We want the animation to start whenever a new item has been selected, and we can tell that from the arguments our composable receives, we are told the currently selected item, so we can trigger the animation when this selected item has been updated. The way to do this in compose is to use Side Effects — these are actions that happen outside the composition, and can be keyed off from some arguments. In this particular case, if the selected item changes we want to trigger an animation, but also if the list of options changes, so we will use 2 keys for this, the list of options and the selected option. We also want to trigger this only once per change, so the Side Effect we need for this is LaunchedEffect.

For the background position, our index tells us the selected item, so we will offset the background by the width of each item, times the current offset.

Let’s see the changes we need for this:

  1. We update the signature of our composable to accept the state, and we default it to our default implementation using the utility remember function. Accepting the state as an argument allows clients to provide their own implementation if they so choose.
  2. We use a LaunchedEffect to trigger the animation, keying off the options list and the currently selected option.
  3. In the body of the LaunchedEffect we update our state with the index of the selected, so that the animation can be kicked off.
  4. Finally, we update the position of the background to take into account the animated index.

With these small changes we get this result:

Rounding the corners

In the GIF above we can see that the background is clipped when it reaches the edge of the container, but it does not smoothly get rounded as it approaches the edge, and it is also not rounded either in between options. Let’s fix that.

As we want to animate the corners as well as the position, we will update our state to also expose an animatable value for the corners. We will need 2 values, one for the left corners (top left, bottom left), and one for the right corners (top right, bottom right).

The corner radius can be specified in different ways, we can specify a fixed corner radius, or as a percentage. For our use case we will use a percentage — when the background is at an edge, we will set the rounded corners to 50% (so it’s a half circle), while elsewhere we will use 15%.

Let’s update our state to expose the rounded corners animatables:

  1. Our state now exposes 2 additional properties, the percentage for the start and end corners.
  2. In our concrete implementation we expose these 2 new properties as a snapshot on the animatable value.
  3. The start corner is an Animatable that we initialize to 50% if the item initially selected is the first one, and 15% otherwise.
  4. We do the same for the end corner, but this time we check if the selected item is the last one.
  5. Next we update the selectOption method to animate the start corner — similar to the initial value, if the selected option is the first one we animate to 50%, otherwise to 15%.
  6. And we do the same with the end corner, checking if the selected item is the last one.

Now that we have this value exposed from the state, we need to apply it to the background, which is easy enough, the snippet below shows the only change we need to implement for this:

  1. When we define our composable content we add a clip modifier to the background, specifying rounded corners with the percentage as determined but our 2 new state properties.

Once we run this, we get this result:

We are getting close to our desired result, the background corners now smoothly animate based on the selected option. Next we need to handle the text color.

Animating the text color

To animate the text, instead of defining a new set of Animatables, one per option, we will instead derive that color from the animated index — that way we can set the text color to change when the background is over the option, so that if we move the background from one side of the selector to the oppose, the text labels animate their color as the background moves behind them.

We will make the selected option an unselected option colors configurable, so we will receive those as arguments in our composable, and we will pass those to the state.

Let’s see how we need to update our state to expose the color for each label:

  1. We update our state to expose a list of Colors, one for each label in the selector.
  2. Our state implementation now receives the 2 text colors in the constructor, one for the selected item and one for the unselected ones.
  3. We override the color list property and provide a snapshot of our animated values.
  4. Unlike the other options which are based on an Animatable, the colors are derived from the index, so we use a derivedStateOf builder for the color state.
  5. To calculate the color, we use the lerp method, which stands for Linear Interpolation — it interpolates a value between a start and a stop value, based on a fraction between 0 and 1.
  6. The fraction is derived from the index — if the index matches the index of the option we are calculating the color for, then the fraction is 1 and we use the selected color. Otherwise, we calculate how far off we are from that index, truncating at 1 at most — which will result in a fraction of 0 and return the unselected color.

We are almost there, Now that we have the colors exposed from the state, it’s just a matter of putting them to use, by updating the content lambda of our composable as shown below:

  1. We get the list of colors from our state.
  2. We apply a color to the text label, based on its index.

And with this our composable is complete, the final code is shown below. The solution shown here provides just the basic functionality for a multiple options selector, and could be further improved, for instance we could make the container background color and the selected item color configurable, add separators between options and so on. These are nice additions that are left as an exercise to the reader. I hope you found this useful, and I’ll see you on the next one!

--

--

Francesc Vilarino Guell
Francesc Vilarino Guell

Responses (2)