SwiftUI - IOS 16 - How to use new NavigationStack and NavigationPath for programatic navigation in MVVM architecture?
Asked Answered
I

2

10

Description

For programatic navigation you could previously use NavigationLink(isActive:, destination:, label:) which would fire navigation when the isActive param is true. In IOS 16 this became deprecated and NavigationStack, NavigationLink(value:, label:) and NavigationPath was introduced.

To read about the usage of these follow the links:

https://developer.apple.com/documentation/swiftui/migrating-to-new-navigation-types https://www.hackingwithswift.com/articles/250/whats-new-in-swiftui-for-ios-16 (search for NavigationStack)

My question is how should I use and maintain the array with the content of the navigation stack (like the NavigationPath object) if I'd like to use it in different Views and in their ViewModels?

As you can see in the code below I created a NavigationPath object to hold my navigation stack in the BaseView or BaseView.ViewModel. This way I can do programatic navigation from this BaseView to other pages (Page1, Page2), which is great.

But if I go to Page1 and try to navigate from there to Page2 programatically I need to have access to the original NavigationPath object object, the one that I use in BaseView.

What would be the best way to access this original object?

It is possible that I misunderstand the usage of this new feature but if you have any possible solutions for programatic navigation from a ViewModel I would be glad to see it :)

Code

What I've tried to do:

struct BaseView: View {
    @StateObject var viewModel = ViewModel()
    
    var body: some View {
        NavigationStack(path: $viewModel.paths) {
            VStack {
                Button("Page 1", action: viewModel.goToPage1)
                Button("Page 2", action: viewModel.goToPage2)
            }
            .navigationDestination(for: String.self) { stringParam in
                Page1(stringParam: stringParam)
            }
            .navigationDestination(for: Int.self) { intParam in
                Page2(intParam: intParam)
            }
            
        }
    }
}

extension BaseView {
    @MainActor class ViewModel: ObservableObject {
        @Published var paths = NavigationPath()
        
        func goToPage1() {
            let param = "Some random string" // gets the parameter from some calculation or async network call
            
            paths.append(param)
        }
        
        func goToPage2() {
            let param = 19 // gets the parameter from some calculation or async network call
            
            paths.append(param)
        }
    }
}

struct Page1: View {
    @StateObject var viewModel = ViewModel()
    let stringParam: String
    
    var body: some View {
        VStack {
            Button("Page 2", action: viewModel.goToPage2)
        }
    }
}

extension Page1 {
    @MainActor class ViewModel: ObservableObject {
        func goToPage2() {
            // Need to add value to the original paths variable in BaseView.ViewModel
        }
    }
}

struct Page2: View {
    @StateObject var viewModel = ViewModel()
    let intParam: Int
    
    var body: some View {
        Text("\(intParam)")
    }
}

extension Page2 {
    @MainActor class ViewModel: ObservableObject {
    }
}
Indifferentism answered 31/10, 2022 at 20:7 Comment(3)
All your views need to share the same view model. In the example you posted, each view has its own view model, so they can't share the variable paths. You shall have only one ViewModel class for all your code, and share the same instance with all views.Biophysics
And the object is usually called Store or ModelData and it's an environment object so doesn't need to be passed into View initsFoundation
I actually ended up using a Singleton object as the navigation which holds the NavigationPaths variable.Cappadocia
D
4

The official migration guide provides a lot of helpful information.

The modifier navigationDestination(for:destination:) enables custom handling of specific data types.

You can "push" chosen data types onto the NavigationPath, then the relevant navigationDestination block will handle it.

I've created a few helper functions to simplify the new Navigation system.

I store these in a custom AppContext class which you'll see mention of below (appContext), but of course place & refer to them wherever's best for your own codebase.

    /// The current navigation stack.
    @Published public var navStack = NavigationPath()
    
    /// Type-erased keyed data stored for a given view.
    var navData = Dictionary<String, Any>()
    
    /// Set to `true` the given "show view" bound Bool (i.e. show that view).
    /// Optionally, provide data to pass to that view.
    public func navigateTo(_ showViewFlag: Binding<Bool>,
                      _ navData: Dictionary<String, Any>? = nil) {
        if let navData { self.navData = navData }
        showViewFlag.wrappedValue = true
    }
    
    /// Pop & retrieve navigation data for the given key.
    /// (Generics undo the type-erasure produced by `navigateTo`)
    public func popNavData<T>(_ key: String) -> T {
        navData.removeValue(forKey: key)! as! T
    }

This destination modifier is a tidier version of the official navigationDestination modifier:

@ViewBuilder
func destination(`for` show: Binding<Bool>,
                 _ destination: @escaping () -> some View ) -> some View {
    self.navigationDestination(isPresented: show) { DeferView(destination) }
}

The DeferView it uses is defined as:

import SwiftUI

public struct DeferView<Content: View>: View {
    let content: () -> Content
    public init(@ViewBuilder _ content: @escaping () -> Content) { self.content = content }
    public var body: some View { content() }
}

So now you can do this:

// Use the standard bound-bool "show view" var format.
@State var showMyView: Bool

// Code to load the view, e.g. in a Button `action`.
navigateTo($showMyView, ["param1": myData1, "param2", myData2])

// Modifier to handle the view load, e.g. on outermost View inside `body`.
.destination(for: $showMyView) {
    MyView(param1: appContext.popNavData("param1"),
           param2: appContext.popNavData("param2"),
           extraParam: $someOtherSource.info)
}
Deoxygenate answered 23/2, 2023 at 23:15 Comment(0)
F
3

There is no need for MVVM in SwiftUI because the View struct plus property wrappers is already equivalent to a view model object but faster and less error prone. Also in SwiftUI we don't even have access to the traditional view layer - it takes our View data structs, diffs them to create/update/remove UIView/NSView objects, using the best ones for the platform/context. If you use an object for view data instead, then you'll just have the same consistency problems that SwiftUI was designed to eliminate.

Sadly the web (and Harvard University) is filled with MVVM SwiftUI articles by people that didn't bother to learn it properly. Fortunately things are changing:

I was wrong! MVVM is NOT a good choice for building SwiftUI applications (Azam Sharp)

How MVVM devs get MVVM wrong in SwiftUI: From view model to state (Jim Lai)

Stop using MVVM for SwiftUI (Apple Developer Forums)

Foundation answered 2/11, 2022 at 17:53 Comment(7)
Very interesting. Could you point me to online sources that explain this (why you should not do MVVM with SwiftUI and how SwiftUI avoided certain problems) in more detail?Lobel
Well Swift introduced value semantics to iOS development and SwiftUI exploits it in its design. I recommend watching all the SwiftUI WWDC videos, e.g. in the following video at 4:18 he says "EditorConfig can maintain invariants on its properties and be tested independently. And because EditorConfig is a value type, any change to a property of EditorConfig, like its progress, is visible as a change to EditorConfig itself." developer.apple.com/videos/play/wwdc2020-10040 Also the second half states that ObservableObject is only used for model data not view data like MVVM is for.Foundation
Added some links for you to check outFoundation
Thanks Malcolm, I'll read these. I've come across the following more chaotic story: developer.apple.com/forums/thread/699003Lobel
This is a very misleading for new developers. The whole purpose of MVVM/MVC is encapsulation. If you're building your first todo list, sure who needs all that boilerplate code. But if you're building an application at scale, you need to be able to easily reason about the presentation versus the behavior of your application.Rriocard
presentation is automatically generated and fully encapsulated in a private layer of the framework, it's worth learning how it works before going down the path of applying a different architecture on top which can lead to inefficiencies and unsolvable problemsFoundation
The 2nd link to Jim Lai's article is not working, I wasn't able to find it via search eitherImpropriety

© 2022 - 2024 — McMap. All rights reserved.