Android Synthesizer App Tutorial Part 2: User Interface with Jetpack Compose

Posted by Jan Wilczek on August 10, 2022 · 23 mins read

Learn easily how to create a modern UI of Android apps!

Please accept marketing cookies to access the video player.

Android Wavetable Synthesizer Tutorial Series

  1. App Architecture
  2. UI with Jetpack Compose (this one)
  3. ViewModel
  4. Calling C++ Code From Kotlin with JNI
  5. Playing Back Audio on Android with C++
  6. Wavetable Synthesis Algorithm in C++

Introduction

In the previous article, we set the goals of our project. The main goal is to build a wavetable synthesizer Android app. A secondary goal is to learn modern Android technologies and best practices along the way.

Today, we will learn the first of these technologies, namely, Jetpack Compose.

As usual, the full source code is available on my GitHub.

Table of Contents

  1. What Is Jetpack Compose?
  2. What Are the Benefits of Using Jetpack Compose?
  3. What Are the Drawbacks of Using Jetpack Compose?
  4. How Does Jetpack Compose Work?
    1. What Are Composables?
    2. Previewing Composables
  5. How to Set Up Your Project for Compose?
  6. Working with Compose
  7. Setting the Theme of Your UI
  8. Building the UI of Our Synthesizer App
    1. Entry Point of the App
    2. What Are Modifiers in Compose?
    3. Dividing the Screen Into Boxes
    4. Wavetable Selection Panel
    5. Pitch and Play Control
    6. What Is State Hoisting in Compose?
    7. Volume Control
    8. Final Touches: Removing Borders
  9. Part 2 Summary
  10. List of Imports
  11. Further Materials

What Is Jetpack Compose?

Jetpack Compose is a modern (as of 2022) framework for building the user interface (UI) of Android apps.

Which framework is not modern anymore then? Well, everything before Compose so views and layouts.

What’s changed?

  1. We don’t have to use XML files anymore.
  2. Gone are findViewById and the like.
  3. All the UI code is simply Kotlin code not XML.

What Are the Benefits of Using Jetpack Compose?

The benefits of the new framework, in my opinion, are

  1. No need to learn a dedicated language (i.e., Android XML schemas). Everything stays in Kotlin.
  2. Easier interaction of app code with the UI. For example, we can use conditional statements to display parts of the UI or not directly in the UI code.
  3. Previews are also generated with code. No need to delve into some preview manager.

What Are the Drawbacks of Using Jetpack Compose?

Well, in my opinion, there are three:

  1. You have to learn a new framework. That’s why I created this tutorial for you 😉
  2. You can throw your knowledge of the previous framework into trash. Unless, you work with the now-legacy views.
  3. You need to rewrite your UI to stay up to date. For any more complicated app, that’s a significant headache.

After complaining, we can now proceed to actually learning Jetpack Compose!

How Does Jetpack Compose Work?

Jetpack Compose, like most of the UI frameworks (including HTML), is hierarchical.

The UI in Compose is built of composables.

Each composable consists of zero or more composables.

Composables nested into one another

Figure 1. Composables are nested within each other building hierarchies.

By composing composables we can create any arbitrarily complex UI.

What Are Composables?

Composables are simply Kotlin functions.

Within these functions we define what’s inside of our composables.

Listing 1

@Composable
fun SomeComposable(
   modifier: Modifier = Modifier
) {
   Column(
       horizontalAlignment = Alignment.CenterHorizontally,
       modifier = modifier
   ) {
       Image(
           //..
       )
       Text(
           //..
       )
   }
}

At the lowest level of the code written by developers, the composables contain the composable delivered by the Compose framework such as Button, Text, etc.

Previewing Composables

What is great about composables is that we can preview them easily, which was not the case for views. Yes, we could preview views but it wasn’t easy to display just parts of them without creating multiple files. With Compose, we can go as deep or as shallow as we want to.

Listing 2

@Preview(showBackground = true, backgroundColor = 0xFFFFFF)
@Composable
fun SomeComposablePreview() {
    SomeComposable()
}

How to Set Up Your Project for Compose?

In this demonstration, I am using Android Studio Chipmunk 2021.2.1 Patch 1.

To create a Compose application, in Android Studio:

  1. In the top menu bar, click File -> New -> New Project….
  2. In the newly opened window, click Empty Compose Activity and then Next.
    New project window of Android Studio
    _Figure 2. New project window of Android Studio._
  3. Give a name to your application and the package. In our tutorial, these are “Wavetable Synthesizer” and “com.thewolfsound.wavetablesynthesizer” respectively. Also choose the location for your project files.
    Project setup window of Android Studio
    _Figure 3. Setup of an empty Compose activity._
  4. Click Finish.

Congratulations! You have just generated a Compose project 😎

Working with Compose

You should have obtained a file named MainActivity.kt. This file shows you how to build a simple composable and how to preview it.

In general, you create a composable by annotating a function with the @Composable annotation.

Listing 3

@Composable
fun Greeting() {
    Text(text = "Hello, World!")
}

You can preview the composable by writing another function and annotating it with the @Preview annotation.

Previews must be composables as well so you also need to prefix them with the @Composable annotation.

Listing 4

@Preview(showBackground = true)
@Composable
fun DefaultPreview() {
    WavetableSynthesizerTheme {
        Greeting()
    }
}

Setting the Theme of Your UI

WavetableSynthesizerTheme is a theme of your app defined in ui.theme subpackage in the file Theme.kt. The theme contains colors, fonts, and shapes your app should use.

For now, we will change the colors to match WolfSound’s visual identity. For this, we need to edit the file Color.kt.

Listing 5

package com.thewolfsound.wavetablesynthesizer.ui.theme

import androidx.compose.ui.graphics.Color

val WolfSoundOrange = Color(0xFFEF7600)
val WolfSoundDarkOrange = Color(0xFF854200)
val WolfSoundGray = Color(0xFF7C7C7C)

We can now edit the Theme.kt file. There, we replace the previous definitions of palettes with our own ones.

Listing 6

//...
private val DarkColorPalette = darkColors(
    primary = WolfSoundOrange,
    primaryVariant = WolfSoundDarkOrange,
    secondary = WolfSoundGray
)

private val LightColorPalette = lightColors(
    primary = WolfSoundOrange,
    primaryVariant = WolfSoundDarkOrange,
    secondary = WolfSoundGray
)
//...

The remaining default code in the file can stay as it was generated.

Building the UI of Our Synthesizer App

Let me show you how the UI of our synthesizer will look like at the end.

Graphical user interface of the synthesizer app

Figure 4. Graphical user interface of the synthesizer app we are going to build.

To build it, we can follow a top-down approach or a bottom-up approach.

I prefer the former because I find it easier to divide the UI mentally into boxes.

Entry Point of the App

Instead of using previews, we will be building our app on the emulator because it’s fast and easy to set up for the landscape orientation.

The entry point of our application is the onCreate() method of MainActivity class. So let’s replace the generated code with the following one.

Listing 7

class MainActivity : ComponentActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE
        setContent {
            WavetableSynthesizerTheme {
                Surface(modifier = Modifier.fillMaxSize(),
                    color = MaterialTheme.colors.background) {
                    WavetableSynthesizerApp(Modifier)
                }
            }
        }
    }
}

@Composable
fun WavetableSynthesizerApp(
    modifier: Modifier
) {
}

Let’s comment on this code a little bit.

  1. super.onCreate(savedInstanceState) was generated by default so we leave it as it is.
  2. requestedOrientation allows us to enforce a particular screen orientation of our activity. In this case, it is the landscape orientation.
  3. setContent is a special method within which we can place our composables. As you can see, we put there a Surface (which is a composable shipped with Compose) wrapped in our theme class.
  4. Surface contains our first own composable, namely, the now-empty WavetableSynthesizerApp.

After compiling and running this, you should obtain a blank screen. But working! 😄

Here, you can see the first rule of composables:

Curly braces ({ and }) after a composable call define what’s inside a composable.

In this way, we can nest composables. Remember, that we cannot nest anything else than composables.

After this success, let’s divide our UI into more fine-grained parts.

What Are Modifiers in Compose?

You may be wondering why we pass a Modifier instance into our composables. This allows us to modify them from outside without changing the code. I am using it here just to demonstrate it as a recommended best practice.

We’ll soon use the modifiers to fill rows and columns relative to their sizes.

Dividing the Screen Into Boxes

We will now divide our emulator’s screen into boxes that correspond to the parts of the UI.

That’s how our main composable looks now

Listing 8

@Composable
fun WavetableSynthesizerApp(
    modifier: Modifier
) {
        Column(
            modifier = modifier.fillMaxSize(),
            horizontalAlignment = Alignment.CenterHorizontally,
            verticalArrangement = Arrangement.Top,
        ) {
            // These two composables will be shortly defined
            WavetableSelectionPanel(modifier)
            ControlsPanel(modifier)
        }
}

In essence, we filled the whole screen with a single column with a specific horizontal alignment and vertical arrangement.

Inside the Column, which is Compose’s container type, we put two more composables: WavetableSelctionPanel and ControlsPanel. As these are placed one after the other, they are treated by Compose as one level of the hierarchy. This is the second and final rule of composables.

In this case, we have a column where the first element starting from the top is WavetableSelctionPanel and the second is ControlsPanel. These two composables are centered horizontally.

We can now define these two composables.

Listing 9

@Composable
private fun WavetableSelectionPanel(
    modifier: Modifier
) {
    Row(
        modifier = modifier
            .fillMaxWidth()
            .fillMaxHeight(0.5f)
            .border(BorderStroke(5.dp, Color.Black)),
        horizontalArrangement = Arrangement.SpaceEvenly,
        verticalAlignment = Alignment.CenterVertically
    ) {
        Column(
            modifier = modifier
                .fillMaxWidth()
                .fillMaxHeight()
                .border(BorderStroke(5.dp, Color.Black)),
            verticalArrangement = Arrangement.SpaceEvenly,
            horizontalAlignment = Alignment.CenterHorizontally
        ) {
            Text("Wavetable selection panel")
        }
    }
}

@Composable
private fun ControlsPanel(
    modifier: Modifier
) {
    Row(
        modifier = modifier
            .fillMaxWidth()
            .fillMaxHeight()
            .border(BorderStroke(5.dp, Color.Black)),
        horizontalArrangement = Arrangement.Center,
        verticalAlignment = Alignment.CenterVertically
    ) {
        Column(
            modifier = modifier
                .fillMaxHeight()
                .fillMaxWidth(0.7f)
                .border(BorderStroke(5.dp, Color.Black)),
            horizontalAlignment = Alignment.CenterHorizontally
        ) {
            Text("Pitch and play control")
        }
        Column(
            verticalArrangement = Arrangement.Center,
            horizontalAlignment = Alignment.CenterHorizontally,
            modifier = modifier
                .fillMaxWidth()
                .fillMaxHeight()
                .border(BorderStroke(5.dp, Color.Black))
        ) {
            Text("Volume control")
        }
    }
}

As you can see, I put rows and columns with specified relative widths and heights to fill our screen.

I intentionally added a thick border to see show you how our columns are placed.

Here’s the result.

Initial version of the UI with rows and columns

Figure 5. App’s UI divided into rows and columns with borders.

Now, let’s fill these boxes!

Wavetable Selection Panel

To create clickable buttons with wavetable names (which will alter the timbre of our synthesizer) we put inside the following two composables.

Listing 10

// inside WavetableSelectionPanel composable
Text(stringResource(R.string.wavetable))
WavetableSelectionButtons(modifier)
//...

@Composable
private fun WavetableSelectionButtons(
    modifier: Modifier
) {
    Row(
        modifier = modifier.fillMaxWidth(),
        horizontalArrangement = Arrangement.SpaceEvenly
    ) {
        for (wavetable in arrayOf("Sine", "Triangle", "Square", "Saw")) {
            WavetableButton(
                modifier = modifier,
                onClick = {},
                label = wavetable)
        }
    }
}

@Composable
private fun WavetableButton(
    modifier: Modifier,
    onClick: () -> Unit,
    label: String,
) {
    Button(modifier = modifier, onClick = onClick) {
        Text(label)
    }
}

In order for the above code to work, you need to modify the strings resource file (res/values/strings.xml).

Listing 11

<resources>
    <string name="app_name">Wavetable Synthesizer</string>
    <string name="wavetable">Wavetable</string>
</resources>

Inside our WavetableSelectionPanel we a put a title (Text composable) and another panel with buttons (Button composables).

As you can see in WavetableSelectionButtons, we can iteratively place composables. How cool is that?

The array I used there is just temporary; we will have a proper Wavetable class later on.

Additionally, we pass an empty onClick callback because we don’t define any logic yet.

Our UI should now look like this:

UI with wavetable selection buttons in the upper half

Figure 6. App’s UI with a complete wavetable selection panel.

Pitch and Play Control

To control the fundamental frequency we will use a simple slider. To control the playback we will use a Play/Stop button.

For this we replace the “Pitch and Play Control” Text composable with two new ones: PitchControl and PlayControl.

Listing 12

// Inside ControlsPanel, instead of Text("Pitch and play control")
PitchControl(modifier)
PlayControl(modifier)
//...

@Composable
private fun PitchControl(
    modifier: Modifier
) {
    val sliderPosition = rememberSaveable { mutableStateOf(300F) }

    PitchControlContent(
        modifier = modifier,
        pitchControlLabel = stringResource(R.string.frequency),
        value = sliderPosition.value,
        onValueChange = {
            sliderPosition.value = it
        },
        valueRange = 20F..3000F,
        frequencyValueLabel = stringResource(R.string.frequency_value,
                                             sliderPosition.value)
    )
}

@Composable
private fun PitchControlContent(
    modifier: Modifier,
    pitchControlLabel: String,
    value: Float,
    onValueChange: (Float) -> Unit,
    valueRange: ClosedFloatingPointRange<Float>,
    frequencyValueLabel: String
) {
    Text(pitchControlLabel, modifier = modifier)
    Slider(modifier = modifier,
           value = value,
           onValueChange = onValueChange,
           valueRange = valueRange)
    Row(
        modifier = modifier,
        horizontalArrangement = Arrangement.Center
    ) {
        Text(modifier = modifier, text = frequencyValueLabel)
    }
}

@Composable
private fun PlayControl(modifier: Modifier) {
    Button(modifier = modifier,
        onClick = {}) {
        Text(stringResource(R.string.play))
    }
}

The above code requires adding the following strings to res/values/strings.xml:

Listing 13

<string name="play">Play</string>
<string name="frequency">Frequency</string>
<string name="frequency_value">%.1f Hz</string>

What has happened here?

The PlayControl is just a simple button with a text and no action on click.

However, the slider is a different thing. You may notice right away that we have separate PitchControl and PitchControlContent. Why is that?

In essence, PitchControlContent is a stateless composable. It is just the UI, nothing more. It does not handle any logic, it does not update any variables. It defines the elements inside passively, we could say.

On the other hand, PitchControl holds all the UI state related to PitchControlContent.

It defines the content of all the labels that PitchControlContent should display. It passes the OnValueChange listener. It defines the slider’s range. It formats the frequency label.

But most importantly, it remembers the mutable state of the slider position.

This is an example of the so-called state hoisting.

What Is State Hoisting in Compose?

State hoisting means that a composable is given its state “from the outside”. That includes the content of its labels, the values of its sliders, the callbacks it should invoke on clicking or dragging, etc.

The reason of hoisting the state outside of a composable is that it makes the composable easier to test. The composables without a state are called stateless composables.

In our example, PitchControlContent is a stateless composable and PitchControl is hoisting its state.

In the next part of the tutorial, we will hoist the state using ViewModels but for now let’s keep it simple.

The important part of the state hoisting is this line:

Listing 14

val sliderPosition = rememberSaveable { mutableStateOf(300F) }

Here we define an instance of a class MutableState<Float>.

The mutableStateOf part creates the MutableState instance with an initial value, which in our case is 300 (as a floating-point number).

The rememberSaveable part remembers the current value even if our app goes into the background or the UI is reconfigured. The state will be “forgotten” only if we close the app.

If we remove the mutableStateOf part, we won’t be able to change the value of the slider; the Compose framework will always set its value to 300 on UI recomposition.

If we remove the rememberSaveable part, we won’t be able to change the value of the slider either; this time the Compose framework won’t know that this is a state that it should observe and cause a UI recomposition if it changes (we change it in onValueChange listener when the slider position changes). So sliderPosition’s value may change underneath (I actually haven’t tested that) but the change won’t be displayed.

We will revisit these concepts in the next part, when we deal with ViewModels.

Volume Control

The last part of the UI is the volume control.

We replace Text("Volume control") with VolumeControl(modifier) in ControlsPanel and add the following code to MainActivity.kt.

Listing 15

@Composable
private fun VolumeControl(modifier: Modifier) {
    val volume = rememberSaveable { mutableStateOf(0F) }

    VolumeControlContent(
        modifier = modifier,
        volume = volume.value,
        volumeRange = -60F..0F,
        onValueChange = { volume.value = it })
}

@Composable
private fun VolumeControlContent(
    modifier: Modifier,
    volume: Float,
    volumeRange: ClosedFloatingPointRange<Float>,
    onValueChange: (Float) -> Unit
) {
    // The volume slider should take around 1/4 of the screen height
    val screenHeight = LocalConfiguration.current.screenHeightDp
    val sliderHeight = screenHeight / 4

    Icon(imageVector = Icons.Filled.VolumeUp, contentDescription = null)
    Column(
        modifier = modifier
            .fillMaxWidth()
            .fillMaxHeight(0.8f)
            .offset(y = 40.dp),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.SpaceBetween
    )
    {
        Slider(
            value = volume,
            onValueChange = onValueChange,
            modifier = modifier
                .width(sliderHeight.dp)
                .rotate(270f),
            valueRange = volumeRange
        )
    }
    Icon(imageVector = Icons.Filled.VolumeMute, contentDescription = null)
}

The usage of icons requires us to add the following dependency to the build.gradle file of our app module:

Listing 16

dependencies {
    implementation "androidx.compose.material:material-icons-extended:$compose_version"
}

where compose_version variable is 1.1.1 in my case.

Here again VolumeControlContent is a stateless composable and VolumeControl hoists its state (the position of the slider).

Because Compose does not support vertical sliders, I had to come up with a little bit hacky code to have one. But it works perfectly 😉

In essence I make a slider which takes 1/4 of the screen’s height (remember that we are in the landscape orientation) and is rotated by 270 degrees.

The range of the volume slider is from -60 to 0 because these are the values in decibels that we will use.

Final Touches: Removing Borders

That’s the current look of our UI:

Final look of the app UI with column borders

Figure 7. The final look of the app UI but still with borders.

We can remove all border modifiers to obtain the final look:

Final look of the app UI

Figure 8. The final look of the app UI.

The full source code of the MainActivity.kt file and the resources files can be found on my GitHub.

Part 2 Summary

In the second part of the tutorial, we discussed

  • how to implement the user interface with buttons, sliders, labels, and icons using Jetpack Compose,
  • we defined the theme of our app,
  • explained what are composables,
  • how to compose them,
  • what are stateless composables,
  • what is state hoisting,
  • and when a recomposition happens.

If you want to check out my guidelines on what knowledge is needed to write sound-processing software, download my free audio plugin developer checklist.

Up next: defining our connection to the model with ViewModels!

List of Imports

Here is the list of the import statements in MainActivity.kt as of this tutorial:

Listing 17

import android.content.pm.ActivityInfo
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.foundation.layout.*
import androidx.compose.material.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.VolumeMute
import androidx.compose.material.icons.filled.VolumeUp
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Devices
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.thewolfsound.wavetablesynthesizer.ui.theme.WavetableSynthesizerTheme

I sometimes find it confusing what to import so I included all the imports from MainActivity.kt here for your convenience 😉

Further Materials

If you want to learn the basics of Compose, I recommend doing this codelab from Google. I found it very approachable for beginners.

If you want to understand the concept of state hoisting, I recommend watching this video by Alejandra Stamato and Manuel Vivo. It is a bit lengthy and maybe not as cleanly explained but still, in the end, you will understand the concept fully.

Share this page on:

Comments powered by Talkyard.

Please accept marketing cookies to access the display and add comments.