Pretty basic question, I have professional dev experience but brand new to Swift and still figuring out its design patterns and such. I’ve been working through the Stanford course. The first couple assignments surround making a card game, and I’m trying to make the architecture make sense.
The basic unit is a Card struct
struct Card: Identifiable, Equatable {
let id: String
var isFaceUp = false
// other stuff not relevant here...
}
The Model class holds an array of Cards, there’s a very simple viewmodel in the middle that just has a computed property var cards = model.cards
, and then the content view lays all the cards out in a grid
var body: some View {
LazyVGrid(...) {
ForEach(viewModel.cards) { card in
CardView(card: card, viewModel: viewModel)
}
}
On tap, a card should flip over.
struct CardView: View {
let card: Card
@ObservedObject var viewModel: ViewModel
var body: some View {
//draw a card
.onTapGesture { viewModel.choose(card: card) }
}
}
The problem is how to actually flip that card in the model. The way given in the Stanford lectures is to pass the card (by value!) to the viewModel, which passes it to the model. Mutating this card doesn’t do anything since it’s a copy of the “real” card in the array, so we then linear search through the array to find the real card and mutate that, something like
for index in cards.indices {
if cards[index] == chosenCard {
cards[index].isFaceUp.toggle()
return
}
}
This works, but doesn’t seem right – that’s an O(n) operation on every button press! Not something that strictly needs optimizing but the architecture smells a bit to me and I’m not sure what the alternative is.
Things that come to mind:
- Make Card a class, so each CardView can hold a pointer and manipulate it directly. Would that work? And would it violate good practice to directly hold pointers to model objects?
- Make the view only hold an index into the model’s cards array, skipping the search step. Problem: the array order changes when the deck is shuffled
- Ok then make the view hold the unique card id, the make
cards
an OrderedDictionary so the deck can have a defined order but constant-time access to a given card - Or let the view hold the Card struct but make
cards
an OrderedSet? - Something interesting with observables?
- ???
This seems super simple but I’m having trouble wrapping my head around how Swift and SwiftUI manage data. It seems pretty built around ‘functional functions’ with immutable inputs rather than methods with side effects, but that makes it hard to actually maintain a source of truth.
What is the properly Swifty way to design this?
Caketray is a new contributor to this site. Take care in asking for clarification, commenting, and answering.
Check out our Code of Conduct.
1
One thing you can do is declare the card variable a binding, like this:
@Binding var card: Card
The Binding property wrapper is used when the source of truth for the variable needs to be outside the View (as in your situation, where the source of truth is held in the model). So when you create the view, you pass in the card like this:
ForEach($model.cards) { $card in // the $ indicates a binding
CardView(card)
}
Using a binding this way means that the model is listening for changes made to the card in CardView and will update the card when it’s updated in the view.
This is a pretty good explanation of @State and @Binding property wrappers: https://www.kodeco.com/books/swiftui-cookbook/v1.0/chapters/1-understanding-state-binding-in-swiftui
2
Regarding your self-answers: don’t overcomplicate the matter.
Instead, keep these simple rules in mind:
- A view should be simply a function of state.
So, what does that mean? Well, the view renders what ever state (or better view state) it has been provided – here, from your View Model. The view state is just a composition of trivial data that precisely represents every aspect of your view.
So, in your case, “precisely” means, that the view state contains information whether a card is flipped or not. Remember, the view just renders exactly what the view state is: view = f(view_state)
.
- A View should not mutate its state
This is a consequence form #1, just emphasising it. 😉
- A View sends Commands to the View Model
A command is any user intent, for example a button tap, or something that happens in the view, for example “on appear”.
One can model all commands in this system with an enum, for example:
enum Event {
case onAppear
case cardTapped(id: CardID)
}
Now, the View Model is the computational model which computes a new view state based on the current view state and an event.
The computational model can be expressed with a function:
(State, Event) -> State
This may implement your presentation logic, or if you want also business logic.
This computational model can be easily extended to call any service functions:
(State, Event) -> (State, [ServiceFunction])
Where ServiceFunction
is a struct which contains a function which will be called in your ViewModel implementation. This represents the “Model” in the MVVM pattern.
Note, that it is a very simple, yet powerful approach. It can be used to implement any MV** pattern and also ELM, Redux, TCA, and potentially more.
So, in order to implement an MVVM pattern, first identify the “view state”. Note, it exactly models your view, using only trivial data types. You can end up with either an enum (modelling different “modes” of your view), or a struct.
Then identify the events that can happen in this use case. One can model it with an enum. Probably the easiest part.
Regarding implementing the View Model. Well, it might become an Observable – this a class, which should also be confined to the main thread, i.e. make it a @MainActor
. Basically it would suffice to implement the function mentioned above. Actually it will only be a few lines of code, and it will usually be very generic (actually, it can be a generic class, with State and Event as type parameter).
It’s also possible to implement the function and the associated state only utilising SwiftUI. So, the “View Model” will be implemented with a SwiftUI view.
The above pattern will more or less enforce you to get an event driven, unidirectional approach. It deliberately does not allow to use two-way bindings, where a view actually can mutate its state, which would violate rule #1.
I don’t want to suggest, how to implement a view model. But, in my opinionated preferred way, my View Model is a generic class, with type parameters State
and Event
. It uses a state machine, and pretty much directly implements the above function, which is a combination of the transition function and an output function from a Finite State Machine. This View Model can also handle and manage “tasks”, i.e. it can call function which creates Tasks and can also cancel them. These tasks usually have “side effects” – i.e. calling a backend. These tasks may also emit one or several events which then get fed back into the system. The whole machinery can also be implemented within SwiftUI view – so no Observable, just SwiftUI.
If we would find a protocol for a view model, it would just be:
protocol ViewModel {
associatedtype State
associatedtype Event
var state { get }
func send(_ event: Event)
}
For a conforming view model, the state is observable. The generic initialisers receive the transition function and the initial state.
Hope this helps 😉
2