1

I'm losing my mind over this, please help

I'm following the standford's iOS tutorial, I'm trying to finish an assignment of creating a card games, I have 3 models, Game, Card, Theme and Themes:

Game and Card are in charge of the main game logic

import Foundation

struct Game {
    
    var cards: [Card]
    var score = 0
    var isGameOver = false
    var theme: Theme
    var choosenCardIndex: Int?

    init(theme: Theme) {
        cards = []
        self.theme = theme
        startTheme()
    }

    mutating func startTheme() {
        cards = []
        var contentItems: [String] = []
        while contentItems.count != theme.numberOfPairs {
            let randomElement = theme.emojis.randomElement()!
            if !contentItems.contains(randomElement) {
                contentItems.append(randomElement)
            }
        }
        let secondContentItems: [String] = contentItems.shuffled()
        for index in 0..<theme.numberOfPairs {
            cards.append(Card(id: index*2, content: contentItems[index]))
            cards.append(Card(id: index*2+1, content: secondContentItems[index]))
        }
    }

    mutating func chooseCard(_ card: Card) {
        print(card)
        if let foundIndex = cards.firstIndex(where: {$0.id == card.id}),
           !cards[foundIndex].isFaceUp,
           !cards[foundIndex].isMatchedUp
        {
            if let potentialMatchIndex = choosenCardIndex {
                if cards[foundIndex].content == cards[potentialMatchIndex].content {
                    cards[foundIndex].isMatchedUp = true
                    cards[potentialMatchIndex].isMatchedUp = true
                }
                choosenCardIndex = nil
            } else {
                for index in cards.indices {
                    cards[index].isFaceUp = false
                }
            }
            cards[foundIndex].isFaceUp.toggle()
        }
        print(card)
    }
   
    mutating func endGame() {
        isGameOver = true
    }

    mutating func penalizePoints() {
        score -= 1
    }

    mutating func awardPoints () {
        score += 2
    }



    struct Card: Identifiable, Equatable {
        static func == (lhs: Game.Card, rhs: Game.Card) -> Bool {
            return lhs.content == rhs.content
        }
        
        var id: Int
        var isFaceUp: Bool = false
        var content: String
        var isMatchedUp: Bool = false
        var isPreviouslySeen = false
    }

    }

Theme is for modeling different kind of content, Themes is for keeping track which one is currently in use and for fetching a new one

import Foundation
import SwiftUI

struct Theme: Equatable {
    
    static func == (lhs: Theme, rhs: Theme) -> Bool {
        return lhs.name == rhs.name
    }
    
    
    internal init(name: String, emojis: [String], numberOfPairs: Int, cardsColor: Color) {
        self.name = name
        self.emojis = Array(Set(emojis))
        
        if(numberOfPairs > emojis.count || numberOfPairs < 1) {
            self.numberOfPairs = emojis.count
        } else {
            self.numberOfPairs = numberOfPairs
        }
        self.cardsColor = cardsColor
    }
    
    var name: String
    var emojis: [String]
    var numberOfPairs: Int
    var cardsColor: Color
}



import Foundation

struct Themes {
    private let themes: [Theme]
    public var currentTheme: Theme?
    
    init(_ themes: [Theme]) {
        self.themes = themes
        self.currentTheme = getNewTheme()
    }
    
    private func getNewTheme() -> Theme {
        let themesIndexes: [Int] = Array(0..<themes.count)
        var visitedIndexes: [Int] = []
        
        while(visitedIndexes.count < themesIndexes.count) {
            let randomIndex = Int.random(in: 0..<themes.count)
            let newTheme = themes[randomIndex]
            if newTheme == currentTheme {
                visitedIndexes.append(randomIndex)
            } else {
                return newTheme
            }
        }
        return themes.randomElement()!
    }
    
    mutating func changeCurrentTheme() -> Theme {
        self.currentTheme = getNewTheme()
        return self.currentTheme!
    }
}

This is my VM:

class GameViewModel: ObservableObject {
    
    static let numbersTheme = Theme(name: "WeirdNumbers", emojis: ["1", "2", "4", "9", "20", "30"], numberOfPairs: 6, cardsColor: .pink)
    
    static let emojisTheme = Theme(name: "Faces", emojis: ["🥰", "😄", "😜", "🥳", "🤓", "😎", "😋", "🤩"], numberOfPairs: 8, cardsColor: .blue)
    
    static let carsTheme = Theme(name: "Cars", emojis: ["🚓", "🏎️", "🚗", "🚎", "🚒", "🚙", "🚑", "🚌"], numberOfPairs: 20, cardsColor: .yellow)
    
    static let activitiesTheme = Theme(name: "Activities", emojis: ["🤺", "🏌️", "🏄‍♂️", "🚣", "🏊‍♂️", "🏋️", "🚴‍♂️"], numberOfPairs: -10, cardsColor: .green)
    
    static let fruitsTheme = Theme(name: "Fruits", emojis: ["🍇", "🍉", "🍈", "🍊", "🍋", "🍎", "🍏", "🥭"], numberOfPairs: 5, cardsColor: .purple)
    
    static var themes = Themes([numbersTheme, emojisTheme, carsTheme, fruitsTheme])
    
    static func createMemoryGame() -> Game {
        Game(theme: themes.currentTheme!)
    }
    
    @Published private var gameController: Game = Game(theme: themes.currentTheme!)
    
    func createNewGame() {
        gameController.theme = GameViewModel.themes.changeCurrentTheme()
        gameController.startTheme()
    }
    
    func choose(_ card: Game.Card) {
        objectWillChange.send()
        gameController.chooseCard(card)
        
    }
    
    var cards: [Game.Card] {
        return gameController.cards
    }
    
    var title: String {
        return gameController.theme.name
    }
    
    var color: Color {
        return gameController.theme.cardsColor
    }
}

And this is my view:

struct ContentView: View {
    var columns: [GridItem]  = [GridItem(.adaptive(minimum: 90, maximum: 400))]
    @ObservedObject var ViewModel: GameViewModel

    var body: some View {
        VStack {
            HStack {
                Spacer()
                Button(action: {
                    ViewModel.createNewGame()
                }, label: {
                    
                    VStack {
                        Image(systemName: "plus")
                        Text("New game")
                            .font(/*@START_MENU_TOKEN@*/.caption/*@END_MENU_TOKEN@*/)
                    }
                })
                .font(/*@START_MENU_TOKEN@*/.title/*@END_MENU_TOKEN@*/)
                .padding(.trailing)
            }
            Section {
                VStack {
                    Text(ViewModel.title)
                        .foregroundColor(/*@START_MENU_TOKEN@*/.blue/*@END_MENU_TOKEN@*/)
                        .font(/*@START_MENU_TOKEN@*/.title/*@END_MENU_TOKEN@*/)
                }
                
            }
          
            ScrollView {
                LazyVGrid(columns: columns ) {
                    ForEach(ViewModel.cards, id: \.id)  { card in
                        Card(card: card, color: ViewModel.color)
                            .aspectRatio(2/3, contentMode: .fit)
                            .onTapGesture {
                                ViewModel.choose(card)
                            }
                    }
                }
                .font(.largeTitle)
            }
            .padding()
            
            Text("Score")
                .frame(maxWidth: .infinity, minHeight: 30)
                .background(Color.blue)
                .foregroundColor(/*@START_MENU_TOKEN@*/.white/*@END_MENU_TOKEN@*/)
                
            Spacer()
            HStack {
                Spacer()
                Text("0")
                    .font(.title2)
                    .bold()
                    
                Spacer()

            }
            
        }
    }
    
}


struct Card: View {
    let card: Game.Card
    let color: Color
    
    var body: some View {
            ZStack {
                let shape = RoundedRectangle(cornerRadius: 10)
                if card.isFaceUp {
                    Text(card.content)
                    shape
                        .strokeBorder()
                        .accentColor(color)
                        .foregroundColor(color)
                }
                else {
                    shape
                        .fill(color)
                }
            }
    }
}

Basically the problem lies with the

.onTapGesture {
                  ViewModel.choose(card)
                          
       }

Of the View, when someone taps a card, the isFaceUp property of the Card is changed to true, but this doesn't get reflected in the UI. If I generate a new view by changing the theme and adding new cards, this works.

Button(action: {
                ViewModel.createNewGame()
            }, label: {
                
                VStack {
                    Image(systemName: "plus")
                    Text("New game")
                        .font(/*@START_MENU_TOKEN@*/.caption/*@END_MENU_TOKEN@*/)
                }
            })

View before pressing the new game button View after pressing the new game button

But when I'm trying to flip a card it doesn't work, the value changes in the Game model but it's not updated on the view

After the tap the ViewModel calls the choose method

func choose(_ card: Game.Card) {
    gameController.chooseCard(card)
    
}

And this changed the value of the Model in the Game.swift file by calling the chooseCard method

mutating func chooseCard(_ card: Card) {
        print(card)
        if let foundIndex = cards.firstIndex(where: {$0.id == card.id}),
           !cards[foundIndex].isFaceUp,
           !cards[foundIndex].isMatchedUp
        {
            if let potentialMatchIndex = choosenCardIndex {
                if cards[foundIndex].content == cards[potentialMatchIndex].content {
                    cards[foundIndex].isMatchedUp = true
                    cards[potentialMatchIndex].isMatchedUp = true
                }
                choosenCardIndex = nil
            } else {
                for index in cards.indices {
                    cards[index].isFaceUp = false
                }
            }
            cards[foundIndex].isFaceUp.toggle()
        }
        print(card)
    }

The values changes but the view does not, the gameController variable of the GameViewModel has the @Published state, which points to an instance of the Game model struct

@Published private var gameController: Game = Game(theme: themes.currentTheme!)

And the view it's accesing this GameViewModel with the @ObservedObject property

@ObservedObject var ViewModel: GameViewModel

I thought I was doing everything right, but I guess not lol, what the heck am I doing wrong? Why can't update my view if I'm using published and observable object on my ViewModel? lol

5
  • You are sending the change before you change the card Commented Aug 16, 2021 at 4:31
  • @Paulw11 I don't understand, what do you mean? Commented Aug 16, 2021 at 4:39
  • You call objectWillChange.send() and then you change the card. Try reversing the order of those two lines in choose(_ card:) Commented Aug 16, 2021 at 5:11
  • I did that but it doesn't work, and actually I don't think that objectWillChange.send() is even needed because my controller which has the array of data that when changed should be updated on the view has the @Published keyword and the class has the ObservableObject protocol, so idk, what else could be the issue? Commented Aug 16, 2021 at 5:18
  • I have watched the Stanford video and see that his app works, but I don't know how, because there is no binding between the @Published property and the view; What you are seeing is what I would expect Commented Aug 16, 2021 at 6:48

2 Answers 2

1

The main reason the card view doesn't see changes is because in your card view you did put an equatable conformance protocol where you specify an equality check == function that just checks for content and not other variable changes

static func ==(lhs: Game.Card, rhs: Game.Card) -> Bool {
            lhs.content == rhs.content 
 // && lhs.isFaceUp && rhs.isFaceUp //<- you can still add this 
        }

if you remove the equatable protocol and leave swift to check for equality it should be the minimal change from your base solution.

I would still use the solution where you change the state of the class card so the view can react to changes as an ObservableObject, and the @Published for changes that the view need to track, like this:

class Card: Identifiable, Equatable, ObservableObject {
    var id: Int
    @Published var isFaceUp: Bool = false
    var content: String
    @Published var isMatchedUp: Bool = false
    var isPreviouslySeen = false
 
    internal init(id: Int, content: String) {
        self.id = id
        self.content = content
    }
    
    static func ==(lhs: Game.Card, rhs: Game.Card) -> Bool {
        lhs.content == rhs.content
    }
}

and in the Card view the card variable will become

struct Card: View {
    @ObservedObject var card: Game.Card
...
}

btw you don't need to notify the view of changes with objectWillChange.send() if you are already using the @Published notation. every set to the variable will trigger an update.

Sign up to request clarification or add additional context in comments.

4 Comments

Thank you, this works, but I don't know why I would have to do something like this when the standford instructor didn't need to change the Card type from structure to class for the view to detect the changes :/, the view detects when the Game.Theme changes but not when Game.Cards[index].content changes, but it also can detect when Game.Cards changes because when I change the cards the View is rebuilt, so idkkkkk whats going on here lol
i edited the answer so it will have minimal impact on the base solution. basically you will need to remove the equatable protocol and leave swift handle the rest. usually the second part is what i'm used to reason with so i can update just the minimal component and not redraw the entire stack just when a single field change
Wow, thank you, you were right, the problem lies with the Card struct conforming to the equatable protocol, so that would mean that when a struct conforms to that protocol the view will only check for the fields that are specified in the protocol func?
when you are adding the static func == you are basically overriding the base equality check for the struct if it conforms to the "Equatable" protocol. for swiftui is enough that it's a struct even if doesn't conform to "Equatable" protocol, it will use every property to check the difference between two objects but in some cases i think it may be better to specify when two structs are equals otherwise you may fall in the same problem you encountered here.
1

you could try this instead of declaring Card a class:

 Card(card: card, color: ViewModel.color, isFaceUp: card.isFaceUp)

and add this to the Card view:

let isFaceUp: Bool

My understanding is that the Card view does not see any changes to the card (not sure why, maybe because it is in an if), but if you give it something that has really changed then it is re-rendered. And as mentioned before no need for objectWillChange.send()

EDIT1:

you could also do this in "ContentView":

 Card(viewModel: ViewModel, card: card)

and then

struct Card: View {
    @ObservedObject var viewModel: GameViewModel
    let card: Game.Card
    
    var body: some View {
        ZStack {
            let shape = RoundedRectangle(cornerRadius: 10)
            if card.isFaceUp {
                Text(card.content)
                shape
                    .strokeBorder()
                    .accentColor(viewModel.color)
                    .foregroundColor(viewModel.color)
            }
            else {
                shape.fill(viewModel.color)
            }
        }
    }
} 

Comments

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.