Search code examples
swiftmvvmswiftuiviewobservedobject

SwiftUI Observed Object not updating when published value changes


I was following along with this lecture when I ran into some problems with my Observed Object updating. I have an @ObservedObject called EmojiMemoryGame with a published MemoryGame<String> variable called model. 'MemoryGame' is a struct that stores an array of cards, which each have a Bool variable that stores whether they are face up or not.

My ContentView is a View that shows each card in a grid on screen. When the user taps the card, viewModel.choose(card) toggles the isFaceUp variable of the card; the problem is that this does not cause the card to flip over on screen. This is my code in ContentView.swift:

struct ContentView: View {
    @ObservedObject var viewModel: EmojiMemoryGame
    
    var body: some View {
        ScrollView {
            LazyVGrid(columns: [GridItem(.adaptive(minimum: 89))]) {
                ForEach(viewModel.cards) { card in
                    CardView(card: card)
                        .aspectRatio(2/3, contentMode: .fit)
                        .onTapGesture {
                            viewModel.choose(card)
                        }
                }
            }
        }
        .foregroundColor(.red)
        .padding(.horizontal)
    }
}

struct CardView: View {
    let card: MemoryGame<String>.Card
    
    var body: some View {
        ZStack {
            let shape = RoundedRectangle(cornerRadius: 20)
            
            if card.isFaceUp {
                shape
                    .fill()
                    .foregroundColor(.white)
                shape
                    .strokeBorder(lineWidth: 3)
                Text(card.content)
                    .font(.largeTitle)
            } else {
                shape
                    .fill()
            }
        }
    }
}

However, the code works as expected if I instead just copy/paste the code from CardView directly into the body of ContentView (see below), so I'm not really sure what is going on here.

struct ContentView: View {
    @ObservedObject var viewModel: EmojiMemoryGame
    
    var body: some View {
        ScrollView {
            LazyVGrid(columns: [GridItem(.adaptive(minimum: 89))]) {
                ForEach(viewModel.cards) { card in
                    ZStack {
                        let shape = RoundedRectangle(cornerRadius: 20)
                        
                        if card.isFaceUp {
                            shape
                                .fill()
                                .foregroundColor(.white)
                            shape
                                .strokeBorder(lineWidth: 3)
                            Text(card.content)
                                .font(.largeTitle)
                        } else {
                            shape
                                .fill()
                        }
                    }
                    .aspectRatio(2/3, contentMode: .fit)
                    .onTapGesture {
                        viewModel.choose(card)
                    }
                }
            }
        }
        .foregroundColor(.red)
        .padding(.horizontal)
    }
}

Edit: Here is my EmojiMemoryGame.swift:

class EmojiMemoryGame: ObservableObject {
    static let emojis = ["🀀", "😀", "πŸ˜Άβ€πŸŒ«οΈ", "🫠", "😱", "🀐", "😏", "😟", "😲", "πŸ€—", "πŸ₯Έ", "πŸ˜‘", "🫣", "πŸ₯Ά", "πŸ˜“", "πŸ˜΅β€πŸ’«", "😝", "πŸ€“", "🀒", "πŸ€”"]
    
    static func createMemoryGame() -> MemoryGame<String> {
        MemoryGame<String>(numberOfPairsOfCards: 4) { pairIndex in
            emojis[pairIndex]
        }
    }

    @Published var model: MemoryGame<String> = createMemoryGame()

    var cards: Array<MemoryGame<String>.Card> {
        return model.cards
    }
    
    func choose(_ card: MemoryGame<String>.Card) {
        model.choose(card)
    }
}

and MemoryGame.swift:

struct MemoryGame<CardContent> where CardContent: Equatable {
    
    struct Card: Identifiable, Equatable {
        var isFaceUp: Bool = false
        var isMatched: Bool = false
        var content: CardContent
        
        var id: Int
        
        static func == (lhs: MemoryGame<CardContent>.Card, rhs: MemoryGame<CardContent>.Card) -> Bool {
            lhs.id == rhs.id
        }
        
    }
    
    private(set) var cards: Array<Card>
        
    init(numberOfPairsOfCards: Int, createCardContent: (Int) -> CardContent) {
        cards = Array<Card>()
        // add numberOfPairsOfCards * 2 cards to cards array
        for pairIndex in 0..<numberOfPairsOfCards {
            let content: CardContent = createCardContent(pairIndex)
            cards.append(Card(content: content, id: pairIndex*2))
            cards.append(Card(content: content, id: pairIndex*2 + 1))
        }
    }
    
    mutating func choose(_ card: Card) {
        let chosenIndex = cards.firstIndex(of: card)
        cards[chosenIndex!].isFaceUp.toggle()
    }
    
}

Solution

  • The ForEach does not detect that any of cards changes because it is uses Equatable which in your case uses only id.

    Here is a fix:

    struct Card: Identifiable, Equatable {
        var isFaceUp: Bool = false
        var isMatched: Bool = false
        var content: CardContent
    
        var id: Int
    
        static func == (lhs: MemoryGame<CardContent>.Card, rhs: MemoryGame<CardContent>.Card) -> Bool {
            lhs.id == rhs.id && lhs.isFaceUp == rhs.isFaceUp   // << here !!
        }
    }
    

    and also needed update for

    mutating func choose(_ card: Card) {
        let chosenIndex = cards.firstIndex{ $0.id == card.id } // << here !!
        cards[chosenIndex!].isFaceUp.toggle()
    }
    

    Tested with Xcode 13.4 / iOS 15.5