Swift and SwiftUI tutorials for iOS and Swift Developers - SWIFTPROGRAMMING.COM

How to Close a View in SwiftUI

As an iOS Developer, managing the navigation flow and presenting interfaces is one of the most daily and essential tasks. With the evolution of the Apple ecosystem, Swift programming has transitioned from imperative frameworks like UIKit or AppKit to a fully declarative model driven by SwiftUI.

In the early versions of the framework, dismissing or closing a view in SwiftUI could be somewhat confusing due to changing environment properties and different approaches depending on the operating system version. Fortunately, in the current versions of Swift, Apple has unified the APIs to offer a clean, expressive, and cross-platform mechanism that works consistently in Xcode for iOS, macOS, and watchOS.

In this step-by-step tutorial, we will explore all the fundamental techniques to manage view dismissal, from using @Environment(\.dismiss) to programmatic history manipulation within a NavigationStack.


1. The Modern Method: Using @Environment(\.dismiss)

Starting from iOS 15, macOS 12, and watchOS 8, Apple introduced a dedicated environment variable to dismiss the currently presenting view. This is the standard and recommended approach for most use cases.

How does @Environment(\.dismiss) work?

When a view is presented modally (via a .sheet, .fullScreenCover, or a popover in macOS), SwiftUI automatically injects an environment value called dismiss. By invoking this value as if it were a function, the framework handles reversing the presentation of the view.

Let’s see a practical example in Swift code:

import SwiftUI

struct ModalDetailView: View {
    // We read the dismiss action from the environment
    @Environment(\.dismiss) private var dismiss

    var body: some View {
        VStack(spacing: 20) {
            Text("Detail View")
                .font(.title)
            
            Text("This view was presented modally.")
                .font(.subheadline)
                .foregroundColor(.secondary)

            Button(action: {
                // We invoke dismiss to close the view
                dismiss()
            }) {
                Label("Close View", systemImage: "xmark.circle.fill")
                    .padding()
                    .background(Color.red.opacity(0.1))
                    .foregroundColor(.red)
                    .cornerRadius(10)
            }
        }
        .padding()
    }
}

And to present this view from the main screen:

struct MainContentView: View {
    @State private var showModal = false

    var body: some View {
        VStack {
            Button("Show Detail") {
                showModal = true
            }
        }
        .sheet(isPresented: $showModal) {
            ModalDetailView()
        }
    }
}

The difference with the old presentationMode

It is common to find older tutorials where @Environment(\.presentationMode) is used. Although it still works for backward compatibility reasons, it is considered an obsolete API. The new dismiss property is more direct, thread-safe, and requires less verbose code to dismiss a view in SwiftUI.


2. Dismissal Based on Bindings (@Binding)

Although @Environment(\.dismiss) is ideal when the child view controls its own closure, there are scenarios where the parent view needs to maintain explicit control of the state or react immediately to changes.

In these cases, passing a @Binding property from the parent to the child remains a highly robust pattern in Swift programming.

Implementation with @Binding

struct CustomSheetView: View {
    // Binding linked to the parent's @State property
    @Binding var isPresented: Bool

    var body: some View {
        VStack(spacing: 16) {
            Text("Configuration Settings")
                .font(.headline)

            Button("Save and Exit") {
                // Modifying the binding closes the view automatically
                isPresented = false
            }
            .buttonStyle(.borderedProminent)
        }
        .padding()
    }
}

Advantages of this approach

  • Bidirectional flow control: The parent knows exactly when the view is shown or hidden.
  • Custom logic on dismiss: You can intercept the modification of isPresented before changing its value to false, for example, to execute data validations before dismissing a view in SwiftUI.

3. Hierarchical Navigation: NavigationStack and NavigationPath

Not all views are presented modally. In many applications developed in Xcode, screens are stacked using a standard navigation flow (Push/Pop).

Since the arrival of iOS 16 and macOS 13, managing this type of navigation in SwiftUI is done through NavigationStack and NavigationPath.

Dismissing views in a navigation stack

If you have advanced several levels within a NavigationStack, there are two ways to return:

  1. Individual Pop (return to the previous screen): You can still use @Environment(\.dismiss). Within a NavigationStack, invoking dismiss() is equivalent to pressing the “Back” button on the navigation bar.
  2. Pop to Root (return to the main screen): For this, we directly manipulate the NavigationPath.
import SwiftUI

struct NavigationExampleView: View {
    // We create the navigation path in the root view
    @State private var path = NavigationPath()

    var body: some View {
        NavigationStack(path: $path) {
            VStack {
                Button("Go to Level 1") {
                    path.append("Level 1")
                }
            }
            .navigationTitle("Home")
            .navigationDestination(for: String.self) { value in
                DetailStepView(stepName: value, path: $path)
            }
        }
    }
}

struct DetailStepView: View {
    let stepName: String
    @Binding var path: NavigationPath
    @Environment(\.dismiss) private var dismiss

    var body: some View {
        VStack(spacing: 20) {
            Text("You are at: \(stepName)")

            Button("Advance to Next Level") {
                path.append("Next Step")
            }

            Button("Go Back One Step") {
                // Individual Pop
                dismiss()
            }

            Button("Return to Home (Pop to Root)") {
                // We clear the entire path to return to the root
                path = NavigationPath()
            }
            .foregroundColor(.red)
        }
        .navigationTitle(stepName)
    }
}

4. Cross-Platform Adaptability in Xcode: iOS, macOS, and watchOS

One of the great strengths of the Apple ecosystem is the ability to reuse the code written in Swift across different platforms. However, the user experience changes significantly between a phone, a computer, and a smartwatch.

Behavior in iOS and iPadOS

On touch platforms, modals are usually fractionated sheets (sheet) or full screens (fullScreenCover). In addition to programmatic dismissal, the user can swipe down to discard the view.

If you want to disable the swipe gesture to force the user to interact with a specific button (for example, when completing a mandatory form), you can use the .interactiveDismissDisabled() modifier:

.sheet(isPresented: $showForm) {
    FormView()
        .interactiveDismissDisabled(hasUnsavedChanges)
}

Behavior in macOS

In macOS, views presented as a modal or floating sheet are rendered as attached windows or floating dialogs. By invoking dismiss(), SwiftUI gently closes the panel or secondary window without breaking the desktop application’s lifecycle.

Behavior in watchOS

In watchOS, screen space is extremely limited. Modal views take up the entirety of the Apple Watch screen. Using @Environment(\.dismiss) or the swipe-from-left-to-right gesture will discard the current view and return the user to the previous interface.


5. Best Practices for the iOS Developer

To maintain clean, maintainable, and scalable code in large-scale projects within Xcode, keep these architectural guidelines in mind:

  1. Avoid excessive coupling: Do not pass the entire ViewModel object to secondary views just to control their closure. Use @Environment(\.dismiss) directly in the view or expose a simple boolean binding.
  2. Separate business logic from UI: If you must validate whether a user can or cannot close a screen (for example, checking if there are unsaved changes), manage the logical condition in your ViewModel and bind the result to the .interactiveDismissDisabled() modifier.
  3. Check the execution state: In unit or UI tests, ensure that your views respond correctly when presentation states change.

Conclusion

Knowing how to dismiss a view in SwiftUI cleanly is a fundamental skill for every iOS Developer. Thanks to the modern APIs offered by Swift, such as the dismiss environment variable and reactive management with NavigationStack, creating fluid and cross-platform navigation flows in Xcode is easier and more intuitive today than ever.

Leave a Reply

Your email address will not be published. Required fields are marked *

Previous Article

Swift vs SwiftUI

Related Posts