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
}
}
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 viaobjectWilLChange
, but it doesn't apply here).You have a single
@Published
property, which is:(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 itsForEach
: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.