So far your apps have only displayed things. A birthday card and a business card are lovely, but they never react when you touch them. Real apps respond: you tap a button and something changes. In this lesson you will build your first interactive app, a dice roller, and learn the single most important idea in Jetpack Compose: state.
This lesson matches three items from Unit 2 of Google's official Android Basics with Compose course: the Intro to Dice Roller app video, the Create an interactive Dice Roller app codelab, and the Practice: Click behavior exercise. We take the same journey with extra explanation and our own code.
By the end of this lesson you will be able to:
- Add a
Buttonto a screen and run code when it is tapped. - Explain why a normal Kotlin variable does not update the screen.
- Use
rememberandmutableIntStateOfso Compose redraws automatically. - Use the
bydelegate with thegetValueandsetValueimports. - Pick a random number with
(1..6).random(). - Choose what to show with a
whenexpression. - Show an image from
res/drawablewithpainterResource, or fall back to text if you have no images yet. - Notice and fix the "my state vanished when I rotated the phone" bug with
rememberSaveable.
A promise: if you only remember one thing from this lesson, remember this sentence. In Compose, the screen is a function of state. Change the state and Compose redraws the screen for you.
Make sure these ideas feel familiar. If one does not, a quick revisit will pay off.
- Composable functions (lesson 15). A function marked
@Composabledescribes a piece of UI. You call composables inside other composables, likeTextinsideColumn. - Modifiers and layouts (lesson 16).
Columnstacks children vertically,Modifier.fillMaxSize()fills the available space, andArrangementandAlignmentposition the children. whenexpressions (lesson 9). Awhenchooses between branches and can return a value.- Ranges (lesson 9).
1..6is every whole number from 1 up to and including 6. - Lambdas (lesson 12). A button's behaviour is a lambda: a small block of code you hand to the button, saying "run this when tapped".
If you are unsure about lambdas, remember this picture: a lambda is a sticky note you give to a button. The note says what to do. The button keeps it and reads it every time it is tapped.
Events
When a user touches the screen, Android creates an event. A tap on a button is a click event. In Compose you do not write "listener objects". You pass a lambda to the composable:
Button(onClick = { /* code that runs on every tap */ }) {
Text("Roll")
}
The parameter is called onClick, and its value is the code to run.
Why a plain variable is not enough
Imagine you try the obvious thing:
@Composable
fun BrokenCounter() {
var count = 0
Column {
Text("Count: $count")
Button(onClick = { count++ }) { Text("Add") }
}
}
You tap the button. The variable count really does go up, but the number on screen never changes. There are two separate problems:
- Compose does not know that
countchanged, so it has no reason to redraw. - Even if it did redraw, the function would run again from the top, and
var count = 0would reset the variable to zero.
State fixes both problems
State is a value that Compose watches. When it changes, Compose re-runs (the official word is recomposes) the composables that read it, so the screen updates. To survive the re-run without resetting, the value must be remembered.
| Ingredient | What it does | Analogy |
|---|---|---|
mutableIntStateOf(1) |
Creates a watched whole-number value starting at 1. | A scoreboard that tells the referee every time it changes. |
remember { ... } |
Keeps the value alive across recompositions instead of recreating it. | Writing the score on a whiteboard instead of in your head. |
by delegate |
Lets you read and write the state like an ordinary variable. | A cover that hides the scoreboard machinery. |
Putting them together:
var result by remember { mutableIntStateOf(1) }
Read it like a sentence: "result is a remembered, watched integer that starts at 1."
The cycle
user taps button
|
v
onClick lambda runs ---> changes state (result = 4)
^ |
| v
screen shows new UI <--- Compose recomposes
Every interactive Compose screen follows this loop: event, state change, recomposition, new UI. You never say "set the text to 4". You only change the state, and the UI follows.
Create a new project exactly as in lesson 03: File > New > New Project > Empty Activity. Name it Dice Roller, keep the package com.example.diceroller, choose a minimum SDK your emulator supports, and finish. Wait for Gradle to sync.
Open MainActivity.kt. The template gives you a MainActivity class that calls setContent. Replace the file contents with the following. Your theme function is named after your app, so it will be DiceRollerTheme if you used the name above.
package com.example.diceroller
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.material3.Button
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.example.diceroller.ui.theme.DiceRollerTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
DiceRollerTheme {
Surface(modifier = Modifier.fillMaxSize()) {
DiceRollerApp()
}
}
}
}
}
@Composable
fun DiceRollerApp(modifier: Modifier = Modifier) {
Column(
modifier = modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(text = "⚀", fontSize = 120.sp)
Spacer(modifier = Modifier.height(24.dp))
Button(onClick = { }) {
Text(text = "Roll")
}
}
}
@Preview(showBackground = true)
@Composable
fun DiceRollerAppPreview() {
DiceRollerTheme {
DiceRollerApp()
}
}
What each part does
Surfacegives the app a background colour from the Material theme.Columnstacks the die and the button, centred both ways usinghorizontalAlignmentandverticalArrangement.- The die is the Unicode character
⚀(die face one), shown at120.sp. It lets you finish the whole lesson without any image files. We replace it with real pictures later. Spaceradds an empty 24 dp gap between the die and the button.Button(onClick = { })has an empty lambda for now, so tapping does nothing.
Run the app (lesson 04) or open the Preview pane. You should see a big die face above a Roll button.
Tip:
sp(scale-independent pixels) is for text, so it respects the user's font-size setting.dp(density-independent pixels) is for everything else. You met both in lesson 15.
Step 1: Add state
Inside DiceRollerApp, before the Column, create the state. Add these imports at the top of the file:
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
The two imports getValue and setValue are what make the by keyword work. If you forget them, Android Studio underlines by in red with a message about a missing getValue operator. Press Alt + Enter and choose Import.
Step 2: Roll a random number
Kotlin makes randomness easy:
val diceRoll = (1..6).random()
1..6 is a range, and .random() picks one number from it. Both ends are included, so you can get 1 or 6.
Step 3: Connect the button to the state
Here is the complete composable. Only DiceRollerApp changes, so the rest of the file stays as before.
@Composable
fun DiceRollerApp(modifier: Modifier = Modifier) {
var result by remember { mutableIntStateOf(1) }
val faces = listOf("⚀", "⚁", "⚂", "⚃", "⚄", "⚅")
Column(
modifier = modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(text = faces[result - 1], fontSize = 120.sp)
Text(text = "You rolled a $result", fontSize = 20.sp)
Spacer(modifier = Modifier.height(24.dp))
Button(onClick = { result = (1..6).random() }) {
Text(text = "Roll")
}
}
}
Walk through it slowly:
var result by remember { mutableIntStateOf(1) }creates the remembered, watched number, starting at 1.facesis a list of six characters. Lists start counting at zero, so the face for a roll ofnisfaces[n - 1]. A roll of 1 uses index 0.- The two
Textcomposables readresult. Because they read it, Compose knows they depend on it. - The button's lambda writes
result = (1..6).random(). That write is the state change. - Compose notices, recomposes
DiceRollerApp, theTexts show the new face and sentence.
Run it and tap Roll several times. The die changes and the sentence updates. You just built a reactive UI.
Expected behaviour: Starting screen shows ⚀ and "You rolled a 1". After a tap you might see ⚄ and "You rolled a 5". Results are random, so yours will differ.
Splitting into smaller composables
Big composables get hard to read. A common pattern is to separate the layout from the dice. Here the state still lives in one place, and a helper composable does the drawing:
@Composable
fun DiceRollerApp(modifier: Modifier = Modifier) {
DiceWithButtonAndFace(
modifier = modifier
.fillMaxSize()
.wrapContentSize(Alignment.Center)
)
}
@Composable
fun DiceWithButtonAndFace(modifier: Modifier = Modifier) {
var result by remember { mutableIntStateOf(1) }
val faces = listOf("⚀", "⚁", "⚂", "⚃", "⚄", "⚅")
Column(modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally) {
Text(text = faces[result - 1], fontSize = 120.sp)
Spacer(modifier = Modifier.height(16.dp))
Button(onClick = { result = (1..6).random() }) {
Text(text = "Roll")
}
}
}
You need import androidx.compose.foundation.layout.wrapContentSize. The modifier wrapContentSize(Alignment.Center) centres the content inside the full-screen box.
Unicode faces are great for learning, but a shipped app usually shows pictures. Here is how to swap in image resources. The official course provides dice image files, and you may also draw your own vector assets or export six images from any design tool.
- In Android Studio switch the Project tool window to the Android view.
- Copy six images named
dice_1.pngtodice_6.png(file names must be lowercase letters, digits and underscores). - Paste them into
app/src/main/res/drawable. The resource names are the file names without the extension.
Now choose the image with a when expression, which returns the value, so it can be assigned directly:
val imageResource = when (result) {
1 -> R.drawable.dice_1
2 -> R.drawable.dice_2
3 -> R.drawable.dice_3
4 -> R.drawable.dice_4
5 -> R.drawable.dice_5
else -> R.drawable.dice_6
}
Image(
painter = painterResource(imageResource),
contentDescription = result.toString()
)
Add the imports androidx.compose.foundation.Image, androidx.compose.ui.res.painterResource and make sure R resolves (it is generated from your package, so no import is needed inside com.example.diceroller).
Two details matter:
whenas an expression must cover every case, so the finalelseis required. Kotlin cannot know thatresultwill only ever be 1 to 6.contentDescriptionis read aloud by screen readers such as TalkBack.result.toString()announces the number. Lesson 47 explains accessibility in depth, but form the habit now.
Think about it: Why use
whenhere instead of sixifstatements? Becausewhenreads like a table, returns a value, and the compiler checks that the cases are complete.
Run the app, roll until you get a 6, then rotate the emulator (use the rotate buttons beside the emulator, or press Ctrl + Left on Windows or Linux). The die resets to 1!
Why? Rotating the phone is a configuration change. Android destroys the Activity and creates a new one, and everything stored with plain remember is lost. Lesson 23 explains the lifecycle in detail. The quick fix is rememberSaveable:
import androidx.compose.runtime.saveable.rememberSaveable
var result by rememberSaveable { mutableIntStateOf(1) }
rememberSaveable stores the value in a bundle that Android keeps across configuration changes and even across process death. For simple types like Int, String, and Boolean nothing else is needed. Use rememberSaveable for state that the user would be annoyed to lose, and plain remember for temporary visual state.
| Function | Survives recomposition | Survives rotation | Survives process death |
|---|---|---|---|
| plain variable | No | No | No |
remember |
Yes | No | No |
rememberSaveable |
Yes | Yes | Yes |
Exercise 1: The Click Counter
Objective: Practise remember, mutableIntStateOf and Button with a different shape of state.
Steps:
- Create a composable
ClickCounterthat showsTaps: 0. - Add a Tap button that adds 1 each time.
- Add a Reset button that sets the counter back to 0.
- Make the text red once the count reaches 10 (hint:
color = Color.Redand anifexpression). - Keep the count across rotation.
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@Composable
fun ClickCounter() {
var taps by rememberSaveable { mutableIntStateOf(0) }
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = "Taps: $taps",
fontSize = 32.sp,
color = if (taps >= 10) Color.Red else MaterialTheme.colorScheme.onBackground
)
Spacer(modifier = Modifier.height(16.dp))
Row {
Button(onClick = { taps++ }) { Text("Tap") }
Spacer(modifier = Modifier.width(12.dp))
Button(onClick = { taps = 0 }) { Text("Reset") }
}
}
}
You also need import androidx.compose.ui.Modifier. taps++ works on a delegated property because it expands to a read followed by a write. The colour uses an if expression, so the whole thing fits inside the parameter. Using MaterialTheme.colorScheme.onBackground keeps the text readable in both light and dark theme.
Self-check: What happens to the count on rotation if you replace rememberSaveable with remember? (It resets to zero.)
Exercise 2: The Lemonade Tapper (the Practice: Click behavior exercise)
Objective: Use a step state and a when inside onClick to build a four-stage tapping game.
Rules:
- Step 1: a lemon tree. Tapping it picks a lemon and chooses a random squeeze count between 2 and 4.
- Step 2: a lemon. Each tap is one squeeze. After the required squeezes, move on.
- Step 3: a glass of lemonade. Tapping it drinks it.
- Step 4: an empty glass. Tapping it starts over at step 1.
- Show an instruction sentence under the picture that changes with the step.
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
@Composable
fun LemonadeApp(modifier: Modifier = Modifier) {
var step by rememberSaveable { mutableIntStateOf(1) }
var squeezesNeeded by rememberSaveable { mutableIntStateOf(0) }
var squeezesDone by rememberSaveable { mutableIntStateOf(0) }
val (emoji, instruction) = when (step) {
1 -> "🌳" to "Tap the lemon tree to pick a lemon"
2 -> "🍋" to "Keep tapping the lemon to squeeze it"
3 -> "🥤" to "Tap the lemonade to drink it"
else -> "🥛" to "Tap the empty glass to start again"
}
Column(
modifier = modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Button(
onClick = {
when (step) {
1 -> {
squeezesNeeded = (2..4).random()
squeezesDone = 0
step = 2
}
2 -> {
squeezesDone++
if (squeezesDone >= squeezesNeeded) step = 3
}
3 -> step = 4
else -> step = 1
}
}
) {
Text(text = emoji, fontSize = 96.sp)
}
Spacer(modifier = Modifier.height(24.dp))
Text(text = instruction, fontSize = 18.sp)
}
}
How it works: step decides what the screen shows and what a tap means. The when that picks the emoji returns a pair that is destructured into two values. The second when runs inside the click lambda and changes state. Because squeeze counts are random, two playthroughs differ, which is the point of the exercise.
Change the dice app so the Roll button is disabled for one second after each tap (hint: look at the enabled parameter of Button, and learn rememberCoroutineScope in lesson 28 to add a delay). If you are not ready for coroutines, simply disable the button once the player rolls a 6.
Mistake #1: A plain var instead of state. The wrong code: var result = 1. What happens: the variable changes but the screen never does. The fix: var result by remember { mutableIntStateOf(1) }.
Mistake #2: remember without the state wrapper. The wrong code: var result by remember { 1 }. What happens: the code does not compile, because by needs a state object. The fix: remember { mutableIntStateOf(1) }.
Mistake #3: Forgetting the getValue and setValue imports. Symptom: a red underline on by. The fix: import androidx.compose.runtime.getValue and androidx.compose.runtime.setValue, or press Alt + Enter.
Mistake #4: Off-by-one in a list. The wrong code: faces[result]. What happens: a roll of 6 crashes with IndexOutOfBoundsException, because the list has indexes 0 to 5. The fix: faces[result - 1]. Reading the red stack trace in Logcat (lesson 18) points straight to the line.
Mistake #5: Creating state outside a composable. The wrong code: putting remember in a top-level property or in a normal function. The fix: remember may only be called from inside a composable.
Mistake #6: Doing heavy work in a composable body. The wrong code: calling (1..6).random() directly in the body of DiceRollerApp so a new number is chosen on every recomposition. What happens: the die changes by itself. The fix: generate the random number only inside onClick.
Mistake #7: Forgetting else in a when expression. What happens: a compile error saying the when must be exhaustive. The fix: add an else branch.
Mistake #8: Losing state on rotation. The fix: use rememberSaveable for anything the user expects to survive.
Answer each question before revealing the answer.
Question 1. What is state in Compose?
State is a value that Compose watches. When it changes, Compose recomposes the composables that read it so the screen shows the new value.
Question 2. Why does var count = 0 inside a composable not make a counter work?
Compose is not told when a plain variable changes, so it does not redraw. And when the composable re-runs, the line var count = 0 runs again and resets it.
Question 3. What do remember and mutableIntStateOf each contribute?
mutableIntStateOf creates a state object Compose can observe. remember keeps that same object alive across recompositions instead of creating a new one each time.
Question 4. Which function would you use so a value survives rotating the phone?
rememberSaveable. Plain remember is lost when the Activity is recreated on a configuration change.
Question 5. What does (1..6).random() return, and can it return 6?
A random integer from 1 to 6. Ranges built with .. include both ends, so 6 is possible.
Go deeper: official resources
- Android Basics with Compose, Unit 2, Pathway 2: Add a button to an app: the official pathway this lesson follows (Dice Roller intro video, codelab and click behaviour practice).
- Android Basics with Compose course page: all eight units.
- Jetpack Compose essentials, pathway 1: state is explored again in the Get started with state codelab.
- Get started with state codelab: the deeper treatment you will study in lessons 19 and 37.
Project goal: Turn the dice roller into a tiny game with two dice. Rolling a total of seven wins, rolling a double earns a bonus message, and any other result invites another try.
Requirements:
- Show two dice faces side by side in a
Row. - A Roll both button rolls both dice at once.
- Show the total under the dice.
- Show a message chosen with a
whenthat has no argument (conditions in each branch):Tap Roll to beginbefore the first roll,Lucky seven! You win!when the total is 7,Double N! Roll againwhen both dice match, andTotal N. Try againotherwise. - Count how many rolls the player has made and show it.
- Keep every piece of state across rotation.
Stretch goal: Add a Best streak counter that remembers the longest run of non-winning rolls before a win.
package com.example.diceroller
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.material3.Button
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.example.diceroller.ui.theme.DiceRollerTheme
private val faces = listOf("⚀", "⚁", "⚂", "⚃", "⚄", "⚅")
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
DiceRollerTheme {
Surface(modifier = Modifier.fillMaxSize()) {
LuckySevenApp()
}
}
}
}
}
@Composable
fun LuckySevenApp(modifier: Modifier = Modifier) {
var first by rememberSaveable { mutableIntStateOf(1) }
var second by rememberSaveable { mutableIntStateOf(1) }
var rolls by rememberSaveable { mutableIntStateOf(0) }
val total = first + second
val message = when {
rolls == 0 -> "Tap Roll to begin"
total == 7 -> "Lucky seven! You win!"
first == second -> "Double ${first}s! Roll again"
else -> "Total $total. Try again"
}
Column(
modifier = modifier.fillMaxSize(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Row {
Text(text = faces[first - 1], fontSize = 96.sp)
Spacer(modifier = Modifier.width(16.dp))
Text(text = faces[second - 1], fontSize = 96.sp)
}
Spacer(modifier = Modifier.height(12.dp))
Text(text = message, fontSize = 22.sp)
Text(text = "Rolls so far: $rolls", fontSize = 16.sp)
Spacer(modifier = Modifier.height(24.dp))
Button(onClick = {
first = (1..6).random()
second = (1..6).random()
rolls++
}) {
Text(text = "Roll both")
}
}
}
@Preview(showBackground = true)
@Composable
fun LuckySevenPreview() {
DiceRollerTheme {
LuckySevenApp()
}
}
Expected screen after the first tap (example):
⚃ ⚂
Lucky seven! You win!
Rolls so far: 1
Why it works: three separate state values change inside one onClick, and Compose recomposes once with all of them. The when without an argument tests conditions from top to bottom, so the order matters: a roll of double three is not seven, but a roll of 3 and 4 is, and the seven check comes first so a win is never reported as a double.
Open your finished project in Android Studio. Check that the .gitignore file the wizard created is in the project root. It keeps local.properties, build folders and keystores out of Git. Never commit passwords or signing keys.
Choose Git > Create Git Repository… (or VCS > Enable Version Control Integration in older versions), select the project folder, then open Git > Commit, tick every file, and write the message “Add Android lesson 17 project”.
On github.com click New repository, name it android-lesson-17-buttons-and-click-behaviour-the-dice-rolle, set it to Public, leave Add a README unticked, and click Create repository.
Back in Android Studio choose Git > Manage Remotes…, add the repository URL as origin, then choose Git > Push… and push your main branch. Prefer the terminal? Run git remote add origin <your-url> then git push -u origin main.
Add a README.md with a screenshot of your app running and a short description. Employers and clients read READMEs first. Your project is now part of your public portfolio. 🎉
Lesson 17 complete! 🎉
You covered:
- Lesson Introduction
- Prerequisite Concepts
- Conceptual Understanding: Events, State and Recomposition
- Step-by-Step Build 1: The Static Screen
- Step-by-Step Build 2: Make It Respond
- Step-by-Step Build 3: Real Dice Images with painterResource
- Rotation, Process Death and rememberSaveable
- Guided Practice Exercises
- Common Beginner Mistakes (and How to Fix Them)
- Knowledge Check