How should we communicate, or hook up the flipping of the card?
Initially this was done as a tapGesture
on the view. But now we have a model, and we need to send a message to:
struct MemoryGame<CardContent> {
func choose(_ card: Card) {
// flip card here
}
}
How should we do that? If we remember or MVVM picture, all communication from view to model goes through the view model.
In other words we need to send an intent our our view model, and then our view model can update the model itself.
So our view currently knows about our view model by this:
@main
struct MemorizeApp: App {
let game = EmojiMemoryGame()
var body: some Scene {
WindowGroup {
ContentView(viewModel: game)
}
}
}
And because our view model is a class:
class EmojiMemoryGame {
static var emojis = [...]
private var model: MemoryGame<String> = createMemoryGame()
var cards: [MemoryGame<String>.Card] {
return model.cards
}
}
It can maintain state long term wrt to what's going on in the app. The problem is we need to observe
this state changes in our view. We need to bind to them somehow. This is where combine and SwiftUI data flow come in.
By making our view model an observed object, our view is now bound to changes in the view model. So a change in the view model will be sent out as a change to the view.
So by:
- make the cards observable
- adding the tap gesture back
- calling ViewModel.flipCard
- pass that onto the underlying model
- our view should update itself
Let's see how Paul does it.
First he adds the tapGesture and adds an intent to the view model:
ContentView
struct ContentView: View {
var viewModel: EmojiMemoryGame
var body: some View {
ScrollView {
LazyVGrid(...) {
ForEach(viewModel.cards) { card in
CardView(card: card)
.onTapGesture {
viewModel.choose(card) // here
}
Then he adds the intent to the view model, which simply passes that request onto the model.
EmojiMemoryGame
class EmojiMemoryGame {
// MARK: - Intent(s)
func choose(_ card: MemoryGame<String>.Card) {
model.choose(card)
}
}
And then the model updates itself.
MemoryGame
struct MemoryGame<CardContent> {
private(set) var cards: [Card]
mutating func choose(_ card: Card) {
let chosenIndex = index(of: card)
cards[chosenIndex].isFaceUp.toggle()
}
Note the mutating
attribute on the func.
Now this will update the card in the model correctly, but we still need to see a changed signal to the UI.
SwiftUI is state driven. Meaning to change the UI, we need to change the state. To make our UI update we need to:
- Make our view model
@ObservableObject
. - Mark key attributes
@Published
. - Bind
@ObservedObject
into our view
Here is where MVVM really pays off. We are going to add three simple, yet power keywords to make our UI reactive to changes in our state.
class EmojiMemoryGame: ObservableObject { ... }
When we make an object observable, we get a var
behind the scenes called:
var objectWillChange: ObserableObjectPublisher
This is the combine framework making view model a publisher, and enabling it to send out signals when certain attributes change.
To fire this event we could go:
func choose(_ card: MemoryGame<String>.Card) {
objectWillChange.send() // here
model.choose(card)
}
We like objectWillChange
here because multiple properties may have changed, and SwiftUI is really good at batching them up and send out all changes at once.
Next thing we can do is choose specifically, which attributes to publish.
@Published private var model: MemoryGame<String> = createMemoryGame()
What we are saying here is that anytime anyone changes the model, we will broadcast out a change event. It will automatically send it out. So we don't even need objectWillChange.send()
.
func choose(_ card: MemoryGame<String>.Card) {
model.choose(card)
}
Next we need to make our view redraw when something changes.
struct ContentView: View {
@ObservedObject var viewModel: EmojiMemoryGame
Now everything is hooked up. Our view model is observable and published. It is going to broadcast changes. And our view is observing those changes through ObservedObject
.
Now our view will update itself when the model changes. Which is the whole basis of MVVM and the foundation of how SwiftUI works.
EmojiMemoryGame
class EmojiMemoryGame: ObservableObject { // 1
static var emojis = ["π²", "π", "π", "π", "π", "π", "π", "π", "π", "βοΈ", "π", "β΅οΈ", "πΈ", "πΆ", "π", "π", "π", "π‘", "π΅", "π", "π", "π", "π»", "π"]
static func createMemoryGame() -> MemoryGame<String> {
MemoryGame<String>(numberOfPairsOfCards: 4) { pairIndex in
EmojiMemoryGame.emojis[pairIndex]
}
}
@Published private var model: MemoryGame<String> = createMemoryGame() // 2
var cards: [MemoryGame<String>.Card] {
return model.cards
}
// MARK: - Intent(s)
func choose(_ card: MemoryGame<String>.Card) {
model.choose(card)
}
}
ContentView
import SwiftUI
struct ContentView: View {
@ObservedObject var viewModel: EmojiMemoryGame // 3
var body: some View {
ScrollView {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 65))]) {
ForEach(viewModel.cards) { card in
CardView(card: card).aspectRatio(2/3, contentMode: .fit)
.onTapGesture {
viewModel.choose(card)
}
}
}
}
.foregroundColor(.red)
.padding(.horizontal)
}
}
Data structures with discrete states.
Can have associated data.
We check enum
state with switch
.
And in Swift we can switch on anything. Not just enum
.
Multiple lines allowed. Also has a default called fallthrough
I had never heard of.
Use let
to access associated data. Name can be different.
Enums can have functions.
And you can get all cases with CaseIterable
.
An Optional
is an enum
.
Long form optional looks like this:
Can upwrap with a !
or if let
.
Or nil-coalescing operator
.
Optionals can be chained.
One difference I notice working in SwiftUI how you access data.
In a UIKit
app everything is stored by reference. So if I have an array of UILabel
, I can just filter
, map
, reduce
them and get the values I want. Because I am affecting their values.
In SwiftUI that is not the case. Everything is a struct. So you need to work with indexes
more and use those to affect data of whatever you are mutating.
struct MemoryGame<CardContent> {
private(set) var cards: [Card]
private var indexOfTheOneAndOnlyFaceUpCard: Int?
mutating func choose(_ card: Card) {
if let chosenIndex = cards.firstIndex(where: { $0.id == card.id }) {
if let potentialMatchIndex = indexOfTheOneAndOnlyFaceUpCard {
if cards[chosenIndex].content == cards[potentialMatchIndex].content {
cards[chosenIndex].isMatched = true
cards[potentialMatchIndex].isMatched = true
}
indexOfTheOneAndOnlyFaceUpCard = nil
} else {
for index in cards.indices {
cards[index].isFaceUp = false
}
}
cards[chosenIndex].isFaceUp.toggle()
}
}
}
When you unwrap an optional you can't use it immediately in an &&
.
if let chosenIndex = cards.firstIndex(where: { $0.id == card.id }) && !card[chosenIndex].isFaceUp { ... }
But if you replace the &&
with a comma ,
it acts just like the &&
.
if let chosenIndex = cards.firstIndex(where: { $0.id == card.id }), !card[chosenIndex].isFaceUp { ... }
Paul does the example with a generic. And it causes us problems with doing the compare.
struct MemoryGame<CardContent> {
if cards[chosenIndex].content == cards[potentialMatchIndex].content {
// Binary operation == can not be applied to two `CardContent` operands
}
}
We can fix this by turing the don't care into int a we care a little bit with a where
clause the behaves like Equatable
.
struct MemoryGame<CardContent> where CardContent: Equatable {
This is the protocol oriented way of design that Swift supports.
For examples when you see a ForEach
in SwiftUI like this:
ForEach(viewModel.cards) { card in
}
The contents the ForEach
takes is a generic. And that generic has on it a where clause of Identifiable
. Meaning everything you pass me here needs an id
.
extension ForEach where ID == Data.Element : Identifiable {}
- Models are UI independent
- No reference in view in model
- Models have data and logic
- Model is the truth
- Purpose of view is to reflect the model
- Views are declarative
- View model binds the view to the model
- Can also act as an interpretter
- Is also the gate keeper from the model to the view
- View model enables reactive architecture
- View model publishes changes (@ObservableObject) via send
- @Published means when this variable changes call
sendObjectWillSend
letting world know something has changed - View has @ObservedObject and when change body gets rebuilt
- Not all subviews - just the ones affected by the change - very efficient