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 padding
  • SizedBox adds consistent spacing between sections
  • Row with Expanded 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 horizontally
  • Expanded 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