Jetpack Compose Tutorial: Replicating Dribbble Audio App Part 1

Nik Afonasov
ProAndroidDev
Published in
5 min readJan 27, 2023

--

Implementing a fully custom UI with complex animations: Waveform Animation

At Exyte we try to contribute to open-source as much as we can, from releasing libraries and components to writing articles and tutorials. One type of tutorials we do is replicating — taking a complex UI component, implementing it using new frameworks and writing a tutorial alongside. We started with SwiftUI some years ago, but today we finally foray into Android, using Google’s declarative UI framework: Jetpack Compose.

Design concept by the talented Taras Migulko @dribbble

Screen Structure

Our app will consist of 3 screens, with animated transitions between them:

For ease of reading, replicating these screens will be discussed in multiple articles that cover the following UI elements and animations:

  • Waveform from the first screen
  • Action panel from the second screen
  • Collapsing Header from the third screen
  • Drag gesture transition
  • Shared element transition

We will start the series with animating the Waveform.

Creating the Waveform

Here is what we want to implement:

The entire element consists of animated vertical volume bars, spaced at a regular distance from each other. Let’s look at the basic dimensions we will be using:

As you well know, the world of Android devices is rich in screens of different sizes, and this poses the following question — how to present the waveform on the screens of different devices so that it takes up all the available space available? To make this widget fit the full width of the screen, you can increase the width of the lines proportionally or you can increase the number of lines. We chose the second way to preserve the crispness of the image. In this case the first step is calculating the necessary number of lines. To do that, we’ll need to divide the available canvas width by the size of a single volume bar (bar width + gap width). Also, we need to use toInt() instead of roundToInt() because we only want to use elements that fully fit the size, regardless of rounding.

val count = (canvasWidth / (barWidthFloat + gapWidthFloat)).toInt().coerceAtMost(MaxLinesCount)

Next, calculate startOffset:

val animatedVolumeWidth = count * (barWidthFloat + gapWidthFloat)
var startOffset = (canvasWidth - animatedVolumeWidth) / 2

To show the absence of audio output, we reduce the amplitude of height fluctuations. Let’s define the max and min values for bar heights:

val barMinHeight = 0f
val barMaxHeight = canvasHeight / 2f / heightDivider

The heightDivider parameter will differ depending on the audio state: idle or playing. For smooth transition between these states, we can use:

val heightDivider by animateFloatAsState(
targetValue = if (isAnimating) 1f else 6f,
animationSpec = tween(1000, easing = LinearEasing)
)

For infinite animation we use rememberInfiniteTransition(), where animations is the list of elements that will be animated, and random is a property that helps us randomly change the animation time.


val infiniteAnimation = rememberInfiniteTransition()
val animations = mutableListOf<State<Float>>()
val random = remember { Random(System.currentTimeMillis()) }

Now let’s look at the animation. Animation of all the bars would take a heavy toll on a phone’s battery life, so to save resources, we only use a small number of Float animation values:

repeat(15) {
val durationMillis = random.nextInt(500, 2000)
animations += infiniteAnimation.animateFloat(
initialValue = 0f,
targetValue = 1f,
animationSpec = infiniteRepeatable(
animation = tween(durationMillis),
repeatMode = RepeatMode.Reverse,
)
)
}

To prevent the animation from repeating every 15 lines, you can set randomized initialMultipliers.

val initialMultipliers = remember {
mutableListOf<Float>().apply {
repeat(MaxLinesCount) { this += random.nextFloat() }
}
}

Now we carry out the following operations for each line:

1) Get random height values, in our case they are repeated every 15 times.

2) Add initialMultipliers to currentSize to reduce the chance of values repeating each other:

3) Use linear interpolation to smoothly resize the height:

// 1
val currentSize = animations[index % animations.size].value
// 2
var barHeightPercent = initialMultipliers[index] + currentSize
if (barHeightPercent > 1.0f) {
val diff = barHeightPercent - 1.0f
barHeightPercent = 1.0f - diff
}
// 3
val barHeight = lerpF(barMinHeight, barMaxHeight, barHeightPercent)

Once you have the dimensions, you can draw a volume bar (1) and calculate the offset for the next volume bar (2).

// 1 draw the bar
drawLine(
color = barColor,
start = Offset(startOffset, canvasCenterY - barHeight / 2),
end = Offset(startOffset, canvasCenterY + barHeight / 2),
strokeWidth = barWidthFloat,
cap = StrokeCap.Round,
)
// 2 calculate the offset for next bar
startOffset += barWidthFloat + gapWidthFloat

For a better understanding, here is the entirety of the combined drawing code that runs in the loop:

repeat(count) { index ->
val currentSize = animations[index % animations.size].value
var barHeightPercent = initialMultipliers[index] + currentSize
if (barHeightPercent > 1.0f) {
val diff = barHeightPercent - 1.0f
barHeightPercent = 1.0f - diff
}
val barHeight = lerpF(barMinHeight, barMaxHeight, barHeightPercent)
drawLine(
color = barColor,
start = Offset(startOffset, canvasCenterY - barHeight / 2),
end = Offset(startOffset, canvasCenterY + barHeight / 2),
strokeWidth = barWidthFloat,
cap = StrokeCap.Round,
)
startOffset += barWidthFloat + gapWidthFloat
}

This concludes implementing the variable-width animated waveform. Next installment in the series will demonstrate implementing the Action Panel. See you soon!

--

--