I am currently exploring the Observation framework and possible integrations with a MVVM architecture (I am aware of the overall discussion on SwiftUI and MVVM and if it is desired to have ViewModels at all, but I am just curious how this actually works under the hood).
Given the following App & ContentView I would like to decouple the UI state and the actions from the ViewModel in order to allow mockable Previews for all kind of UI states.
import SwiftUI
@main
struct PlaygroundApp: App {
var body: some Scene {
WindowGroup {
ContentView(viewModel: ListStateViewModel())
}
}
}
struct ContentView: View {
private var state: ListState
private var perform: ListActionable
init(viewModel: ListStateViewModel) {
self.state = viewModel.state
self.perform = viewModel.perform
}
var body: some View {
List(state.items) { item in
Text(item.title)
}
Button("Add Item") {
perform(.addItem)
}
}
}
The ViewModel and the State would look like this:
import Foundation
struct ListItem: Identifiable {
let id = UUID()
let title: String
}
@Observable
class ListState {
var items: [ListItem] = []
}
typealias ListActionable = (ListAction) -> Void
enum ListAction {
case addItem
}
class ListStateViewModel {
private(set) var state = ListState()
init() {
print(state.items.count) // simply printing the count will break the functionality
}
func perform(action: ListAction) {
switch action {
case .addItem:
addItem()
}
}
func addItem() {
let item = ListItem(title: "Item #\(state.items.count)")
state.items.append(item)
}
}
How can accessing the state (even just accessing the items property and print it) in the init of the ViewModel break the functionality and completely decouple the state used in the View from the instance created in the ViewModel?
Having the print button will cause the View to re-initialize and break the Button functionality.
Moving the creation of the ListStateViewModel outside of the body of the App would also work but in a final implementation I would use the Router pattern and the creation of the ViewModel would also be part of a ViewBuilder.
Marking the ViewModel with @Observable and having the items property directly in the ViewModel also has no effect, so it is not necessarily related to having the state as an instance variable.
Haven't tried your code, but at first glance seems like some weird side effect of having an instance of @Observable outside of a view.
Otherwise, your ListStateViewModel has no purpose other than passing two parameters - your state and actionable objects - to the view, and in fact ListStateViewModel could have been a struct in which case the problems might go away just as well.
[Edit: sorry, the purpose of your model class is to "host" the actions and so it kind of stays alive since the instance is captured, but still, the problem of having an observable in another class seems like a potential source of trouble.]
What I usually do is, mark the model itself as @Observable and ensure its instance only "lives" within the view.
When you access an observable property, the view body will become dependent on it. The access can be direct or indirect. With that in mind, let's analyze your example:
PlaygroundApp.body calls ListStateViewModel.init, which calls ListStateViewModel.state.items, an observable property of ListState.
So you've made PlaygroundApp.body depend on ListState.items.
The broken-ness comes from the fact that when you modify .items, PlaygroundApp.body gets re-evaluated, as part of that process, your code creates a new instance of ListStateViewModel from scratch.
@main
struct PlaygroundApp: App {
@State private var model = ListStateViewModel()
var body: some Scene {
WindowGroup {
ContentView (viewModel: model )
}
}
}
TestApp
import SwiftUI
@main
struct TestApp: App {
@State private var model = ListStateViewModel()
var body: some Scene {
WindowGroup {
ContentView (viewModel: model )
}
}
}
struct ContentView: View {
private var state: ListState
private var perform: ListActionable
init (viewModel: ListStateViewModel) {
self.state = viewModel.state
self.perform = viewModel.perform
}
var body: some View {
VStack {
List (state.items) { item in
Text(item.title)
}
Button("Add Item") {
perform(.addItem)
}
.padding ()
}
}
}
import Foundation
struct ListItem: Identifiable {
let id = UUID()
let title: String
}
@Observable
class ListState {
var items: [ListItem] = []
}
typealias ListActionable = (ListAction) -> Void
enum ListAction {
case addItem
}
@Observable
class ListStateViewModel {
private(set) var state = ListState()
init() {
print(state.items.count) // simply printing the count will break the functionality
}
func perform(action: ListAction) {
switch action {
case .addItem:
addItem()
}
}
func addItem() {
let x = state.items.count
let item = ListItem(title: "Item \(x): \(Emoji.at (index: x))")
state.items.append(item)
}
}
// Emoji.swift
import Foundation
struct Emoji {
static let s0 = "π"
static let s1 = "π"
static let s2 = "π"
static let s3 = "π"
static let s4 = "π"
static let s5 = "π"
static let s6 = "π"
static let s7 = "π"
static let s8 = "π"
static let s9 = "π"
static let sA = "π"
static let sB = "π"
static let sC = "π"
static let sD = "π"
static let sE = "π"
static let sF = "π³"
}
extension Emoji {
static let sv: [String] = [s0, s1, s2, s3, s4, s5, s6, s7, s8, s9, sA, sB, sC, sD, sE, sF]
}
extension Emoji {
static var random: String {
get {
let x: Int = Int.random (in: 0..<sv.count)
return sv [x]
}
}
static func at (index: Int) -> String {
return sv [index % sv.count]
}
}
@main
struct PlaygroundApp: App {
@State private var model = ListStateViewModel()
var body: some Scene {
WindowGroup {
ContentView (viewModel: model)
}
}
}
but not here:
@main
struct PlaygroundApp: App {
var body: some Scene {
WindowGroup {
ContentView (viewModel: ListStateViewModel() )
}
}
}
Thanks for your reply! I also tried the struct and it has no effect. The underlying issue here is really where the @Observable is created. As you mentioned ensuring that this instance only lives within the view is the best solution but that would then mean that the View is responsible of creating the ViewModel in my case and usually the VM has more dependencies that only the VM should know about (e.g. a service or repository).
While this makes sense I was wondering why the PlaygroundApp.body is being re-evaluated purely by observing / accessing the state observable. In my view the issue would make sense if I would update the items in the init and that this would cause the re-creation of the View. But that reading the value is causing an update is strange to me.
Because when I think about a Child and Parent view, where the Parent owns the state (and Obsevable) and the child just reads it and draws the UI, I do not want to re-evaluate the parent in the process (only if the child actually makes updates to the state that the parent needs to act upon).
But I think in my example the whole issue just comes down to the fact where the Observable instance is created. It should not be part of the View body but live outside of the View lifecylce.