Introduction
SwiftUI, Apple's declarative framework since iOS 13, revolutionizes iOS UI development in 2026 with live previews and native state management. This intermediate tutorial guides you through building a complete Todo app: dynamic lists, add/delete, detail navigation, local persistence, and mock API task fetching.
Why it matters: Modern apps demand smooth reactivity without UIKit boilerplate. You'll master @State, @Binding, NavigationStack, @AppStorage, and Task for async ops. By the end, you'll have a bookmarkable, testable app in Xcode 16+ simulator. Time: 30 min. Ready to level up your iOS skills? (112 words)
Prerequisites
- Xcode 16+ (free on Mac App Store)
- Basic Swift knowledge (structs, optionals)
- iPhone simulator set up
- No external dependencies: pure SwiftUI
- Intermediate level: familiarity with closures and protocols
Basic view with static list
import SwiftUI
struct TodoItem: Identifiable {
let id = UUID()
var title: String
var isCompleted: Bool = false
}
struct ContentView: View {
let todos = [
TodoItem(title: "Learn SwiftUI"),
TodoItem(title: "Build the Todo App", isCompleted: true)
]
var body: some View {
NavigationStack {
List(todos) { todo in
HStack {
Text(todo.title)
Spacer()
Image(systemName: todo.isCompleted ? "checkmark.circle.fill" : "circle")
}
}
.navigationTitle("My Todos")
}
}
}
#Preview {
ContentView()
}This code creates a basic ContentView with an identifiable TodoItem model and a static list using List. NavigationStack (iOS 16+) handles future navigation. The preview lets you test instantly. Always use Identifiable arrays to avoid List crashes.
Dynamic state management
Now add mutable state with @State. Think of state like a spring: change it, and the view redraws automatically. We'll add a button to toggle tasks.
Add state and toggle
import SwiftUI
struct TodoItem: Identifiable {
let id = UUID()
var title: String
var isCompleted: Bool = false
}
struct ContentView: View {
@State private var todos: [TodoItem] = [
TodoItem(title: "Learn SwiftUI"),
TodoItem(title: "Build the Todo App", isCompleted: true)
]
var body: some View {
NavigationStack {
List {
ForEach($todos) { $todo in
HStack {
Text(todo.title)
Spacer()
Image(systemName: todo.isCompleted ? "checkmark.circle.fill" : "circle")
.foregroundStyle(todo.isCompleted ? .green : .gray)
.onTapGesture {
todo.isCompleted.toggle()
}
}
}
}
.navigationTitle("My Todos")
}
}
}
#Preview {
ContentView()
}@State makes todos mutable; $todos provides @Binding for ForEach. The .onTapGesture toggles state, triggering a redraw. Always use $ for mutable bindings in List, or changes won't persist.
Adding tasks
@Binding connects parent-child: change the child, the parent updates. Introduce a Sheet for adding todos via a bound TextField.
Add sheet with form
import SwiftUI
struct TodoItem: Identifiable {
let id = UUID()
var title: String
var isCompleted: Bool = false
}
struct ContentView: View {
@State private var todos: [TodoItem] = [
TodoItem(title: "Learn SwiftUI"),
TodoItem(title: "Build the Todo App", isCompleted: true)
]
@State private var showingAddSheet = false
@State private var newTodoTitle = ""
var body: some View {
NavigationStack {
List {
ForEach($todos) { $todo in
HStack {
Text(todo.title)
Spacer()
Image(systemName: todo.isCompleted ? "checkmark.circle.fill" : "circle")
.foregroundStyle(todo.isCompleted ? .green : .gray)
.onTapGesture {
todo.isCompleted.toggle()
}
}
}
.onDelete(perform: deleteTodos)
}
.navigationTitle("My Todos")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("+") {
showingAddSheet = true
}
}
}
.sheet(isPresented: $showingAddSheet) {
NavigationStack {
Form {
TextField("New Todo", text: $newTodoTitle)
}
.navigationTitle("Add")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Add") {
let todo = TodoItem(title: newTodoTitle)
todos.append(todo)
newTodoTitle = ""
showingAddSheet = false
}
.disabled(newTodoTitle.isEmpty)
}
}
}
}
}
}
private func deleteTodos(at offsets: IndexSet) {
todos.remove(atOffsets: offsets)
}
}
#Preview {
ContentView()
}@State for showingAddSheet and newTodoTitle; sheet with binding. .onDelete enables swipe-to-delete. Button disables if empty (.disabled). .toolbar positions the + action cleanly over deprecated .navigationBarItems.
Local persistence
Save data with @AppStorage: like a vault shared app-wide, serializing to UserDefaults. Ideal for simple todos.
Persistence with AppStorage
import SwiftUI
struct TodoItem: Identifiable, Codable {
let id = UUID()
var title: String
var isCompleted: Bool = false
}
struct ContentView: View {
@AppStorage("todos") private var todosData: Data = Data()
@State private var todos: [TodoItem] = []
@State private var showingAddSheet = false
@State private var newTodoTitle = ""
var body: some View {
NavigationStack {
List {
ForEach($todos) { $todo in
HStack {
Text(todo.title)
Spacer()
Image(systemName: todo.isCompleted ? "checkmark.circle.fill" : "circle")
.foregroundStyle(todo.isCompleted ? .green : .gray)
.onTapGesture {
todo.isCompleted.toggle()
saveTodos()
}
}
}
.onDelete(perform: deleteTodos)
}
.navigationTitle("My Todos")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("+") {
showingAddSheet = true
}
}
}
.sheet(isPresented: $showingAddSheet) {
NavigationStack {
Form {
TextField("New Todo", text: $newTodoTitle)
}
.navigationTitle("Add")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Add") {
let todo = TodoItem(title: newTodoTitle)
todos.append(todo)
newTodoTitle = ""
showingAddSheet = false
saveTodos()
}
.disabled(newTodoTitle.isEmpty)
}
}
}
}
.onAppear {
loadTodos()
}
}
}
private func saveTodos() {
if let data = try? JSONEncoder().encode(todos) {
todosData = data
}
}
private func loadTodos() {
if let decodedTodos = try? JSONDecoder().decode([TodoItem].self, from: todosData) {
todos = decodedTodos
}
}
private func deleteTodos(at offsets: IndexSet) {
todos.remove(atOffsets: offsets)
saveTodos()
}
}
#Preview {
ContentView()
}TodoItem conforms to Codable. @AppStorage("todos") stores JSON Data. saveTodos/loadTodos encode/decode. Called on onAppear, toggle, and delete. Skip direct UserDefaults: @AppStorage handles redraws automatically.
Navigation to details
NavigationLink for details: like a portal to a child view. Add a TodoDetail view to edit the title.
Details with NavigationLink
import SwiftUI
struct TodoDetailView: View {
@Bindable var todo: TodoItem
@Environment(\dismiss) private var dismiss
var body: some View {
Form {
TextField("Title", text: $todo.title)
Toggle("Completed", isOn: $todo.isCompleted)
}
.navigationTitle("Todo Detail")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Save") {
dismiss()
}
}
}
}
}
#Preview {
NavigationStack {
TodoDetailView(todo: TodoItem(title: "Example"))
}
}@Bindable (iOS 17+) for bidirectional binding. @Environment(\dismiss) closes the view. Integrate via NavigationLink(value: todo) in ContentView. @Bindable is safer than @Binding for complex structs.
Integrate details in ContentView
import SwiftUI
struct TodoItem: Identifiable, Codable {
let id = UUID()
var title: String
var isCompleted: Bool = false
}
struct ContentView: View {
@AppStorage("todos") private var todosData: Data = Data()
@State private var todos: [TodoItem] = []
@State private var showingAddSheet = false
@State private var newTodoTitle = ""
var body: some View {
NavigationStack {
List {
ForEach($todos) { $todo in
NavigationLink(value: todo) {
HStack {
Text(todo.title)
Spacer()
Image(systemName: todo.isCompleted ? "checkmark.circle.fill" : "circle")
.foregroundStyle(todo.isCompleted ? .green : .gray)
}
}
}
.navigationDestination(for: TodoItem.self) { todo in
TodoDetailView(todo: todo)
}
}
.navigationTitle("My Todos")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("+") {
showingAddSheet = true
}
}
}
.sheet(isPresented: $showingAddSheet) {
NavigationStack {
Form {
TextField("New Todo", text: $newTodoTitle)
}
.navigationTitle("Add")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Add") {
let todo = TodoItem(title: newTodoTitle)
todos.append(todo)
newTodoTitle = ""
showingAddSheet = false
saveTodos()
}
.disabled(newTodoTitle.isEmpty)
}
}
}
}
.onAppear {
loadTodos()
}
}
}
private func saveTodos() {
if let data = try? JSONEncoder().encode(todos) {
todosData = data
}
}
private func loadTodos() {
if let decodedTodos = try? JSONDecoder().decode([TodoItem].self, from: todosData) {
todos = decodedTodos
}
}
}
#Preview {
ContentView()
}NavigationLink(value:) + .navigationDestination (iOS 16+) routes to TodoDetailView. Detail changes persist via @Bindable. Add import SwiftUI and TodoDetailView to your project. Remove redundant list toggle/details.
Asynchronous fetch
Simulate an API with Task and async/await: like a delivery service that loads without blocking the UI.
Mock API fetch
import SwiftUI
struct TodoItem: Identifiable, Codable {
let id = UUID()
var title: String
var isCompleted: Bool = false
}
struct ContentView: View {
@AppStorage("todos") private var todosData: Data = Data()
@State private var todos: [TodoItem] = []
@State private var showingAddSheet = false
@State private var newTodoTitle = ""
@State private var isLoading = false
var body: some View {
NavigationStack {
List {
ForEach($todos) { $todo in
NavigationLink(value: todo) {
HStack {
Text(todo.title)
Spacer()
Image(systemName: todo.isCompleted ? "checkmark.circle.fill" : "circle")
.foregroundStyle(todo.isCompleted ? .green : .gray)
}
}
}
.navigationDestination(for: TodoItem.self) { todo in
TodoDetailView(todo: todo)
}
}
.navigationTitle("My Todos")
.overlay {
if isLoading {
ProgressView("Loading...")
}
}
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("+") {
showingAddSheet = true
}
}
ToolbarItem {
Button("Refresh") {
Task { await fetchTodos() }
}
}
}
.sheet(isPresented: $showingAddSheet) {
NavigationStack {
Form {
TextField("New Todo", text: $newTodoTitle)
}
.navigationTitle("Add")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Add") {
let todo = TodoItem(title: newTodoTitle)
todos.append(todo)
newTodoTitle = ""
showingAddSheet = false
saveTodos()
}
.disabled(newTodoTitle.isEmpty)
}
}
}
}
.task {
await fetchTodos()
}
.onAppear {
loadTodos()
}
}
}
private func fetchTodos() async {
isLoading = true
defer { isLoading = false }
do {
// Mock API delay
try await Task.sleep(nanoseconds: 1_000_000_000)
let url = URL(string: "https://jsonplaceholder.typicode.com/todos?_limit=5")!
let (data, _) = try await URLSession.shared.data(from: url)
todos = try JSONDecoder().decode([TodoItem].self, from: data)
saveTodos()
} catch {
print("Fetch error: \(error)")
}
}
private func saveTodos() {
if let data = try? JSONEncoder().encode(todos) {
todosData = data
}
}
private func loadTodos() {
if let decodedTodos = try? JSONDecoder().decode([TodoItem].self, from: todosData) {
todos = decodedTodos
}
}
}
#Preview {
ContentView()
}.task runs fetchTodos() on mount. async/await + URLSession fetches from JSONPlaceholder (mock todos). defer handles loading. ProgressView overlay. Adapt TodoItem for real APIs; handle errors with do-catch.
Best practices
- Always preview:
#Previewspeeds iteration x10. - Use
@Bindablefor mutable structs (iOS 17+). - Limit
@State: prefer@AppStorageor@Observable(iOS 17). - Adopt
NavigationStackover deprecatedNavigationView. - Test on device: Simulator sometimes skips advanced gestures.
Common errors to avoid
- Forget
Identifiableon models: List crashes. - Mutate
@Statewithout$binding: state lost. - Block UI in async: always use
Taskor.task. - Skip persistence: app empty on relaunch.
Next steps
Integrate SwiftData for native ORM or Combine for reactive streams. Explore Advanced SwiftUI Animations. Join our Learni iOS trainings for live masterclasses. Official docs: SwiftUI by Apple.