Stanford cs193p Applying MVVM
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
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:
- The data type is a String, Int or uncleared.
- 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 ofemojis
are nowEmojiMemoryGame.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:
.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
MARK: -
When we use MAKR: - XXX
, the -
. It looks like lined of space.
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.
Comments are disabled.