How to keep SwiftUI from creating additional StateObjects in this custom page view?

Viewed 29

Abstract

I'm creating an app that allows for content creation and display. The UX I yearn for requires the content creation view to use programmatic navigation. I aim at architecture with a main view model and an additional one for the content creation view. The problem is, the content creation view model does not work as I expected in this specific example.


Code structure

Please note that this is a minimal reproducible example.

Suppose there is a ContentView: View with a nested AddContentPresenterView: View. The nested view consists of two phases:

  • specifying object's name
  • summary screen

To allow for programmatic navigation with NavigationStack (new in iOS 16), each phase has an associated value.

Assume that AddContentPresenterView requires the view model. No workarounds with @State will do - I desire to learn how to handle ObservableObject in this case.

Code

ContentView

struct ContentView: View {
    @EnvironmentObject var model: ContentViewViewModel
    var body: some View {
        VStack {
            NavigationStack(path: $model.path) {
                List(model.content) { element in
                    Text(element.name)
                }
                .navigationDestination(for: Content.self) { element in
                    ContentDetailView(content: element)
                }
                .navigationDestination(for: Page.self) { page in
                    AddContentPresenterView(page: page)
                }
            }
            Button {
                model.navigateToNextPartOfContentCreation()
            } label: {
                Label("Add content", systemImage: "plus")
            }

        }
    }
}

ContentDetailView (irrelevant)

struct ContentDetailView: View {
    let content: Content
    var body: some View {
        Text(content.name)
    }
}

AddContentPresenterView

As navigationDestination associates a destination view with a presented data type for use within a navigation stack, I found no better way of adding a paged view to be navigated using the NavigationStack than this.

extension AddContentPresenterView {
    var contentName: some View {
        TextField("Name your content", text: $addContentViewModel.contentName)
            .onSubmit {
                model.navigateToNextPartOfContentCreation()
            }
    }
    var contentSummary: some View {
        VStack {
            Text(addContentViewModel.contentName)
            Button {
                model.addContent(addContentViewModel.createContent())
                model.navigateToRoot()
            } label: {
                Label("Add this content", systemImage: "checkmark.circle")
            }
        }
    }
}

ContentViewViewModel

Controls the navigation and adding content.

class ContentViewViewModel: ObservableObject {
    @Published var path = NavigationPath()
    @Published var content: [Content] = []
    
    func navigateToNextPartOfContentCreation() {
        switch path.count {
        case 0:
            path.append(Page.contentName)
        case 1:
            path.append(Page.contentSummary)
        default:
            fatalError("Navigation error.")
        }
    }
    
    func navigateToRoot() {
        path.removeLast(path.count)
    }
    
    func addContent(_ content: Content) {
        self.content.append(content)
    }
}

AddContentViewModel

Manages content creation.

class AddContentViewModel: ObservableObject {
    @Published var contentName = ""
    
    func createContent() -> Content {
        return Content(name: contentName)
    }
}

Page

Enum containing creation screen pages.

enum Page: Hashable {
    case contentName, contentSummary
}

What is wrong

Currently, for each page pushed onto the navigation stack, a new StateObject is created. That makes the creation of object impossible, since the addContentViewModel.contentName holds value only for the bound screen.

I thought that, since StateObject is tied to the view's lifecycle, it's tied to AddContentPresenterView and, therefore, I would be able to share it.

What I've tried

The error is resolved when addContentViewModel in AddContentPresenterView is an EnvironmentObject initialized in App itself. Then, however, it's tied to the App's lifecycle and subsequent content creations greet us with stale data - as it should be.

Wraping up

How to keep SwiftUI from creating additional StateObjects in this custom page view?

Should I resort to ObservedObject and try some wizardry? Should I just implement a reset method for my AddContentViewModel and reset the data on entering or quiting the screen?

Or maybe there is a better way of achieving what I've summarized in abstract?

1 Answers

If you declare @StateObject var addContentViewModel = AddContentViewModel() in your AddContentPresenterView it will always initialise new AddContentViewModel object when you add AddContentPresenterView in navigation stack. Now looking at your code and app flow I don't fill you need AddContentViewModel.


First, update your contentSummary of the Page enum with an associated value like this.

enum Page {
    case contentName, contentSummary(String)
}

Now update your navigate to the next page method of your ContentViewModel like below.

func navigateToNextPage(_ page: Page) {
    path.append(page)
}

Now for ContentView, I think you need to add VStack inside NavigationStack otherwise that bottom plus button will always be visible.

ContentView

struct ContentView: View {
    @EnvironmentObject var model: ContentViewViewModel
    
    var body: some View {
        NavigationStack(path: $model.path) {
            VStack {
                List(model.content) { element in
                    Text(element.name)
                }
                .navigationDestination(for: Content.self) { element in
                    ContentDetailView(content: element)
                }
                .navigationDestination(for: Page.self) { page in
                    switch page {
                    case .contentName: AddContentView()
                    case .contentSummary(let name): ContentSummaryView(contentName: name)
                    }
                }
                Button {
                    model.navigateToNextPage(.contentName)
                } label: {
                    Label("Add content", systemImage: "plus")
                }
            }
            
        }
    }
}

So now it will push destination view on basis of the type of the Page. So you can remove your AddContentPresenterView and add AddContentView and ContentSummaryView.

AddContentView

struct AddContentView: View {
    @EnvironmentObject var model: ContentViewViewModel
    @State private var contentName = ""
    
    var body: some View {
        TextField("Name your content", text: $contentName)
            .onSubmit {
                model.navigateToNextPage(.contentSummary(contentName))
            }
    }
}

ContentSummaryView

struct ContentSummaryView: View {
    @EnvironmentObject var model: ContentViewViewModel
    let contentName: String
    
    var body: some View {
        VStack {
            Text(contentName)
            Button {
                model.addContent(Content(name: contentName))
                model.navigateToRoot()
            } label: {
                Label("Add this content", systemImage: "checkmark.circle")
            }
        }
    }
}

So as you can see I have used @State property in AddContentView to bind it with TextField and on submit I'm passing it as an associated value with contentSummary. So this will reduce the use of AddContentViewModel. So now there is no need to reset anything or you want face any issue of data loss when you push to ContentSummaryView.

Related