Recreating Google Podcasts’ Speed Selector in Jetpack Compose

How to create a horizontally draggable selector with snapping

Introduction

In this article we will learn how to implement the playback speed selector from Google’s Podcasts app using Jetpack Compose. This is the composable we want to end up with:

Setting the stage

In Jetpack Compose it is recommended to have a state class that controls custom composables, so that we can customize and drive that composable, and offer a default behaviour that will fit most needs. You can check this article for a more detailed overview on this principle.

With that in mind, let’s start by defining a state for our composable. We will name our composable PodcastSlider so, adhering to Jetpack Compose conventions, our state will be named PodcastSliderState. What we need from our state are these properties and methods:

  • Provide the current slider value and the range of available values.

  • Provide a method to snap to a certain value.

  • Provide a method to decay a drag gesture so that the slider settles on a value after a drag.

  • Provide a method to stop any animation that is current running (we’ll see later why we need this).

With our requirements clear, we can define our state as shown below:

As you may have noticed, the currentValue is a Float, but the range is a ClosedRange<Int> — the reason for this is that we want to make the slider more generic and agnostic to the type of content it displays. The slider will be given a range in integers, for instance from 5 to 30, and that will later be translated into an actual display range. Using Int for the range makes our slider simpler and more flexible.

Implementing the state

Next let’s implement the state, this will be the default state if users of this composable do not provide their own implementation.

Let’s first see the code and then we’ll go over it in detail:

We can see that:

  1. Our state implements the interface we defined a bit earlier, and takes 2 arguments, the current value to initialize the slider with, and the range of valid values.

  2. We define a helper Float range from the Int range we receive in the constructor — we’ll use this to coerce values so that they remain within bounds.

  3. We create an Animatable that we will use to animate the current value in the slider, and we initialize it to the initial value.

  4. We expose the Animatable's value as the slider’s current value.

  5. we implement the stop method, which simply delegates to the Animatable.

  6. We implement the snapTo method — we coerce the received value to ensure it is within bounds, then snap our Animatable to that value.

  7. We have the decayTo method, that we will explain in detail later.

  8. Finally we define a Saver so that we can persist the state of our slider and restore in case the app is recreated, for instance on screen rotation.

Creating the state

Next we need a method that will create, and remember, our state. This method is not strictly necessary, but it’s helpful to define it and have a single place where instantiating the state takes place. Per Jetpack Compose naming conventions, this method will be prefixed with remember as its main purpose is to ensure our state is retained across recompositions. Let’s see our method:

Here we can see that:

  1. We take as argument the initial value for the slider, which in this case we are defaulting to 10. Remember from before that, although we want to display values in float ranges (in our case from 0.5 to 3.0), our slider works in integer values and those will later be mapped to floats, so this 10 actually means 1.0.

  2. We also take the range of valid values as a constructor argument, which we are defaulting to 5..30 which will translate to 0.5..3.0.

  3. We create the state using the rememberSaveable method and provide the Saver that will be responsible for persisting and restoring the state.

  4. When we instantiate our slider we need to position it at the initial value — this needs to happen only once, so a LaunchedEffect is perfect for this. We use the snapTo method from our state as we want to jump to that value instantly. Note that we convert the value to Integer and then back to Float; the reason for this is that we want to set the slider on an integer value, as our slider won’t allow intermediate values. If, for instance, our slider is initialized with the value 10.6 this will be rounded to 11.

  5. Finally we return the state we just instantiated.

Creating the slider

We have our state in place, it’s now time to move to the slider proper. We will keep this fairly simple, so we will not offer too many customization options. For this exercise, we will define our slider signature as shown here:

  1. As is customary on Jetpack Compose, our composable accepts a Modifier, which is the first optional argument in the composable function.

  2. Next we take the state, which we are defaulting to our own implementation.

  3. The next argument is the number of segments to show at once, i.e., the number of vertical bars in our slider. We also provide a default here.

  4. Related to this, the next argument is the color for those bars, which we, by default, set to the onSurface color from material theme.

  5. The next argument is a Composable function that takes the current value in our slider and emits a Composable for that value. This will be used to render the value above the slider, which reads 1.0x in the screenshot at the beginning of this article. This argument is optional and, if none is provided, we just emit a Text with the current value as is.

  6. Finally we have another Composable function that takes an Int — this will be used for the indicators below the bar, those reading 0.5x, 1.0x and 1.5x in the screenshot.

The last 2 functions is what makes our composable slider flexible, the values we handle internally are Int, but those can be mapped to other values by virtue of these 2 composable functions.

Fleshing out the slider

Top content

Now that we have our method defined it’s time to implement the slider. The composable can be divided in 2 main sections, the top showing the current value with an arrow, and the vertical lines showcasing the possible values.

This lends itself to using a Column, we will include 3 elements in this column, the current value, the arrow, and the vertical bars. The first 2 elements are straightforward, so let’s start there:

  1. We create the Column, using the Modifier that we receive as argument. We also indicate that the content for this column should be centered horizontally.

  2. We use the Composable function to render the current value.

  3. We display the arrow below the current value.

If we now add this composable to our app, as shown here, we will see this

Vertical bars

Next we have to display the vertical bars. The way we will do this is display a set of Columns of equal width, each with a vertical line within and a label under that line.

We need to know how wide the Columns will be, so we will encapsulate the vertical bars in a BoxWithConstraints — that will give us access to the size of our composable. Once we have the width of our composable, each bar will take totalWidth / numSegments pixels in width. Note that BoxWithConstraints comes with a performance penalty as it uses SubcomposeLayout underneath, a more performant solution would be to use a Layout composable, but for this article, given that we only need the size, we will accept the small performance penalty for the simplicity it brings.

To offset the vertical bars based on the current value of our slider we will first specify an alignment of TopCenter for our BoxWithContraints which, by default, would render all the vertical lines in the middle of the container, all bunched up. To offset them we will apply a graphicsLayer modifier to our BoxWithConstraints and then use the translationX property of this modifier to shift each vertical line on the X axis based on its value and the current value of our slider. This will probably become clearer if we look at the code, so let’s do so:

  1. The BoxWithConstraints will fill all the available width from its container.

  2. We will align our children composables centered horizontally and at the top.

  3. We compute the width of each vertical segment based on the constraints for our container — here we see the reason for using a BoxWithConstraints as this composable exposes the min/max width both in Dp and in pixels.

  4. We calculate how many segments we need to render on each half of the screen, to the left and right of the middle position.

  5. We calculate the value of the leftmost and rightmost vertical lines, ensuring we are coercing these values to the range we were given and that is made available in our state.

  6. Now we loop over the number of vertical line we need to render.

  7. For each vertical line we calculate its offset, based on the value corresponding to the vertical line and the current value of our slider. If the values are the same, the vertical line will be centered in its parent, otherwise it gets shifted horizontally based on how far it is from the slider’s current value.

  8. Each vertical line will be a Column.

  9. The width of these Columns is fixed and set to the value we calculated earlier, in step 3.

  10. We apply a graphicsLayer modifier and shift this column horizontally by the amount we calculated in step 7.

  11. The content of the Columns will be centered horizontally.

  12. We now render the vertical line, we use a Box of BarWidth width (defined as 2dp) and of height BarHeight (defined as 24dp), and with the color specified in the composable parameters.

  13. Finally we render the indicator below the bar, using the second composable function.

If we run this code calling like we showed above, we get this result:

Note that when we display our slider and provide the composable function for the labels under the vertical lines we have an if statement that skips all values except those multiple of 5, so that we only render a text for those values, so 1.0 and 1.5 as above.

Adding alpha

We’re getting close. In the original screenshot we can see that the vertical bars fade towards the edges, so let’s add that.

Similar to how we calculate the offset for each vertical bar, we will calculate an alpha value. We want the alpha for the vertical bar that is centered to be 1f, and for the bars at the right and left edges to be 0.25f. For any value in between we will interpolate the value between those 2.

The updated code with alpha is shown here:

  1. We calculate the alpha based on the offset for this vertical bar. MinAlpha is defined as 0.25f.

  2. We apply the alpha to the graphicsLayer we used for the horizontal translation.

Now we get this result:

We have now completed the rendering of the slider, but this is just static, it can’t be dragged to change its value. Let’s fix that.

Adding drag support

To enable dragging we will create an extension on Modifier and apply that to our BoxWithConstraints. To enable input controls we will use the pointerInput modifier. This is a modifier that captures input events and calls a suspending block to handle those events. Let’s see the code:

  1. We create an extension on Modifier that takes the slider state and the number of segments. This extension builds on top of pointerInput.

  2. We calculate the width of each segment. We will use this to normalize the drag events.

  3. We wrap our drag handling in a coroutineScope — this block will not leave until all children coroutines complete.

  4. Now we loop and process events as they are generated.

  5. We first await for an event to trigger. This suspending function will suspend until a pointer event is triggered.

  6. Once the event comes in, the first thing we do is stop any pending animations on our composable. Here we can see why we needed the stop method in our state — if the slider is currently animating, we want it to stop immediately when the user taps on it.

  7. We start observing events now that the pointer is down.

  8. Here we read horizontal drag events. When the user drags the finger horizontally over our slider the block on this horizontalDrag function will be called, where we receive a PointerInputChange parameter that gives us information about the drag event.

  9. The PointerInputChange tells us how many pixels the user has dragged over our slider, but this value needs to be normalized. If the user drags a number of pixels corresponding to the width of 1 segment, we want to slide our slider by 1 whole unit, so we divide the horizontal drag in pixels by the width of a segment — this is our normalized value.

  10. Once we have this we launch a coroutine to update the value of our slider, we snap to the computed value.

  11. Finally we consume the event.

If we add this to our BoxWithConstraints as shown here

we get a draggable slider:

We’re getting there. The last step is to add a decay animation so that when we lift our finger the animation does not stop abruptly, but instead slows down and settles on a full value — currently at the end of a drag we stop at an intermediate value, between vertical lines.

Adding the decay animation

The first thing we need to do to enable the decay animation is to complete our state — we left the decayTo method empty. Let’s update our state to add support for animating the decay:

To support the decay animation we need to:

  1. Define a decay animation spec. Here we use a FloatSpringSpec but there are other options available in Jetpack Compose. This spec interpolates the values from the current value in our Animatable to a final value. This particular spec offers 2 configuration parameters, dampingRatio — how bouncy it is, and stiffness, how fast the Animatable will come to a rest. You can change these values and see how they affect the slider.

  2. We complete our decayTo method — here we take the current velocity and the value we have to decay to. We first calculate the final value as a full Integer as we do not want our slider to end up in an intermediate value, so we round to the nearest integer.

  3. Once we know the final value, we use the animateTo method in the Animatable, specifying the final value, the initial velocity and the animation spec defined in step 2. As we provide the velocity to the Animatable the transition from user dragging to slider decaying will be smooth as the decay will start with the same velocity the user was dragging with.

With this in place, we need to update our drag extension on Modifier to handle the decay animation. Let’s see how we do so:

  1. We instantiate a splineBasedDecay — we will use this to calculate the target value we want to decay to after the user releases their finger.

  2. We need to keep track of the current drag velocity, so we instantiate a VelocityTracker to do so.

  3. Each time we get an update on the pointer input we pass the information to the velocity tracker.

  4. Once the drag event is complete we calculate the velocity. Similar to how we calculated the drag offset, we normalize the velocity by the width of one segment.

  5. We use the splineBasedDecay spec to calculate the target value we want to animate to, based on our current value and the velocity.

  6. Once we have calculated the target value we call the decayTo method on the state to initiate the animation.

Now, when we release the finger after a drag gesture, our slider animates towards the target value and comes to a rest, as we can see below. If you look closely you can see the bouncing at the end of the animation, when the slider settles on the final value:

And we’re done, the full code is available in this gist.