Test Driven Development in SwiftUI - Part 2

This creates the View for Tic Tac Toe app utilising the Model and ViewModel created in part 1. More unit tests are added as functionality is required and then the code to get the tests to pass is implemented.

Related articles on TDD in SwiftUI:

  1. Test Driven Development in SwiftUI - Part 1
  2. Test Driven Development in SwiftUI - Part 2
  3. Understanding Minimax Algorithm with Tic Tac Toe
  4. Test Driven Development in SwiftUI - Part 3
  5. Fixing a bug in Tic Tac Toe with TDD


Change a cell in the View

The GridView is broken out into a separate SwiftUI view. A temporary green border is placed around each cell to make the cells visible. Each cell is made into a Button and an action is added to set the cell to the X value. This calls the ViewModel, which calls the Model and sets the cell to the X value. The change in the model object, that is marked with the @Published property wrapper, triggers a reloading of all views observing that object to reflect the changes.

 1struct GridView: View {
 2    @ObservedObject var ticVm: TicViewModel
 3    
 4    var body: some View {
 5        VStack(spacing:3) {
 6            let n = 3
 7            ForEach(0..<n, id:\.self) { r in
 8                HStack(spacing:3) {
 9                    ForEach(0..<n, id:\.self) { c in
10                        let index = (r*n) + c
11                        
12                        Button(action: {
13                            // set the cell to X or O
14                            ticVm.setCell(index: index,
15                                          cellValue: .x)
16                        }) {
17                            ZStack {
18                                RoundedRectangle(cornerRadius: 10)
19                                    .stroke(Color.green, lineWidth:5)
20                                VStack {
21                                    Text("\(ticVm.grid[index].rawValue)")
22                                }
23                            }
24                            .frame(width: 80, height: 80)
25                        }
26                    }
27                }
28            }
29        }
30    }
31}
 1struct TicTacToeView: View {
 2    @ObservedObject private var ticVm: TicViewModel
 3    
 4    init() {
 5        ticVm = TicViewModel()
 6    }
 7    
 8    var body: some View {
 9        VStack {
10            Text("Tic Tac Toe Game")
11            
12            GridView(ticVm: ticVm)
13            
14            Spacer()
15        }
16        
17    }
18}

Grid for Tic-Tac-Toe in a separate SwiftUI view
Grid for Tic-Tac-Toe in a separate SwiftUI view


Main SwiftUI view using the gridview
Main SwiftUI view using the gridview



Alternate X and O

A State boolean variable could be added to the view and toggled each time a button is selected. But does this logic belong in the view? It can be tempting to quickly add a State variable and get the SwiftUI view working, promising to move the logic later. This is a mistake and it does not take that long to write a failing test for the model, implement a property to state whos turn is next.

The Tic Tac Toe game is played with alternating payer turns, we can start with setting the game as player X goes first. Add a test to verify that it is the turn of player X when a new model is created. This fails to compile.

Model Tests

1    func test_startGame_xPlayerTurn() {
2        // Arrange
3        let ticModel = TicModel()
4
5        // Act
6
7        // Assert
8        XCTAssertTrue(ticModel.isXTurn)
9    }

Whos turn could be stored in an enum, but since there are only two players, a boolean value can be used for whether or not it is X's turn. Add a private variable to store the Player X turn and set it to true in the initialiser. This property is updated in the setCell function to toggle from the current value. Then add a public readonly property to expose this to the ViewModel.

Model

 1struct TicModel {
 2    ...
 3
 4    private var _playerXTurn: Bool
 5
 6    init() {
 7        ...
 8        _playerXTurn = true
 9    }
10    
11    var isXTurn: Bool {
12        get { _playerXTurn }
13    }
14
15    ...
16
17    mutating func setCell(n:Int, c: Cell) {
18        guard _grid.indices.contains(n) else {
19            return
20        }
21        guard _grid[n] == .b else {
22            return
23        }
24        _grid[n] = c
25        _playerXTurn.toggle()
26    }
27    ...
28}

Once the first test is passing, add another couple of tests such as ensuring the property is false after the first cell is set. Validate that attempting to set the same cell over and over again does not change the isXTurn value.

Model Tests

 1    func test_secondTurn_isO() {
 2        // Arrange
 3        var ticModel = TicModel()
 4
 5        // Act
 6        ticModel.setCell(n: 5, c: .x)
 7
 8        // Assert
 9        XCTAssertFalse(ticModel.isXTurn)
10    }
11    
12    func test_repeatTurn_ignored() {
13        // Arrange
14        var ticModel = TicModel()
15
16        // Act
17        ticModel.setCell(n: 5, c: .x)
18        ticModel.setCell(n: 5, c: .o)
19        ticModel.setCell(n: 5, c: .b)
20
21        // Assert
22        XCTAssertFalse(ticModel.isXTurn)
23    }

Next add similar unit tests to the ViewModel. These will also fail as they will not compile.

ViewModel Tests

 1    func test_startGame_xPlayerTurn() {
 2        // Arrange
 3        let ticViewModel = TicViewModel()
 4
 5        // Act
 6
 7        // Assert
 8        XCTAssertTrue(ticViewModel.isXTurn)
 9    }
10
11    func test_alternateTurns_xTurn() {
12        // Arrange
13        let ticViewModel = TicViewModel()
14
15        for i in 1...9 {
16            // Act
17            ticViewModel.setCell(index: i-1, cellValue: .o)
18
19            // Assert
20            XCTAssertEqual(i%2==0  , ticViewModel.isXTurn)
21        }
22    }

The change to the ViewModel is simply to expose the readonly property of isXTurn. Now the tests should pass.

ViewModel

1class TicViewModel: ObservableObject {
2    ...
3    
4    var isXTurn: Bool {
5        get { ticModel.isXTurn }
6    }
7
8    ...
9}

Once the Model and ViewModel are updated the change to GridView is simply to check the isXTurn value and set the cell to X if true, otherwise set it to O. It is now possible to play a game with X starting and each player taking turns. There are still a number of improvements such as stopping the game when a player has won and starting a new game. Although, it is not possible to change a cell once it has been set, the cell animates when it is selected by a player.

View Update

1    ...
2    
3    Button(action: {
4        // set the cell to X or O
5        ticVm.setCell(index: index,
6                      cellValue: ticVm.isXTurn ? .x : .o)
7    }) {
8    
9    ...

Alternate X and O tokens in cells

Alternate X and O tokens in cells



Add Gridlines to the board

We could continue completing the functionality or start improving the UI. It can be surprising how much better the game seems when the colors and icons are changed. Start with removing the green borders and adding a grid to the Tic Tac Toe board. A GridLinesView is created and added over the cells in the GridViewin a ZStack.

 1struct GridLinesView: View {
 2    var body: some View {
 3        GeometryReader { gr in
 4            let w = gr.size.width
 5            let h = gr.size.height
 6 
 7            ZStack {
 8                VStack(spacing:0) {
 9                    HStack(spacing:0) {
10                        Spacer()
11                        RoundedRectangle(cornerRadius: w*0.02)
12                            .frame(width: w*0.02, height: h*0.98)
13                        Spacer()
14                        RoundedRectangle(cornerRadius: w*0.02)
15                            .frame(width: w*0.02, height: h*0.98)
16                        Spacer()
17                    }
18                }
19                HStack(spacing:0) {
20                    VStack(spacing:0) {
21                        Spacer()
22                        RoundedRectangle(cornerRadius: w*0.02)
23                            .frame(width: w*0.98, height: h*0.02)
24                        Spacer()
25                        RoundedRectangle(cornerRadius: w*0.02)
26                            .frame(width: w*0.98, height: h*0.02)
27                        Spacer()
28                    }
29                }
30            }
31        }
32    }
33}

Grid lines added to the Tic Tac Toe Board

Grid lines added to the Tic Tac Toe Board



Add indicator for Players Turn

Create a visual indicator to show whos turn it is to play next. Both player icons become disabled when the game ends, but cells can still be filled in if there are any remaining blank cells. ActivePlayerView defines an icon for a player with color and size changed between active and inactive states.

 1struct ActivePlayerView: View {
 2    var isActive: Bool
 3    var player: String
 4    
 5    var body: some View {
 6        RoundedRectangle(cornerRadius: 10)
 7            .fill(isActive ? Colors.activeGradient : Colors.inactiveGradient)
 8            .frame(width:120, height:30)
 9            .overlay(
10                Text(player))
11            .font(.system(size: 20, weight: .bold, design: .rounded))
12            .foregroundColor(.white)
13            .scaleEffect(isActive ? 1.0 : 0.85)
14            .animation(.easeInOut(duration: 0.5))
15    }
16}

The player icons are added to the main Tic Tac Toe view as well as setting the background color.

 1struct TicTacToeView: View {
 2    @ObservedObject private var ticVm: TicViewModel
 3    
 4    init() {
 5        ticVm = TicViewModel()
 6    }
 7    
 8    var body: some View {
 9        ZStack {
10            Colors.bgGradient
11                .edgesIgnoringSafeArea(.all)
12            
13            VStack {
14                Text("Tic Tac Toe Game")
15                    .foregroundColor(Colors.darkPurple)
16                    .font(.custom("Helvetica Neue", size: 36, relativeTo: .largeTitle))
17                    .fontWeight(.bold)
18                
19                HStack {
20                    ActivePlayerView(
21                        isActive: ticVm.isXTurn && !ticVm.isGameOver,
22                        player: "Player X")
23
24                    ActivePlayerView(
25                        isActive: !ticVm.isXTurn && !ticVm.isGameOver,
26                        player: "Player O")
27                }
28                
29                GridView(ticVm: ticVm)
30                
31                Spacer()
32            }
33        }
34    }
35}

Icon added to show whos turn - in this case player-O

Icon added to show whos turn - in this case player-O



Custom Shapes

The values for X and O on the Tic Tac Toe board could be left as letters or symbols could be used from sf-symbols. Instead, I created shapes using Path for the X and O shapes. This helps gives the App a custom look and feel.

 1struct OShape: Shape {
 2    func path(in rect: CGRect) -> Path {
 3        let size = min(rect.width, rect.height)
 4        let xOffset = (rect.width > rect.height) ? (rect.width - rect.height) / 2.0 : 0.0
 5        let yOffset = (rect.height > rect.width) ? (rect.height - rect.width) / 2.0 : 0.0
 6        
 7        func adjustPoint(x: Double, y:Double) -> CGPoint {
 8            return CGPoint(x: (x * size) + xOffset, y: (y * size) + yOffset)
 9        }
10        
11        let path = Path { path in
12            path.move(to: adjustPoint(x: 0.62, y: 0.02))
13            path.addCurve(to: adjustPoint(x: 0.04, y: 0.52),
14                          control1: adjustPoint(x: 0.3, y: 0.02),
15                          control2: adjustPoint(x: 0.1, y: 0.22))
16            path.addCurve(to: adjustPoint(x: 0.50, y: 0.98),
17                          control1: adjustPoint(x: 0.03, y: 0.70),
18                          control2: adjustPoint(x: 0.04, y: 0.99))
19            path.addCurve(to: adjustPoint(x: 0.75, y: 0.95),
20                          control1: adjustPoint(x: 0.50, y: 0.98),
21                          control2: adjustPoint(x: 0.65, y: 0.99))
22            path.addCurve(to: adjustPoint(x: 0.95, y: 0.63),
23                          control1: adjustPoint(x: 0.84, y: 0.90),
24                          control2: adjustPoint(x: 0.93, y: 0.78))
25            path.addCurve(to: adjustPoint(x: 0.5, y: 0.13),
26                          control1: adjustPoint(x: 0.96, y: 0.43),
27                          control2: adjustPoint(x: 0.8, y: 0.0))
28            path.addCurve(to: adjustPoint(x: 0.4, y: 0.33),
29                          control1: adjustPoint(x: 0.4, y: 0.18),
30                          control2: adjustPoint(x: 0.35, y: 0.33))
31            path.addCurve(to: adjustPoint(x: 0.7, y: 0.84),
32                          control1: adjustPoint(x: 0.8, y: -0.10),
33                          control2: adjustPoint(x: 0.99, y: 0.70))
34            path.addCurve(to: adjustPoint(x: 0.22, y: 0.80),
35                          control1: adjustPoint(x: 0.5, y: 0.92),
36                          control2: adjustPoint(x: 0.4, y: 0.90))
37            path.addCurve(to: adjustPoint(x: 0.30, y: 0.22),
38                          control1: adjustPoint(x: -0.05, y: 0.50),
39                          control2: adjustPoint(x: 0.30, y: 0.22))
40            path.addCurve(to: adjustPoint(x: 0.62, y: 0.02),
41                          control1: adjustPoint(x: 0.5, y: 0.05),
42                          control2: adjustPoint(x: 0.80, y: 0.06))
43            path.closeSubpath()
44        }
45        return path
46    }
47}

Update the GridView to use the appropriate shape based on the value in the grid.

 1   ...
 2      ForEach(0..<n, id:\.self) { c in
 3          let index = (r*n) + c
 4          
 5          Button(action: {
 6              // set the cell to X or O
 7              ticVm.setCell(index: index,
 8                            cellValue: ticVm.isXTurn ? .x : .o)
 9          }) {
10              ZStack {
11                  BackGroundCardView()
12                      .padding(2)
13
14                  Group {
15                      if ticVm.grid[index] == .b {
16                          // leave cell blank
17                      } else if ticVm.grid[index] == .x {
18                          XShape()
19                              .fill(Color(red: 150/255, green: 20/255, blue: 20/255))
20                      } else {
21                          OShape()
22                              .fill(Color(red: 100/255, green: 20/255, blue: 140/255))
23                      }
24                  }
25                  .padding(12)
26              }
27              .frame(width: 80, height: 80)
28          }
29      }
30   ...

Use Path to create custom shapes for X and O

Use Path to create custom shapes for X and O

Tic Tac Toe board with custom shapes

Tic Tac Toe board with custom shapes



Stop when game over

There are two aspects to stopping the game when it is over. The first is to stop anymore cells being updated and the second is to display some message that the game is over. The information is already available from the ViewModel with a boolean property isGameOver. The disabled property is used on each cell button, setting it to true when the game is over. Each cell is also disabled if the cell is not blank to avoid animating the cell when nothing happens.

 1   ...
 2        ForEach(0..<n, id:\.self) { c in
 3            let index = (r*n) + c
 4            let cellContent = ticVm.grid[index]
 5            
 6            Button(action: {
 7                // set the cell to X or O
 8                ticVm.setCell(index: index,
 9                              cellValue: ticVm.isXTurn ? .x : .o)
10            }) {
11                ZStack {
12                    BackGroundCardView()
13                        .padding(2)
14
15                    Group {
16                        if cellContent == .b {
17                            // leave cell blank
18                        } else if cellContent == .x {
19                            XShape()
20                                .fill(Color(red: 150/255, green: 20/255, blue: 20/255))
21                        } else {
22                            OShape()
23                                .fill(Color(red: 100/255, green: 20/255, blue: 140/255))
24                        }
25                    }
26                    .padding(12)
27                }
28                .frame(width: 80, height: 80)
29            }
30            .disabled(ticVm.isGameOver || cellContent != .b)
31        }
32   ...

A Text view is added to the main SwiftUI view to display a "Game Over" message if the game is over.

 1    var body: some View {
 2        ZStack {
 3            Colors.bgGradient
 4                .edgesIgnoringSafeArea(.all)
 5            
 6            VStack {
 7                Text("Tic Tac Toe Game")
 8                    .foregroundColor(Colors.darkPurple)
 9                    .font(.custom("Helvetica Neue", size: 36, relativeTo: .largeTitle))
10                    .fontWeight(.bold)
11                
12                HStack {
13                    ActivePlayerView(
14                        isActive: ticVm.isXTurn && !ticVm.isGameOver,
15                        player: "Player X")
16
17                    ActivePlayerView(
18                        isActive: !ticVm.isXTurn && !ticVm.isGameOver,
19                        player: "Player O")
20                }
21                
22                GridView(ticVm: ticVm)
23                
24                if ticVm.isGameOver {
25                    Text("GAME OVER !")
26                        .foregroundColor(Colors.darkPurple)
27                        .font(.custom("Helvetica Neue", size: 46, relativeTo: .largeTitle))
28                        .fontWeight(.bold)
29                }
30                
31                Spacer()
32            }
33        }
34    }

Message displaying Game Over

Message displaying Game Over



Show the result of the game

There is a winner property in the ViewModel that could be used to interpret which player won and display a suitable message. However, this code will always display "Draw" while the game is ongoing. So we need to add another check that the game is over. This is presentation logic that should be placed in the ViewModel.

1    if ticVm.winner == .x {
2        Text("X Wins")
3    }
4    if ticVm.winner == .o {
5        Text("O Wins")
6    }
7    if ticVm.winner == .none {
8        Text("Draw")
9    }

What the View wants is a text message to display who has won the game or whether it is a draw. Add a Unit test to the TicViewModelTests to verify the winner display message is empty at the start of a game. This test fails until we add the property to the ViewModel.


TicViewModelTests

1    func test_startGame_winnerDisplayEmpty() {
2        // Arrange
3        let ticViewModel = TicViewModel()
4
5        // Act
6
7        // Assert
8        XCTAssertEqual("", ticViewModel.winnerDisplay)
9    }

TicViewModel

Add the winnerDisplay property to the ViewModel to return the appropriate message, which can be one of 4 values depending on the state of the game.

  • An empty string - when the game is ongoing
  • "Draw" - when the game is over and no player won
  • "X Wins" - when Player X has won
  • "O Wins" - when Player O has won
 1    var winnerDisplay: String {
 2        get {
 3            var message = ""
 4            if ticModel.winner == .x {
 5                message = "X Wins"
 6            }
 7            else if ticModel.winner == .o {
 8                message = "O Wins"
 9            }
10            else if ticModel.winner == .none && isGameOver {
11                message = "Draw"
12            }
13            return message
14        }
15    }

TicViewModelTests

Add more unit tests to validate each of the possible states from a game.

 1    func test_xWins_winnerDisplayXWins() {
 2        // Arrange
 3        let ticViewModel = TicViewModel()
 4
 5        // Act
 6        for i in [0,1,2] {
 7            ticViewModel.setCell(index: i, cellValue: .x)
 8        }
 9        
10        // Assert
11        XCTAssertTrue(ticViewModel.isGameOver)
12        XCTAssertEqual("X Wins", ticViewModel.winnerDisplay)
13    }
14
15    func test_xWinsFull_winnerDisplayXWins() {
16        // Arrange
17        let ticViewModel = TicViewModel()
18
19        // Act
20        let fullGrid: [Cell] = [.x, .o, .x,
21                                .o, .x, .o,
22                                .x, .o, .x]
23        for (n,c) in zip(0..<9, fullGrid) {
24            ticViewModel.setCell(index: n, cellValue: c)
25        }
26        // Assert
27        XCTAssertTrue(ticViewModel.isGameOver)
28        XCTAssertEqual("X Wins", ticViewModel.winnerDisplay)
29    }
30
31    func test_oWins_winnerDisplayOWins() {
32        // Arrange
33        let ticViewModel = TicViewModel()
34
35        // Act
36        let fullGrid: [Cell] = [.x, .o, .x,
37                                .x, .x, .b,
38                                .o, .o, .o]
39        for (n,c) in zip(0..<9, fullGrid) {
40            ticViewModel.setCell(index: n, cellValue: c)
41        }
42        // Assert
43        XCTAssertTrue(ticViewModel.isGameOver)
44        XCTAssertEqual("O Wins", ticViewModel.winnerDisplay)
45    }
46
47    func test_draw_winnerDisplayDraw() {
48        // Arrange
49        let ticViewModel = TicViewModel()
50
51        // Act
52        let fullGrid: [Cell] = [.x, .o, .x,
53                                .x, .x, .o,
54                                .o, .x, .o]
55        for (n,c) in zip(0..<9, fullGrid) {
56            ticViewModel.setCell(index: n, cellValue: c)
57        }
58        // Assert
59        XCTAssertTrue(ticViewModel.isGameOver)
60        XCTAssertEqual("Draw", ticViewModel.winnerDisplay)
61    }

Now the previous winner property can be removed from the ViewModel and the associated unit tests. The winner property is still required in the Model as this is used by the new winnerDisplay property in the ViewModel.

All unit tests passing after adding winnerDisplay property
All unit tests passing after adding winnerDisplay property


TicTacToeView

The change to the View is the addition of a Text View to display the winnerDisplay.

1    ...
2       Text(ticVm.winnerDisplay)
3           .foregroundColor(Colors.darkPurple)
4           .font(.custom("Helvetica Neue", size: 46, relativeTo: .largeTitle))
5           .fontWeight(.bold)
6    ...

Winner displayed when the game is over
Winner displayed when the game is over



Start a new game

A mechanism is needed to start a new game. The simplest solution is a New Game button that would call a function in the ViewModel to reset the game. Add a unit test to ViewModel tests to reset and ensure the grid contains 9 empty cells. This fails to compile until the function is added to the ViewModel.


TicViewModelTests

 1    func test_reset_gridEmpty() {
 2        // Arrange
 3        let ticViewModel = TicViewModel()
 4
 5        // Act
 6        ticViewModel.reset()
 7
 8        // Assert
 9        XCTAssertEqual(ticViewModel.grid.count, 9)
10        XCTAssertEqual((ticViewModel.grid.filter { $0 == Cell.b }.count), 9)
11    }

TicViewModel

The reset function only has to set the model to a new instance of TicModel.

1    ...
2    func reset() {
3        // initialize a new model
4        ticModel = TicModel()
5    }
6    ...

TicViewModelTests

Validate that reset works in the middle of a game and when the grid is full.

 1    func test_resetAfterOne_gridEmpty() {
 2        // Arrange
 3        let ticViewModel = TicViewModel()
 4
 5        // Act
 6        ticViewModel.setCell(index: 0, cellValue: .x)
 7        ticViewModel.reset()
 8
 9        // Assert
10        XCTAssertEqual(ticViewModel.grid.count, 9)
11        XCTAssertEqual((ticViewModel.grid.filter { $0 == Cell.b }.count), 9)
12    }
13
14    func test_resetGameOver_gridEmpty() {
15        // Arrange
16        let ticViewModel = TicViewModel()
17
18        // Act
19        let fullGrid: [Cell] = [.x, .o, .x,
20                                .x, .x, .o,
21                                .o, .x, .o]
22        for (n,c) in zip(0..<9, fullGrid) {
23            ticViewModel.setCell(index: n, cellValue: c)
24        }
25        ticViewModel.reset()
26
27        // Assert
28        XCTAssertEqual(ticViewModel.grid.count, 9)
29        XCTAssertEqual((ticViewModel.grid.filter { $0 == Cell.b }.count), 9)
30    }

TicTacToeView

A button is added to the View to call the reset function in the ViewModel.

1    ...
2                Button("New Game", action: ticVm.reset)
3                    .buttonStyle(ActionButtonStyle())
4    ...

All unit tests passing after adding reset function
All unit tests passing after adding reset function


new game button added to start again
New game button added to start again



Highlight the winning line

The final touch on the UI is to highlight the winning line when the game is won. There are 8 possible winning line options, 3 horizontal, 3 vertical and two diagonal. It is possible to win a game with up to two winning lines simultaneously. One approach to implementing this feature is to add a layer with all the winning lines in SwiftUI and then set the line opacity based on which line wins.

Start by adding a test that the initial game has no winning line. Let's say that the winning lines are stored in a collection of boolean values as to whether or not they are the winning lines. Then all 8 values are expected to be false at the start of a new game. This will fail to compile until we add a property for winning lines.


TicModelTests

1    func test_winLinesNewGame_empty() {
2        // Arrange
3        let ticModel = TicModel()
4
5        // Act
6
7        // Assert
8        XCTAssertEqual((ticModel.winningLines.filter { $0 == false }.count), 8)
9    }

TicModel

A private member variable is declared to store the array of boolean values for the winning lines and a public readonly property is created to expose the values. The initialiser is updated to set this to a list of false values for each of the 8 winning lines.

 1struct TicModel {
 2    ...
 3    
 4    private var _winningLines: [Bool]
 5
 6    ...
 7    
 8    init() {
 9        _grid = []
10        for _ in 0..<9 {
11            _grid.append(Cell.b)
12        }
13        _winningLines = []
14        for _ in 0..<8 {
15            _winningLines.append(false)
16        }
17        _winner = .none
18        _playerXTurn = true
19    }
20
21    ...
22    
23    var winningLines: [Bool] {
24        get { _winningLines }
25    }
26    ...
27}

TicModelTests

The first test now passes, so we add a second test that setting the top row will return true for the first value in the winningLines and false for the rest. This will fail as there is no code to alter the winning lines once it has been initialised.

 1    func test_winLinesTopRow_oneTrue() {
 2        // Arrange
 3        var ticModel = TicModel()
 4
 5        // Act
 6        for i in [0,1,2] {
 7            ticModel.setCell(n: i, c: .x)
 8        }
 9        let _ = ticModel.updateGameStatus()
10
11        // Assert
12        XCTAssertTrue(ticModel.winningLines[0])
13        XCTAssertEqual((ticModel.winningLines.filter { $0 == false }.count), 7)
14    }

TicModel

The updateGameStatus function is modified to include a step to set the winning line when there is a check to see if either player won the game. Rerun the test to see that it now passes. All the unit tests are run and pass, which assures us that we have not introduced any issues with this change.

 1    mutating func updateGameStatus() -> Bool {
 2        // There are 8 possible winning options in Tic Tac Toe
 3        // The order of these options needs to match _winningLines
 4        let winOptions: [Set<Int>] = [
 5            [0,1,2], [3,4,5], [6,7,8],
 6            [0,3,6], [1,4,7], [2,5,8],
 7            [0,4,8], [2,4,6]]
 8
 9        let oCells: Set<Int> = Set(_grid.indices.map { _grid[$0] == Cell.o ? $0 : -1 })
10        let xCells: Set<Int> = Set(_grid.indices.map { _grid[$0] == Cell.x ? $0 : -1 })
11
12        for (i, win) in winOptions.enumerated() {
13            if win.intersection(xCells) == win {
14                _winningLines[i] = true
15                _winner = .x
16                return true
17            }
18            if win.intersection(oCells) == win {
19                _winningLines[i] = true
20                _winner = .o
21                return true
22            }
23        }
24
25        return false
26    }

Ensure all tests are passing after adding winning lines to model

Ensure all tests are passing after adding winning lines to model


TicModelTests

Add further unit tests to test the other winning scenarios as well as a draw. A grid full of all one player tokens should return true for all grid lines. This is not possible in Tic Tac Toe, but the winning lines is just focussed on whether each of the eight options contain the same token.

  1    func test_winLinesNewGame_empty() {
  2        // Arrange
  3        let ticModel = TicModel()
  4
  5        // Act
  6
  7        // Assert
  8        XCTAssertEqual((ticModel.winningLines.filter { $0 == false }.count), 8)
  9    }
 10
 11    func test_winLinesTopRow_oneTrue() {
 12        // Arrange
 13        var ticModel = TicModel()
 14
 15        // Act
 16        for i in [0,1,2] {
 17            ticModel.setCell(n: i, c: .x)
 18        }
 19        let _ = ticModel.updateGameStatus()
 20
 21        // Assert
 22        XCTAssertTrue(ticModel.winningLines[0])
 23        XCTAssertEqual((ticModel.winningLines.filter { $0 == false }.count), 7)
 24    }
 25
 26    func test_winLinesMiddleRow_oneTrue() {
 27        // Arrange
 28        var ticModel = TicModel()
 29
 30        // Act
 31        for i in [3,4,5] {
 32            ticModel.setCell(n: i, c: .o)
 33        }
 34        let _ = ticModel.updateGameStatus()
 35
 36        // Assert
 37        XCTAssertTrue(ticModel.winningLines[1])
 38        XCTAssertEqual((ticModel.winningLines.filter { $0 == false }.count), 7)
 39    }
 40    
 41    func test_winLinesBottomRow_oneTrue() {
 42        // Arrange
 43        var ticModel = TicModel()
 44
 45        // Act
 46        for i in [6,7,8] {
 47            ticModel.setCell(n: i, c: .o)
 48        }
 49        let _ = ticModel.updateGameStatus()
 50
 51        // Assert
 52        XCTAssertTrue(ticModel.winningLines[2])
 53        XCTAssertEqual((ticModel.winningLines.filter { $0 == false }.count), 7)
 54    }
 55    
 56    func test_winLinesLeftCol_oneTrue() {
 57        // Arrange
 58        var ticModel = TicModel()
 59
 60        // Act
 61        for i in [0,3,6] {
 62            ticModel.setCell(n: i, c: .x)
 63        }
 64        let _ = ticModel.updateGameStatus()
 65
 66        // Assert
 67        XCTAssertTrue(ticModel.winningLines[3])
 68        XCTAssertEqual((ticModel.winningLines.filter { $0 == false }.count), 7)
 69    }
 70
 71    func test_winLinesMiddleCol_oneTrue() {
 72        // Arrange
 73        var ticModel = TicModel()
 74
 75        // Act
 76        for i in [1,4,7] {
 77            ticModel.setCell(n: i, c: .x)
 78        }
 79        let _ = ticModel.updateGameStatus()
 80
 81        // Assert
 82        XCTAssertTrue(ticModel.winningLines[4])
 83        XCTAssertEqual((ticModel.winningLines.filter { $0 == false }.count), 7)
 84    }
 85
 86    func test_winLinesRightCol_oneTrue() {
 87        // Arrange
 88        var ticModel = TicModel()
 89
 90        // Act
 91        for i in [2,5,8] {
 92            ticModel.setCell(n: i, c: .x)
 93        }
 94        let _ = ticModel.updateGameStatus()
 95
 96        // Assert
 97        XCTAssertTrue(ticModel.winningLines[5])
 98        XCTAssertEqual((ticModel.winningLines.filter { $0 == false }.count), 7)
 99    }
100
101    func test_winLinesDiag1_oneTrue() {
102        // Arrange
103        var ticModel = TicModel()
104
105        // Act
106        for i in [0,4,8] {
107            ticModel.setCell(n: i, c: .o)
108        }
109        let _ = ticModel.updateGameStatus()
110
111        // Assert
112        XCTAssertTrue(ticModel.winningLines[6])
113        XCTAssertEqual((ticModel.winningLines.filter { $0 == false }.count), 7)
114    }
115
116    func test_winLinesDiag2_oneTrue() {
117        // Arrange
118        var ticModel = TicModel()
119
120        // Act
121        for i in [2,4,6] {
122            ticModel.setCell(n: i, c: .o)
123        }
124        let _ = ticModel.updateGameStatus()
125
126        // Assert
127        XCTAssertTrue(ticModel.winningLines[7])
128        XCTAssertEqual((ticModel.winningLines.filter { $0 == false }.count), 7)
129    }
130
131    func test_winLinesFullDraw_noneTrue() {
132        // Arrange
133        var ticModel = TicModel()
134
135        // Act
136        let fullGrid: [Cell] = [.x, .o, .x,
137                                .x, .x, .o,
138                                .o, .x, .o]
139        for (i,c) in fullGrid.enumerated() {
140            ticModel.setCell(n: i, c: c)
141        }
142        let _ = ticModel.updateGameStatus()
143
144        // Assert
145        XCTAssertEqual((ticModel.winningLines.filter { $0 == false }.count), 8)
146    }
147
148    func test_winLinesFullGridX_allTrue() {
149        // Arrange
150        var ticModel = TicModel()
151
152        // Act
153        let fullGrid: [Cell] = [.x, .x, .x,
154                                .x, .x, .x,
155                                .x, .x, .x]
156        for (i,c) in fullGrid.enumerated() {
157            ticModel.setCell(n: i, c: c)
158        }
159        let _ = ticModel.updateGameStatus()
160
161        // Assert
162        XCTAssertEqual((ticModel.winningLines.filter { $0 == true }.count), 8)
163    }
164
165    func test_winLinesTwo_twoTrue() {
166        // Arrange
167        var ticModel = TicModel()
168
169        // Act
170        let fullGrid: [Cell] = [.x, .x, .x,
171                                .o, .x, .o,
172                                .o, .x, .o]
173        for (i,c) in fullGrid.enumerated() {
174            ticModel.setCell(n: i, c: c)
175        }
176        let _ = ticModel.updateGameStatus()
177
178        // Assert
179        XCTAssertEqual((ticModel.winningLines.filter { $0 == true }.count), 2)
180    }

TicModel

The last two tests fail. This is because the updateGameStatus function exits once one winning line is found. The function is updated to loop through all the winning options so that all the winning lines are found and set to true. All the tests pass without any further update.

 1    mutating func updateGameStatus() -> Bool {
 2        var result = false
 3        // There are 8 possible winning options in Tic Tac Toe
 4        // The order of these options needs to match _winningLines
 5        let winOptions: [Set<Int>] = [
 6            [0,1,2], [3,4,5], [6,7,8],
 7            [0,3,6], [1,4,7], [2,5,8],
 8            [0,4,8], [2,4,6]]
 9
10        let oCells: Set<Int> = Set(_grid.indices.map { _grid[$0] == Cell.o ? $0 : -1 })
11        let xCells: Set<Int> = Set(_grid.indices.map { _grid[$0] == Cell.x ? $0 : -1 })
12
13        for (i, win) in winOptions.enumerated() {
14            if win.intersection(xCells) == win {
15                _winningLines[i] = true
16                _winner = .x
17                result = true
18            }
19            if win.intersection(oCells) == win {
20                _winningLines[i] = true
21                _winner = .o
22                result = true
23            }
24        }
25
26        return result
27    }

The winning lines needs to be exposed to the View through the ViewModel. So we add similar tests to the ViewModel and update the ViewModel to get them to pass.


TicViewModelTests

 1    func test_winLinesNewGame_noneTrue() {
 2        // Arrange
 3        let ticViewModel = TicViewModel()
 4
 5        // Act
 6
 7        // Assert
 8        XCTAssertEqual((ticViewModel.winningLines.filter { $0 == false }.count), 8)
 9    }
10
11    func test_winLinesDraw_noneTrue() {
12        // Arrange
13        let ticViewModel = TicViewModel()
14
15        // Act
16        let fullGrid: [Cell] = [.x, .o, .x,
17                                .x, .x, .o,
18                                .o, .x, .o]
19        for (i,c) in fullGrid.enumerated() {
20            ticViewModel.setCell(index: i, cellValue: c)
21        }
22
23        // Assert
24        XCTAssertEqual((ticViewModel.winningLines.filter { $0 == false }.count), 8)
25    }
26
27    func test_winLinesTopRow_oneTrue() {
28        // Arrange
29        let ticViewModel = TicViewModel()
30
31        // Act
32        let fullGrid: [Cell] = [.x, .x, .x,
33                                .b, .o, .o,
34                                .b, .b, .b]
35        for (i,c) in fullGrid.enumerated() {
36            ticViewModel.setCell(index: i, cellValue: c)
37        }
38
39        // Assert
40        XCTAssertTrue(ticViewModel.winningLines[0])
41        XCTAssertEqual((ticViewModel.winningLines.filter { $0 }.count), 1)
42    }
43
44    func test_winLinesTwoRow_twoTrue() {
45        // Arrange
46        let ticViewModel = TicViewModel()
47
48        // Act
49        let fullGrid: [Cell] = [.x, .x, .x,
50                                .o, .x, .o,
51                                .o, .x, .o]
52        for (i,c) in fullGrid.enumerated() {
53            ticViewModel.setCell(index: i, cellValue: c)
54        }
55
56        // Assert
57        XCTAssertTrue(ticViewModel.winningLines[0])
58        XCTAssertTrue(ticViewModel.winningLines[4])
59        XCTAssertEqual((ticViewModel.winningLines.filter { $0 }.count), 2)
60    }
61
62    func test_winLinesAllRows_eightTrue() {
63        // Arrange
64        let ticViewModel = TicViewModel()
65
66        // Act
67        let fullGrid: [Cell] = [.x, .x, .x,
68                                .x, .x, .x,
69                                .x, .x, .x]
70        for (i,c) in fullGrid.enumerated() {
71            ticViewModel.setCell(index: i, cellValue: c)
72        }
73
74        // Assert
75        XCTAssertEqual((ticViewModel.winningLines.filter { $0  }.count), 8)
76    }

TicViewModel

1    ...
2    var winningLines: [Bool] {
3        get { ticModel.winningLines }
4    }
5    ...

Finally, we need to update the View to bind to the winning lines and set the opacity of the lines based on whether the values are true or false. A new SwiftUI view WinLinesView is defined that binds to a list of boolean values representing the winning lines. This view sets scale effect and opacity and uses a spring animation to display the winning lines when the model changes.

 1struct WinLinesView: View {
 2    var winningLines: [Bool]
 3    
 4    var body: some View {
 5        GeometryReader { gr in
 6            let size = min(gr.size.width, gr.size.height)
 7            let pad = size * 0.145
 8            let thickness = size * 0.05
 9            let corner = size * 0.1
10            let width = gr.size.width * 0.95
11            let height = gr.size.height * 0.9
12
13            ZStack {
14                // Horizontal Lines
15                HStack {
16                    Spacer()
17                    VStack(spacing:0) {
18                        ForEach([0, 1, 2], id: \.self) {
19                            Spacer()
20                                .frame(height: pad)
21                            RoundedRectangle(cornerRadius: corner)
22                                .fill(Color(red: 100/255,
23                                            green: 255/255,
24                                            blue: 140/255,
25                                            opacity: winningLines[$0] ? 0.45 : 0.0))
26                                .scaleEffect(winningLines[$0] ? 1.0 : 0.1)
27                                .animation(.interpolatingSpring(stiffness: 20, damping: 3))
28                                .frame(width: width, height: thickness)
29                            Spacer()
30                                .frame(height: pad)
31                        }
32                        Spacer()
33                    }
34                    Spacer()
35                }
36                // Vertical Lines
37                VStack {
38                    Spacer()
39                    HStack(spacing:0) {
40                        ForEach([3, 4, 5], id: \.self) {
41                            Spacer()
42                                .frame(width: pad)
43                            RoundedRectangle(cornerRadius: corner)
44                                .fill(Color(red: 100/255,
45                                            green: 255/255,
46                                            blue: 140/255,
47                                            opacity: winningLines[$0] ? 0.45 : 0.0))
48                                .scaleEffect(winningLines[$0] ? 1.0 : 0.1)
49                                .animation(.interpolatingSpring(stiffness: 20, damping: 3))
50                                .frame(width: thickness, height: height)
51                            Spacer()
52                                .frame(width: pad)
53                        }
54                        Spacer()
55                    }
56                    Spacer()
57                }
58
59                // Diagonal Lines
60                let diagStart = size * 0.1
61                let diagEnd = size * 0.9
62                Path { path in
63                    path.move(to: CGPoint(x: diagStart, y: diagStart))
64                    path.addLine(to: CGPoint(x: diagEnd, y: diagEnd))
65                }
66                .stroke(Color(red: 100/255,
67                              green: 255/255,
68                              blue: 140/255,
69                              opacity: winningLines[6] ? 0.45 : 0.0),
70                        style: StrokeStyle(lineWidth: thickness, lineCap: .round))
71                .scaleEffect(winningLines[6] ? 1.0 : 0.1)
72                .animation(.interpolatingSpring(stiffness: 20, damping: 3))
73
74                Path { path in
75                    path.move(to: CGPoint(x: diagStart, y: diagEnd))
76                    path.addLine(to: CGPoint(x: diagEnd, y: diagStart))
77                }
78                .stroke(Color(red: 100/255,
79                              green: 255/255,
80                              blue: 140/255,
81                              opacity: winningLines[7] ? 0.45 : 0.0),
82                        style: StrokeStyle(lineWidth: thickness, lineCap: .round))
83                .scaleEffect(winningLines[7] ? 1.0 : 0.1)
84                .animation(.interpolatingSpring(stiffness: 20, damping: 3))
85            }
86        }
87    }
88}

The WinLinesView is added to the ZStack in the GridView so the winning lines are always in the view.

 1struct GridView: View {
 2    @ObservedObject var ticVm: TicViewModel
 3    
 4    var body: some View {
 5        ZStack {
 6            VStack(spacing:3) {
 7                let n = 3
 8                ForEach(0..<n, id:\.self) { r in
 9                    HStack(spacing:3) {
10                        ForEach(0..<n, id:\.self) { c in
11                            let index = (r*n) + c
12                            let cellContent = ticVm.grid[index]
13                            
14                            Button(action: {
15                                // set the cell to X or O
16                                ticVm.setCell(index: index,
17                                              cellValue: ticVm.isXTurn ? .x : .o)
18                            }) {
19                                ZStack {
20                                    BackGroundCardView()
21                                        .padding(2)
22
23                                    Group {
24                                        if cellContent == .b {
25                                            // leave cell blank
26                                        } else if cellContent == .x {
27                                            XShape()
28                                                .fill(Color(red: 150/255, green: 20/255, blue: 20/255))
29                                        } else {
30                                            OShape()
31                                                .fill(Color(red: 100/255, green: 20/255, blue: 140/255))
32                                        }
33                                    }
34                                    .padding(12)
35                                }
36                                .frame(width: 80, height: 80)
37                            }
38                            .disabled(ticVm.isGameOver || cellContent != .b)
39                        }
40                    }
41                }
42            }
43            
44            GridLinesView()
45                .foregroundColor(Colors.darkPurple)
46                .frame(width: 240, height: 240)
47            
48            // Winning Lines View
49            WinLinesView(winningLines: ticVm.winningLines)
50                .foregroundColor(Color.yellow)
51                .frame(width: 240, height: 240)
52        }
53    }
54}

Tic Tac Toe board showing Winning lines to highlight the winning row

Tic Tac Toe board showing Winning lines to highlight the winning row



Tic Tac Toe game

Tic Tac Toe game




Conclusion

This article continued the implementation of Tic Tac Toe using the Model and ViewModel developed in part 1. As functionality is discovered such as whos turn it is, unit tests are added for the model and then the logic is added to the model and more tests added to fully test the logic. Then tests and code is added to the ViewModel and finally the View is bound to the ViewModel to present the user elements in the App.

Perhaps the GameOver message could be combined with the Winner message to encapsulate the logic in the ViewModel in a single property. This message would then be present in the View and the contents would change depending on the state of the game. On the other hand, keeping them as separate properties allows the UI the flexibility to display them in different locations or format the text differently.

TDD can seem like more work at first to develop Apps, but it can also help one slow down and think through the required functionality at the model design level. There tends to be less surprises and unexpected defects arise when creating the SwiftUI views as the logic has already been implemented and tested.