7 Design Patterns in SwiftUI

Introduction

Design patterns in SwiftUI, like in any programming framework, help organize code and promote maintainability, scalability, and readability. SwiftUI is a modern declarative framework, and it encourages the use of design patterns that fit its paradigm. Here are 7 common design patterns in SwiftUI:

  1. MVVM (Model-View-ViewModel): This is a widely used pattern in SwiftUI. It separates your code into three components:
    • Model: Represents your data and business logic.
    • View: Represents the UI and its structure.
    • ViewModel: Acts as a bridge between the Model and View, preparing data for the View and handling user interactions.
  2. Observable Object: SwiftUI provides the ObservableObject protocol to make data observable. It’s often used in conjunction with the @Published property wrapper. An observed object can notify views when its data changes.
  3. EnvironmentObject: This is a way to share data throughout your SwiftUI app. It’s especially useful for global settings or user data that many views need access to.
  4. Dependency Injection: SwiftUI makes it easy to pass data down the view hierarchy. You can inject dependencies directly into views or view models to make your code more modular and testable.
  5. Combine Framework: Combine is a powerful framework for handling asynchronous and event-driven code. It works well with SwiftUI and can be used to create reactive data flows.
  6. Coordinator: In more complex SwiftUI apps, you might use the Coordinator pattern to manage navigation and other complex UI components. This is often used with UIKit integrations.
  7. ViewModifiers: SwiftUI allows you to create reusable view modifiers to encapsulate and share view styling and behavior.

MVVM Pattern

Swift
import SwiftUI

// Model
struct Task: Identifiable {
    let id = UUID() // Assigns a unique ID to each task
    var title: String
    var completed: Bool
}

// ViewModel
class TaskViewModel: ObservableObject {
    @Published var tasks: [Task] = []

    init() {
        // Simulate loading tasks from a database or API
        tasks = [
            Task(title: "Buy groceries", completed: false),
            Task(title: "Walk the dog", completed: true),
            Task(title: "Water plants", completed: false)
        ]
    }
    
    func toggleTaskCompletion(task: Task) {
        if let index = tasks.firstIndex(where: { $0.id == task.id }) {
            tasks[index].completed.toggle()
        }
    }
}

// View
struct ContentView: View {
    @ObservedObject var viewModel = TaskViewModel()

    var body: some View {
        NavigationView {
            List(viewModel.tasks) { task in
                HStack {
                    Text(task.title)
                    Spacer()
                    Image(systemName: task.completed ? "checkmark.circle.fill" : "circle")
                        .onTapGesture {
                            viewModel.toggleTaskCompletion(task: task)
                        }
                }
            }
            .navigationBarTitle("Tasks")
        }
    }
}

@main
struct YourApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

Observable Object Pattern

Swift
import SwiftUI

// Step 1: Define an ObservableObject
class UserData: ObservableObject {
    @Published var username = "John Doe" // Property with @Published will trigger updates
    
    func changeUsername(newUsername: String) {
        username = newUsername
    }
}

// Step 2: Create a View that uses the ObservableObject
struct ContentView: View {
    @ObservedObject var userData = UserData() // Initialize the observable object

    var body: some View {
        VStack {
            Text("Welcome, \(userData.username)!")
                .font(.headline)
            
            Button("Change Username") {
                userData.changeUsername(newUsername: "Alice Smith")
            }
        }
    }
}

@main
struct YourApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

EnvironmentObject Pattern

Swift
import SwiftUI

// Step 1: Define an ObservableObject for your settings
class UserSettings: ObservableObject {
    @Published var darkMode = false
    @Published var fontSize = 16
}

// Step 2: Create an environment object in your main app
@main
struct YourApp: App {
    // Step 3: Initialize the environment object
    @StateObject private var userSettings = UserSettings()
    
    var body: some Scene {
        WindowGroup {
            // Step 4: Inject the environment object into the view hierarchy
            ContentView()
                .environmentObject(userSettings)
        }
    }
}

// Step 5: Create a view that uses the environment object
struct ContentView: View {
    // Step 6: Declare an environment object property
    @EnvironmentObject var userSettings: UserSettings

    var body: some View {
        NavigationView {
            VStack {

                VStack {
                    Text("Settings View")
                        .font(.system(size: CGFloat(userSettings.fontSize)))
                        .padding()
                }

                NavigationLink(destination: SecondView()) {
                    Text("Go to Second View")
                }

                Spacer()

                Toggle("Dark Mode", isOn: $userSettings.darkMode)
                    .padding()

                Stepper("Font Size: \(userSettings.fontSize)", value: $userSettings.fontSize, in: 12...32)
                    .padding()
            }
            .preferredColorScheme(userSettings.darkMode ? .dark : .light)
            .navigationTitle("Settings")
        }
    }
}

struct SecondView: View {
    // Step 7: Declare an environment object property
    @EnvironmentObject var userSettings: UserSettings

    var body: some View {
        VStack {
            Text("Second View")
                .font(.system(size: CGFloat(userSettings.fontSize)))
                .padding()

            Spacer()
        }
        .preferredColorScheme(userSettings.darkMode ? .dark : .light)
        .navigationBarTitle("Second View")
    }
}

You can use EnvironmentObject to share data cross views.

Dependency Injection Pattern

Constructor injection

Swift
import SwiftUI

// Step 1: Define a protocol for AuthenticationService
protocol AuthenticationService {
    func login(username: String, password: String, completion: @escaping (Bool) -> Void)
}

// Step 2: Create a concrete implementation of AuthenticationService
class AuthService: AuthenticationService {
    func login(username: String, password: String, completion: @escaping (Bool) -> Void) {
        // Simulate a login request (replace with actual authentication logic)
        DispatchQueue.global().asyncAfter(deadline: .now() + 2) {
            let success = username == "user" && password == "password"
            completion(success)
        }
    }
}

// Step 3: Define a protocol for LoginViewModel
protocol LoginViewModelProtocol: ObservableObject {
    var isLoggedIn: Bool { get }
    func login(username: String, password: String)
}

// Step 4: Create a concrete implementation of LoginViewModel
class LoginViewModel: LoginViewModelProtocol {
    @Published var isLoggedIn = false
    let authService: AuthenticationService
    
    init(authService: AuthenticationService) {
        self.authService = authService
    }
    
    func login(username: String, password: String) {
        authService.login(username: username, password: password) { success in
            DispatchQueue.main.async {
                self.isLoggedIn = success
            }
        }
    }
}

// Step 5: Create a ContentView that uses LoginViewModel
struct ContentView: View {
    @ObservedObject var loginViewModel: LoginViewModelProtocol
    
    var body: some View {
        VStack {
            if loginViewModel.isLoggedIn {
                Text("Logged In!")
            } else {
                Text("Not Logged In")
                Button("Login") {
                    loginViewModel.login(username: "user", password: "password")
                }
            }
        }
        .onAppear {
            // You can initialize LoginViewModel with AuthService here
            let authService = AuthService()
            loginViewModel = LoginViewModel(authService: authService)
        }
    }
}

@main
struct YourApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView(loginViewModel: LoginViewModel(authService: AuthService()))
        }
    }
}

Environment Injection

In the previous example where we used EnvironmentObject to share settings, it’s important to recognize that EnvironmentObject serves as a form of Dependency Injection (DI) in SwiftUI. While it’s commonly associated with sharing simple settings and state, it can also be used to inject more complex dependency modules into the app’s view hierarchy. This makes EnvironmentObject a versatile tool for decoupling dependencies and enabling flexible design, allowing us to provide not only data and settings but also more sophisticated services and dependencies to our views seamlessly.

Swift
import SwiftUI

protocol AuthenticationService {
    func login(username: String, password: String) -> Bool
}

class AuthService: AuthenticationService {
    func login(username: String, password: String) -> Bool {
        // Simulate a login process
        return username == "user" && password == "password"
    }
}

class AuthContainer: ObservableObject {
    @Published var authService: AuthenticationService = AuthService()
}

@main
struct MyApp: App {
    @StateObject private var authContainer = AuthContainer()
    
    var body: some Scene {
        WindowGroup {
            ContentView()
                .environmentObject(authContainer) // Inject the AuthContainer
        }
    }
}

struct ContentView: View {
    @EnvironmentObject var authContainer: AuthContainer // Access the AuthContainer
    
    @State private var loggedIn = false
    @State private var username = ""
    @State private var password = ""
    
    var body: some View {
        VStack {
            if loggedIn {
                Text("Logged In!")
            } else {
                TextField("Username", text: $username)
                    .textFieldStyle(RoundedBorderTextFieldStyle())
                
                SecureField("Password", text: $password)
                    .textFieldStyle(RoundedBorderTextFieldStyle())
                
                Button("Login") {
                    if authContainer.authService.login(username: username, password: password) {
                        loggedIn = true
                    }
                }
            }
        }
    }
}

You can certainly put the AuthService directly into EnvironmentObject without using an intermediate container like AuthContainer. However, there are scenarios where using a container can be beneficial:

  1. Multiple Dependencies: If your application has multiple dependencies beyond just AuthService, a container can help organize and manage all these dependencies in one place. This can make your codebase cleaner and more maintainable.
  2. Flexibility: A container allows you to include additional logic for dependency creation, configuration, or modification if needed. For example, you can implement different configurations or behaviors for different environments (e.g., development, testing, production) in the container.
  3. Testing: When it comes to unit testing, having a container makes it easier to provide mock or stub dependencies for testing different parts of your code. You can swap out real implementations with test implementations more easily.
  4. Modularity: Containers promote a modular design. They encapsulate the creation and management of dependencies, allowing you to change or extend parts of your application without affecting the rest.
  5. Consistency: By centralizing dependency management in a container, you ensure consistency in how dependencies are created and configured throughout your application.

While it’s possible to directly use @EnvironmentObject with AuthService, using a container offers more control and flexibility in managing dependencies, which can be advantageous as your application grows and becomes more complex. Ultimately, the choice depends on the specific needs and architecture of your project.

Combine Framework Pattern

Reactive programming has become a crucial part of modern app development, despite its initial complexity. It allows apps to respond to user actions and keep their user interfaces up-to-date. However, in Swift, reactive programming wasn’t straightforward until the introduction of the Combine framework.

The Combine framework is a powerful tool for handling asynchronous and event-driven code in Swift. It enables you to work with publishers and subscribers to manage data flow and handle events.

As described by the official Swift documentation, “The Combine framework provides a declarative Swift API for processing values over time. … Combine declares publishers to expose values that can change over time, and subscribers to receive those values from the publishers.”

Here’s an example of using the Combine framework in a common pattern:

Swift
import Combine
import Foundation

// Define a simple data model
struct User {
    let id: Int
    let name: String
}

// Create a publisher for fetching user data
class UserManager {
    // Simulate fetching user data asynchronously
    func fetchUser() -> AnyPublisher<User, Error> {
        return Future { promise in
            // Simulate a network request or data processing
            DispatchQueue.global().async {
                Thread.sleep(forTimeInterval: 3) // simulate a delay
                if Bool.random() {
                    promise(.success(User(id: 1, name: "John Doe")))
                } else {
                    promise(.failure(NSError(domain: "UserManager", code: 404, userInfo: nil)))
                }
            }
        }
        .eraseToAnyPublisher()
    }
}

// Create a Combine pipeline to fetch and handle user data
class UserProfileViewModel: ObservableObject {
    private var userManager = UserManager()
    
    @Published var user: User?
    @Published var isLoading = false
    @Published var error: Error?
    
    private var cancellables = Set<AnyCancellable>()
    
    func fetchUser() {
        isLoading = true
        error = nil
        
        userManager.fetchUser()
            .receive(on: DispatchQueue.main) // Ensure UI updates on the main thread
            .sink { completion in
                switch completion {
                case .finished:
                    break
                case .failure(let error):
                    self.error = error
                }
                self.isLoading = false
            } receiveValue: { user in
                self.user = user
            }
            .store(in: &cancellables)
    }
}

struct UserProfileView: View {
    @ObservedObject var viewModel = UserProfileViewModel()
    
    var body: some View {
        VStack {
            if viewModel.isLoading {
                Text("User ID: loading...")
                Text("Name: loading...")
            }
            else if let user = viewModel.user {
                Text("User ID: \(user.id)")
                Text("Name: \(user.name)")
            } 
            else {
                Text("Loading user data...")
            }
            
            if let error = viewModel.error {
                Text("Error: \(error.localizedDescription)")
            }
            
            Button(action: {
                viewModel.fetchUser()
            }) {
                Text(viewModel.isLoading ? "Fetching User..." : "Fetch User")
            }
            .disabled(viewModel.isLoading)
        }
        .onAppear {
            viewModel.fetchUser()
        }
    }
}

@main
struct CombineExampleApp: App {
    var body: some Scene {
        WindowGroup {
            UserProfileView()
        }
    }
}

Coordinator Pattern

The Coordinator pattern is commonly used in SwiftUI to manage navigation and communication between different views in your app. Here’s a simple example of how to implement the Coordinator pattern:

Swift
import SwiftUI

// Step 1: Create a Coordinator class
class MyCoordinator: NSObject, UINavigationControllerDelegate {
    var parent: ContentView // Reference to the parent view

    init(_ parent: ContentView) {
        self.parent = parent
    }

    // Implement any necessary navigation logic here
}

// Step 2: Create a custom view for the Coordinator
struct ContentView: View {
    @State private var showDetail = false

    var body: some View {
        NavigationView {
            NavigationLink("Show Detail", destination: DetailView(coordinator: MyCoordinator(self)))
        }
    }
}

// Step 3: Create the DetailView
struct DetailView: View {
    var coordinator: MyCoordinator

    var body: some View {
        Button("Go Back") {
            // Implement navigation logic using the coordinator
            coordinator.parent.showDetail = false
        }
    }
}

@main
struct MyApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

ViewModifiers

The ViewModifiers pattern in SwiftUI is a way to encapsulate and reuse view styling and behavior. ViewModifiers are custom modifiers that you can apply to your views to modify their appearance or behavior. Here’s an example of how to create and use ViewModifiers:

Let’s say you have several views in your app that need a consistent card-style appearance with a shadow, padding, and background color. Instead of repeating the same styling code for each view, you can create a custom ViewModifier to encapsulate this styling and apply it consistently.

Swift
import SwiftUI

// Step 1: Define a custom ViewModifier
struct CardStyleModifier: ViewModifier {
    func body(content: Content) -> some View {
        content
            .padding()
            .background(Color.white)
            .cornerRadius(10)
            .shadow(radius: 5)
    }
}

// Step 2: Create a custom view that uses the ViewModifier
struct CardView: View {
    var body: some View {
        Text("Card1 Content")
            .font(.headline)
            .modifier(CardStyleModifier()) // Apply the card style
        Text("Card2 Content")
            .font(.headline)
            .modifier(CardStyleModifier()) // Apply the card style
        Text("Card3 Content")
            .font(.headline)
            .modifier(CardStyleModifier()) // Apply the card style
    }
}

// Step 3: Use the custom CardView in your ContentView
struct ContentView: View {
    var body: some View {
        VStack {
            CardView() // Reusable card-style view
            Text("Regular View")
        }
    }
}

@main
struct MyApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}