· Development  · 4 min read

A SwiftUI Navigation Destination Surprise

Early instantiation of views yet to come.

In a recent iOS development project, I encountered a situation where navigation occurs from the main view to a subview upon the tap of a button, and back again after completing the work in the subview. It was critical that the lifetime of the subview was strictly limited to the period of the use case. In other words, the subview must be instantiated after the button tap on the main view, and it must be cleaned up and cleared from memory when dismissing and returning to the main view. The exact reason for this requirement is not the focus of this article, but it stems from an external SDK integration, where some expensive resources must be set up and torn down for this use case.

My goal was to use the latest navigation features in SwiftUI, so I started implementing this with the NavigationStack component and the navigationDestination(isPresented) function. Unfortunately, this turned out to be the wrong choice. After investigating some issues with the SDK integration, I learned that:

The target view is immediately instantiated when rendering the parent view’s body when using navigationDestination(isPresented).

Tracing Instantiation

Let’s dive into how this works. For experimentation, create a class that prints a message to the debug console when it is instantiated:

struct InitPrinter {
    init(text: String) {
        print(text)
    }
}

Next, you can add a view class that creates an instance of InitPrinter upon initialization, which can later be embedded in the body of other views:

struct InitPrinterView: View {  
    private let printer = InitPrinter(text: "Creating InitPrinterView")

    var body: some View {
        Text("InitPrinterView")
    }
}

A Basic Navigation Flow

Now, let’s add these components to a navigation flow with a main view and a subview:

struct MainView: View {
    private let printer = InitPrinter(text: "Creating MainView")
    @State private var isSubviewVisible = false
    var body: some View {
        NavigationStack {
            VStack {
                Text("Main View")
                Button {
                    print("Button tapped")
                    isSubviewVisible.toggle()
                } label: {
                    Text("To subview")
                }
            }
            .navigationDestination(isPresented: $isSubviewVisible) {
                SubView()
            }
        }
    }
}
struct SubView: View {
    private let printer = InitPrinter(text: "Creating SubView")
    
    var body: some View {
        VStack {
            Text("Subview")
            InitPrinterView()
        }
    }
}
  • The main view contains a button that, when tapped, navigates to the subview.
  • The subview creates an InitPrinter property upon construction.
  • The subview also creates an InitPrinter instance when rendering the body via the InitPrinterView.

Log Output

In the debug console, after repeatedly tapping the button and navigating back to the main view, we see the following output:

Creating MainView
Creating SubView
Button tapped
Creating InitPrinterView
Button tapped
Creating InitPrinterView
Button tapped
Creating InitPrinterView

This shows that the SubView is instantiated when rendering the body of the MainView, even before it becomes visible via the isSubviewVisible property. The same instance of the SubView is reused (until SwiftUI decides that it should be removed from memory, which I did not encounter here). The body of this instance is executed each time the user navigates to the subview.

Another NavigationDestination

Now, let’s use a different variant of NavigationDestination, one that relies on a binding to a NavigationPath. This allows navigation to be initiated by adding or removing items from the path, for example when a button is tapped. In this simple case, we add the string subview to the path, but in a real application, you can add dynamic data to pass to the subview. When the navigation destination is evaluated for a given data type, you can decide what kind of subview to show depending on the situation. In this case, I simply check for the item to be equal to subview in order to show a SubView.

struct MainView: View {
    private let printer = InitPrinter(text: "Creating MainView")
    @State private var path = NavigationPath()
    var body: some View {
        NavigationStack(path: $path) {
            VStack {
                Text("Main View")
                Button {
                    print("Button tapped")
                    path.append("subview")
                } label: {
                    Text("To subview")
                }
            }
            .navigationDestination(for: String.self) { name in
                if name == "subview" {
                    SubView()
                }
            }
        }
    }
}

Log Output Revisited

In the debug console, the log shows that the SubView is not instantiated when rendering the MainViews body. Instead, each time the button is tapped, a new instance of SubView is created, and its body is executed:

Creating MainView
Button tapped
Creating SubView
Creating InitPrinterView
Button tapped
Creating SubView
Creating InitPrinterView

Conclusion

The two variants of NavigationDestination we explored expose different behaviors regarding the instantiation of the subview. This distinction may be crucial when the subview manages expensive resources or uses components that should only exist during the view’s appearance. As a side note, a NavigationPath binding cannot be used with the isPresented variant of NavigationDestination.

Of course there’s no absolute right or wrong between the two; the choice depends on your use case.

Xcode Version 16.0
Swift Version 5.10
iOS Version 18.0
Share:

Related Posts

View All Posts »