In the tip calculator, all the state lived inside one composable. That is fine for a small screen, but real apps have rules and data that must survive screen rotation and be shared between screens. The standard answer in Android is the ViewModel. A ViewModel is a class that holds the state of a screen and the logic that changes it. It lives longer than the composable, so it survives rotation. This project builds a to-do list and teaches the pattern you will use in every remaining project.
Concepts in this project
- ViewModel: holds screen state and logic, and survives rotation.
- StateFlow: a stream that always holds a current value. The ViewModel changes it and the screen watches it.
- collectAsStateWithLifecycle: turns a StateFlow into Compose state, and stops collecting when the app is in the background.
- State hoisting: a composable does not own its data. It receives the value and the events (lambdas) from above. This makes small composables simple and reusable.
- Unidirectional data flow: events go up, state comes down. The UI asks the ViewModel to change something, the ViewModel updates the state, and the UI redraws from the new state.
- LazyColumn: shows only the rows that are visible, so a list of thousands stays fast. Give each item a stable
keyso Compose can move and animate rows correctly.
Create the project
- Create an Empty Activity project named
Todo, packagecom.example.todo. - Open
app/build.gradle.ktsand add these lines todependencies, then press Sync Now. - Add the four Kotlin files shown below.
`app/build.gradle.kts (dependencies)`
// app/build.gradle.kts -> dependencies { ... }
// A new "Empty Activity" project already has Compose, Material 3 and the activity library.
// Projects 2 to 4 need these extra lines:
implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.8.7") // viewModel() in Compose
implementation("androidx.lifecycle:lifecycle-runtime-compose:2.8.7") // collectAsStateWithLifecycle()
implementation("androidx.navigation:navigation-compose:2.8.5") // project 4 only
implementation("androidx.compose.material:material-icons-core") // Add and Delete icons (version comes from the Compose BOM)
Todo/app/src/main/java/com/example/todo/
MainActivity.kt shows TodoScreen
Task.kt the data: id, title, done
TodoViewModel.kt the list and the rules (add, toggle, remove)
TodoScreen.kt the UI that shows the list and sends events
The code
`app/src/main/java/com/example/todo/Task.kt`
package com.example.todo
data class Task(
val id: Long,
val title: String,
val done: Boolean = false
)
`app/src/main/java/com/example/todo/TodoViewModel.kt`
package com.example.todo
import androidx.lifecycle.ViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
class TodoViewModel : ViewModel() {
private val _tasks = MutableStateFlow<List<Task>>(emptyList())
val tasks: StateFlow<List<Task>> = _tasks.asStateFlow()
private var nextId = 1L
fun add(title: String) {
val clean = title.trim()
if (clean.isEmpty()) return
_tasks.update { it + Task(id = nextId++, title = clean) }
}
fun toggle(id: Long) {
_tasks.update { list -> list.map { if (it.id == id) it.copy(done = !it.done) else it } }
}
fun remove(id: Long) {
_tasks.update { list -> list.filterNot { it.id == id } }
}
fun clearDone() {
_tasks.update { list -> list.filterNot { it.done } }
}
}
Read TodoViewModel carefully. _tasks is private and mutable: only the ViewModel can change it. tasks is the public, read-only view of it. Each function builds a new list with update instead of changing the old list. Because the list is never edited in place, Compose can tell that something changed. This "copy, do not mutate" habit prevents many bugs.
`app/src/main/java/com/example/todo/TodoScreen.kt`
package com.example.todo
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
@Composable
fun TodoScreen(vm: TodoViewModel = viewModel()) {
val tasks by vm.tasks.collectAsStateWithLifecycle()
var draft by remember { mutableStateOf("") }
Column(Modifier.fillMaxSize().systemBarsPadding().padding(16.dp)) {
Text("To-Do", style = MaterialTheme.typography.headlineMedium)
Spacer(Modifier.height(12.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
OutlinedTextField(
value = draft,
onValueChange = { draft = it },
label = { Text("New task") },
singleLine = true,
modifier = Modifier.weight(1f)
)
Spacer(Modifier.width(8.dp))
FilledIconButton(onClick = { vm.add(draft); draft = "" }) {
Icon(Icons.Default.Add, contentDescription = "Add task")
}
}
Spacer(Modifier.height(12.dp))
val left = tasks.count { !it.done }
Text("$left left, ${tasks.size - left} done", style = MaterialTheme.typography.bodyMedium)
if (tasks.isEmpty()) {
Box(Modifier.fillMaxWidth().weight(1f), contentAlignment = Alignment.Center) {
Text("Nothing to do. Add your first task above.")
}
} else {
LazyColumn(Modifier.weight(1f)) {
items(tasks, key = { it.id }) { task ->
TaskRow(task, onToggle = { vm.toggle(task.id) }, onDelete = { vm.remove(task.id) })
}
}
}
TextButton(onClick = vm::clearDone, enabled = tasks.any { it.done }) { Text("Clear finished tasks") }
}
}
@Composable
private fun TaskRow(task: Task, onToggle: () -> Unit, onDelete: () -> Unit) {
Row(Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically) {
Checkbox(checked = task.done, onCheckedChange = { onToggle() })
Text(
task.title,
modifier = Modifier.weight(1f),
textDecoration = if (task.done) TextDecoration.LineThrough else TextDecoration.None
)
IconButton(onClick = onDelete) { Icon(Icons.Default.Delete, contentDescription = "Delete ${task.title}") }
}
}
TodoScreen collects the state and passes small pieces down. TaskRow knows nothing about the ViewModel: it gets one Task and two lambdas, onToggle and onDelete. That is state hoisting. A composable like this is easy to preview and test.
`app/src/main/java/com/example/todo/MainActivity.kt`
package com.example.todo
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent { MaterialTheme { Surface { TodoScreen() } } }
}
}
Expected output
The first screen shows the title To-Do, a text box with a plus button, the line "0 left, 0 done" and the message "Nothing to do. Add your first task above." Add Buy milk and Call Dad: both appear and the line reads "2 left, 0 done". Tick Buy milk: its text gets a line through it and the line reads "1 left, 1 done". Rotate the phone: the list stays, because the ViewModel survived. Press Clear finished tasks and only Call Dad remains.
Your task
- Build and run the app, and check the example above.
- Show a line at the bottom that says "All done, well done!" when every task is ticked and the list is not empty.
- Add an
editfunction to the ViewModel and let the user tap a title to change it. - Write down one bug that would happen if
TaskRowchanged the list itself instead of callingonToggle.
How to check it worked
- Tasks survive rotation.
- Ticking, deleting and clearing all work, with the counter updating.
- You can explain why the screen calls the ViewModel instead of editing the list.