0

I have a NavigationStack with three forms inside it and each form has a couple of screens. How can I share a form's ViewModel only across its screens without having to create an instance globally( inside ContentView). Currently each ViewModel is created whether it's used or not. Here is my code

struct ContentView: View {

    @StateObject private var pathStore = PathStore()

    @StateObject var formOneVM = FormOneViewModel()
    @StateObject var formTwoVM = FormTwoViewModel()

    var body: some View {

        NavigationStack(path: $pathStore.path) {

            HomeView()
            .navigationDestination(FormOneRoutes.self){ route in
                switch route{
                    case .screenOne:
                        FormOneScreenOne()
                    case .screenTwo:
                        FormOneScreenTwo()
            }
            .navigationDestination(FormTwoRoutes.self){ route in
                switch route{
                    case .screenOne:
                        FormTwoScreenOne()
                    case .screenTwo:
                        FormTwoScreenTwo()
        }
    }
    .environmentObject(formOneVM)
    .environmentObject(formTwoVM)
}

I have tried placing each form inside its own NavigationStack but nested NavigationStack doesn't seem to work for me. If you got nested NavigationStack working or have other alternatives please share.

peeta
  • 46
  • 3
  • Do you really need NavigationStack with navigationDestination ? May be you should try to rethink your app. Wanting to absolutely use specific coding pattern is useless if does not fit the need. – Ptit Xav Jun 28 '23 at 19:59
  • Hmm what do you suggest? Creating VM instance on first screen then passing along as `ObservedObject` with `NavigationLink` ? – peeta Jun 28 '23 at 20:09
  • It depends on what you do in HomeView and how you show Form views for VM A . – Ptit Xav Jun 28 '23 at 20:21
  • HomeView is just buttons to navigate to forms. Once each form is complete I return to HomeView . I was using navigationDestination because it was easier to append a route to path based on form state. – peeta Jun 28 '23 at 20:32
  • If I understand, you start creating a VM by tapping a button in HomeView which send you to FormOneVirwOne then push FormOneViewTwo,… when forms is finished go to HomeView and forget Form ? In this case the VM can be declared in as StateObject view one and passed as ObservableObject in subviews. So when back to home views it’s gone. – Ptit Xav Jun 28 '23 at 20:44
  • In SwiftUI the View struct is the view model already, you shouldn't use objects – malhal Jun 28 '23 at 21:33
  • @PtitXav Yeah, only problem is I can't use NavigationLink to programmatically navigate if I'm using NavigationStack as stated [here](https://stackoverflow.com/questions/72586394/programmatically-push-a-navigationlink-in-ios16) or [here](https://stackoverflow.com/questions/75659442/how-do-i-use-navigationlink-in-ios-16) . I have to rely on navigationDestination which means I have to create instance at root level. – peeta Jun 28 '23 at 22:01
  • @malhal I saw your explanation [here](https://github.com/QuickBirdEng/XUI/issues/2) While I'd have to look into it, my question was on how to share form state across my `NavigationStack` – peeta Jun 28 '23 at 22:15
  • navigationDestination supports a binding now – malhal Jun 28 '23 at 22:31
  • @malal I tried that approach but I can't use navigationDestination inside child views because of [this](https://stackoverflow.com/a/74362840). So I have to create my state at root level? – peeta Jun 29 '23 at 19:02

1 Answers1

0

An example with a view model created only when doing into a specific form hierarchy. First level use navigationDestination, then navigationLink and binding :

class PathStore: ObservableObject {
    @Published var path: NavigationPath = NavigationPath()
    func gotoToTop() {
        path.removeLast()
    }
}

class FormOneViewModel: ObservableObject {
    @Published var oneDone = false
    @Published var twoDone = false
}

class FormTwoViewModel: ObservableObject {
    // view one entries
    @Published var entry11 = false
    @Published var entry12 = false
    
    // view two entries
    @Published var entry21 = false
    @Published var entry22 = false
}

enum FormOneRoutes: Hashable {
    case screenOne
    case screenTwo

}

enum FormTwoRoutes: Hashable {
    case screenOne
    case screenTwo
}

struct ContentView: View {
    
    @StateObject private var pathStore = PathStore()
    
    var body: some View {
        
        NavigationStack(path: $pathStore.path) {
            HomeView()
            // here just to start first view of first model
                .navigationDestination(for: FormOneRoutes.self){ route in
                    FormOneScreenOne()
                }
            // here just to start first view of second model
                .navigationDestination(for: FormTwoRoutes.self){ route in
                    FormTwoScreenOne()
                }
        }
        .environmentObject(pathStore)
    }
}


struct HomeView: View {
    var body: some View {
        VStack {
            NavigationLink(value: FormOneRoutes.screenOne) {
                Text("Form one")
            }

            NavigationLink(value: FormTwoRoutes.screenOne) {
                Text("Form Two")
            }

        }
    }
}

struct FormOneScreenOne: View {
    // model only exists while in first form hierachy
    @StateObject var formVM = FormOneViewModel()
    var body: some View {
        VStack {
            Text("FormOneScreenOne \(formVM.oneDone ? "1" : "-") \(formVM.twoDone ? "2" : "-")")
            Toggle("One", isOn: $formVM.oneDone)
            NavigationLink {
                FormOneScreenTwo(formVM: formVM)
            } label: {
                Text("-> Form one screen two")
            }
        }
    }
}

struct FormOneScreenTwo: View {
    @ObservedObject var formVM: FormOneViewModel
    @EnvironmentObject var pathStore: PathStore
    var body: some View {
        VStack {
            Text("FormOneScreenTwo \(formVM.oneDone ? "1" : "-") \(formVM.twoDone ? "2" : "-")")
            Toggle("Two", isOn: $formVM.twoDone)
            Button {
                pathStore.gotoToTop()
            } label: {
                Text("Done")
            }
        }
    }
}

enum YesNo {
    case yes
    case no
    case undefined
}

struct FormTwoScreenOne: View {
    // model only exists while in second form hierachy
    @StateObject var formVM = FormTwoViewModel()
    @State var yesNo1: YesNo = .undefined
    @State var yesNo2: YesNo = .undefined
    var allDefined: Bool {
        yesNo1 != .undefined && yesNo2 != .undefined
    }
    @State var allEntriesDone: Bool = false
    var body: some View {
        VStack {
            Text("FormTwoScreenOne \(yesNo1 == .undefined ? "--" : (formVM.entry11 ? "+1" : "-1")) \(yesNo2 == .undefined ? "--" : ( formVM.entry12 ? "+2" : "-2"))")
            Picker("Choose entry 1", selection: $yesNo1) {
                Text("Yes").tag(YesNo.yes)
                Text("No").tag(YesNo.no)
            }
            Picker("Choose entry 2", selection: $yesNo2) {
                Text("Yes").tag(YesNo.yes)
                Text("No").tag(YesNo.no)
            }
        }
        .onChange(of: yesNo1) { newValue in
            set(value: newValue, for: $formVM.entry11)
        }
        .onChange(of: yesNo2) { newValue in
            set(value: newValue, for: $formVM.entry12)
        }
        .navigationDestination(isPresented: $allEntriesDone) {
            FormTwoScreenTwo(formVM: formVM)
        }
    }
    
    func set(value: YesNo, for bind: Binding<Bool>) {
        switch value {
            case .yes:
                bind.wrappedValue = true
            case .no:
                bind.wrappedValue = false
            case .undefined:
                break
        }
        allEntriesDone = allDefined
    }
}

struct FormTwoScreenTwo: View {
    @EnvironmentObject var pathStore: PathStore
    @ObservedObject var formVM: FormTwoViewModel
    @State var yesNo1: YesNo = .undefined
    @State var yesNo2: YesNo = .undefined
    var allDefined: Bool {
        yesNo1 != .undefined && yesNo2 != .undefined
    }
    var body: some View {
        VStack {
            Text("FormTwoScreenTwo \(yesNo1 == .undefined ? "--" : (formVM.entry21 ? "+1" : "-1")) \(yesNo2 == .undefined ? "--" : ( formVM.entry22 ? "+2" : "-2"))")
            Picker("Choose entry 1", selection: $yesNo1) {
                Text("Yes").tag(YesNo.yes)
                Text("No").tag(YesNo.no)
            }
            Picker("Choose entry 2", selection: $yesNo2) {
                Text("Yes").tag(YesNo.yes)
                Text("No").tag(YesNo.no)
            }
            if allDefined {
                Text("Everything entered")
            }
            Button {
                pathStore.gotoToTop()
            } label: {
                Text("Exit")
            }
        }
        .onChange(of: yesNo1) { newValue in
            set(value: newValue, for: $formVM.entry21)
        }
        .onChange(of: yesNo2) { newValue in
            set(value: newValue, for: $formVM.entry22)
        }
    }
    
    func set(value: YesNo, for bind: Binding<Bool>) {
        switch value {
            case .yes:
                bind.wrappedValue = true
            case .no:
                bind.wrappedValue = false
            case .undefined:
                break
        }
    }
}
Ptit Xav
  • 3,006
  • 2
  • 6
  • 15
  • Correct me if I'm wrong but doesn't this mean the `NavigationLink` is always active whether oneDone is true or false ? So user can navigate to screen two without filling screen one – peeta Jun 29 '23 at 18:48
  • I just made a simple test to explain how to have only one active VM. I do not know what you want to do in your app and the logic you want. You can set the navigation link inside a test which will be only true when a screen is complete. – Ptit Xav Jun 29 '23 at 20:35
  • Yeah I understand, thanks. Could you explain what you mean by ‘set the navigationlink inside a test’ If it was the old navigationlink api I could just set its isActive property to match my form but that’s deprecated now. – peeta Jun 30 '23 at 10:06
  • @peeta: Made a little change in second form to handle conditional navigation and conditional display of text. – Ptit Xav Jun 30 '23 at 15:10
  • Hey Ptit, when I tried this approach I am getting error saying I can't use navigationDestination inside child views ( more info [here](https://stackoverflow.com/a/74362840) ) – peeta Jul 03 '23 at 11:49