What You Will Build

A task management app with an input field for adding new tasks, checkboxes for marking completion, strikethrough text on done items, swipe-to-delete via an icon button, and a live completion counter. This is the core pattern behind apps like Todoist and Microsoft To Do.

Why This Pattern Matters

Task lists are the most common CRUD pattern in mobile apps. This implementation teaches you how to manage a mutable list of data class objects, handle checkbox state changes immutably with .map and .copy, and use .filter for deletion.

Step 1: Define the Task Model

data class Task(
    val id: String = java.util.UUID.randomUUID().toString(),
    val title: String,
    var done: Boolean = false
)

Step 2: Build the Input Row

An OutlinedTextField paired with a FilledIconButton lets users type and submit new tasks:

Row(
    modifier = Modifier.fillMaxWidth().padding(16.dp),
    verticalAlignment = Alignment.CenterVertically
) {
    OutlinedTextField(
        value = newTask,
        onValueChange = { newTask = it },
        modifier = Modifier.weight(1f),
        placeholder = { Text("New task...") },
        singleLine = true
    )
    Spacer(Modifier.width(8.dp))
    FilledIconButton(onClick = {
        if (newTask.isNotBlank()) {
            tasks = listOf(Task(title = newTask)) + tasks
            newTask = ""
        }
    }) {
        Icon(Icons.Default.Add, "Add")
    }
}

Step 3: Build the Task List with Checkboxes

LazyColumn(modifier = Modifier.weight(1f)) {
    items(tasks, key = { it.id }) { task ->
        Card(
            modifier = Modifier
                .fillMaxWidth()
                .padding(horizontal = 16.dp, vertical = 4.dp)
                .animateItem()
        ) {
            Row(
                modifier = Modifier.padding(12.dp),
                verticalAlignment = Alignment.CenterVertically
            ) {
                Checkbox(
                    checked = task.done,
                    onCheckedChange = { checked ->
                        tasks = tasks.map {
                            if (it.id == task.id) it.copy(done = checked) else it
                        }
                    }
                )
                Text(
                    task.title,
                    modifier = Modifier.weight(1f),
                    textDecoration = if (task.done)
                        TextDecoration.LineThrough else null,
                    color = if (task.done)
                        MaterialTheme.colorScheme.onSurfaceVariant
                    else MaterialTheme.colorScheme.onSurface
                )
                IconButton(onClick = {
                    tasks = tasks.filter { it.id != task.id }
                }) {
                    Icon(Icons.Default.Delete, "Delete",
                        tint = MaterialTheme.colorScheme.error)
                }
            }
        }
    }
}

Tips and Pitfalls

  • Immutable updates are critical. Use tasks.map { ... } and .copy() rather than mutating in place. Compose only detects state changes through new object references.
  • animateItem() on the Card gives smooth insertion and removal animations in the LazyColumn.
  • Prepend new tasks (listOf(new) + tasks) so users see them immediately at the top without scrolling.
  • Completion counter: tasks.count { it.done } / tasks.size updates reactively because tasks is a state variable.

Min SDK: 21 | Compose BOM: 2024.01.00+

Get New Tutorials by Email

No spam. Just clear, practical breakdowns you can apply right away.

Enjoy this tutorial?

Get new practical tech tutorials in your inbox.