Learn easily how to create a modern UI of Android apps!
Android Wavetable Synthesizer Tutorial Series
- App Architecture
- UI with Jetpack Compose (this one)
- ViewModel
- Calling C++ Code From Kotlin with JNI
- Playing Back Audio on Android with C++
- 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
- What Is Jetpack Compose?
- What Are the Benefits of Using Jetpack Compose?
- What Are the Drawbacks of Using Jetpack Compose?
- How Does Jetpack Compose Work?
- How to Set Up Your Project for Compose?
- Working with Compose
- Setting the Theme of Your UI
- Building the UI of Our Synthesizer App
- Part 2 Summary
- List of Imports
- 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?
- We don’t have to use XML files anymore.
- Gone are
findViewById
and the like. - 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
- No need to learn a dedicated language (i.e., Android XML schemas). Everything stays in Kotlin.
- 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.
- 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:
- You have to learn a new framework. That’s why I created this tutorial for you 😉
- You can throw your knowledge of the previous framework into trash. Unless, you work with the now-legacy views.
- 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.
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:
- In the top menu bar, click File -> New -> New Project….
- In the newly opened window, click Empty Compose Activity and then Next.
- 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.
- 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.
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.
super.onCreate(savedInstanceState)
was generated by default so we leave it as it is.requestedOrientation
allows us to enforce a particular screen orientation of our activity. In this case, it is the landscape orientation.setContent
is a special method within which we can place our composables. As you can see, we put there aSurface
(which is a composable shipped with Compose) wrapped in our theme class.Surface
contains our first own composable, namely, the now-emptyWavetableSynthesizerApp
.
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.
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:
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 ViewModel
s 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 ViewModel
s.
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:
Figure 7. The final look of the app UI but still with borders.
We can remove all border
modifiers to obtain the final look:
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 ViewModel
s!
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.
Comments powered by Talkyard.