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:
- Test Driven Development in SwiftUI - Part 1
- Test Driven Development in SwiftUI - Part 2
- Understanding Minimax Algorithm with Tic Tac Toe
- Test Driven Development in SwiftUI - Part 3
- 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
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
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 GridView
in 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
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
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
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
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
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
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
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
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 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.