Introduction
SwiftUI, Apple's declarative framework for iOS, macOS, and beyond, has evolved in 2026 with SwiftUI 6: improved performance, smooth animations, and native AI integration. This intermediate tutorial walks you through building a complete Todo List app, handling reactive state (@State, @Binding), navigation (NavigationStack), interactive lists (swipe to delete), modal forms, and local persistence (@AppStorage).
Why it matters: Modern iOS apps demand responsive UIs without UIKit boilerplate. You'll learn to structure scalable apps, sidestep common issues like unnecessary re-renders, and optimize for iOS 20. At the end, you'll have copy-pasteable code ready for the App Store. Perfect for devs with Swift basics aiming to level up. (128 words)
Prerequisites
- Xcode 16+ (2026 version recommended)
- Basic Swift knowledge (structs, optionals, closures)
- iOS 20 simulator or physical device
- Empty SwiftUI iOS project (create via File > New > Project > App)
Main App Structure
import SwiftUI
@main
struct ToDoApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
#Preview {
ContentView()
}This entry-point file defines the SwiftUI app with @main. WindowGroup handles multi-platform windows. The #Preview enables live previews in Xcode, speeding up iterative development. Avoid adding state here to keep the app lightweight.
Task Data Model
Let's define a simple yet extensible Task model: unique ID, title, completion status, and creation date. Use Identifiable for SwiftUI lists and Codable for future persistence. Think of it like a Lego blueprint—reusable everywhere.
Define the Task Model
import Foundation
struct Task: Identifiable, Codable {
let id = UUID()
var title: String
var isCompleted: Bool = false
var createdAt: Date = Date()
mutating func toggle() {
isCompleted.toggle()
}
}
extension Task: Hashable {
func hash(into hasher: inout Hasher) {
hasher.combine(id)
}
}This struct is Identifiable for List usage and Codable for JSON persistence. The toggle() method encapsulates business logic. The Hashable extension prevents comparison bugs in Sets/Dictionaries. Pitfall: Forgetting unique UUID() causes duplicates in Lists.
ContentView with Interactive List
import SwiftUI
struct ContentView: View {
@State private var tasks: [Task] = []
@State private var showingAddTask = false
var body: some View {
NavigationStack {
List {
ForEach($tasks) { $task in
TaskRowView(task: $task)
}
.onDelete(perform: deleteTasks)
}
.navigationTitle("Mes Todos")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("+") {
showingAddTask = true
}
}
}
.sheet(isPresented: $showingAddTask) {
AddTaskView(tasks: $tasks)
}
}
}
private func deleteTasks(at offsets: IndexSet) {
tasks.remove(atOffsets: offsets)
}
}
#Preview {
ContentView()
}@State manages reactive local state for tasks. NavigationStack (iOS 16+) replaces NavigationView for stack-based navigation. ForEach($tasks) with binding $task enables inline edits. Swipe-to-delete via .onDelete. Modal sheet for adding avoids UI overload.
Task Row and Add View
Create a TaskRowView to customize each item: checkbox, strikethrough, timestamp. Then build AddTaskView with a validated form. @Binding propagates changes between parent and child, like unidirectional reactive data flow.
TaskRowView and AddTaskView
import SwiftUI
struct TaskRowView: View {
@Binding var task: Task
var body: some View {
HStack {
Image(systemName: task.isCompleted ? "checkmark.circle.fill" : "circle")
.foregroundStyle(task.isCompleted ? .green : .gray)
.onTapGesture {
task.toggle()
}
Text(task.title)
.strikethrough(task.isCompleted)
.foregroundStyle(task.isCompleted ? .secondary : .primary)
Spacer()
Text(task.createdAt, style: .date)
.font(.caption)
.foregroundStyle(.secondary)
}
.padding(.vertical, 4)
}
}
struct AddTaskView: View {
@Binding var tasks: [Task]
@Environment(\dismiss) private var dismiss
@State private var newTaskTitle = ""
var body: some View {
NavigationStack {
Form {
TextField("Titre de la tâche", text: $newTaskTitle)
}
.navigationTitle("Nouvelle tâche")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Ajouter") {
let newTask = Task(title: newTaskTitle)
tasks.append(newTask)
dismiss()
}
.disabled(newTaskTitle.trimmingCharacters(in: .whitespaces).isEmpty)
}
ToolbarItem(placement: .topBarLeading) {
Button("Annuler") { dismiss() }
}
}
}
}
}
#Preview {
TaskRowView(task: .constant(Task(title: "Test")))
}
#Preview {
AddTaskView(tasks: .constant([]))
}TaskRowView uses @Binding to mutate the parent task. onTapGesture handles visual toggling. AddTaskView validates input (disabled if empty). @Environment(dismiss) closes sheets natively. Form auto-validates and styles. Pitfall: Without trimmingCharacters, spaces create empty tasks.
Add Persistence with TaskStore
import SwiftUI
@Observable
class TaskStore {
var tasks: [Task] = []
private let storageKey = "tasks"
init() {
loadTasks()
}
func addTask(_ task: Task) {
tasks.append(task)
saveTasks()
}
func deleteTasks(at offsets: IndexSet) {
tasks.remove(atOffsets: offsets)
saveTasks()
}
func toggleTask(id: UUID) {
if let index = tasks.firstIndex(where: { $0.id == id }) {
tasks[index].toggle()
saveTasks()
}
}
private func saveTasks() {
if let data = try? JSONEncoder().encode(tasks) {
UserDefaults.standard.set(data, forKey: storageKey)
}
}
private func loadTasks() {
if let data = UserDefaults.standard.data(forKey: storageKey),
let savedTasks = try? JSONDecoder().decode([Task].self, from: data) {
tasks = savedTasks
}
}
}
struct PersistentContentView: View {
@State private var taskStore = TaskStore()
@State private var showingAddTask = false
var body: some View {
NavigationStack {
List {
ForEach(taskStore.tasks) { task in
TaskRowView(task: task)
.onTapGesture {
taskStore.toggleTask(id: task.id)
}
}
.onDelete(perform: taskStore.deleteTasks)
}
.navigationTitle("Todos Persistants")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("+") { showingAddTask = true }
}
}
.sheet(isPresented: $showingAddTask) {
AddPersistentTaskView(taskStore: taskStore)
}
}
}
}
#Preview {
PersistentContentView()
}@Observable (Swift 5.9+) replaces ObservableObject for modern data flow. TaskStore acts like a singleton to persist via UserDefaults/JSON. addTask, delete, and toggle auto-save. Swap out @State tasks in ContentView for this upgrade. Pitfall: Without try?, decoding failures crash the app.
Final Integration and Testing
Replace ContentView with PersistentContentView. Test by adding/deleting/toggling tasks, then restart the app—data persists. Add animations (.transition) for pro polish.
Task Detail View with NavigationLink
import SwiftUI
struct TaskDetailView: View {
let task: Task
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
HStack {
Image(systemName: task.isCompleted ? "checkmark.circle.fill" : "circle")
.font(.largeTitle)
.foregroundStyle(task.isCompleted ? .green : .gray)
Text(task.title)
.font(.title)
.fontWeight(.semibold)
}
Label("Créée le", systemImage: "calendar")
.labelStyle(.titleOnly)
Text(task.createdAt, style: .date)
.foregroundStyle(.secondary)
Divider()
if task.isCompleted {
Text("✅ Terminée !")
.font(.headline)
.foregroundStyle(.green)
} else {
Text("⏳ En cours")
.font(.headline)
.foregroundStyle(.orange)
}
}
.padding()
}
.navigationTitle(task.title)
.navigationBarTitleDisplayMode(.inline)
}
}
#Preview {
NavigationStack {
TaskDetailView(task: Task(title: "Exemple"))
}
}Detail view for in-depth task inspection. ScrollView + VStack for responsive layout. SF Symbols and Labels for native Apple UX. Integrate via NavigationLink(value: task) in the List (with .navigationDestination). Avoids List clutter and boosts accessibility.
Best Practices
- Separate concerns: Pure models, stateless Views where possible, Stores for data.
- Use
@Observableover@StateObjectfor 2026 performance. - Add
.animation(.spring(), value: tasks)for smooth transitions. - Test on device: Simulator lags with long lists.
- Always validate inputs:
trimmingCharacters(in: .whitespaces)+ min length.
Common Errors to Avoid
- Infinite re-renders: Avoid mutating
@Stateinbody; use computed properties. - Data loss: Always call
saveTasks()after mutations, handletry?gracefully. - Navigation bugs: Prefer
NavigationStackover deprecatedNavigationView. - Broken previews: Provide mocks like
.constant()for bindings.
Next Steps
Upgrade to CloudKit/SwiftData for multi-device sync. Explore Advanced SwiftUI Animations. Join our Learni iOS/SwiftUI courses for live masterclasses and real-world projects.