Home > Blockchain >  Multiple layers in SwiftUI MVVM
Multiple layers in SwiftUI MVVM

Time:08-05

I'm trying to get my head around the final(ish) bits around MVVM in swiftUI. I'm trying very hard not to fall back into the bad habits of callback hell.

I have a View hierarchy 3 layers deep. I need to be able to have a change to a property on the deepest view be reflected on the middle layer, but also invoke a function on the top layer.

I'm missing a step but I'm not sure what. Do I need to use onChange() or is this where I need PreferenceKey? Or is it something simpler I'm overlooking. Here is an outline of my structure:

import SwiftUI

struct AModel {
    var bModels: [BModel]
}

struct AView: View {
    @StateObject var viewModel: ViewModel
    
    var body: some View {
        ForEach(viewModel.aModel.bModels) { bChild in
            BView(viewModel: .init(bModel: bChild))
        }
    }
    
    func methodNeedsToRun(onUpdated bProperty: String) {
        // I need some way of invoking this when bProperty is updated
    }
}

extension AView {
    class ViewModel: ObservableObject {
        let aModel: AModel
        
        init(aModel: AModel) {
            self.aModel = aModel
        }
    }
}

struct BModel: Identifiable {
    var id = UUID()
    var bProperty: String
}

struct BView: View {
    @StateObject var viewModel: ViewModel
    
    var body: some View {
        Text("Current bProperty is \(viewModel.bModel.bProperty)") // I also need this to update
        CView(viewModel: .init(toUpdate: $viewModel.bModel.bProperty))
    }
}

extension BView {
    class ViewModel: ObservableObject {
        var bModel: BModel
        
        init(bModel: BModel) {
            self.bModel = bModel
        }
    }
}

struct CView: View {
    @StateObject var viewModel: ViewModel
    
    var body: some View {
        Button("Update to 'Foo'") {
            viewModel.toUpdate = "Foo"
        }
        
        Button("Update to 'Bar") {
            viewModel.toUpdate = "Bar"
        }
    }
}

extension CView {
    class ViewModel: ObservableObject {
        @Binding var toUpdate: String
        
        init(toUpdate: Binding<String>) {
            self._toUpdate = toUpdate
        }
    }
}

Thanks for any suggestions in advance.

CodePudding user response:

You've overcomplicated things, nested/chained view models require additional code to make sure they properly sync, and that boilerplate code is also fragile with respect to changes in the structure of the view models.

Try to stick as much as possible with the SwiftUI paradigms, it will help you a lot on the long run.

So, thinking bottom-up, starting with the view in the lowest level, ViewC, you only need to specify a binding that the view will update:

struct CView: View {
    @Binding var value: String
    
    var body: some View {
        Button("Update to 'Foo'") {
            value = "Foo"
        }
        
        Button("Update to 'Bar") {
            value = "Bar"
        }
    }
}

This binding will be injected by ViewB, and while were at it, let's get rid of it's view model also:

struct BView: View {
    @Binding var bModel: BModel
    
    var body: some View {
        VStack {
            Text("Current bProperty is \(bModel.bProperty)") // I also need this to update
            CView(value: $bModel.bProperty)
        }
    }
}

What's left is for ViewA to generate the bindings for ViewB, and subscribe to the change notifications of bProperty:

struct AView: View {
    @StateObject var viewModel = ViewModel(aModel: .init(bModels: [.init(bProperty: "Prop")]))
    @State var lastReceivedValue: String?
    
    var body: some View {
        VStack {
            Text("Last value: \(lastReceivedValue ?? "(none)")")
            ForEach($viewModel.aModel.bModels) { bChild in
                BView(bModel: bChild)
                    .onChange(of: bChild.wrappedValue.bProperty, perform: self.methodNeedsToRun)
            }
        }
    }
    
    func methodNeedsToRun(onUpdated bProperty: String) {
        lastReceivedValue = bProperty
    }
}

As others have recommended, try to use only one model for all views, if you need to bring data back to that model. And use the builtin support.

  • Related