I've been experimenting with the MVVM model that's used in SwiftUI
and there are some things I don't quite get yet.
SwiftUI
uses @ObservableObject
/@ObservedObject
to detect changes in a view model that trigger a recalculation of the body
property to update the view.
In the MVVM model, that's the communication between the view and the view model. What I don't quite understand is how the model and the view model communicate.
When the model changes, how is the view model supposed to know that? I thought about manually using the new Combine
framework to create publishers inside the model that the view model can subscribe to.
However, I created a simple example that makes this approach pretty tedious, I think. There's a model called Game
that holds an array of Game.Character
objects. A character has a strength
property that can change.
So what if a view model changes that strength
property of a character? To detect that change, the model would have to subscribe to every single character that the game has (among possibly many other things). Isn't that a little too much? Or is it normal to have many publishers and subscribers?
Or is my example not properly following MVVM? Should my view model not have the actual model game
as property? If so, what would be a better way?
// My Model
class Game {
class Character {
let name: String
var strength: Int
init(name: String, strength: Int) {
self.name = name
self.strength = strength
}
}
var characters: [Character]
init(characters: [Character]) {
self.characters = characters
}
}
// ...
// My view model
class ViewModel: ObservableObject {
let objectWillChange = PassthroughSubject<ViewModel, Never>()
let game: Game
init(game: Game) {
self.game = game
}
public func changeCharacter() {
self.game.characters[0].strength += 20
}
}
// Now I create a demo instance of the model Game.
let bob = Game.Character(name: "Bob", strength: 10)
let alice = Game.Character(name: "Alice", strength: 42)
let game = Game(characters: [bob, alice])
// ..
// Then for one of my views, I initialize its view model like this:
MyView(viewModel: ViewModel(game: game))
// When I now make changes to a character, e.g. by calling the ViewModel's method "changeCharacter()", how do I trigger the view (and every other active view that displays the character) to redraw?
I hope it's clear what I mean. It's difficult to explain because it is confusing
Thanks!
Don't over-engineering! No suggested architecture for SwiftUI, just MVC without the C. On SwiftUI you get extra (or wrong) work and complexity for no benefits.
Careful observers noticed SwiftUI uses the Model-View-ViewModel (MVVM) design pattern, whereas “legacy” Apple apps are Model-View-Controller (MVC) builds.
In order to create the needed binding between the View and the ViewModel, in Swift we need to create an object that conforms to the ObservableObject protocol. Then we have to declare the bound variables with the SwiftUI property wrapper @Published in order to allow us to recalculate the body property in the bound View.
Although you can use any design pattern to build your SwiftUI apps, MVVM pattern is preferred due to the declarative nature of the SwiftUI framework.
I've spent the few last hours playing around with the code and I think I've come up with a pretty good way of doing this. I don't know if that's the intended way or if it's proper MVVM but it seems to work and it's actually quite convenient.
I will post an entire working example below for anyone to try out. It should work out of the box.
Here are some thoughts (which might be complete garbage, I don't know anything about that stuff yet. Please correct me if I'm wrong :))
I think that view models
probably shouldn't contain or save any actual data from the model. Doing this would effectively create a copy of what's already saved in the model layer
. Having data stored in multiple places causes all kinds of synchronization and update problems you have to consider when changing anything. Everything I tried ended up being a huge, unreadable chunk of ugly code.
Using classes for the data structures inside the model doesn't really work well because it makes detecting changes more cumbersome (changing a property doesn't change the object). Thus, I made the Character
class a struct
instead.
I spent hours trying to figure out how to communicate changes between the model layer
and the view model
. I tried setting up custom publishers, custom subscribers that track any changes and update the view model accordingly, I considered having the model
subscribe to the view model
as well to establish two-way communication, etc. Nothing worked out. It felt unnatural. But here's the thing: The model doesn't have to communicate with the view model. In fact, I think it shouldn't at all. That's probably what MVVM is about. The visualisation shown in an MVVM tutorial on raywenderlich.com shows this as well:
(Source: https://www.raywenderlich.com/4161005-mvvm-with-combine-tutorial-for-ios)
That's a one-way connection. The view model reads from the model and maybe makes changes to the data but that's it.
So instead of having the model
tell the view model
about any changes, I simply let the view
detect changes to the model
by making the model an ObservableObject
. Every time it changes, the view is being recalculated which calls methods and properties on the view model
. The view model
, however, simply grabs the current data from the model (as it only accesses and never saves them) and provides it to the view. The view model simply doesn't have to know whether or not the model has been updated. It doesn't matter.
With that in mind, it wasn't hard to make the example work.
Here's the example app to demonstrate everything. It simply shows a list of all characters while simultaneously displaying a second view that shows a single character.
Both views are synched when making changes.
import SwiftUI
import Combine
/// The model layer.
/// It's also an Observable object so that swiftUI can easily detect changes to it that trigger any active views to redraw.
class MyGame: ObservableObject {
/// A data object. It should be a struct so that changes can be detected very easily.
struct Character: Equatable, Identifiable {
var id: String { return name }
let name: String
var strength: Int
static func ==(lhs: Character, rhs: Character) -> Bool {
lhs.name == rhs.name && lhs.strength == rhs.strength
}
/// Placeholder character used when some data is not available for some reason.
public static var placeholder: Character {
return Character(name: "Placeholder", strength: 301)
}
}
/// Array containing all the game's characters.
/// Private setter to prevent uncontrolled changes from outside.
@Published public private(set) var characters: [Character]
init(characters: [Character]) {
self.characters = characters
}
public func update(_ character: Character) {
characters = characters.map { $0.name == character.name ? character : $0 }
}
}
/// A View that lists all characters in the game.
struct CharacterList: View {
/// The view model for CharacterList.
class ViewModel: ObservableObject {
/// The Publisher that SwiftUI uses to track changes to the view model.
/// In this example app, you don't need that but in general, you probably have stuff in the view model that can change.
let objectWillChange = PassthroughSubject<Void, Never>()
/// Reference to the game (the model).
private var game: MyGame
/// The characters that the CharacterList view should display.
/// Important is that the view model should not save any actual data. The model is the "source of truth" and the view model
/// simply accesses the data and prepares it for the view if necessary.
public var characters: [MyGame.Character] {
return game.characters
}
init(game: MyGame) {
self.game = game
}
}
@ObservedObject var viewModel: ViewModel
// Tracks what character has been selected by the user. Not important,
// just a mechanism to demonstrate updating the model via tapping on a button
@Binding var selectedCharacter: MyGame.Character?
var body: some View {
List {
ForEach(viewModel.characters) { character in
Button(action: {
self.selectedCharacter = character
}) {
HStack {
ZStack(alignment: .center) {
Circle()
.frame(width: 60, height: 40)
.foregroundColor(Color(UIColor.secondarySystemBackground))
Text("\(character.strength)")
}
VStack(alignment: .leading) {
Text("Character").font(.caption)
Text(character.name).bold()
}
Spacer()
}
}
.foregroundColor(Color.primary)
}
}
}
}
/// Detail view.
struct CharacterDetail: View {
/// The view model for CharacterDetail.
/// This is intentionally only slightly different to the view model of CharacterList to justify a separate view model class.
class ViewModel: ObservableObject {
/// The Publisher that SwiftUI uses to track changes to the view model.
/// In this example app, you don't need that but in general, you probably have stuff in the view model that can change.
let objectWillChange = PassthroughSubject<Void, Never>()
/// Reference to the game (the model).
private var game: MyGame
/// The id of a character (the name, in this case)
private var characterId: String
/// The characters that the CharacterList view should display.
/// This does not have a `didSet { objectWillChange.send() }` observer.
public var character: MyGame.Character {
game.characters.first(where: { $0.name == characterId }) ?? MyGame.Character.placeholder
}
init(game: MyGame, characterId: String) {
self.game = game
self.characterId = characterId
}
/// Increases the character's strength by one and updates the game accordingly.
/// - **Important**: If the view model saved its own copy of the model's data, this would be the point
/// where everything goes out of sync. Thus, we're using the methods provided by the model to let it modify its own data.
public func increaseCharacterStrength() {
// Grab current character and change it
var character = self.character
character.strength += 1
// Tell the model to update the character
game.update(character)
}
}
@ObservedObject var viewModel: ViewModel
var body: some View {
ZStack(alignment: .center) {
RoundedRectangle(cornerRadius: 25, style: .continuous)
.padding()
.foregroundColor(Color(UIColor.secondarySystemBackground))
VStack {
Text(viewModel.character.name)
.font(.headline)
Button(action: {
self.viewModel.increaseCharacterStrength()
}) {
ZStack(alignment: .center) {
Circle()
.frame(width: 80, height: 80)
.foregroundColor(Color(UIColor.tertiarySystemBackground))
Text("\(viewModel.character.strength)").font(.largeTitle).bold()
}.padding()
}
Text("Tap on circle\nto increase number")
.font(.caption)
.lineLimit(2)
.multilineTextAlignment(.center)
}
}
}
}
struct WrapperView: View {
/// Treat the model layer as an observable object and inject it into the view.
/// In this case, I used @EnvironmentObject but you can also use @ObservedObject. Doesn't really matter.
/// I just wanted to separate this model layer from everything else, so why not have it be an environment object?
@EnvironmentObject var game: MyGame
/// The character that the detail view should display. Is nil if no character is selected.
@State var showDetailCharacter: MyGame.Character? = nil
var body: some View {
NavigationView {
VStack(alignment: .leading) {
Text("Tap on a character to increase its number")
.padding(.horizontal, nil)
.font(.caption)
.lineLimit(2)
CharacterList(viewModel: CharacterList.ViewModel(game: game), selectedCharacter: $showDetailCharacter)
if showDetailCharacter != nil {
CharacterDetail(viewModel: CharacterDetail.ViewModel(game: game, characterId: showDetailCharacter!.name))
.frame(height: 300)
}
}
.navigationBarTitle("Testing MVVM")
}
}
}
struct WrapperView_Previews: PreviewProvider {
static var previews: some View {
WrapperView()
.environmentObject(MyGame(characters: previewCharacters()))
.previewDevice(PreviewDevice(rawValue: "iPhone XS"))
}
static func previewCharacters() -> [MyGame.Character] {
let character1 = MyGame.Character(name: "Bob", strength: 1)
let character2 = MyGame.Character(name: "Alice", strength: 42)
let character3 = MyGame.Character(name: "Leonie", strength: 58)
let character4 = MyGame.Character(name: "Jeff", strength: 95)
return [character1, character2, character3, character4]
}
}
Thanks Quantm for posting an example code above. I followed your example, but simplified a bit. The changes I made:
With these changes, the MVVM setup is pretty straightforward and the two-way communication between the view model and view is all provided by the SwiftUI framework, there is no need to add any additional calls to trigger any update, it all happens automatically. Hope this also helps answer your original question.
Here is the working code that does about the same as your sample code above:
// Character.swift
import Foundation
class Character: Decodable, Identifiable{
let id: Int
let name: String
var strength: Int
init(id: Int, name: String, strength: Int) {
self.id = id
self.name = name
self.strength = strength
}
}
// GameModel.swift
import Foundation
struct GameModel {
var characters: [Character]
init() {
// Now let's add some characters to the game model
// Note we could change the GameModel to add/create characters dymanically,
// but we want to focus on the communication between view and viewmodel by updating the strength.
let bob = Character(id: 1000, name: "Bob", strength: 10)
let alice = Character(id: 1001, name: "Alice", strength: 42)
let leonie = Character(id: 1002, name: "Leonie", strength: 58)
let jeff = Character(id: 1003, name: "Jeff", strength: 95)
self.characters = [bob, alice, leonie, jeff]
}
func increaseCharacterStrength(id: Int) {
let character = characters.first(where: { $0.id == id })!
character.strength += 10
}
func selectedCharacter(id: Int) -> Character {
return characters.first(where: { $0.id == id })!
}
}
// GameViewModel
import Foundation
class GameViewModel: ObservableObject {
@Published var gameModel: GameModel
@Published var selectedCharacterId: Int
init() {
self.gameModel = GameModel()
self.selectedCharacterId = 1000
}
func increaseCharacterStrength() {
self.gameModel.increaseCharacterStrength(id: self.selectedCharacterId)
}
func selectedCharacter() -> Character {
return self.gameModel.selectedCharacter(id: self.selectedCharacterId)
}
}
// GameView.swift
import SwiftUI
struct GameView: View {
@ObservedObject var gameViewModel: GameViewModel
var body: some View {
NavigationView {
VStack {
Text("Tap on a character to increase its number")
.padding(.horizontal, nil)
.font(.caption)
.lineLimit(2)
CharacterList(gameViewModel: self.gameViewModel)
CharacterDetail(gameViewModel: self.gameViewModel)
.frame(height: 300)
}
.navigationBarTitle("Testing MVVM")
}
}
}
struct GameView_Previews: PreviewProvider {
static var previews: some View {
GameView(gameViewModel: GameViewModel())
.previewDevice(PreviewDevice(rawValue: "iPhone XS"))
}
}
//CharacterDetail.swift
import SwiftUI
struct CharacterDetail: View {
@ObservedObject var gameViewModel: GameViewModel
var body: some View {
ZStack(alignment: .center) {
RoundedRectangle(cornerRadius: 25, style: .continuous)
.padding()
.foregroundColor(Color(UIColor.secondarySystemBackground))
VStack {
Text(self.gameViewModel.selectedCharacter().name)
.font(.headline)
Button(action: {
self.gameViewModel.increaseCharacterStrength()
self.gameViewModel.objectWillChange.send()
}) {
ZStack(alignment: .center) {
Circle()
.frame(width: 80, height: 80)
.foregroundColor(Color(UIColor.tertiarySystemBackground))
Text("\(self.gameViewModel.selectedCharacter().strength)").font(.largeTitle).bold()
}.padding()
}
Text("Tap on circle\nto increase number")
.font(.caption)
.lineLimit(2)
.multilineTextAlignment(.center)
}
}
}
}
struct CharacterDetail_Previews: PreviewProvider {
static var previews: some View {
CharacterDetail(gameViewModel: GameViewModel())
}
}
// CharacterList.swift
import SwiftUI
struct CharacterList: View {
@ObservedObject var gameViewModel: GameViewModel
var body: some View {
List {
ForEach(gameViewModel.gameModel.characters) { character in
Button(action: {
self.gameViewModel.selectedCharacterId = character.id
}) {
HStack {
ZStack(alignment: .center) {
Circle()
.frame(width: 60, height: 40)
.foregroundColor(Color(UIColor.secondarySystemBackground))
Text("\(character.strength)")
}
VStack(alignment: .leading) {
Text("Character").font(.caption)
Text(character.name).bold()
}
Spacer()
}
}
.foregroundColor(Color.primary)
}
}
}
}
struct CharacterList_Previews: PreviewProvider {
static var previews: some View {
CharacterList(gameViewModel: GameViewModel())
}
}
// SceneDelegate.swift (only scene func is provided)
func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
// Use this method to optionally configure and attach the UIWindow `window` to the provided UIWindowScene `scene`.
// If using a storyboard, the `window` property will automatically be initialized and attached to the scene.
// This delegate does not imply the connecting scene or session are new (see `application:configurationForConnectingSceneSession` instead).
// Use a UIHostingController as window root view controller.
if let windowScene = scene as? UIWindowScene {
let window = UIWindow(windowScene: windowScene)
let gameViewModel = GameViewModel()
window.rootViewController = UIHostingController(rootView: GameView(gameViewModel: gameViewModel))
self.window = window
window.makeKeyAndVisible()
}
}
Short answer is to use @State, whenever state property changes, view is rebuilt.
Long answer is to update MVVM paradigm per SwiftUI.
Typically for something to be a "view model", some binding mechanism needs to be associated with it. In your case there's nothing special about it, it is just another object.
The binding provided by SwiftUI comes from value type conforming to View protocol. This set it apart from Android where there's no value type.
MVVM is not about having an object called view model. It's about having model-view binding.
So instead of model -> view model -> view hierarchy, it's now struct Model: View with @State inside.
All in one instead of nested 3 level hierarchy. It may go against everything you thought you knew about MVVM. In fact I'd say it's an enhanced MVC architecture.
But binding is there. Whatever benefit you can get from MVVM binding, SwiftUI has it out-of-box. It just presents in an unique form.
As you stated, it would be tedious to do manual binding around view model even with Combine, because SDK deems it not necessary to provide such binding as of yet. (I doubt it ever will, since it's a major improvement over traditional MVVM in its current form)
Semi-pseudo code to illustrate above points:
struct GameModel {
// build your model
}
struct Game: View {
@State var m = GameModel()
var body: some View {
// access m
}
// actions
func changeCharacter() { // mutate m }
}
Note how simple this is. Nothing beats simplicity. Not even "MVVM".
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With