This article is related notes for the 4th episode of Stanford University's CS193p course.

cs193p class:

The lectures for the Spring 2023 version of Stanford University's course CS193p (Developing Applications for iOS using SwiftUI) were given in person but, unfortunately, were not video recorded. However, we did capture the laptop screen of the presentations and demos as well as the associated audio. You can watch these screen captures using the links below. You'll also find links to supporting material that was distributed to students during the quarter (homework, demo code, etc.).

cs193p class website: https://cs193p.sites.stanford.edu/2023


Episode 3 last 15 mins

Code Clean up

We need to clean up our code to apply MVVM.

Remove the following parts:

    @State var cardCount: Int = 4
    var cardCountAdjsters: some View {
        HStack {
            cardRemover
            Spacer()
            cardAdder
        }
        .imageScale(.large)
    }
    
    func cardCountAdjsters(by offset: Int, symbol: String) -> some View {
        Button(action: {
            cardCount += offset
        }, label: {
            Image(systemName: symbol)
        })
        .disabled(cardCount + offset < 1 || cardCount + offset > emojis.count)
    }
    
    var cardRemover: some View {
        return cardCountAdjsters(by: -1, symbol: "rectangle.stack.badge.minus.fill")
    }
    
    var cardAdder: some View {
        return cardCountAdjsters(by: 1, symbol: "rectangle.stack.badge.plus.fill")
    }

Change the following parts:

...        
        VStack {
            ScrollView {
                cards
            }
            Spacer()
            cardCountAdjsters
        }
...
// change to
...
        ScrollView {
            cards
        }
...
            ForEach(0..<cardCount, id: \.self) { index in
// change to
            ForEach(emojis.indices, id: \.self) { index in

Create the Model File

File -> New -> File -> Swift File, then named as MemorizeGame

create-memorizeGame-swift-file

Implement MemorizeGame (Model):

import Foundation

struct MemoryGame<CardContent> {
    var cards: Array<Card>
    
    func choose(card: Card) {
        
    }
    
    
    struct Card {
        var isFaceUp: Bool
        var isMatched: Bool
        var content: CardContent
    }
}

Create the ViewModel File

File -> New -> File -> Swift File, then named as EmojiMemoryGame

Implement MemorizeGame (ViewModel):

import SwiftUI

class EmojiMemoryGame {
    var model: MemoryGame<String>
}

Episode 4

Access Control

Partial Separation

Let's take a look at our MVVM Files:

View:

import SwiftUI

struct ContentView: View {
    var viewModel: EmojiMemoryGame
    
    let emojis = ["πŸ‘»", "πŸŽƒ", "πŸ•·οΈ", "πŸŽ‰","πŸ˜„", "😎", "πŸ’©"]
...

Model:

import Foundation

struct MemoryGame<CardContent> {
    var cards: Array<Card>
    
    func choose(card: Card) {
        
    }
...

ViewModel:

import SwiftUI

class EmojiMemoryGame {
    var model: MemoryGame<String>
}

Here, we can access our Model from View directly by viewModel.model.xxx, and it is a partial seperation.

Full Seperation

If we want to prevent View directly access our Model, we can use the keywords private. It is also known as full seperation.

By changing our ViewModel:

import SwiftUI

class EmojiMemoryGame {
    private var model: MemoryGame<String>
}

How do we access the model? It turns our we need to implement stuffs to make them accessible.

Here is our modified ViewModel:

import SwiftUI

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

private(set)

private(set) allows others only be able to read, but not modified.

struct MemoryGame<CardContent> {
    private(set) var cards: Array<Card>
...

No External Name

// MemoryGame.swift
    func choose(_ card: Card) {
        
    }

// EmojiMemoryGame.swift
    func choose(_ card: MemoryGame<String>.Card) {
        model.choose(card)
    }

We don't need external name of this choose function. However, we do want external name in some cases:

  1. The data type is a String, Int or uncleared.
  2. The external name can make the code read better.

Class Initializer

The class initializer have no argument, and only work when all variables have a default value. We are now starting to implement our ViewModel (EmojiMemoryGame.swift) Initializer.

We want to initialize our Model (MemoryGame.swift) by using numberOfPairsOfCards and cardContentFactory, so we also want an initializer of the Model. (The defalut initializer cannot achieve it.)

Note: This part is pretty long. The final initializer is in the end of the section.

For Loop

We can use _ to ignore the index of a for loop.

for pairIndex in 0..<numberOfPairsOfCards {
    cards.append(XXXX)
    cards.append(XXXX)
}
// Use _ to ignore the pairIndex
for _ in 0..<numberOfPairsOfCards {
    cards.append(XXXX)
    cards.append(XXXX)
}

Closure Syntax

// MemoryGame.swift
import Foundation
struct MemoryGame<CardContent> {
    private(set) var cards: Array<Card>
    
    init(numberOfPairsOfCards: Int, cardContentFactory: (Int) -> CardContent) {
        cards = []
        // add numberOfParisOfCards x 2 cards
        for pairIndex in 0..<numberOfPairsOfCards {
            let content = cardContentFactory(pairIndex)
            cards.append(Card(content: content))
            cards.append(Card(content: content))
        }
    }
...

Here, we need initialize the model variable:

// EmojiMemoryGame.swift
import SwiftUI
func createCardContent(forPairAtIndex index: Int) -> String {
    return ["πŸ‘»", "πŸŽƒ", "πŸ•·οΈ", "πŸŽ‰","πŸ˜„", "😎", "πŸ’©"][index]
}

class EmojiMemoryGame {
    private var model = MemoryGame(
        numberOfPairsOfCards: 4,
        cardContentFactory: createCardContent
    )
...

The second parameter of MemoryGame initializer accepts a function that accept an Int and return a String. createCardContent is a function that accept an Int and return a String, so we pass through it.

However, we can use Closure Syntax to make it nicer.

// EmojiMemoryGame.swift
import SwiftUI
class EmojiMemoryGame {
    private var model = MemoryGame(
        numberOfPairsOfCards: 4,
        cardContentFactory: { (index: Int) -> String in
            return ["πŸ‘»", "πŸŽƒ", "πŸ•·οΈ", "πŸŽ‰","πŸ˜„", "😎", "πŸ’©"][index]
        }
    )
...

We can even use type inference to make it simpler:

// EmojiMemoryGame.swift
import SwiftUI
class EmojiMemoryGame {
    private var model = MemoryGame(
        numberOfPairsOfCards: 4,
        cardContentFactory: { index in
            return ["πŸ‘»", "πŸŽƒ", "πŸ•·οΈ", "πŸŽ‰","πŸ˜„", "😎", "πŸ’©"][index]
        }
    )
...

Also, since cardContentFactory is the last argument of the function. We can apply trailing closure syntax:

// EmojiMemoryGame.swift
import SwiftUI
class EmojiMemoryGame {
    private var model = MemoryGame(numberOfPairsOfCards: 4) { index in
        return ["πŸ‘»", "πŸŽƒ", "πŸ•·οΈ", "πŸŽ‰","πŸ˜„", "😎", "πŸ’©"][index]
    }
...

$0

$0 is a special placeholder for the first argument.

// EmojiMemoryGame.swift
...
    private var model = MemoryGame(numberOfPairsOfCards: 4) { index in
        return ["πŸ‘»", "πŸŽƒ", "πŸ•·οΈ", "πŸŽ‰","πŸ˜„", "😎", "πŸ’©"][index]
    }
...
// If we use $0
...
    private var model = MemoryGame(numberOfPairsOfCards: 4) { $0
        return ["πŸ‘»", "πŸŽƒ", "πŸ•·οΈ", "πŸŽ‰","πŸ˜„", "😎", "πŸ’©"][$0]
    }
...

static vars and funcs

We will receive a error if we do the following:

//  EmojiMemoryGame.swift
import SwiftUI

class EmojiMemoryGame {
    let emojis = ["πŸ‘»", "πŸŽƒ", "πŸ•·οΈ", "πŸŽ‰","πŸ˜„", "😎", "πŸ’©"]
    
    private var model = MemoryGame(numberOfPairsOfCards: 4) { pairIndex in
        return emojis[pairIndex]
    }
...

The error message is "Cannot use instance member 'emojis' within property initializer; property initializers run before 'self' is available". emojis and model are all so-called property initializer, but the order of property initialized are undetermined (NOT the order in the source code).

To solve this, we make it static, meaning make the emojis global (actually called type variable) but namespace inside of my class. Global variable will initialize first.

//  EmojiMemoryGame.swift
import SwiftUI

class EmojiMemoryGame {
    private static let emojis = ["πŸ‘»", "πŸŽƒ", "πŸ•·οΈ", "πŸŽ‰","πŸ˜„", "😎", "πŸ’©"]
    
    private var model = MemoryGame(numberOfPairsOfCards: 4) { pairIndex in
        // EmojiMemoryGame.emojis[pairIndex]
        return emojis[pairIndex]
    }
...
Note the full name of emojis are now EmojiMemoryGame.emojis.

Let's make a function to create our model:

//  EmojiMemoryGame.swift
...
    private var model = createMemoryGame()
    
    func createMemoryGame() {
        return MemoryGame(numberOfPairsOfCards: 4) { pairIndex in
            return emojis[pairIndex]
        }
    }
...

We will get a bunch of error messages as expected.

To fix it, we need make our fuction static and write return types. Return types are non-inferable in Swift.

//  EmojiMemoryGame.swift
...
    private static func createMemoryGame() -> MemoryGame<String> {
        return MemoryGame(numberOfPairsOfCards: 4) { pairIndex in
            return emojis[pairIndex]
        }
    }

        private var model = createMemoryGame()
...

".thing"

When we see ".thing", it can be either static var or enmu. Let's take a look.

...
LazyVGrid(columns: [GridItem(.adaptive(minimum: 85))]) {
    ...
    ...
}
.foregroundColor(.orange)
...

We have the .orange in our code snippet, which is the same as Color.orange, and when we open up the documentation:

static-var-color-orange

.orange is just a static var.

Code Protection

//  EmojiMemoryGame.swift
...
    private static func createMemoryGame() -> MemoryGame<String> {
        return MemoryGame(numberOfPairsOfCards: 4) { pairIndex in
            return emojis[pairIndex]
        }
    }
...

We want to protect our code, so it will not out of bound.

//  EmojiMemoryGame.swift
...    
    private static func createMemoryGame() -> MemoryGame<String> {
        return MemoryGame(numberOfPairsOfCards: 4) { pairIndex in
            if emojis.indices.contains(pairIndex) {
                return emojis[pairIndex]
            } else {
                return "⁉️"
            }
        }
    }
...

We also want to make sure our we have at least 4 cards.

//  MemorizeGame.swift
...
    init(numberOfPairsOfCards: Int, cardContentFactory: (Int) -> CardContent) {
        cards = []
        // add numberOfParisOfCards x 2 cards
        for pairIndex in 0..<max(2, numberOfPairsOfCards) {
            let content = cardContentFactory(pairIndex)
            cards.append(Card(content: content))
            cards.append(Card(content: content))
        }
    }
...

Use ViewModel in View

Note: We renamed the ContentView.swift to EmojiMemoryGameView.swift
//  EmojiMemoryGameView.swift
...
    var cards: some View {
        LazyVGrid(columns: [GridItem(.adaptive(minimum: 85))]) {
            ForEach(emojis.indices, id: \.self) { index in
                CardView(content: emojis[index])
                    .aspectRatio(2/3, contentMode: .fit)
            }
        }
        .foregroundColor(.orange)
    }
}

struct CardView: View {
    let content: String
    @State var isFaceUp = true
    var body: some View {
        ZStack {
            let base = RoundedRectangle(cornerRadius: 12)
            Group {
                base.fill(.white)
                base.strokeBorder(lineWidth: 2)
                Text(content).font(.largeTitle)
            }
            .opacity(isFaceUp ? 1 : 0)
            base.fill().opacity(isFaceUp ? 0 : 1)
        }
        .onTapGesture {
            isFaceUp.toggle()
        }
    }
}
...

We are now iterating the cards.

//  EmojiMemoryGameView.swift
...
    var cards: some View {
        LazyVGrid(columns: [GridItem(.adaptive(minimum: 85))]) {
            ForEach(viewModel.cards.indices, id: \.self) { index in
                CardView(card: viewModel.cards[index])
                    .aspectRatio(2/3, contentMode: .fit)
            }
        }
        .foregroundColor(.orange)
    }
}

struct CardView: View {
    let card: MemoryGame<String>.Card
    
    var body: some View {
        ZStack {
            let base = RoundedRectangle(cornerRadius: 12)
            Group {
                base.fill(.white)
                base.strokeBorder(lineWidth: 2)
                Text(card.content).font(.largeTitle)
            }
            .opacity(card.isFaceUp ? 1 : 0)
            base.fill().opacity(card.isFaceUp ? 0 : 1)
        }
    }
}
...

Now, if we want to the front of the card. We only need to change our Model, the MemorizeGame.swift file.

init(_

One thing we don't like is to call the CardView. We don't like card:.

CardView(card: viewModel.cards[index])

To remove it, we need to create our own init.

//  EmojiMemoryGameView.swift
...
    var cards: some View {
        LazyVGrid(columns: [GridItem(.adaptive(minimum: 85))]) {
            ForEach(viewModel.cards.indices, id: \.self) { index in
                CardView(viewModel.cards[index])
                    .aspectRatio(2/3, contentMode: .fit)
            }
        }
        .foregroundColor(.orange)
    }
}

struct CardView: View {
    let card: MemoryGame<String>.Card
    
    init(_ card: MemoryGame<String>.Card) {
        self.card = card
    }
...

Make the emoji bigger

make-the-emoji-bigger

MARK: -

When we use MAKR: - XXX, the -. It looks like lined of space.

MARK-

Card Shuffle

Modify the View and ViewModel

Implement ViewModel:

//  EmojiMemoryGame.swift
...
class EmojiMemoryGame {
        ...
      ...
    // MARK: - Intents
    
    func shuffle() {
        model.shuffle()
    }
        ...
      ...
}

Implement View:

//  EmojiMemoryGameView.swift
...
    var body: some View {
        VStack {
            ScrollView {
                cards
            }
            Button("Shuffle") {
                viewModel.shuffle()
            }
        }
        .padding()
    }
...

mutating

We also need make our Model support card shuffle.

//  MemorizeGame.swift
...
    func shuffle() {
        cards.shuffle()
    }
...

However, the self (Model) is immutable.

//  MemorizeGame.swift
...
    mutating func shuffle() {
        cards.shuffle()
    }
...

Any functions modify the Model has to be marked mutating. It aims to reminds us you are modifying it and result in copy on write.

Reactive UI

ObservableObject

//  EmojiMemoryGame.swift
...
class EmojiMemoryGame: ObservableObject {
    ...
      @Published private var model = createMemoryGame()
      ...
    // MARK: - Intents
    
    func shuffle() {
        model.shuffle()
        objectWillChange.send()
    }
...

objectWillChange.send() notify the UI (View), something is about to change. @Published means if something changes, it will say something changed.

//  EmojiMemoryGameView.swift
...
struct EmojiMemoryGameView: View {
    @ObservedObject var viewModel: EmojiMemoryGame = EmojiMemoryGame()
        
    var body: some View {
        VStack {
            ScrollView {
                cards
            }
            Button("Shuffle") {
                viewModel.shuffle()
            }
        }
        .padding()
    }
...

We also need to mark @ObservedObject of our viewModel var. @ObservedObject means if this thing says something changed, re-draw me.

IMPORTANT: Having an @ObservedObject and saying = to something is bad.

@ObservedObject var viewModel: EmojiMemoryGame = EmojiMemoryGame()

The CORRECT Way:

// EmojiMemoryGameView.swift
import SwiftUI

struct EmojiMemoryGameView: View {
    @ObservedObject var viewModel: EmojiMemoryGame  
        ...
        ...
}

#Preview {
    EmojiMemoryGameView(viewModel: EmojiMemoryGame())
}

Also needed to changed the App:

//  MemorizeApp.swift
import SwiftUI

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

@StateObject means you cannot share this object.