0

I want to implement a wizard whereby the user has to go through multiple screens in order to complete a signup process.

In SwiftUI the easiest way to do this is to have each view when it's finished push the next view on the navigation stack, but this codes the entire navigation between views in the views themselves, and I would like to avoid it.

What I want to do is have a parent view show the navigation view and then push the different steps on that navigation view.

I have something working already that looks like this:

struct AddVehicleView: View {
    @ObservedObject var viewModel: AddVehicleViewModel

    var body: some View {
        NavigationView {
            switch viewModel.state {
            case .description:
                AddDescriptionView(addDescriptionViewModel: AddVehicleDescriptionViewModel(), addVehicleViewModel: viewModel)
            case .users:
                AddUsersView(viewModel: AddUsersViewModel(viewModel.vehicle), addVehicleViewModel: viewModel)
            }
        }
    }
}

This works fine. In the first step the AddVehicleViewModel is updated with the necessary info, the AddVehicleView is re-evaluated, the switch case jumps to the next option and the next view is presented to complete the wizard.

The issue with this however is that there are no navigation stack animations. Views simply get replaced. How can I change this to a system whereby the views are pushed, without implementing the push inside the AddDescriptionView object?

Should I write wrapper views that do the navigation stack handling on top of those views, and get rid of the switch case?

Joris Mans
  • 6,024
  • 6
  • 42
  • 69
  • Not sure I understood you, but probably you just need NavigationLink with tag/selection constructor. – Asperi Jan 27 '21 at 10:10
  • I want to do the switch case as above, but it should push the views instead of replacing them – Joris Mans Jan 27 '21 at 10:22
  • Does this answer your question https://stackoverflow.com/a/59692268/12299030? – Asperi Jan 27 '21 at 10:47
  • No, because that requires all views to be instantiated at once. I can't instantiate the 2nd view if the first one hasn't finished generating some data to pass to it. Using a VStack and including all views will run the constructors. – Joris Mans Jan 27 '21 at 12:02
  • @JorisMans have you found the solution to your problem? I stuck trying to figure out the same in my app. – Hariprasad Jun 15 '21 at 21:18
  • 1
    @Hariprasad My conclusion is that SwiftUI just sucks for handling navigation. I use it as a layout tool. I wrote a generic UIViewController subclass that uses a UIHostingController to show the SwiftUI view, subclass that one for each View I use. Each View defines its own navigation protocol. The subclass implements that protocol, the subclass is weakly referenced by my ViewModel and I delegate all navigation tasks through my ViewModel to that UIViewController. So the view layout and controls are handled by SwiftUI, but presenting/pushing/popping is done through good old reliable UIKit. – Joris Mans Jun 16 '21 at 12:53
  • @JorisMans - "I can't instantiate the 2nd view if the first one hasn't finished generating some data to pass to it" - this should not be a blocker to you, as when the view model changes, the views are re-created. View creation in SwiftUI is cheap (if your view adheres to the SwiftUI principles). – Cristik Jul 10 '21 at 10:54

1 Answers1

0

Ok so if you want to go from view a to b you should implement this not in your NavigationView but the view after the NavigationView, this way you wont break the animations. Why? Good question, I really don't know. When possible I keep my NavigationView always in the App struct under WindowGroup.

To get back to the point. Basically there should be an intermediate view between your steps and NavigationView. This view (StepperView) will contain the navigation logic of your steps. This way you keep the animations intact.

import SwiftUI

class AddVehicleViewModel: ObservableObject {
    
    enum StateType {
        case description
        case users1
        case users2
    }
    
    
    @Published var state: StateType? = nil
    
}

struct AddDescriptionView: View {

    @ObservedObject var viewModel: AddVehicleViewModel
    
    @State var text: String = ""
    var body: some View {
        GeometryReader {proxy in
            VStack {
                TextField("test", text: self.$text).background(RoundedRectangle(cornerRadius: 10).fill(Color.white).frame(width: 150, height: 40)).padding()
                Button("1") {
                    viewModel.state = .users1
                }
            }.frame(width: proxy.size.width, height: proxy.size.height, alignment: .center).background(Color.orange)
        }
    }
}

struct AddUsersView: View {

    @ObservedObject var viewModel: AddVehicleViewModel
    
    
    var body: some View {
        GeometryReader {proxy in
            ZStack {
                Button("2") {
                    viewModel.state = .users2
                }
            }.frame(width: proxy.size.width, height: proxy.size.height, alignment: /*@START_MENU_TOKEN@*/.center/*@END_MENU_TOKEN@*/).background(Color.orange)
        }
    }
}

struct AddUsersView2: View {

    @ObservedObject var viewModel: AddVehicleViewModel
    
    
    var body: some View {
        GeometryReader {proxy in
            ZStack {
                Button("3") {
                    viewModel.state = .description
                }
            }.frame(width: proxy.size.width, height: proxy.size.height, alignment: /*@START_MENU_TOKEN@*/.center/*@END_MENU_TOKEN@*/).background(Color.orange)
        }
    }
}

struct StepperView: View {
    
    @ObservedObject var viewModel: AddVehicleViewModel = AddVehicleViewModel()
    
    var body: some View {
        VStack {
            NavigationLink(
                destination: AddDescriptionView(viewModel: viewModel),
                isActive: .constant(viewModel.state == .description),
                label: {EmptyView()})
            if viewModel.state == .users1 {
                NavigationLink(
                    destination: AddUsersView(viewModel: viewModel),
                    isActive: .constant(true),
                    label: {EmptyView()})
            }
            if viewModel.state == .users2 {
                NavigationLink(
                    destination: AddUsersView2(viewModel: viewModel),
                    isActive: .constant(true),
                    label: {EmptyView()})
            }
        }.onAppear {
            viewModel.state = .description
        }
    }
}

class BackBarButtonItem: UIBarButtonItem {
    @available(iOS 14.0, *)
    override var menu: UIMenu? {
        set {
            // Don't set the menu here
            // super.menu = menu
        }
        get {
            return super.menu
        }
    }
}

struct AddVehicleView: View {
    @ObservedObject var viewModel: AddVehicleViewModel = AddVehicleViewModel()
    
    var body: some View {
        NavigationView {
            NavigationLink(
                destination: StepperView(),
                isActive: .constant(true),
                label: {EmptyView()})
        }
    }
}
ARR
  • 2,074
  • 1
  • 19
  • 28
  • This solution has the same problem. Since you are using a VStack all those AddUserView/AddDescriptionView structs are instantiated at the same time, and this is something that should be avoided as the 2nd and 3rd views can't be built unless the first one has completed. – Joris Mans Jan 29 '21 at 07:23
  • Well you can add the if statements in the vstack, i've updated the code. But I don't think it matters that they are instantiated at the same time. Because changes in the observable viewmodel in view a should also be visible in view b – ARR Jan 29 '21 at 08:40