Skip to content

Instantly share code, notes, and snippets.

@epaga
Last active July 3, 2019 06:57
Show Gist options
  • Save epaga/720e8c8c31e53808dbf8b2243a9c8fea to your computer and use it in GitHub Desktop.
Save epaga/720e8c8c31e53808dbf8b2243a9c8fea to your computer and use it in GitHub Desktop.
A simple SwiftUI Tic Tac Toe game I made together with my son to learn SwiftUI together
//
// ContentView.swift
// TicTacToe
//
// Created by John Goering on 08.06.19.
// Copyright © 2019 John Goering. All rights reserved.
//
import SwiftUI
import Combine
class GameData : BindableObject {
var turnIsX = true {
didSet {
didChange.send(Void())
}
}
var game:[String] {
didSet {
didChange.send(Void())
}
}
init(game:[String] = [
" "," "," ",
" "," "," ",
" "," "," "
]) {
self.game = game
}
var didChange = PassthroughSubject<Void, Never>()
func reset() {
game = [
" "," "," ",
" "," "," ",
" "," "," "
]
turnIsX = true
}
var winningIndexes: [Int]? {
get {
let waysToWin:[[Int]] = [
[0,1,2],
[3,4,5],
[6,7,8],
[0,3,6],
[1,4,7],
[2,5,8],
[0,4,8],
[2,4,6]
]
return waysToWin.first{
wayToWin in
return game[wayToWin[0]] == game[wayToWin[1]] &&
game[wayToWin[1]] == game[wayToWin[2]] &&
game[wayToWin[0]] != " "
}
}
}
var gameIsOver: Bool {
get {
return winningIndexes != nil ||
game.first {$0 == " "} == nil
}
}
}
struct ContentView : View {
@ObjectBinding var game:GameData
var body: some View {
let turnMessage = game.gameIsOver ? "Game Over!" :
"It's \(game.turnIsX ? "X" : "O")'s turn!"
return ZStack {
VStack(spacing: 0) {
Text(turnMessage)
.font(.largeTitle)
.padding()
Spacer()
Row(rowIndex: 0, game:game)
Row(rowIndex: 1, game:game)
Row(rowIndex: 2, game:game)
Spacer()
}.background(Color(white: 0.8))
if game.gameIsOver {
ResetButton(game: game)
}
}
}
}
struct ResetButton : View {
@ObjectBinding var game:GameData
var body: some View {
VStack {
Spacer()
Button(action: {
self.game.reset()
}) {
Text("Reset")
.font(.largeTitle)
}
.padding()
.background(Color.black)
.cornerRadius(10)
.offset(x: 0, y: -20)
}
}
}
struct Row : View {
var rowIndex:Int
@ObjectBinding var game:GameData
var body: some View {
HStack(spacing: 0) {
Field(rowIndex: rowIndex, colIndex: 0, game:game)
Field(rowIndex: rowIndex, colIndex: 1, game:game)
Field(rowIndex: rowIndex, colIndex: 2, game:game)
}
}
}
struct Field : View {
var rowIndex:Int
var colIndex:Int
@ObjectBinding var game:GameData
var body: some View {
let gameIndex = rowIndex * 3 + colIndex
let isWinningIndex = (game.winningIndexes ?? []).contains(gameIndex)
return ZStack {
if isWinningIndex {
Color.gray
.border(Color.black)
.animation(.basic())
} else {
Color.white
.border(Color.black)
.animation(.basic())
}
Text(game.game[gameIndex])
.font(.system(size: 100))
.color(isWinningIndex ? Color.red : Color.black )
}
.tapAction {
if self.game.game[gameIndex] == " " {
if self.game.turnIsX {
self.game.game[gameIndex] = "X"
} else {
self.game.game[gameIndex] = "O"
}
self.game.turnIsX.toggle()
}
}
}
}
#if DEBUG
struct ContentView_Previews : PreviewProvider {
static var previews: some View {
ContentView(game:GameData(game: [
"X"," "," ",
" ","O"," ",
" "," ","X"
]))
}
}
#endif
@edward-greenaway
Copy link

I communicated with John that I had taken his could work to clone and port to watchOS as an exercise in how a quite complex UI could be transitioned. I have left my workings in places as // comments where I tried something before moving on to finally ending up with a UI design the works in the 40mm Watch simulator. I said to John I would "branch" his .gist however seems I can not branch nor attach my watchOS SwiftUI code as a file; however I can inline it below ... I hope this is useful for others ... I am sure John will check/ test my workings and comment appropriately. Thanks John for giving me a great learning opportunity.


//
//  ContentView.swift
//  OXO WatchKit Extension
//
//  Original iOS Created by John Goering on 08.06.19.
//  Copyright © 2019 John Goering. All rights reserved.
//
//  (re)Created by Edward Greenaway on 27/6/19.
//  Ported from iOS version by John Goering to WatchOS
//  Copyright © 2019 Edward Greenaway. All rights reserved.
//
//  Note the game has a complex UI overlayed 2 ZStacks, 3 VStacks, 1 HStack, and a Button
//  Comments (// EJG:) added for understanding of the paradigm
//    and for platform implementation details
//    and compensating for device layouts
//  Note most of my layout discussions pertain to tests for the 40mm watch face
//    ??? marks area(s) for investigation/ correction/ adjusting
//  My thanks to Audrey Tam for listening to, and commeting on,
//    my analysis of the game's combine/bindableobject design and architecture
//  I found it useful for my understanding of John Goering's app architecture
//    and the use of SwiftUI and the Combine processing to create this model:
/*
        ____________________        ____________________
        |     GameData     |<-------|       game       |
        |__________________|        |__________________|
        | game             |        |                  |
        | init()           |        |                  |
        | didChange        |        |                  |
        | winningIndexes   |        | winningIndexes   |______
        | gameIsOver       |        | gameIsOver       |____  |
        | turnIsX          |        | turnIsX          |__  | |
        |__________________|        |__________________|  | | |
        | reset()          |        | reset()          |  | | |
        |__________________|        |__________________|  | | |
                                            |             | | |
                                    ________V___________  | | |
                                    |   Content View   |  | | |
                                    |__________________|  | | |
                                    | Text X, O or Win |==/-+ |
                                    | Row x3           |    | |
                 ___________________| ResetButton      |____| |
                |                   |__________________|      |
                |                           |                 |
        ________V___________        ________V___________      |
        |   ResetButton    |        |     Row View     |      |
        |__________________|        |__________________|      |
        | Button .reset()  |        | Field x3         |      |
        | Text             |        |                  |      |
        |__________________|        |__________________|      |
                                            |                 |
                                    ________V___________      |
                                    |    Field View    |      |
                                    |__________________|      |
                                    | Text ' ', Xs, Os |      |
                                    | .TapAction       |______|
                                    |__________________|
 
 */

import SwiftUI
import Combine                      // EJG: support reactive environmanet and BindableObject publish subscribe

class GameData : BindableObject {   // EJG: set up for "typealias publishertype"
    
    var turnIsX = true {
        didSet {
            didChange.send(Void())
        }
    }
    
    var game:[String] {
        didSet {
            didChange.send(Void())
        }
    }
    init(game:[String] = [
        " "," "," ",
        " "," "," ",
        " "," "," "
        ]) {
        self.game = game
    }
    
    var didChange = PassthroughSubject<Void, Never>()
    
    func reset() {
        game = [
            " "," "," ",
            " "," "," ",
            " "," "," "
        ]
        turnIsX = true
    }
    
    var winningIndexes: [Int]? {
        get {
            let waysToWin:[[Int]] = [
                [0,1,2],
                [3,4,5],
                [6,7,8],
                [0,3,6],
                [1,4,7],
                [2,5,8],
                [0,4,8],
                [2,4,6]
            ]
            return waysToWin.first{
                wayToWin in
                return game[wayToWin[0]] == game[wayToWin[1]] &&    // EJG: do we have a line
                    game[wayToWin[1]] == game[wayToWin[2]] &&       // where all fields are the same and
                    game[wayToWin[0]] != " "                        // where none (any one) is not blank
            }
        }
    }
    
    var gameIsOver: Bool {
        get {
            return winningIndexes != nil ||                         // EJG: if a winning line was identified
                game.first {$0 == " "} == nil                       //
        }
    }
}


struct ContentView : View {
    
    @ObjectBinding var game:GameData
    
    var body: some View {
        let turnMessage = game.gameIsOver ? "Game Over!" : // EJG: change to distinguish Win from Draw ???
        "It's \(game.turnIsX ? "X" : "O")'s turn!"
        return ZStack {
            VStack(spacing: 0) {
                Text(turnMessage)
                    .font(.headline)
                        // .font(.title) // EJG: for iOS was too big for watchOS ...
                        // EJG: watch .headline was OK here but not later in Button
                        // EJG: may need to calculate size: for true device flexibility ???
                    .padding()  // EJG: for watchOS (.all) drops the Button 1/2 a field
                Spacer()        // EJG: watchOS squishes Fields from tall squares [_] to smaller space
                Row(rowIndex: 0, game:game)
                Row(rowIndex: 1, game:game)
                Row(rowIndex: 2, game:game)
                Spacer()        // EJG: watchOS squishes Fields from tall squares [_] to smaller space
                }.background(Color(white: 0.0)) // EJG: for iOS was (white: 0.8)
                                                // EJG: "black" for watch face OLED paradigm and ? battery !
                                                // EJG: (black: 0.8 does not compile !
                                                //   & now 0.0 generates a runtime callback !!!
                    /*
                    2019-07-01 11:20:39.213088+1000 OXO WatchKit Extension[8104:1928552] [WindowServer] display_timer_callback: unexpected state (now:270bf4404d80 < expected:270bf4fb430f)
                    */
            if game.gameIsOver {
                ResetButton(game: game)
            }
            }
    }
}

struct ResetButton : View {
    
    @ObjectBinding var game:GameData
    
    var body: some View {
        HStack {        // EJG: wrapped an HStack around the iOS VStack to better position the Button :( ???
            Spacer()    // EJG: added Spacer()s to move Button over Field
            Spacer()    // EJG: added Spacer()s to move Button over Field
        VStack {
            Spacer()    // EJG: added Spacer()s to move Button over Field
            Spacer()    // EJG: added Spacer()s to move Button over Field
            Spacer()
            Button(action: {
                self.game.reset()
            }) {
                Text("◁").font(.system(size: 26))
                    .color(Color.red)
                // EJG: iOS used "Reset" as there was more UI area to play with
                // .font(.title) // EJG: too big for watchOS ...
                // iOS was .largeTitle
                // watch .headline too small for the Button
                // need to calculate size: for true device flexibility ???
                // settled on size: 26 through trial & error :(
                }
                .padding()                  // EJG: watchOS (.all) squishes the Button to the right hand side
                .background(Color.green)    // EJG: in iOS was .gray
                                            // EJG: change to distinguish Win from Draw ???
                .cornerRadius(10)
                .mask(Circle())             // EJG: trying to show that playing field at end of game
            //  .offset(x: 0, y: +20) // EJG: was .offset(x: 0, y: -20) for iOS; tried y: 0 and y: +20
            // EJG: no space for button to float at bottom of watchOS's UI ???
            //Spacer() // EJG: added and then removed Spacer()s to move Button over Field
            //Spacer() // EJG: added and then removed Spacer()s to move Button over Field
            //Spacer() // EJG: added and then removed Spacer()s to move Button over Field
            // EJG: added Spacer()s to move Button over Field
            }
            //Spacer() // EJG: added and then removed Spacer()s to move Button over Field
            //Spacer() // EJG: added and then removed Spacer()s to move Button over Field
            // EJG: added Spacer()s to move Button over Field
        }
    }
}

struct Row : View {

    var rowIndex:Int                            // EJG: "crazy" ??? however this var must preceed binding
    
    @ObjectBinding var game:GameData
    
    var body: some View {
        HStack(spacing: 0) {
            Spacer()            // EJG: watchOS squishes Fields from lozenges [___] to squares [_]
            Field(rowIndex: rowIndex, colIndex: 0, game:game)
            Field(rowIndex: rowIndex, colIndex: 1, game:game)
            Field(rowIndex: rowIndex, colIndex: 2, game:game)
            Spacer()            // EJG: watchOS squishes Fields from lozenges [___] to squares [_]
        }
    }
}

struct Field : View {
    
    var rowIndex:Int                            // EJG: "crazy" ??? however these vars must preceed binding
    var colIndex:Int
    
    @ObjectBinding var game:GameData
    
    // var devTextSize: Int = 20
    // watch 20, phone 100 if need to calculate
    
    var body: some View {
        
        let gameIndex = rowIndex * 3 + colIndex
        let isWinningIndex = (game.winningIndexes ?? []).contains(gameIndex)
        
        return ZStack {
            if isWinningIndex {
                Color.gray
                    .border(Color.black)
                    .animation(.basic())
            } else {
                Color.white
                    .border(Color.black)
                    .animation(.basic())
            }
            Text(game.game[gameIndex])
                //.font(.system(size: Length(devTextSize)))
                .font(.headline)
                // phone .largeTitle
                // watch .headline
                // need to calculate size: ?
                .color(isWinningIndex ? Color.green : Color.black )       // EJG: highlight winning line in green rather than red
            }
            .tapAction {
                if self.game.game[gameIndex] == " " {
                    if self.game.turnIsX {
                        self.game.game[gameIndex] = "X"
                    } else {
                        self.game.game[gameIndex] = "O"
                    }
                    self.game.turnIsX.toggle()
                }
        }
    }
}

#if DEBUG
struct ContentView_Previews : PreviewProvider {
    static var previews: some View {
        ContentView(game:GameData(game: [
            "X"," "," ",
            " ","O"," ",
            " "," ","X"
            ]))
    }
}
#endif

@edward-greenaway
Copy link

After closer inspection I have made some changes to the two platform initialisers and to the debug code initialisation:

  1. HostingController.swift for watchOS
class HostingController : WKHostingController<ContentView> {
    override var body: ContentView {
        return ContentView(game: GameData())          // EJG: initialise added for watchOS hosting
    }
}

or, 2. SceneDelegate.swift for iOS (this would have been in AppDelegate.swift in the past, but this split was split out for SwiftUI

// Use a UIHostingController as window root view controller
        if let windowScene = scene as? UIWindowScene {
            let window = UIWindow(windowScene: windowScene)
            window.rootViewController = UIHostingController(rootView: ContentView(game:GameData())) // EJG: initialise added for iOS hosting
            self.window = window
            window.makeKeyAndVisible()
        }

And I noticed that debug game with its two Xs and one O was incorrectly initialised (given that the default for turnIsX = true); so I could have initialised the board to all " " (and at one stage I did), or rescued it as above from two Xs and an O to simple one X and one O; however I decided to set ip up follows, as it enables a discussion around forking as a strategy to win:

  1. for both iOS and watchOS the debug section now reads as follows:
#if DEBUG
struct ContentView_Previews : PreviewProvider {
    static var previews: some View {
        ContentView(game:GameData(game: [
            "X"," "," ",
            " ","O"," ",
            "O"," ","X"
            ]))
    }
}
#endif

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment