Skip to main content

Implementing iOS Design Patterns

1. Understanding MVC Pattern

Component Description Responsibilities
Model Represents the data and business logic of the application. Manages data, performs calculations, handles data persistence.
View Represents the UI elements displayed to the user. Displays data, captures user input, presents visual feedback.
Controller Serves as the intermediary between Model and View. Handles user input, updates the Model, refreshes the View accordingly.

Example: Basic MVC Implementation in Swift

import UIKit

// Model
struct User {
    let name: String
    let email: String
}

// View
class UserView: UIView {
    let nameLabel = UILabel()
    let emailLabel = UILabel()

    override init(frame: CGRect) {
        super.init(frame: frame)
        setupView()
    }

    required init?(coder: NSCoder) {
        super.init(coder: coder)
        setupView()
    }

    private func setupView() {
        addSubview(nameLabel)
        addSubview(emailLabel)
        nameLabel.text = "John Doe"
        emailLabel.text = "john@example.com"
    }
}

// Controller
class UserController: UIViewController {
    let userView = UserView()
    let user = User(name: "John Doe", email: "john@example.com")

    override func viewDidLoad() {
        super.viewDidLoad()
        setupController()
    }

    private func setupController() {
        view.addSubview(userView)
        userView.nameLabel.text = user.name
        userView.emailLabel.text = user.email
    }
}
Note: In iOS development, the MVC pattern is often adapted to fit the UIKit framework's structure. The controller in this context is typically a UIViewController, which manages the view hierarchy and handles user interactions.

2. Implementing MVVM Pattern

Component Description Responsibility
Model struct User { let name: String; let email: String } Handles data logic and business rules
View Model class UserViewModel { private let user: User; var displayName: String { return user.name } } Transforms model data into view-ready format
View UIViewController { label.text = viewModel.displayName } Renders UI and handles user interactions

Example: Binding in MVVM

class UserViewModel {
    private let user: User
    var displayName: String { return user.name }
    
    init(user: User) {
        self.user = user
    }
}

class UserViewController: UIViewController {
    @IBOutlet weak var nameLabel: UILabel!
    var viewModel: UserViewModel?
    
    override func viewDidLoad() {
        super.viewDidLoad()
        nameLabel.text = viewModel?.displayName
    }
}
Note: MVVM promotes separation of concerns by decoupling the UI from business logic, making testing and maintenance easier.

Key Benefits of MVVM

  • Separation of concerns between UI and business logic
  • Easier unit testing of view models
  • Improved reusability of UI components

3. Using Coordinator Pattern for Navigation

Aspect Description Implementation
Purpose Decouples navigation logic from view controllers Coordinator manages flow between screens
Structure Coordinator protocol defines navigation actions Concrete coordinator implements navigation logic
View Controller Integration View controllers notify coordinator of navigation events Coordinator presents new view controllers or dismisses current ones

Example: Coordinator Protocol

protocol Coordinator {
    func start()
    func navigate(to route: Route)
}

enum Route {
    case login
    case dashboard
    case profile
}

Example: Login Coordinator

class LoginCoordinator: Coordinator {
    private let navigationController: UINavigationController
    
    init(navigationController: UINavigationController) {
        self.navigationController = navigationController
    }
    
    func start() {
        let loginVC = LoginViewController()
        loginVC.coordinator = self
        navigationController.pushViewController(loginVC, animated: false)
    }
    
    func navigate(to route: Route) {
        switch route {
        case .dashboard:
            let dashboardVC = DashboardViewController()
            navigationController.pushViewController(dashboardVC, animated: true)
        default:
            break
        }
    }
}
Note: The Coordinator pattern is particularly useful in large apps where navigation logic becomes complex and tightly coupled to view controllers.

4. Implementing Repository Pattern for Data

Component Description Implementation
Repository Protocol Defines the interface for data operations
protocol UserRepository {
    func save(user: User, completion: (Result<Bool, Error>) -> Void)
    func fetchUser(id: String, completion: (Result<User?, Error>) -> Void)
}
Repository Implementation Concrete implementation of the repository protocol
class UserRepositoryImpl: UserRepository {
    private let userDataSource: UserDataSource
    
    init(userDataSource: UserDataSource) {
        self.userDataSource = userDataSource
    }
    
    func save(user: User, completion: (Result<Bool, Error>) -> Void) {
        userDataSource.save(user: user) { result in
            completion(result)
        }
    }
    
    func fetchUser(id: String, completion: (Result<User?, Error>) -> Void) {
        userDataSource.fetchUser(id: id) { result in
            completion(result)
        }
    }
}
Data Source Handles actual data access (e.g., API or local storage)
protocol UserDataSource {
    func save(user: User, completion: (Result<Bool, Error>) -> Void)
    func fetchUser(id: String, completion: (Result<User?, Error>) -> Void)
}

class NetworkUserDataSource: UserDataSource {
    func save(user: User, completion: (Result<Bool, Error>) -> Void) {
        // API call to save user
    }
    
    func fetchUser(id: String, completion: (Result<User?, Error>) -> Void) {
        // API call to fetch user
    }
}

Example: Using Repository in a View Model

class UserViewModel {
    private let userRepository: UserRepository
    
    init(userRepository: UserRepository) {
        self.userRepository = userRepository
    }
    
    func loadUser(id: String) {
        userRepository.fetchUser(id: id) { result in
            switch result {
            case .success(let user):
                // Update UI with fetched user
            case .failure(let error):
                print("Failed to load user: \(error)")
            }
        }
    }
}
Note: The repository pattern helps decouple the data layer from the UI, improving testability and maintainability.

5. Using Dependency Injection

Concept Description Implementation
Protocol-Based DI Define dependencies as protocols to enable easy mocking and testing.
protocol NetworkService {
    func fetchData(completion: @escaping (Data?, Error?) -> Void)
}

struct RealNetworkService: NetworkService {
    func fetchData(completion: @escaping (Data?, Error?) -> Void) {
        // Real implementation
    }
}
Constructor Injection Pass dependencies through the initializer.
class ViewController {
    private let networkService: NetworkService
    
    init(networkService: NetworkService) {
        self.networkService = networkService
    }
}
Property Injection Inject dependencies as properties after initialization.
class ViewController {
    var networkService: NetworkService?
    
    func setNetworkService(_ service: NetworkService) {
        self.networkService = service
    }
}
Service Locator Use a centralized registry to resolve dependencies.
class ServiceLocator {
    static let shared = ServiceLocator()
    
    func resolve<T>(type: T.Type) -> T {
        // Return instance of type T
    }
}

Example: Dependency Injection in Action

protocol UserRepository {
    func fetchUser(id: String, completion: @escaping (User?, Error?) -> Void)
}

struct RealUserRepository: UserRepository {
    func fetchUser(id: String, completion: @escaping (User?, Error?) -> Void) {
        // Fetch from API or database
    }
}

class UserViewModel {
    private let userRepository: UserRepository
    
    init(userRepository: UserRepository) {
        self.userRepository = userRepository
    }
    
    func loadUser(id: String) {
        userRepository.fetchUser(id: id) { user, error in
            // Handle result
        }
    }
}

Benefits of Dependency Injection

  • Improves testability by allowing mock dependencies.
  • Reduces tight coupling between components.
  • Increases flexibility and modularity in code structure.

6. Applying Factory Pattern

Pattern Description Use Case Implementation
Factory Method Creates objects without specifying the exact class to be instantiated. When object creation logic is complex or varies based on input.
protocol VehicleFactory {
    func createVehicle() -> Vehicle
}

class CarFactory: VehicleFactory {
    func createVehicle() -> Vehicle {
        return Car()
    }
}
Abstract Factory Creates families of related or dependent objects without specifying their concrete classes. When a system should be independent of how its products are created, composed, or represented.
protocol AbstractFactory {
    func createButton() -> Button
    func createTextBox() -> TextBox
}

class MacOSFactory: AbstractFactory {
    func createButton() -> Button {
        return MacOSButton()
    }
    
    func createTextBox() -> TextBox {
        return MacOSTextBox()
    }
}

Example: Factory Method in iOS

protocol Animal {
    func makeSound()
}

class Dog: Animal {
    func makeSound() {
        print("Woof!")
    }
}

class Cat: Animal {
    func makeSound() {
        print("Meow!")
    }
}

class AnimalFactory {
    static func createAnimal(type: String) -> Animal? {
        switch type {
        case "dog":
            return Dog()
        case "cat":
            return Cat()
        default:
            return nil
        }
    }
}

7. Implementing Observer Pattern

Component Description Implementation
Observer Object that observes changes in the subject class Observer { }
Subject Object that holds state and notifies observers class Subject { var observers: [Observer] = [] }
Notification Method to notify all observers of a change func notifyObservers() { observers.forEach { $0.update() } }

Example: Basic Observer Pattern Implementation

protocol Observer {
    func update()
}

protocol Subject {
    var observers: [Observer] { get set }
    func addObserver(_ observer: Observer)
    func removeObserver(_ observer: Observer)
    func notifyObservers()
}

class ConcreteSubject: Subject {
    var observers: [Observer] = []
    var state: Int = 0 {
        didSet {
            notifyObservers()
        }
    }

    func addObserver(_ observer: Observer) {
        observers.append(observer)
    }

    func removeObserver(_ observer: Observer) {
        observers.removeAll { $0 === observer }
    }

    func notifyObservers() {
        observers.forEach { $0.update() }
    }
}

class ConcreteObserver: Observer {
    func update() {
        print("Observer notified")
    }
}
Note: In Swift, the Observer pattern is often implemented using NotificationCenter or KVO (Key-Value Observing) for more complex scenarios.

Key Points

  • Observer pattern allows one-to-many dependencies between objects.
  • When the state of one object changes, all dependents are notified automatically.
  • Swift provides NotificationCenter and KVO for built-in observer implementations.

8. Using Singleton Pattern

Property Description Usage
sharedInstance Static property that provides access to the singleton instance let instance = MyClass.sharedInstance
init() Private initializer to prevent external instantiation private init() { ... }

Example: Singleton Implementation

class DatabaseManager {
    static let shared = DatabaseManager()
    
    private init() {
        // Initialization logic
    }
    
    func connect() {
        print("Connected to database")
    }
}
Note: The singleton pattern ensures a class has only one instance and provides global access to it.

9. Applying Strategy Pattern

The Strategy pattern defines a family of algorithms, encapsulates each one, and makes them interchangeable. In iOS development, this pattern is commonly used to switch between different implementations of a behavior at runtime.

Component Description Usage
Strategy Protocol Defines the interface for algorithms. Used to enforce a common contract across different strategies.
Concrete Strategies Implement the strategy protocol with specific logic. Each concrete strategy provides a different implementation of the same behavior.
Context Uses a strategy object to perform an operation. Manages the selection and execution of strategies.

Example: Payment Processing Strategy

protocol PaymentStrategy {
    func processPayment(amount: Double) -> Bool
}

struct CreditCardStrategy: PaymentStrategy {
    func processPayment(amount: Double) -> Bool {
        print("Processing $\(amount) via Credit Card")
        return true
    }
}

struct PayPalStrategy: PaymentStrategy {
    func processPayment(amount: Double) -> Bool {
        print("Processing $\(amount) via PayPal")
        return true
    }
}

class PaymentContext {
    private var strategy: PaymentStrategy
    
    init(strategy: PaymentStrategy) {
        self.strategy = strategy
    }
    
    func setStrategy(_ strategy: PaymentStrategy) {
        self.strategy = strategy
    }
    
    func processPayment(amount: Double) -> Bool {
        return strategy.processPayment(amount: amount)
    }
}

// Usage
let context = PaymentContext(strategy: CreditCardStrategy())
context.processPayment(amount: 100.0) // Output: Processing $100.0 via Credit Card

context.setStrategy(PayPalStrategy())
context.processPayment(amount: 150.0) // Output: Processing $150.0 via PayPal
Note: The Strategy pattern is particularly useful in iOS apps where you might want to switch between different UI behaviors or data processing methods based on user preferences or device capabilities.

10. Understanding Clean Architecture

Layer Description Responsibility Examples
Presentation Layer Handles UI and user interactions Displays data, handles user input UIViewController, UIView
Business Logic Layer Contains core application logic Processes data, implements business rules UseCase, Service
Data Layer Manages data sources and persistence Fetches/saves data from/to APIs, databases Repository, NetworkManager

Example: Layered Architecture

class UserViewModel {
    private let getUserUseCase: GetUserUseCase
    private let userRepository: UserRepository

    func fetchUser(id: String) {
        getUserUseCase.execute(userId: id) { [weak self] result in
            switch result {
            case .success(let user):
                // Update UI with user data
            case .failure(let error):
                print("Failed to fetch user: \(error)")
            }
        }
    }
}
Note: Clean Architecture promotes separation of concerns, making code more testable and maintainable.

Key Benefits

  • Separation of concerns improves code organization
  • Easier to unit test each layer independently
  • Flexibility in changing data sources or UI frameworks

Was this article helpful?