0

(You can skip this part and just look at the code.) I'm creating a complicated form. The form creates, say, a Post object, but I want to be able to create several Comment objects at the same time. So I have a Post form and a Comment form. In my Post form, I can fill out the title, description, etc., and I can add several Comment forms as I create more comments. Each form has an @ObservedObject viewModel of its own type. So I have one parent Post @ObservedObject viewModel, and another @ObservedObject viewModel for the array of the Comment objects which is also a @ObservedObject viewModel.

I hope that made some sense -- here is code to minimally reproduce the issue (unrelated to Posts/Comments). The objective is to make the count of the "Childish" viewModels at the parent level count up like how they count up for the "Child" view.

import Combine
import SwiftUI

final class ParentScreenViewModel: ObservableObject {
    @Published var childScreenViewModel = ChildScreenViewModel()
}

struct ParentScreen: View {
    @StateObject private var viewModel = ParentScreenViewModel()

    var body: some View {
        Form {
            NavigationLink(destination: ChildScreen(viewModel: viewModel.childScreenViewModel)) {
                Text("ChildishVMs")
                Spacer()
                Text("\(viewModel.childScreenViewModel.myViewModelArray.count)") // FIXME: this count is never updated
            }
        }
    }
}

struct ParentScreen_Previews: PreviewProvider {
    static var previews: some View {
        ParentScreen()
    }
}

// MARK: - ChildScreenViewModel

final class ChildScreenViewModel: ObservableObject {
    @Published var myViewModelArray: [ChildishViewModel] = []
    
    func appendAnObservedObject() {
        objectWillChange.send() // FIXME: does not work
        myViewModelArray.append(ChildishViewModel())
    }
}

struct ChildScreen: View {
    @ObservedObject private var viewModel: ChildScreenViewModel
    
    init(viewModel: ChildScreenViewModel = ChildScreenViewModel()) {
        self.viewModel = viewModel
    }

    var body: some View {
        Button {
            viewModel.appendAnObservedObject()
        } label: {
            Text("Append a ChildishVM (current num: \(viewModel.myViewModelArray.count))")
        }
    }
}

struct ChildScreen_Previews: PreviewProvider {
    static var previews: some View {
        ChildScreen()
    }
}

final class ChildishViewModel: ObservableObject {
    @Published var myProperty = "hey!"
}

ParentView: ParentView does not update count

ChildView: ChildView does update count

I can't run this in previews either -- seems to need to be run in the simulator. There are lots of questions similar to this one but not quite like it (e.g. the common answer of manually subscribing to the child's changes using Combine does not work). Would using @EnvironmentObject help somehow? Thanks!

aspear
  • 78
  • 9

1 Answers1

-1

First get rid of the view model objects, we don't use those in SwiftUI. The View data struct is already the model for the actual views on screen e.g. UILabels, UITables etc. that SwiftUI updates for us. It takes advantage of value semantics to resolve consistency bugs you typically get with objects, see Choosing Between Structures and Classes. SwiftUI structs uses property wrappers like @State to make these super-fast structs have features like objects. If you use actual objects on top of the View structs then you are slowing down SwiftUI and re-introducing the consistency bugs that Swift and SwiftUI were designed to eliminate - which seems to me is exactly the problem you are facing. So it of course is not a good idea to use Combine to resolve consistency issues between objects it'll only make the problem worse.

So with that out of the way, you just need correct some mistakes in your design. Model types should be structs (these can be arrays or nested structs) and have a single model object to manage the life-cycle and side effects of the struct. You can have structs within structs and use bindings to pass them between your Views when you need write access, if you don't then its simply a let and SwiftUI will automatically call body whenever a View is init with a different let from last time.

Here is a basic example:

struct Post: Identifiable {
    let id = UUID()
    var text = ""
}

class Model: ObservableObject {
    @Published var posts: [Post] = []

    // func load
    // func save
    // func delete a post by ID
}

struct ModelController {
    static let shared = ModelController()
    let model = Model()
    //static var preview: ModelController {
    // ...
    //}()
}

@main
struct TestApp: App {

    var body: some Scene {
        WindowGroup {
            ContentView()
                .environmentObject(ModelController.shared.model)
        }
    }
}

struct ContentView: View {
    @EnvironmentObject var model: Model
    var body: some View {
       ForEach($model.posts) { $post in 
            ContentView2(post: post)
       }
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView().environmentObject(ModelController.shared.preview)
    }
}

struct ConventView2: View {
    @Binding var post: Post

    var body: some View {
        TextField("Enter Text", text: $post.text)
    }
}

For a more detail check out Apple's Fruta and Scrumdinger samples.

malhal
  • 26,330
  • 7
  • 115
  • 133
  • Who is "we" ? A lot of people do use ViewModels in SwiftUI and they work quite well. Each person should use the architectural pattern that they are comfortable with and that works for them. – Brett Jun 15 '23 at 02:58
  • I think the people that are using view model objects instead of the view model struct are more comfortable sticking with classes instead of learning the view struct and property wrappers. As new features are added to the view model struct it will become increasingly problematic to try to keep using custom view model objects. – malhal Jun 15 '23 at 07:59
  • 1
    That hasn't been my experience in the past 3 years of using SwiftUI and View Models. – Brett Jun 15 '23 at 08:38