Search code examples
xcodedata-bindingswiftuiwatchosswiftui-navigationlink

EnvironmentObject refresh/hand over problems using NavigationLink


I am currently developing an app for watchOS 6 (independent app) using Swift/SwiftUI in XCode 11.5 on macOS Catalina.

Before a user can use my app, a configuration process is required. As the configuration process consists of several different views which are shown one after each other, I implemented this by using navigation links.

After the configuration process has been finished, the user should click on a button to return to the "main" app (main view). For controlling views which are on the same hierarchical level, my plan was to use an EnvironmentObject (as far as I understood, an EnvironmentObject once injected is handed over to the subviews and subviews can use the EnvironmentObject) in combination with a "controlling view" which controls the display of the views. Therefore, I followed the tutorial: https://blckbirds.com/post/how-to-navigate-between-views-in-swiftui-by-using-an-environmentobject/

This is my code:

ContentView.swift

struct ContentView: View {
    var body: some View {
        ContentViewManager().environmentObject(AppStateControl())        
    }
}

struct ContentViewManager: View {
    @EnvironmentObject var appStateControl: AppStateControl
    
    var body: some View {
        VStack {
            if(appStateControl.callView == "AppConfig") {
                AppConfig()
            }
            if(appStateControl.callView == "AppMain") {
                AppMain()
            }
        }
    }
}

AppStateControl.swift

class AppStateControl: ObservableObject {
    @Published var callView: String = "AppConfig"
}

AppConfig.swift

struct AppConfig: View {
    @EnvironmentObject var appStateControl: AppStateControl
    var body: some View {
        VStack {
            Text("App Config Main")
            NavigationLink(destination: DetailView1().environmentObject(appStateControl)) {
                Text("Show Detail View 1")
            }
        }
    }
}

struct DetailView1: View {
    @EnvironmentObject var appStateControl: AppStateControl
    var body: some View {
        VStack {
            Text("App Config Detail View 1")
            NavigationLink(destination: DetailView2().environmentObject(appStateControl)) {
                Text("Show Detail View 2")
            }
        }
    }
}

struct DetailView2: View {
    @EnvironmentObject var appStateControl: AppStateControl
    var body: some View {
        VStack {
            Text("App Config Detail View 2")
            Button(action: {
                self.appStateControl.callView = "AppMain"
            }) {
             Text("Go to main App")
            }
        }
    }
}

AppMain.swift

struct AppMain: View {
    var body: some View {
        Text("Main App")
    }
}

In a previous version of my code (without the handing over of the EnvironmentObject all the time) I got a runtime error ("Thread 1: Fatal error: No ObservableObject of type AppStateControl found. A View.environmentObject(_:) for AppStateControl may be missing as an ancestor of this view.") caused by line 41 in AppConfig.swift. In the internet, I read that this is probably a bug of NavigationLink (see: https://www.hackingwithswift.com/forums/swiftui/environment-object-not-being-inherited-by-child-sometimes-and-app-crashes/269, https://twitter.com/twostraws/status/1146315336578469888). Thus, the recommendation was to explicitly pass the EnvironmentObject to the destination of the NavigationLink (above implementation). Unfortunately, this also does not work and instead a click on the button "Go to main App" in "DetailView2" leads to the view "DetailView1" instead of "AppMain".

Any ideas how to solve this problem? To me, it seems that a change of the EnvironmentObject in a view called via a navigation link does not refresh the views (correctly).

Thanks in advance.


Solution

  • One of the solutions is to create a variable controlling whether to display a navigation stack.

    class AppStateControl: ObservableObject {
        ...
        @Published var isDetailActive = false // <- add this
    }
    

    Then you can use this variable to control the first NavigationLink by setting isActive parameter. Also you need to add .isDetailLink(false) to all subsequent links.

    First link in stack:

    NavigationLink(destination: DetailView1().environmentObject(appStateControl), isActive: self.$appStateControl.isDetailActive) {
        Text("Show Detail View 1")
    }
    .isDetailLink(false)
    

    All other links:

    NavigationLink(destination: DetailView2().environmentObject(appStateControl)) {
        Text("Show Detail View 2")
    }
    .isDetailLink(false)
    

    Then just set isDetailActive to false to pop all your NavigationLinks and return to the main view:

    Button(action: {
        self.appStateControl.callView = "AppMain"
        self.appStateControl.isDetailActive = false // <- add this
    }) {
        Text("Go to main App")
    }