Recreating Google Podcasts’ speed selector in Jetpack Compose
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:
- We need to expose the current slider value and the range of available values.
- We need to offer a method to snap to a certain value.
- We need to offer a method to decay a drag gesture so that the slider settles on a value after a drag.
- And finally, we need 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:
- 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.
- We define a helper
Floatrange from the
Intrange we receive in the constructor — we’ll use this to coerce values so that they remain within bounds.
- We create an
Animatablethat we will use to animate the current value in the slider, and we initialize it to the initial value.
- We expose the
Animatable's value as the slider’s current value.
- we implement the
stopmethod, which simply delegates to the
- We implement the
snapTomethod — we coerce the received value to ensure it is within bounds, then snap our
Animatableto that value.
- We have the
decayTomethod, that we will explain in detail later.
- Finally we define a
Saverso 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:
- 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
- We also take the range of valid values as a constructor argument, which we are defaulting to
5..30which will translate to
- We create the state using the
rememberSaveablemethod and provide the
Saverthat will be responsible for persisting and restoring the state.
- When we instantiate our slider we need to position it at the initial value — this needs to happen only once, so a
LaunchedEffectis perfect for this. We use the
snapTomethod from our state as we want to jump to that value instantly. Note that we convert the value to
Integerand 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.6this will be rounded to
- 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:
- As is customary on Jetpack Compose, our composable accepts a
Modifier, which is the first optional argument in the composable function.
- Next we take the state, which we are defaulting to our own implementation.
- 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.
- Related to this, the next argument is the color for those bars, which we, by default, set to the
onSurfacecolor from material theme.
- The next argument is a
Composablefunction that takes the current value in our slider and emits a
Composablefor that value. This will be used to render the value above the slider, which reads
1.0xin the screenshot at the beginning of this article. This argument is optional and, if none is provided, we just emit a
Textwith the current value as is.
- Finally we have another
Composablefunction that takes an
Int— this will be used for the indicators below the bar, those reading
1.5xin 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
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:
- We create the
Column, using the
Modifierthat we receive as argument. We also indicate that the content for this column should be centered horizontally.
- We use the
Composablefunction to render the current value.
- We display the arrow below the current value.
If we now add this composable to our app, as shown here, we will see this
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.
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:
BoxWithConstraintswill fill all the available width from its container.
- We will align our children composables centered horizontally and at the top.
- We compute the width of each vertical segment based on the constraints for our container — here we see the reason for using a
BoxWithConstraintsas this composable exposes the min/max width both in
Dpand in pixels.
- We calculate how many segments we need to render on each half of the screen, to the left and right of the middle position.
- 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.
- Now we loop over the number of vertical line we need to render.
- 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.
- Each vertical line will be a
- The width of these
Columns is fixed and set to the value we calculated earlier, in step 3.
- We apply a
graphicsLayermodifier and shift this column horizontally by the amount we calculated in step 7.
- The content of the
Columns will be centered horizontally.
- We now render the vertical line, we use a
BarWidthwidth (defined as
2dp) and of height
24dp), and with the color specified in the composable parameters.
- 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.5 as above.
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:
- We calculate the
alphabased on the offset for this vertical bar.
MinAlphais defined as
- We apply the alpha to the
graphicsLayerwe 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:
- We create an extension on
Modifierthat takes the slider state and the number of segments. This extension builds on top of
- We calculate the width of each segment. We will use this to normalize the drag events.
- We wrap our drag handling in a
coroutineScope— this block will not leave until all children coroutines complete.
- Now we loop and process events as they are generated.
- We first await for an event to trigger. This
suspending function will suspend until a pointer event is triggered.
- 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
stopmethod in our state — if the slider is currently animating, we want it to stop immediately when the user taps on it.
- We start observing events now that the pointer is down.
- Here we read horizontal drag events. When the user drags the finger horizontally over our slider the block on this
horizontalDragfunction will be called, where we receive a
PointerInputChangeparameter that gives us information about the drag event.
PointerInputChangetells 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.
- Once we have this we launch a coroutine to update the value of our slider, we snap to the computed value.
- 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:
- Define a decay animation spec. Here we use a
FloatSpringSpecbut there are other options available in Jetpack Compose. This spec interpolates the values from the current value in our
Animatableto a final value. This particular spec offers 2 configuration parameters,
dampingRatio— how bouncy it is, and
stiffness, how fast the
Animatablewill come to a rest. You can change these values and see how they affect the slider.
- We complete our
decayTomethod — here we take the current velocity and the value we have to decay to. We first calculate the final value as a full
Integeras we do not want our slider to end up in an intermediate value, so we round to the nearest integer.
- Once we know the final value, we use the
animateTomethod 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
Animatablethe 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:
- We instantiate a
splineBasedDecay— we will use this to calculate the target value we want to decay to after the user releases their finger.
- We need to keep track of the current drag velocity, so we instantiate a
VelocityTrackerto do so.
- Each time we get an update on the pointer input we pass the information to the velocity tracker.
- 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.
- We use the
splineBasedDecayspec to calculate the target value we want to animate to, based on our current value and the velocity.
- Once we have calculated the target value we call the
decayTomethod 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 shown below
I hope you enjoyed this article, and I’ll see you on the next one.