SwiftUI and MVVM - Communication between model and view model

23,435

Solution 1

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:

enter image description here (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.

enter image description here

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]
    }
}

Solution 2

Thanks Quantm for posting an example code above. I followed your example, but simplified a bit. The changes I made:

  • No need to use Combine
  • The only connection between view model and view is the binding SwiftUI provides. eg: use @Published (in view model) and @ObservedObject (in view) pair. We could also use @Published and @EnvironmentObject pair if we want to build bindings across multiple views with the view model.

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()
      }
   }

Solution 3

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".

Share:
23,435

Related videos on Youtube

Quantm
Author by

Quantm

Updated on July 09, 2022

Comments

  • Quantm
    Quantm almost 2 years

    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!

    • Admin
      Admin over 4 years
      Haven't gone through this yet, but these tutorials are usually very good. If nothing, it should help you make your issue much more concise: raywenderlich.com/4161005-mvvm-with-combine-tutorial-for-ios
    • Quantm
      Quantm over 4 years
      Great article. In their app example, the model layer is designed to be "passive". The view model can request a refresh (loading new weather data) but the model doesn't hold any persistent data (like the characters in my example). So either their example doesn't cover this use case or MVVM is actually meant to have these types of models. However, I'm not sure how to adapt my example to make it conform to MVVM then.
  • Quantm
    Quantm over 4 years
    Thanks. But this would only cause this specific view to redraw. What about all other views that are displaying that specific character? They have to update as well.
  • Ken Mueller
    Ken Mueller over 4 years
    If all of your views reference a single ViewModel, then when you call .send() all of them will update.
  • Quantm
    Quantm over 4 years
    But they don't necessarily do, that's the problem. The trigger has to come from the model or something
  • Arek
    Arek over 4 years
    I have to disagree with your approach. The purpose of the view model is to separate the view from the model and encapsulate the business logic and data formatting. By notifying the view about changes in the model you essentially brake this design pattern. I also believe it's not a one way connection as MVVM is mostly about binding and changes to the view model should result in the view being notified of them. In my opinion this article has a more accurate diagram resembling MVVM: medium.com/ios-os-x-development/…
  • AirXygène
    AirXygène over 4 years
    Nothing beats simplicity, agreed. But this still leaves me with some questions. As m is a struct, it is copied by value. When you mutate it, it is locally mutated. How do you update the “real” model ? For example, if the another view shows the same elements of the model in some other form, how is it warned to update itself, as it has its - yet another copy - own model ?
  • Jim lai
    Jim lai over 4 years
    For shared state you'd need reference type binding.
  • Jim lai
    Jim lai over 4 years
    Local states are independent. There's no "real" model behind them to update. What you are describing is shared state, which would require reference type model in either EnvironmentObject or ObservableObject binding. I'd say it's often not worth it to turn the whole model into reference type for a few properties that can be shared. Refactor those out to be a shared state and leave the rest as value type and local state. For exmaple, one common problem with MVVM is that they tend to mix networking with model, which inevitably has to be reference type. Why not refactor out networking?
  • Darko
    Darko about 4 years
    Disagree. @State should always be private. It's about the internal state for the view, managed by SwiftUI.
  • Jim lai
    Jim lai about 4 years
    Not sure what you are referring to. @State is guarded by compiler to be "private", i.e.; not accessible from outside the view. What I mean by "shared state" refers to view models that are in fact models with shared state rather than shared @State.
  • Lumii
    Lumii over 3 years
    I always thought, SwiftUI need to keep a "copy" of old model snapshot. Only with such information, it can perform comparison with current model, and perform efficient UI update. Is that the reason why struct is used for model, instead of class? Is this being written somewhere in official documentation?
  • Lumii
    Lumii over 3 years
    Also, I saw you conform protocol Identifiable even it is not being mentioned in most of the tutorial - hackingwithswift.com/books/ios-swiftui/… May I know, is it because of this reason? stackoverflow.com/questions/63487142/…
  • KissTheCoder
    KissTheCoder over 3 years
    I like this solution a lot since it makes the views reusable. I can treat each view and its viewModel as a component that is only dependent on the model it interacts with. However, I wish there was a way to store the charList and charDetail viewModels in the wrapper view so they aren't recreated every time the model changes. I've tried, but the two views no longer stay in sync. Thoughts?