Project 2: To-Do List with ViewModel and LazyColumn

Android Development with Kotlin: 21 Real Projects · Compose and State · 8 min read · Updated

Events up, state down

  1. The user taps add or a checkbox. The screen sends that event up to the ViewModel.
  2. The ViewModel builds a new list and stores it in the StateFlow.
  3. The screen collects the flow, so it receives the new list.
  4. LazyColumn draws the rows from the new list, using each task id as the key.

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 key so Compose can move and animate rows correctly.

Create the project

  1. Create an Empty Activity project named Todo, package com.example.todo.
  2. Open app/build.gradle.kts and add these lines to dependencies, then press Sync Now.
  3. 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

  1. Build and run the app, and check the example above.
  2. Show a line at the bottom that says "All done, well done!" when every task is ticked and the list is not empty.
  3. Add an edit function to the ViewModel and let the user tap a title to change it.
  4. Write down one bug that would happen if TaskRow changed the list itself instead of calling onToggle.

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.

Frequently asked questions

Why use a ViewModel instead of remember?

A ViewModel survives screen rotation and holds logic that does not belong in UI code. remember is fine for small, temporary UI state such as the text in a box.

What is state hoisting?

It means moving state up out of a composable and passing it in as a value plus lambdas for events. The composable becomes simpler, easier to reuse and easier to test.

Why must LazyColumn items have a key?

The key tells Compose which row is which when the list changes. Without it, Compose may redraw or move the wrong rows and lose state such as a checkbox animation.