Radu Dan
MVC Banner

Architecture Series - Model View Controller (MVC)

Motivation

Before diving into iOS app development, it's crucial to carefully consider the project's architecture. We need to thoughtfully plan how different pieces of code will fit together, ensuring they remain comprehensible not just today, but months or years later when we need to revisit and modify the codebase. Moreover, a well-structured project helps establish a shared technical vocabulary among team members, making collaboration more efficient.

This article kicks off an exciting series where we'll explore different architectural approaches by building the same application using various patterns. Throughout the series, we'll analyze practical aspects like build times and implementation complexity, weigh the pros and cons of each pattern, and most importantly, examine real, production-ready code implementations. This hands-on approach will help you make informed decisions about which architecture best suits your project needs.

Architecture Series Articles

If you're eager to explore the implementation details directly, you can find the complete source code in our open-source repository here.

Why Your iOS App Needs a Solid Architecture Pattern

The cornerstone of any successful iOS application is maintainability. A well-architected app clearly defines boundaries - you know exactly where view logic belongs, what responsibilities each view controller has, and which components handle business logic. This clarity isn't just for you; it's essential for your entire development team to understand and maintain these boundaries consistently.

Here are the key benefits of implementing a robust architecture pattern:

  • Maintainability: Makes code easier to update and modify over time
  • Testability: Facilitates comprehensive testing of business logic through clear separation of concerns
  • Team Collaboration: Creates a shared technical vocabulary and understanding among team members
  • Clean Separation: Ensures each component has clear, single responsibilities
  • Bug Reduction: Minimizes errors through better organization and clearer interfaces between components

Project Requirements Overview

Given a medium-sized iOS application consisting of 6-7 screens, we'll demonstrate how to implement it using the most popular architectural patterns in the iOS ecosystem: MVC (Model-View-Controller), MVVM (Model-View-ViewModel), MVP (Model-View-Presenter), VIPER (View-Interactor-Presenter-Entity-Router), VIP (Clean Swift), and the Coordinator pattern. Each implementation will showcase the pattern's strengths and potential challenges.

Our demo application, Football Gather, is designed to help friends organize and track their casual football matches. It's complex enough to demonstrate real-world architectural challenges while remaining simple enough to clearly illustrate different patterns.

Core Features and Functionality

  • Player Management: Add and maintain a roster of players in the application
  • Team Assignment: Flexibly organize players into different teams for each match
  • Player Customization: Edit player details and preferences
  • Match Management: Set and control countdown timers for match duration

Screen Mockups

Football Gather Mockups

Backend

The application is supported by a web backend developed using the Vapor framework, a popular choice for building modern REST APIs in Swift. You can explore the details of this implementation in our article here, which covers the fundamentals of building REST APIs with Vapor and Fluent in Swift. Additionally, we have documented the transition from Vapor 3 to Vapor 4, highlighting the enhancements and new features in our article here.

What is MVC

Model-View-Controller (MVC) is arguably the most widely recognized architectural pattern in software development.

At its core, MVC consists of three distinct components: Model, View, and Controller. Let's explore each one in detail.

Model

  • Encompasses all data classes, helper utilities, and networking code
  • Contains application-specific data and the business logic that processes it
  • In our application, the Model layer includes everything within the Utils, Storage, and Networking groups
  • Supports various relationships between model objects (e.g., many-to-many between players and gathers, one-to-many between users and players/gathers)
  • Maintains independence from the View layer and should not be concerned with user interface details

Communication Flow

  • User actions in the View layer are communicated to the Model through the Controller
  • When the Model's data changes, it notifies the Controller, which then updates the View accordingly

View

  • Represents the visual elements that users interact with on screen
  • Handles user input and interaction events
  • Displays Model data and facilitates user interactions
  • Built using Apple's core frameworks: UIKit and AppKit
  • In our application: LoadingView, EmptyView, PlayerTableViewCell, and ScoreStepper are examples of View components

Communication Flow

  • Views never communicate directly with the Model - all interaction is mediated through the Controller

Controller

  • Acts as the central coordinator of the MVC architecture
  • Manages View updates and Model mutations
  • Processes Model changes and ensures View synchronization
  • Handles object lifecycle management and setup tasks

Communication Flow

  • Maintains bidirectional communication with both Model and View layers
  • Interprets user actions and orchestrates corresponding Model updates
  • Ensures UI consistency by propagating Model changes to the View

Evolution of MVC

The traditional MVC pattern differs from Apple's Cocoa MVC implementation. In the original pattern, View and Model layers could communicate directly, while the View remained stateless and was rendered by the Controller after Model updates.
First introduced in Smalltalk-79, MVC was built upon three fundamental design patterns: composite, strategy, and observer.

Composite Pattern

The view hierarchy in an application consists of nested view objects working together cohesively. These visual components span from windows to complex views like table views, down to basic UI elements such as buttons. User interactions can occur at any level within this composite structure.

Consider the UIView hierarchy in iOS development. Views serve as the fundamental building blocks of the user interface, capable of containing multiple subviews.
For instance, our LoginViewController's main view contains a hierarchy of stack views, which in turn contain text fields for credentials and a login button.

Strategy Pattern

Controllers implement specific strategies for view objects. While views focus solely on their visual presentation, controllers handle all application-specific logic and interface behavior decisions.

Observer Pattern

Model objects maintain and notify interested components—typically views—about changes in their state.

A significant drawback of traditional MVC is the tight coupling between all three layers, which can make testing, maintenance, and code reuse challenging.

Applying to our code

In FootballGather we implement each screen as a View Controller:

LoginViewController

Login Screen

Description

  • landing page where users can login with their credentials or create new users

UI elements

  • usernameTextField - this is the text field where users enter their username
  • passwordTextField - secure text field for entering passwords
  • rememberMeSwitch - is an UISwitch for saving the username in Keychain after login and autopopulate the field next time we enter the app
  • loadingView - is used to show an indicator while a server call is made

Services

  • loginService - used to call the Login API with the entered credentials
  • usersService - used to call the Register API, creating a new user

As we can see, this class has three major functions: login, register and remember my username. Jumping to next screen is done via performSegue.

Code snippet

@IBAction private func login(_ sender: Any) {
    // Validate required fields
    // Both username and password must be non-empty strings
    guard let userText = usernameTextField.text, userText.isEmpty == false,
        let passwordText = passwordTextField.text, passwordText.isEmpty == false else {
            AlertHelper.present(in: self, title: "Error", message: "Both fields are mandatory.")
            return
    }

    // Display loading indicator while authentication is in progress
    showLoadingView()

    // Prepare login credentials model for API request
    let requestModel = UserRequestModel(username: userText, password: passwordText)

    // Attempt to authenticate user with provided credentials
    // Response is handled asynchronously
    loginService.login(user: requestModel) { [weak self] result in
        guard let self = self else { return }

        DispatchQueue.main.async {
            // Hide loading indicator once response is received
            self.hideLoadingView()

            switch result {
            case .failure(let error):
                // Authentication failed - Display error message to user
                AlertHelper.present(in: self, title: "Error", message: String(describing: error))

            case .success(_):
                // Authentication successful:
                // 1. Save "Remember Me" preference if enabled
                // 2. Clear stored credentials if disabled
                // 3. Navigate to main screen
                self.handleSuccessResponse()
            }
        }
    }
}

PlayerListViewController

Main Screen Selection Screen

Description

  • shows the players for the logged in user. Consists of a main table view, each player is displayed in a separated row.

UI elements

  • playerTableView - The table view that displays players
  • confirmOrAddPlayersButton - Action button from the bottom of the view, that can either correspond to an add player action or confirms the selected players
  • loadingView - is used to show an indicator while a server call is made
  • emptyView - Shown when the user hasn't added any players
  • barButtonItem - The top right button that can have different states based on the view mode we are in. Has the title "Cancel" when we go into selection mode to choose the players we want for the gather or "Select" when we are in view mode.

Services

  • playersService - Used to retrieve the list of players and to delete a player

Models

  • players - An array of players created by the user. This are the rows we see in playerTableView
  • selectedPlayersDictionary - A cache dictionary that stores the row index of the selected player as key and the selected player as value.

If you open up Main.storyboard you can see that from this view controller you can perform three segues

  • ConfirmPlayersSegueIdentifier - After you select what players you want for your gather, you go to a confirmation screen where you assign the teams they will be part of.
  • PlayerAddSegueIdentifier - Goes to a screen where you can create a new player
  • PlayerDetailSegueIdentifier - Opens a screen where you can see the details of the player

We have the following function to retrieve the model for this View Controller.

private func loadPlayers() {
    // Disable user interaction during data fetch
    // Prevents multiple requests and shows loading state
    view.isUserInteractionEnabled = false

    // Fetch players from remote service
    // Returns array of PlayerResponseModel or error
    playersService.get { [weak self] (result: Result<[PlayerResponseModel], Error>) in
        DispatchQueue.main.async {
            // Re-enable user interaction after response
            self?.view.isUserInteractionEnabled = true

            switch result {
            case .failure(let error):
                // Handle service error:
                // Display error message and retry options to user
                self?.handleServiceFailures(withError: error)

            case .success(let players):
                // Update data model and refresh UI:
                // 1. Store retrieved players
                // 2. Update table view
                // 3. Handle empty states
                self?.players = players
                self?.handleLoadPlayersSuccessfulResponse()
            }
        }
    }
}

And if we want to delete one player we do the following:

func tableView(
    _ tableView: UITableView,
    commit editingStyle: UITableViewCell.EditingStyle,
    forRowAt indexPath: IndexPath
) {
    // Only handle deletion actions, ignore other editing styles
    guard editingStyle == .delete else { return }

    // Show a confirmation dialog before deleting the player
    let alertController = UIAlertController(
        title: "Delete player",
        message: "Are you sure you want to delete the selected player?",
        preferredStyle: .alert
    )
    
    // Configure delete action with destructive style
    let confirmAction = UIAlertAction(
        title: "Delete",
        style: .destructive
    ) { [weak self] _ in
        self?.handleDeletePlayerConfirmation(forRowAt: indexPath)
    }
    alertController.addAction(confirmAction)

    // Configure cancel action to dismiss dialog
    let cancelAction = UIAlertAction(
        title: "Cancel",
        style: .cancel,
        handler: nil
    )
    alertController.addAction(cancelAction)

    // Present the confirmation dialog
    present(alertController, animated: true, completion: nil)
}

The service call is presented below:

private func requestDeletePlayer(
    at indexPath: IndexPath,
    completion: @escaping (Bool) -> Void
) {
    // Retrieve player to be deleted from data model
    let player = players[indexPath.row]
    var service = playersService

    // Perform delete request using player's ID
    service.delete(withID: ResourceID.integer(player.id)) { [weak self] result in
        DispatchQueue.main.async {
            switch result {
            case .failure(let error):
                // Handle deletion failure
                // Notify user of error and pass false to completion handler
                self?.handleServiceFailures(withError: error)
                completion(false)

            case .success(_):
                // Deletion successful
                // Pass true to completion handler
                completion(true)
            }
        }
    }
}

PlayerAddViewController

Add Player Screen

Description

  • This screen is used for creating a player.

UI Elements

  • playerNameTextField - Used to enter the name of the player
  • doneButton - Bar button item that is used to confirm the player to be created and initiates a service call
  • loadingView - Is used to show an indicator while a server call is made

Services

  • We use the StandardNetworkService that points to /api/players. To add players, we initiate a POST request.

Code snippet

private func createPlayer(
    _ player: PlayerCreateModel,
    completion: @escaping (Bool) -> Void
) {
    // Initialize network service for player creation
    let service = StandardNetworkService(
        resourcePath: "/api/players",
        authenticated: true
    )
    
    // Perform create request with player data
    service.create(player) { result in
        // Check result of the network request
        if case .success(_) = result {
            // Player creation successful
            completion(true)
        } else {
            // Player creation failed
            completion(false)
        }
    }
}

PlayerDetailViewController

Player Details Screen

Description

  • maps a screen that shows the details of a player (name, age, position, skill and favourite team)

UI elements

  • playerDetailTableView - A tableview that displays the details of the player.

Model

  • player - The model of the player as PlayerResponseModel

We have no services in this ViewController. A request to update an information of player is received fromPlayerEditViewController and passed to PlayerListViewController through delegation.

The sections are made with a factory pattern:

private func makeSections() -> [PlayerSection] {
    // Create and return an array of PlayerSection objects
    return [
        PlayerSection(
            title: "Personal",
            rows: [
                // Add player name row
                PlayerRow(
                    title: "Name",
                    value: self.player?.name ?? "",
                    editableField: .name
                ),
                // Add player age row
                PlayerRow(
                    title: "Age",
                    value: self.player?.age != nil ? "\(self.player!.age!)" : "",
                    editableField: .age
                )
            ]
        ),
        PlayerSection(
            title: "Play",
            rows: [
                // Add preferred position row
                PlayerRow(
                    title: "Preferred position",
                    value: self.player?.preferredPosition?.rawValue.capitalized ?? "",
                    editableField: .position
                ),
                // Add skill row
                PlayerRow(
                    title: "Skill",
                    value: self.player?.skill?.rawValue.capitalized ?? "",
                    editableField: .skill
                )
            ]
        ),
        PlayerSection(
            title: "Likes",
            rows: [
                // Add favourite team row
                PlayerRow(
                    title: "Favourite team",
                    value: self.player?.favouriteTeam ?? "",
                    editableField: .favouriteTeam
                )
            ]
        )
    ]
}

PlayerEditViewController

Edit Position Screen Edit Skill Screen Edit Age Screen

Description

  • Edits a player information.

UI Elements

  • playerEditTextField - The field that is filled with the player's detail we want to edit
  • playerTableView - We wanted to have a similar behaviour and UI as we have in iOS General Settings for editing a details. This table view has either one row with a text field or multiple rows with a selection behaviour.
  • loadingView - is used to show an indicator while a server call is made
  • doneButton - An UIBarButtonItem that performs the action of editing.

Services

  • Update Player API, used as a StandardNetworkService:
private func updatePlayer(
    _ player: PlayerResponseModel,
    completion: @escaping (Bool) -> Void
) {
    // Initialize network service for player update
    var service = StandardNetworkService(
        resourcePath: "/api/players",
        authenticated: true
    )
    
    // Perform update request with player data
    // Use player's ID for resource identification
    service.update(
        PlayerCreateModel(player),
        resourceID: ResourceID.integer(player.id)
    ) { result in
        // Check result of the network request
        if case .success(let updated) = result {
            // Player update successful
            completion(updated)
        } else {
            // Player update failed
            completion(false)
        }
    }
}

Models

  • viewType - An enum that can be .text (for player details that are entered via keyboard) or .selection (for player details that are selected by tapping one of the cells, for example the preferred position).
  • player - The player we want to edit.
  • items - An array of strings corresponding to all possible options for preferred positions or skill. This array is nil when a text entry is going to be edited.

ConfirmPlayersViewController

Confirm Players Screen

Description

  • Before reaching the Gather screen we want to put the players in the desired teams

UI elements

  • playerTableView - A table view split in three sections (Bench, Team A and Team B) that shows the selected players we want for the gather.
  • startGatherButton - Initially disabled, when tapped triggers an action to perform the Network API calls required to start the gather and at last, it pushes the next screen.
  • loadingView - is used to show an indicator while a server call is made.

Services

  • Create Gather - Adds a new gather by making a POST request to /api/gathers.
  • Add Player to Gather - After we are done with selecting teams for our players, we add them to the gather by doing a POST request to api/gathers/{gather_id}/players/{player_id}.

Models

  • playersDictionary - Each team has an array of players, so the dictionary has the teams as keys (Team A, Team B or Bench) and for values we have the selected players (array of players).

When we are done with the selection (UI), a new gather is created and each player is assigned a team.

@IBAction func startGatherAction(_ sender: Any) {
    // Show loading indicator while creating gather
    showLoadingView()
    
    // Create new gather with authenticated request
    // Returns UUID on success, nil on failure
    createGather { [weak self] uuid in
        guard let self = self else { return }
        
        guard let gatherUUID = uuid else {
            // Handle gather creation failure
            self.handleServiceFailure()
            return
        }
        
        // Add selected players to the newly created gather
        // Players will be assigned to their respective teams
        self.addPlayersToGather(havingUUID: gatherUUID)
    }
}

The for loop to add players is presented below:

private func addPlayersToGather(havingUUID gatherUUID: UUID) {
    // Get array of players with their team assignments
    let players = self.playerTeamArray
    
    // Create dispatch group to handle multiple concurrent requests
    // This ensures all player additions complete before updating UI
    let dispatchGroup = DispatchGroup()
    var serviceFailed = false
    
    // Add each player to the gather with their assigned team
    players.forEach { playerTeamModel in
        dispatchGroup.enter()
        
        self.addPlayer(
            playerTeamModel.player,
            toGatherHavingUUID: gatherUUID,
            team: playerTeamModel.team,
            completion: { playerWasAdded in
                // Track if any player addition fails
                if !playerWasAdded {
                    serviceFailed = true
                }
                
                dispatchGroup.leave()
            }
        )
    }
    
    // Handle completion after all player additions finish
    dispatchGroup.notify(queue: DispatchQueue.main) {
        self.hideLoadingView()
        
        if serviceFailed {
            // Handle case where one or more players failed to add
            self.handleServiceFailure()
        } else {
            // All players added successfully - navigate to gather screen
            self.performSegue(
                withIdentifier: SegueIdentifiers.gather.rawValue,
                sender: GatherModel(
                    players: players,
                    gatherUUID: gatherUUID
                )
            )
        }
    }
}

GatherViewController

Gather Screen

Description

  • This is the core screen of the application, where you are in the gather mode and start / pause or stop the timer and in the end, finish the match.

UI elements

  • playerTableView - Used to display the players in gather, split in two sections: Team A and Team B.
  • scoreLabelView - A view that has two labels for displaying the score, one for Team A and the other one for Team B.
  • scoreStepper - A view that has two steppers for the teams.
  • timerLabel - Used to display the remaining time in the format mm:ss.
  • timerView - An overlay view that has a UIPickerView to choose the time of the gather.
  • timePickerView - The picker view with two components (minutes and seconds) for selecting the gather's time.
  • actionTimerButton - Different state button that manages the countdown timer (resume, pause and start).
  • loadingView - is used to show an indicator while a server call is made.

Services

  • Update Gather - when a gather is ended, we do a PUT request to update the winner team and the score

Models

  • GatherTime - A tuple that has minutes and seconds as Int.
  • gatherModel - Contains the gather ID and an array of player team model (the player response model and the team he belongs to). This is created and passed from ConfirmPlayersViewController.
  • timer - Used to countdown the minutes and seconds of the gather.
  • timerState - Can have three states stopped, running and paused. We observer when one of the values is set so we can change the actionTimerButton's title accordingly. When it's paused the button's title will be Resume. When it's running the button's title will be Pause and Start when the timer is stopped.

When the actionTimerButton is tapped, we verify if we want to invalidate or start the timer:

@IBAction func actionTimer(_ sender: Any) {
    // Check if the user selected a time more than 1 second
    guard selectedTime.minutes > 0 || selectedTime.seconds > 0 else {
        return
    }
    
    switch timerState {
    case .stopped, .paused:
        // Timer was stopped or paused, start running
        timerState = .running
    case .running:
        // Timer is running, pause it
        timerState = .paused
    }
    
    if timerState == .paused {
        // Stop the timer
        timer.invalidate()
    } else {
        // Start the timer and call updateTimer every second
        timer = Timer.scheduledTimer(
            timeInterval: 1,
            target: self,
            selector: #selector(updateTimer),
            userInfo: nil,
            repeats: true
        )	    
    }
}

To cancel a timer we have the following action implemented:

@IBAction func cancelTimer(_ sender: Any) {
    // Set timer state to stopped and invalidate the timer
    timerState = .stopped
    timer.invalidate()
    
    // Reset selected time to default (10 minutes)
    selectedTime = Constants.defaultTime
    timerView.isHidden = true
}

The selector updateTimer is called each second:

@objc func updateTimer(_ timer: Timer) {
    // Check if seconds are zero to decrement minutes
    if selectedTime.seconds == 0 {
        selectedTime.minutes -= 1
        selectedTime.seconds = 59
    } else {
        selectedTime.seconds -= 1
    }
    
    // Stop timer if time reaches zero
    if selectedTime.seconds == 0 && selectedTime.minutes == 0 {
        timerState = .stopped
        timer.invalidate()
    }
}

Before ending a gather, check the winner team:

guard let scoreTeamAString = scoreLabelView.teamAScoreLabel.text,
      let scoreTeamBString = scoreLabelView.teamBScoreLabel.text,
      let scoreTeamA = Int(scoreTeamAString),
      let scoreTeamB = Int(scoreTeamBString) else {
    return
}

// Format the score as a string
let score = "\(scoreTeamA)-\(scoreTeamB)"

// Determine the winner team based on scores
var winnerTeam: String = "None"
if scoreTeamA > scoreTeamB {
    winnerTeam = "Team A"
} else if scoreTeamA < scoreTeamB {
    winnerTeam = "Team B"
}

// Create gather model with score and winner team
let gather = GatherCreateModel(score: score, winnerTeam: winnerTeam)

And the service call:

private func updateGather(
    _ gather: GatherCreateModel,
    completion: @escaping (Bool) -> Void
) {
    // Verify gather model exists before proceeding
    guard let gatherModel = gatherModel else {
        completion(false)
        return
    }
    
    // Initialize network service for gather update
    var service = StandardNetworkService(
        resourcePath: "/api/gathers",
        authenticated: true
    )
    
    // Perform update request with gather data
    // Use player's ID for resource identification
    service.update(
        gather,
        resourceID: ResourceID.uuid(gatherModel.gatherUUID)
    ) { result in
        // Check result of the network request
        if case .success(let updated) = result {
            // Update successful
            completion(updated)
        } else {
            // Update failed
            completion(false)
        }
    }
}

The private method updateGather is called from endGather:

let gather = GatherCreateModel(score: score, winnerTeam: winnerTeam)
  
showLoadingView()
updateGather(gather) { [weak self] gatherWasUpdated in
    guard let self = self else { return }
    
    DispatchQueue.main.async {
        self.hideLoadingView()
        
        if !gatherWasUpdated {
            // The server call failed, make sure we show an alert to the user
            AlertHelper.present(
                in: self,
                title: "Error update",
                message: "Unable to update gather. Please try again."
            )
        } else {
            guard let playerViewController = self.navigationController?.viewControllers
                .first(where: { $0 is PlayerListViewController }) as? PlayerListViewController else {
                return
            }
            
            // The PlayerListViewController is in a selection mode state
            // We make sure we turn it back to .list
            playerViewController.toggleViewState()
            
            // Pop to PlayerListViewController, skipping confirmation screen
            self.navigationController?.popToViewController(
                playerViewController,
                animated: true
            )
        }
    }
}

Testing our business logic

We saw a first iteration of applying MVC to the demo app FootbalGather. Of course, we can refactor the code and make it better and decouple some of the logic, split it into different classes, but for the sake of the exercise we are going to keep this version of the codebase.

Let's see how we can write unit tests for our classes. We are going to exemplify for GatherViewController and try to reach close to 100% code coverage.

First, we see GatherViewController is part of Main storyboard. To make our lives easier, we use an identifier and instantiate it with the method storyboard.instantiateViewController. Let's use the setUp method for this logic:

final class GatherViewControllerTests: XCTestCase {
	  
    var sut: GatherViewController! // System Under Test (SUT)
    
    override func setUp() {
        super.setUp()
        
        // Load the storyboard named "Main"
        let storyboard = UIStoryboard(name: "Main", bundle: nil)
        
        // Instantiate GatherViewController from the storyboard
        if let viewController = storyboard.instantiateViewController(identifier: "GatherViewController") as? GatherViewController {
            sut = viewController // Assign to SUT
            sut.gatherModel = gatherModel // Set the gather model
            _ = sut.view // Load the view to trigger viewDidLoad
        } else {
            XCTFail("Unable to instantiate GatherViewController") // Fail the test if instantiation fails
        }
    }

//…
}

For our first test, we verify all outlets are not nil:

func testOutlets_whenViewControllerIsLoadedFromStoryboard_areNotNil() {
    // Verify all IBOutlets are properly connected from storyboard
    XCTAssertNotNil(sut.playerTableView)      // Table view showing players
    XCTAssertNotNil(sut.scoreLabelView)       // View displaying team scores
    XCTAssertNotNil(sut.scoreStepper)         // Stepper controls for adjusting scores
    XCTAssertNotNil(sut.timerLabel)           // Label showing countdown time
    XCTAssertNotNil(sut.timerView)            // Container view for timer controls
    XCTAssertNotNil(sut.timePickerView)       // Picker for setting match duration
    XCTAssertNotNil(sut.actionTimerButton)    // Button to start/pause/resume timer
}

Now let's see if viewDidLoad is called. The title is set and some properties are configured. We verify the public parameters:

func testViewDidLoad_whenViewControllerIsLoadedFromStoryboard_setsVariables() {
    XCTAssertNotNil(sut.title)
    XCTAssertTrue(sut.timerView.isHidden)
    XCTAssertNotNil(sut.timePickerView.delegate)
}

The variable timerView is a pop-up custom view where users set their match timer.

Moving forward let's unit test our table view methods:

func testViewDidLoad_whenViewControllerIsLoadedFromStoryboard_setsVariables() {
    // Verify initial view setup after loading
    XCTAssertNotNil(sut.title)                    // Check view controller title is set
    XCTAssertTrue(sut.timerView.isHidden)         // Timer view should be hidden initially
    XCTAssertNotNil(sut.timePickerView.delegate)  // Time picker should have delegate set
}

The variable timerView is a pop-up custom view where users set their match timer.

Moving forward let's unit test our table view methods:

func testNumberOfSections_whenGatherModelIsSet_returnsTwoTeams() {
    // Verify table view has correct number of sections (Team A and Team B)
    XCTAssert(sut.playerTableView?.numberOfSections == Team.allCases.count - 1)
}

We have just two teams: Team A and Team B. The Bench team is not visible and not part of this screen.

func testTitleForHeaderInSection_whenSectionIsTeamAAndGatherModelIsSet_returnsTeamATitleHeader() {
    // Test header title for Team A section
    let teamASectionTitle = sut.tableView(sut.playerTableView, titleForHeaderInSection: 0)
    XCTAssertEqual(teamASectionTitle, Team.teamA.headerTitle)
}
    
func testTitleForHeaderInSection_whenSectionIsTeamBAndGatherModelIsSet_returnsTeamBTitleHeader() {
    // Test header title for Team B section
    let teamBSectionTitle = sut.tableView(sut.playerTableView, titleForHeaderInSection: 1)
    XCTAssertEqual(teamBSectionTitle, Team.teamB.headerTitle)
}

Our tableview should have two sections with both header titles being set to the team names (Team A and Team B).

For checking the number of rows, we inject a mocked gather model:

private let gatherModel = ModelsMockFactory.makeGatherModel(numberOfPlayers: 4)

static func makeGatherModel(numberOfPlayers: Int, gatherUUID: UUID = ModelsMock.gatherUUID) -> GatherModel {
    // Get all possible player skills and positions
    let allSkills = PlayerSkill.allCases
    let allPositions = PlayerPosition.allCases
    
    // Initialize empty array to store player-team assignments
    var playerTeams: [PlayerTeamModel] = []
    
    // Create specified number of players with random attributes

    // For each player
    ...
        // Randomly assign skill and position
        // Alternate between Team A and Team B
        // Create player model with incremental attributes
    ...
    
    return GatherModel(players: playerTeams, gatherUUID: gatherUUID)
}

Nil scenario when the section is invalid.


func testNumberOfRowsInSection_whenGatherModelIsNil_returnsZero() {
    // Test edge case when gather model is nil
    sut.gatherModel = nil
    XCTAssertEqual(sut.tableView(sut.playerTableView, numberOfRowsInSection: -1), 0)
}

func testCellForRowAtIndexPath_whenSectionIsTeamA_setsCellDetails() {
    // Set up test data for Team A
    let indexPath = IndexPath(row: 0, section: 0)
    let playerTeams = gatherModel.players.filter({ $0.team == .teamA })
    let player = playerTeams[indexPath.row].player
    
    // Get cell from table view
    let cell = sut.playerTableView.cellForRow(at: indexPath)
    
    // Verify cell content matches player data
    XCTAssertEqual(cell?.textLabel?.text, player.name)
    XCTAssertEqual(cell?.detailTextLabel?.text, player.preferredPosition?.acronym)
}
  
func testCellForRowAtIndexPath_whenSectionIsTeamB_setsCellDetails() {
    // Set up test data for Team B
    let indexPath = IndexPath(row: 0, section: 1)
    let playerTeams = gatherModel.players.filter({ $0.team == .teamB })
    let player = playerTeams[indexPath.row].player
    
    // Get cell from table view
    let cell = sut.playerTableView.cellForRow(at: indexPath)
    
    // Verify cell content matches player data
    XCTAssertEqual(cell?.textLabel?.text, player.name)
    XCTAssertEqual(cell?.detailTextLabel?.text, player.preferredPosition?.acronym)
}

func testPickerViewNumberOfComponents_returnsAllCountDownCases() {
    // Verify picker view has correct number of components
    XCTAssertEqual(sut.timePickerView.numberOfComponents, 
                    GatherViewController.GatherCountDownTimerComponent.allCases.count)
}

func testPickerViewNumberOfRowsInComponent_whenComponentIsMinutes_returns60() {
    // Test number of rows for minutes component
    let minutesComponent = GatherViewController.GatherCountDownTimerComponent.minutes.rawValue
    let numberOfRows = sut.pickerView(sut.timePickerView, numberOfRowsInComponent: minutesComponent)
    
    XCTAssertEqual(numberOfRows, 60)
}
  
func testPickerViewNumberOfRowsInComponent_whenComponentIsSecounds() {
    // Test number of rows for seconds component
    let secondsComponent = GatherViewController.GatherCountDownTimerComponent.seconds.rawValue
    let numberOfRows = sut.pickerView(sut.timePickerView, numberOfRowsInComponent: secondsComponent)
    
    XCTAssertEqual(numberOfRows, 60)
}

func testPickerViewTitleForRow_whenComponentIsMinutes_containsMin() {
    // Test title format for minutes component
    let minutesComponent = GatherViewController.GatherCountDownTimerComponent.minutes.rawValue
    let title = sut.pickerView(sut.timePickerView, titleForRow: 0, forComponent: minutesComponent)
    
    XCTAssertTrue(title!.contains("min"))
}
  
func testPickerViewTitleForRow_whenComponentIsSeconds_containsSec() {
    // Test title format for seconds component
    let secondsComponent = GatherViewController.GatherCountDownTimerComponent.seconds.rawValue
    let title = sut.pickerView(sut.timePickerView, titleForRow: 0, forComponent: secondsComponent)
    
    XCTAssertTrue(title!.contains("sec"))
}

func testSetTimer_whenActionIsSent_showsTimerView() {
    // Test timer view visibility when setting timer
    sut.setTimer(UIButton())
    XCTAssertFalse(sut.timerView.isHidden)
}

func testCancelTimer_whenActionIsSent_hidesTimerView() {
    // Test timer view visibility when canceling timer
    sut.cancelTimer(UIButton())
    XCTAssertTrue(sut.timerView.isHidden)
}

func testTimerCancel_whenActionIsSent_hidesTimerView() {
    // Test timer view visibility when canceling from overlay
    sut.timerCancel(UIButton())
    XCTAssertTrue(sut.timerView.isHidden)
}

func testTimerDone_whenActionIsSent_hidesTimerViewAndSetsMinutesAndSeconds() {
    // Test timer completion setup
    sut.timerDone(UIButton())
    
    // Get selected time components
    let minutes = sut.timePickerView.selectedRow(inComponent: 
        GatherViewController.GatherCountDownTimerComponent.minutes.rawValue)
    let seconds = sut.timePickerView.selectedRow(inComponent: 
        GatherViewController.GatherCountDownTimerComponent.seconds.rawValue)
    
    // Verify timer view state and time settings
    XCTAssertTrue(sut.timerView.isHidden)
    XCTAssertGreaterThan(minutes, 0)
    XCTAssertEqual(seconds, 0)
}

func testActionTimer_whenSelectedTimeIsZero_returns() {
    // Set up components
    let minutesComponent = GatherViewController.GatherCountDownTimerComponent.minutes.rawValue
    let secondsComponent = GatherViewController.GatherCountDownTimerComponent.seconds.rawValue
    
    // Set time to 00:00
    sut.timePickerView.selectRow(0, inComponent: minutesComponent, animated: false)
    sut.timePickerView.selectRow(0, inComponent: secondsComponent, animated: false)
    sut.timerDone(UIButton())
    sut.actionTimer(UIButton())
    
    // Verify time remains at 00:00
    XCTAssertEqual(sut.timePickerView.selectedRow(inComponent: minutesComponent), 0)
    XCTAssertEqual(sut.timePickerView.selectedRow(inComponent: secondsComponent), 0)
}

func testActionTimer_whenSelectedTimeIsSet_updatesTimer() {
    // Set up test components
    let minutesComponent = GatherViewController.GatherCountDownTimerComponent.minutes.rawValue
    let secondsComponent = GatherViewController.GatherCountDownTimerComponent.seconds.rawValue
    
    // Configure initial time (0:01)
    sut.timePickerView.selectRow(0, inComponent: minutesComponent, animated: false)
    sut.timePickerView.selectRow(1, inComponent: secondsComponent, animated: false)
    
    // Verify initial state
    sut.timerDone(UIButton())
    XCTAssertEqual(sut.actionTimerButton.title(for: .normal), "Start")
    
    // Start timer
    sut.actionTimer(UIButton())
    XCTAssertEqual(sut.actionTimerButton.title(for: .normal), "Pause")
    
    // Wait for timer completion
    let exp = expectation(description: "Timer expectation")
    DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
        XCTAssertEqual(self.sut.actionTimerButton.title(for: .normal), "Start")
        exp.fulfil()
    }
    
    waitForExpectations(timeout: 5, handler: nil)
}

func testActionTimer_whenTimerIsSetAndRunning_isPaused() {
    // Set up test components
    let sender = UIButton()
    let minutesComponent = GatherViewController.GatherCountDownTimerComponent.minutes.rawValue
    let secondsComponent = GatherViewController.GatherCountDownTimerComponent.seconds.rawValue
    
    // Configure initial time (0:03)
    sut.timePickerView.selectRow(0, inComponent: minutesComponent, animated: false)
    sut.timePickerView.selectRow(3, inComponent: secondsComponent, animated: false)
    
    // Start timer and verify initial state
    sut.timerDone(sender)
    XCTAssertEqual(sut.actionTimerButton.title(for: .normal), "Start")
    sut.actionTimer(sender)
    
    // Pause timer after 1 second
    let exp = expectation(description: "Timer expectation")
    DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
        self.sut.actionTimer(sender)
        XCTAssertEqual(self.sut.actionTimerButton.title(for: .normal), "Resume")
        exp.fulfil()
    }
    
    waitForExpectations(timeout: 5, handler: nil)
}

func testUpdateTimer_whenSecondsReachZero_decrementsMinuteComponent() {
    // Set up test components
    let sender = UIButton()
    let timer = Timer()
    let minutesComponent = GatherViewController.GatherCountDownTimerComponent.minutes.rawValue
    let secondsComponent = GatherViewController.GatherCountDownTimerComponent.seconds.rawValue
    
    // Set initial time to 1:00
    sut.timePickerView.selectRow(1, inComponent: minutesComponent, animated: false)
    sut.timePickerView.selectRow(0, inComponent: secondsComponent, animated: false)
    sut.timerDone(sender)
    XCTAssertEqual(sut.timerLabel.text, "01:00")
    
    // Update timer and verify decrement
    sut.updateTimer(timer)
    XCTAssertEqual(sut.timerLabel.text, "00:59")
}

In this test we checked if the seconds are decremented when the minutes component is reaching zero.

Having access to the outlets, we can easily verify the stepperDidChangeValue delegates:

func testStepperDidChangeValue_whenTeamAScores_updatesTeamAScoreLabel() {
    // Simulate Team A scoring
    sut.scoreStepper.teamAStepper.value = 1
    sut.scoreStepper.teamAStepperValueChanged(UIButton())
    
    // Verify Team A's score label is updated
    XCTAssertEqual(sut.scoreLabelView.teamAScoreLabel.text, "1")
    // Verify Team B's score label remains unchanged
    XCTAssertEqual(sut.scoreLabelView.teamBScoreLabel.text, "0")
}

func testStepperDidChangeValue_whenTeamBScores_updatesTeamBScoreLabel() {
    // Simulate Team B scoring
    sut.scoreStepper.teamBStepper.value = 1
    sut.scoreStepper.teamBStepperValueChanged(UIButton())
    
    // Verify Team B's score label is updated
    XCTAssertEqual(sut.scoreLabelView.teamAScoreLabel.text, "0")
    // Verify Team A's score label remains unchanged
    XCTAssertEqual(sut.scoreLabelView.teamBScoreLabel.text, "1")
}

func testStepperDidChangeValue_whenTeamIsBench_scoreIsNotUpdated() {
    // Simulate score change for Bench team
    sut.stepper(UIStepper(), didChangeValueForTeam: .bench, newValue: 1)
    
    // Verify no score labels are updated
    XCTAssertEqual(sut.scoreLabelView.teamAScoreLabel.text, "0")
    XCTAssertEqual(sut.scoreLabelView.teamBScoreLabel.text, "0")
}

Finally, the hardest and probably the most important method we have in GatherViewController is the endGather method. Here, we do a service call updating the gather model. We pass the winnerTeam and the score of the match.

It is a big method, does more than one thing and is private. (we use it as per example, functions should not be big and functions should do one thing!).

The responsibilities of this function are detailed below. endGather does the following:

  • gets the score from scoreLabelViews
  • computes the winner team by comparing the score
  • creates the GatherModel for the service call
  • shows a loading spinner
  • does the updateGather service call
  • hides the loading spinner
  • handles success and failure
  • for success, the view controller is popped to PlayerListViewController (this view should be in the stack)
  • for failure, it presents an alert

How we should test all of that? (Again, as best practice, this function should be splitted down into multiple functions).

Let's take one step at a time.

Creating a mocked service and injecting it in our sut:

// Set up mock networking components
private let session = URLSessionMockFactory.makeSession()
private let resourcePath = "/api/gathers"
private let appKeychain = AppKeychainMockFactory.makeKeychain()

// Configure mock endpoint and service
let endpoint = EndpointMockFactory.makeSuccessfulEndpoint(path: resourcePath)
sut.updateGatherService = StandardNetworkService(
    session: session, 
    urlRequest: AuthURLRequestFactory(endpoint: endpoint, keychain: appKeychain)
)

Testing the success handler. We use a protocol instead of the concrete class PlayerListViewController and we mock it in our test class:

// Protocol definition for view state toggling
protocol PlayerListTogglable {
    func toggleViewState()
}

// Main view controller conforming to protocol
class PlayerListViewController: UIViewController, PlayerListTogglable { .. }

private extension GatherViewControllerTests {
    // Mock implementation for testing
    final class MockPlayerTogglableViewController: UIViewController, PlayerListTogglable {
        weak var viewStateExpectation: XCTestExpectation?
        private(set) var viewState = true
        
        func toggleViewState() {
            viewState = !viewState
            viewStateExpectation?.fulfil()
        }
    }
}

This should be part of a navigation controller:

// Set up view controller hierarchy
let playerListViewController = MockPlayerTogglableViewController()
let window = UIWindow()
let navController = UINavigationController(rootViewController: playerListViewController)
window.rootViewController = navController
window.makeKeyAndVisible()

// Trigger view loading and verify initial state
_ = playerListViewController.view
XCTAssertTrue(playerListViewController.viewState)

// Set up expectation for state change
let exp = expectation(description: "Timer expectation")
playerListViewController.viewStateExpectation = exp

// Add test view controller to navigation stack
navController.pushViewController(sut, animated: false)

We check the initial viewState. It should be true.

The rest of the unit test is presented below:

// Set up mock score state
sut.scoreLabelView.teamAScoreLabel.text = "1"
sut.scoreLabelView.teamBScoreLabel.text = "1"

// Configure mock networking
let endpoint = EndpointMockFactory.makeSuccessfulEndpoint(path: resourcePath)
sut.updateGatherService = StandardNetworkService(
    session: session,
    urlRequest: AuthURLRequestFactory(endpoint: endpoint, keychain: appKeychain)
)

// Trigger gather end and handle alert
sut.endGather(UIButton())
let alertController = (sut.presentedViewController as! UIAlertController)
alertController.tapButton(atIndex: 0)

// Wait for and verify state change
waitForExpectations(timeout: 5) { _ in
    XCTAssertFalse(playerListViewController.viewState)
}

Because endGather is a private method, we had to use the IBAction that calls this method. And for tapping on OK in the alert controller that was presented we had to use its private API:

private extension UIAlertController {
    // Type definition for alert action handler
    typealias AlertHandler = @convention(block) (UIAlertAction) -> Void

    func tapButton(atIndex index: Int) {
        // Access private handler using key-value coding
        guard let block = actions[index].value(forKey: "handler") else { return }
        
        // Convert and execute handler
        let handler = unsafeBitCast(block as AnyObject, to: AlertHandler.self)
        handler(actions[index])
    }
}

We don't have the guarantee this unit test will work in future Swift versions. This is bad.

Key Metrics

Lines of code

File Number of lines of code
PlayerAddViewController 79
PlayerListViewController 387
PlayerDetailViewController 204
LoginViewController 126
PlayerEditViewController 212
GatherViewController 359
ConfirmPlayersViewController 260
TOTAL 1627

Unit Tests

Topic Data
Number of key classes (the ViewControllers) 7
Key Class GatherViewController
Number of Unit Tests 30
Code Coverage of Gathers feature 95.7%
How hard to write unit tests 5/5

Build Times

Build Time (sec)*
Average Build Time (after clean Derived Data & Clean Build) 9.78
Average Build Time 0.1
Average Unit Test Execution Time (after clean Derived Data & Clean Build) 12.78

* tests were run on an 8-Core Intel Core i9, MacBook Pro, 2019

Conclusion

Model-View-Controller (MVC) remains one of the most widely adopted architectural patterns in iOS development, and for good reason. Throughout this article, we've explored its practical implementation in a real-world application.

In our demonstration, we took a straightforward approach by mapping each screen to a dedicated View Controller. While this implementation works for our sample application, it's important to note that this simplified approach may not be suitable for more complex screens with numerous actions and responsibilities. In production applications, it's often better to distribute responsibilities across multiple components, such as through the use of child view controllers.

We've provided a comprehensive breakdown of each screen in our application, detailing:

  • The functional role and purpose of each component
  • The UI elements and their interactions
  • The underlying data models and controller interactions
  • Key implementation methods with practical code examples

Our experience with unit testing, particularly with the GatherViewController, revealed some challenges inherent to the MVC pattern. The need to rely on private APIs (such as with UIAlertController) highlighted potential maintenance risks, as these implementations could break with future iOS updates.

Despite these challenges, MVC remains a powerful architectural pattern when implemented thoughtfully. Its primary advantages include:

  • Straightforward implementation for smaller applications
  • Lower initial complexity compared to other patterns
  • Strong integration with Apple's frameworks
  • Familiar structure for most iOS developers

While our metrics analysis is preliminary, we anticipate that MVC will show advantages in terms of code conciseness, as other architectural patterns typically introduce additional layers and complexity. However, a complete comparison with other patterns will be necessary to draw definitive conclusions.

In the upcoming articles in this series, we'll explore alternative architectural patterns, providing a comprehensive comparison that will help you make informed decisions for your own iOS projects.

Thank you for following along with this deep dive into MVC! Be sure to check out the additional resources below for more information and practical examples.

Useful Links

Item Series Links
The iOS App - Football Gather GitHub Repo Link
The web server application made in Vapor GitHub Repo Link
'Building Modern REST APIs with Vapor and Fluent in Swift' article link
'From Vapor 3 to 4: Elevate your server-side app' article link
Model View Controller (MVC) GitHub Repo Link
Article Link
Model View ViewModel (MVVM) GitHub Repo Link
Article Link
Model View Presenter (MVP) GitHub Repo Link
Article Link
Coordinator Pattern - MVP with Coordinators (MVP-C) GitHub Repo Link
Article Link
View Interactor Presenter Entity Router (VIPER) GitHub Repo Link
Article Link
View Interactor Presenter (VIP) GitHub Repo Link
Article Link