IOS 14 adding ObservedObject to a var causes views not to appear on iPhone

293 views Asked by At

I am working through the examples from Stanford's online CS139p. The cards for the memory game are drawn. I make the viewModel an ObservableObject, I publish the var that represents the memory game, and run the app. Everything happens as I expect. I then add the @ObservedObject property processor to the viewModel var in the View Controller. When I run the app I no longer see the cards get drawn.

Question 1: What is happening?

Question 2: How would I debug this? I tried setting a breakpoint and walking through but I could not identify what was going wrong. I googled it and found some things to try but ultimately could not make it work. This example worked for me 1 year ago when I tried it the first time.

MemorizeApp.swift

import SwiftUI

@main
struct MemorizeApp: App {
    var body: some Scene {
        WindowGroup {
            let game = EmojiMemoryGame()
            EmojiMemoryGameView(viewModel: game)
        }
    }
}

EmojiMemoryGameView.swift

import SwiftUI

// uncomment the below line and then comment the line below it to see the condition that does not work. 


struct EmojiMemoryGameView: View {
//    @ObservedObject var viewModel: EmojiMemoryGame
    var viewModel:EmojiMemoryGame
    var body: some View {
        HStack {
            ForEach(viewModel.cards) { card in
                CardView(card: card).onTapGesture {
                    viewModel.choose(card: card)
                }
        }
        }
        .foregroundColor(Color.orange)
        .padding()
        .font(viewModel.cards.count == 10 ? Font.title : Font.largeTitle)
        
    }
}


struct CardView: View {
    var card: MemoryGame<String>.Card
    var body: some View {
        ZStack {
            
            if card.isFaceUp {
                RoundedRectangle(cornerRadius: 10.0).fill(Color.white)
                RoundedRectangle(cornerRadius: 10.0).stroke(lineWidth: 3.0)
                Text(card.content)
            } else {
                RoundedRectangle(cornerRadius: 10.0).fill()
            }
        }
        .aspectRatio(2/3, contentMode: .fit)
    }
}



struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        EmojiMemoryGameView(viewModel: EmojiMemoryGame())
    }
}

EmojiMemoryGame.swift


import SwiftUI

func createCardContent(pairIndex: Int) -> String {
    return ""
}
// This is the viewModel
class EmojiMemoryGame: ObservableObject {
    
    @Published private var model: MemoryGame<String> = EmojiMemoryGame.createMemoryGame()
    
    static func createMemoryGame() -> MemoryGame<String> {
        let emojies = ["", "", "", "", "", "", "", "", "", "", "", ""]
        return MemoryGame<String>(numberOfPairsOfCards: Int.random(in: 2...5)) { pairIndex in
            return emojies.randomElement()!
            //return emojies[pairIndex]
        }
    }
    
    
    // MARK: Access to the Model
    
    var cards: Array<MemoryGame<String>.Card> {
        model.cards.shuffle()
        return model.cards
    }
    
    // MARK: Intent(s)
    
    func choose(card: MemoryGame<String>.Card) {
        model.choose(card: card)
    }

}

MemoryGame.swift

import Foundation

struct MemoryGame<CardContent> {
    
    var cards: Array<Card>
    
    mutating func choose(card: Card) {
        print("Card Choosen: \(card)")
        let chosenIndex: Int = index(of: card)
        cards[chosenIndex].isFaceUp = !cards[chosenIndex].isFaceUp
        
    }
    
    func index(of card: Card) -> Int {
        for index in 0..<cards.count {
            if self.cards[index].id == card.id {
                return index
            }
        }
        return 0 // TODO: bogus!
    }
    
    init(numberOfPairsOfCards: Int, cardContentFactory: (Int) -> CardContent) {
        cards = Array<Card>()
        for pairIndex in 0..<numberOfPairsOfCards {
            let content = cardContentFactory(pairIndex)
            cards.append(Card(content: content, id: pairIndex*2))
            cards.append(Card(content: content, id: pairIndex*2+1))
        }
    }
    
    struct Card: Identifiable {
        
        
        var isFaceUp: Bool = true
        var isMatched: Bool = false
        var content: CardContent
        var id: Int
    }
}

1

There are 1 answers

1
New Dev On BEST ANSWER

Making an object @ObservedObject makes the view re-render itself whenever the observed object is changed, which is determined through its @Published properties (also directly via objectWilLChange, but it doesn't apply here).

You have a single @Published property, which is:

@Published private var model: MemoryGame<String> = ...

(It matters not that it's private, the object still notifies the observing view)

So, whenever model is updated, the view re-renders.

And, in fact, the model is always updated in the computed property cards, which the View accesses in its ForEach:

var cards: Array<MemoryGame<String>.Card> {
    model.cards.shuffle() // this mutates the model
    return model.cards
}

So, basically, as the view re-computes its body, which accesses its view model, which causes it to mutate as a side-effect, the state is already invalidated, and it requires another re-computation - in an infinite loop.


It's generally a poor practice to have side-effects inside a getter.

The fix is to remove model.cards.shuffle() from inside the getter and put it elsewhere where it makes sense.