We will do a simple demonstration of SwiftUI's capabilities for state management, including the use
of @State
, @Binding
, @ObservedObject
, and @EnvironmentObject
, using a TODO list app. This app allows users to
add tasks, mark them as completed, edit task details, and includes animations when reacting to state changes.
You can name it however you want; pick a fun name! For now, we'll call it "TaskManager."
Let's get started!
- Create a New SwiftUI Project: Open Xcode, select "Create a new Xcode project," choose the SwiftUI App template, and name your project "TaskManager."
- Project Structure Overview:
- The
ContentView.swift
file is where we'll spend most of our time, crafting the UI and logic of our app. - The
@main
App struct inTaskManagerApp.swift
, which serves as the entry point of our SwiftUI application.
- The
Create a TodoItem.swift
file, define a TodoItem
struct with the following properties:
import Foundation
struct TodoItem: Identifiable {
var id = UUID()
var name: String
var isCompleted: Bool
}
In this step, we'll create a view that lists all the tasks. We'll use @State
to manage the array of tasks within this
view.
Modify the ContentView
to include a @State
variable that holds an array of TodoItem
s and display them in a List
.
import SwiftUI
struct ContentView: View {
@State private var tasks = [TodoItem]()
var body: some View {
List(tasks) { task in
Text(task.name)
}
.navigationBarTitle("TODOs")
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
Now, we'll add functionality to allow users to add new tasks to the list. We'll use a TextField
for input and
a Button
to submit the new task. This is a common combination that creates something similar to an HTML form for user
input.
Embed the existing List
in a VStack
and add a TextField
and a Button
above it to allow users to enter a new task
name and add it to the list.
struct ContentView: View {
@State private var tasks = [TodoItem]()
@State private var newTaskName = "" // For capturing user input
var body: some View {
NavigationView {
VStack {
TextField("Enter new task", text: $newTaskName)
.padding()
Button(action: {}) {
Text("Add Task")
}
.padding()
.disabled(newTaskName.isEmpty)
List(tasks) { task in
Text(task.name)
}
.navigationBarTitle("TODOs")
}
}
}
}
Next, we implement the button functionality to add a new task upon form submission.
struct ContentView: View {
// ...
private func addNewTask() {
let newTask = TodoItem(name: newTaskName, isCompleted: false)
tasks.append(newTask)
newTaskName = "" // Reset input field
}
// ...
}
Button(action: addNewTask)
It is common to create a custom view for individual item views in a list, especially when the item view is complex. To
share data between the parent list and the children item views, we can use @Binding
.
First, create a TaskItemView.swift
file, and implement the item view:
import SwiftUI
struct TaskItemView: View {
@Binding var task: TodoItem
var body: some View {
Text(task.name)
}
}
Then, update the List
in ContentView
to use the custom item view:
List($tasks) { $task in
TaskItemView(task: $task)
}
To allow users to mark tasks as completed, we'll add a toggle next to each task in the list. Modify the TaskItemView
to include a Toggle
for each task, bound to the task's isCompleted
property.
struct TaskItemView: View {
@Binding var task: TodoItem
var body: some View {
Toggle(isOn: $task.isCompleted) {
Text(task.name)
}
}
}
When you have data that needs to be shared across multiple views or when your data model involves more complex
interactions, @ObservedObject
becomes invaluable. It allows views to observe changes in an object that conforms to the
ObservableObject
protocol, making it perfect for scenarios like editing task details.
First, let's define a TaskViewModel
that will act as an @ObservedObject
. This view model will manage the state of
the task being edited. Create a TaskViewModel.swift
and implement the view model:
import Foundation
class TaskViewModel: ObservableObject {
@Published var task: TodoItem
@Published var draftName: String
init(task: TodoItem) {
self.task = task
self.draftName = task.name
}
// Function to update the task's name
func updateTaskName() {
task.name = draftName
}
// Function to toggle the task's completion status
func toggleCompletion() {
task.isCompleted.toggle()
}
}
Then, update TaskItemView
to use TaskViewModel
as an @ObservedObject
. This allows TaskItemView
to respond to
changes in the task's properties, such as its name or completion status. Note that .onChange
is used for updating the
task name.
import SwiftUI
struct TaskItemView: View {
@ObservedObject var viewModel: TaskViewModel
var body: some View {
HStack {
TextField("Task Name", text: $viewModel.draftName)
.onChange(of: viewModel.draftName) {
viewModel.updateTaskName()
}
Toggle(isOn: $viewModel.task.isCompleted) {
EmptyView()
}
}
}
}
Finally, adjust the List
in ContentView
to create TaskItemView
by initializing a TaskViewModel
with the
corresponding TodoItem
.
List($tasks) { $task in
TaskItemView(viewModel: TaskViewModel(task: task))
}
Now, you may notice that when you add a new task, all existing tasks are reset. This is because we kept a simple @State
array of tasks which is not shared with the tasks inside the view models. To solve this, we can turn TodoItem
from a
struct to a class, so that it is passed by reference.
class TodoItem: Identifiable {
var id = UUID()
var name: String
var isCompleted: Bool
init(id: UUID = UUID(), name: String, isCompleted: Bool) {
self.id = id
self.name = name
self.isCompleted = isCompleted
}
}
We can use transitions to visually distinguish between active and completed tasks. For instance, when a task is marked as completed, it could fade out or move to a different section of the UI.
struct TaskItemView: View {
@ObservedObject var viewModel: TaskViewModel
var body: some View {
HStack {
TextField("Task Name", text: $viewModel.draftName)
.onChange(of: viewModel.draftName) {
viewModel.updateTaskName()
}
.opacity(viewModel.task.isCompleted ? 0.5 : 1) // Reduced opacity when completed
.animation(.default, value: viewModel.task.isCompleted) // Animate on isCompleted change
Toggle(isOn: $viewModel.task.isCompleted) {
EmptyView()
}
}
}
}
To make the addition of new tasks visually appealing, we can wrap the insertion logic in a withAnimation
block.
private func addNewTask() {
withAnimation {
let newTask = TodoItem(name: newTaskName, isCompleted: false)
tasks.append(newTask)
newTaskName = "" // Reset input field
}
}
@EnvironmentObject
is ideal for sharing global data, such as user preferences or themes, across multiple views without
the need to pass them through each view explicitly.
First, define a UserPreferences
class in a new UserPrefrences.swift
file that includes user preferences like theme
settings. This class will be observed by various parts of our app to reflect changes in real-time.
import Foundation
import SwiftUI
class UserPreferences: ObservableObject {
@Published var themeColor: Color = .blue // Default theme color
// Add more preferences as needed
}
Now, modify TaskManagerApp.swift
to create an instance of UserPreferences
and provide it as an environment object to
the entire app. This way, all views within the app can access and react to changes in user preferences.
import SwiftUI
@main
struct TaskManagerApp: App {
var userPreferences = UserPreferences() // Instantiate a UserPreferences object
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(userPreferences) // Add the UserPreferences object to the environment
}
}
}
Now, we can access it from TaskItemView
using the @EnvironmentObject
property wrapper:
struct TaskItemView: View {
@EnvironmentObject var userPreferences: UserPreferences // Access the environment object
// ...
var body: some View {
// ...
TextField("Task Name", text: $viewModel.draftName)
.onChange(of: viewModel.draftName) {
viewModel.updateTaskName()
}
.foregroundColor(userPreferences.themeColor) // Use theme color for text
.opacity(viewModel.task.isCompleted ? 0.5 : 1)
.animation(.default, value: viewModel.task.isCompleted)
// ...
}
}
Remember how we said it's bad to use classes until absolutely necessary? Here, you can keep TodoItem
a struct by using
a @EnvironmentObject
to store the list of tasks. Try it out yourself! You may also find @StateObject
useful when
initializing the object you use to keep track of the list of tasks.
Great! You've just created your first full app from scratch. You can now call yourself an iOS developer. 😎