Tutorial: Todo List App
Build a fully functional todo list app with Shaft step by step.
Interactive Demo
Try the complete todo app below:
Todo List App
What You’ll Learn
- Managing collections with
@Observable
- Working with arrays and structs
- Building reusable widget components
- Using
Identifiable
protocol for list items - Conditional rendering
Step-by-Step Guide
Step 1: Define the Data Model
Create a struct for todo items:
struct TodoItem: Identifiable {
let id = UUID()
var title: String
var isCompleted: Bool = false
}
Step 2: Create the Observable State
Build a class to manage the todo list:
@Observable
class TodoList {
var items: [TodoItem] = [
TodoItem(title: "Learn Shaft basics"),
TodoItem(title: "Build a counter app", isCompleted: true),
TodoItem(title: "Create a todo list")
]
var newItemText = ""
func addItem() {
guard !newItemText.isEmpty else { return }
items.append(TodoItem(title: newItemText))
newItemText = ""
}
func toggleItem(id: UUID) {
if let index = items.firstIndex(where: { $0.id == id }) {
items[index].isCompleted.toggle()
}
}
func deleteItem(id: UUID) {
items.removeAll { $0.id == id }
}
var remainingItems: Int {
items.filter { !$0.isCompleted }.count
}
}
Key points:
- Array of
TodoItem
holds all todos - Methods encapsulate all list operations
remainingItems
computed property automatically calculates incomplete items- When
items
array changes, UI updates automatically
Step 3: Build the Main UI
Create the app structure:
final class TodoApp: StatelessWidget {
func build(context: any BuildContext) -> any Widget {
ListView(padding: .all(24)) {
// Header
Text("My Todo List")
.textStyle(.init(fontSize: 24, fontWeight: .bold))
.padding(.only(bottom: 8))
// Stats
Text("(todoList.remainingItems) remaining")
.textStyle(.init(color: Color(0xFF_666666), fontSize: 14))
SizedBox(height: 8)
// Items will go here
SizedBox(height: 8)
// Add new item input
Row(spacing: 8) {
Expanded {
TextField(
onChanged: { todoList.newItemText = $0 },
placeholder: "Add a new item"
)
}
Button {
todoList.addItem()
} child: {
Text("+ Add Item")
}
}
}
}
}
Layout:
ListView
provides scrollable list with paddingSizedBox
adds consistent spacing between sectionsRow
withExpanded
creates input field + button layout
Step 4: Display Todo Items
Loop through items and display each one:
// Inside ListView
for item in todoList.items {
TodoItemView(item: item)
.padding(.only(bottom: 8))
}
How it works:
- Swift’s
for
loop generates a widget for each item - When
items
array changes, Shaft rebuilds this section - Each item gets its own
TodoItemView
widget
Step 5: Create the Item Widget
Build a reusable component for each todo:
final class TodoItemView: StatelessWidget {
let item: TodoItem
init(item: TodoItem) {
self.item = item
}
func build(context: any BuildContext) -> any Widget {
Row(crossAxisAlignment: .center, spacing: 12) {
// Checkbox
Button {
todoList.toggleItem(id: self.item.id)
} child: {
SizedBox(width: 20, height: 20) {
Text(item.isCompleted ? "✓" : "")
.textStyle(.init(color: Color(0xFF_FFFFFF), fontSize: 14))
.center()
}
.decoration(
.box(
color: item.isCompleted
? Color(0xFF_4CAF50)
: Color(0xFF_EEEEEE),
borderRadius: .circular(4)
))
}
// Title
Expanded {
Text(item.title)
.textStyle(
.init(
color: item.isCompleted
? Color(0xFF_999999)
: Color(0xFF_000000),
fontSize: 16
))
}
// Delete button
Button {
todoList.deleteItem(id: self.item.id)
} child: {
Text("×")
.textStyle(.init(color: Color(0xFF_FF5252), fontSize: 20))
}
}
.padding(.all(12))
.decoration(
.box(
color: Color(0xFF_F5F5F5),
borderRadius: .circular(8)
)
)
.buttonStyle(TodoItemButton())
}
}
struct TodoItemButton: Button.Style {
func build(context: any Button.StyleContext) -> any Widget {
context.child
}
}
Component structure:
Row
arranges checkbox, title, and delete button horizontallyExpanded
makes title take remaining space- Conditional styling based on
isCompleted
state TodoItemButton
style removes default button styling
Step 6: Add Statistics
Show remaining items count using the computed property:
Text("(todoList.remainingItems) remaining")
.textStyle(.init(color: Color(0xFF_666666), fontSize: 14))
How it works:
remainingItems
computed property automatically calculates incomplete items- Updates automatically when items change
- Cleaner than inline filtering in the UI
Understanding the Pattern
Data Flow
User clicks checkbox
↓
toggleItem() modifies items array
↓
@Observable detects change
↓
Shaft rebuilds TodoApp
↓
For loop creates updated TodoItemViews
↓
UI shows new state
Next Steps
- Review the Counter App for basics
- Learn more about State Management patterns
- Explore Layout System for complex layouts
- Check out Widget Styling for customization