Home > Software design >  Computed Property from Child's ViewModel does not update @ObservedObject Parent's ViewMode
Computed Property from Child's ViewModel does not update @ObservedObject Parent's ViewMode

Time:10-31

I have a parent view and a child view, each with their own viewModels. The parent view injects the child view's viewModel.

The parent view does not correctly react to the changes on the child's computed property isFormInvalid (the child view does).

@Published cannot be added to a computed property, and other questions/answers I've seen around that area have not focused on having separate viewModels as this question does. I want separate viewModels to increase testability, since the child view could become quite a complex form.

Here is a file to minimally reproduce the issue:

import SwiftUI

extension ParentView {
    final class ViewModel: ObservableObject {
        @ObservedObject var childViewViewModel: ChildView.ViewModel

        init(childViewViewModel: ChildView.ViewModel = ChildView.ViewModel()) {
            self.childViewViewModel = childViewViewModel
        }
    }
}

struct ParentView: View {
    @ObservedObject private var viewModel: ViewModel

    init(viewModel: ViewModel = ViewModel()) {
        self.viewModel = viewModel
    }

    var body: some View {
        ChildView(viewModel: viewModel.childViewViewModel)
        .navigationBarTitle("Form", displayMode: .inline)
        .toolbar {
            ToolbarItem(placement: .confirmationAction) {
                addButton
            }
        }
    }

    private var addButton: some View {
        Button {
            print("======")
            print(viewModel.childViewViewModel.$name)
        } label: {
            Text("ParentIsValid?")
        }
        .disabled(viewModel.childViewViewModel.isFormInvalid) // FIXME: doesn't work, but the actual fields work in terms of two way updating
    }
}

struct ParentView_Previews: PreviewProvider {
    static var previews: some View {
        let childVm = ChildView.ViewModel()
        let vm = ParentView.ViewModel(childViewViewModel: childVm)

        NavigationView {
            ParentView(viewModel: vm)
        }
    }
}

// MARK: child view

extension ChildView {
    final class ViewModel: ObservableObject {

        // MARK: - public properties

        @Published var name = ""
        
        var isFormInvalid: Bool {
            print("isFormInvalid")
            return name.isEmpty
        }
    }
}

struct ChildView: View {
    @ObservedObject private var viewModel: ViewModel
    
    init(viewModel: ViewModel = ViewModel()) {
        self.viewModel = viewModel
    }
    
    var body: some View {
        Form {
            Section(header: Text("Name")) {
                nameTextField
            }
            Button {} label: {
                Text("ChildIsValid?: \(String(!viewModel.isFormInvalid))")
            }
            .disabled(viewModel.isFormInvalid)
        }
    }
    
    private var nameTextField: some View {
        TextField("Add name", text: $viewModel.name)
            .autocapitalization(.words)
    }
}

struct ChildView_Previews: PreviewProvider {
    static var previews: some View {
        let vm = ChildView.ViewModel()
        ChildView(viewModel: vm).preferredColorScheme(.light)
    }
}

Thank you!

CodePudding user response:

Computed properties do not trigger any updates. It is the changed to @Publised property that triggers an update, when that happens the computed property is reevaluated. This works as expected which you can see in your ChildView. The problem you are facing is that ObservableObjects are not really designed for chaining (updated to child does not trigger update to the parent. You can workaround the fact by republishing an update from the child: you have subscribe to child's objectWillChange and each time it emits manually trigger objectWillChange on the parent.

extension ParentView {
    final class ViewModel: ObservableObject {
        @ObservedObject var childViewViewModel: ChildView.ViewModel
        private var cancellables = Set<AnyCancellable>()

        init(childViewViewModel: ChildView.ViewModel = ChildView.ViewModel()) {
            self.childViewViewModel = childViewViewModel
            childViewViewModel
                .objectWillChange
                .receive(on: RunLoop.main)
                .sink { [weak self] _ in
                    self?.objectWillChange.send()
                }
                .store(in: &cancellables)
        }
    }
}
  • Related