· 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.
NavigationDestination
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 theInitPrinterView
.
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 MainView
s 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.