As an iOS Developer, you know that modal sheets are one of the most used user interface components across the Apple ecosystem. They are perfect for presenting creation flows, settings, secondary details, or login forms. However, while the swipe-to-dismiss gesture is intuitive on modern iPhones, relying solely on it can be a serious usability mistake.
In this comprehensive tutorial, we are going to explore in depth how to add a close button to a sheet in SwiftUI. Through Swift programming, we will learn not only to implement the basic functionality, but to create robust, accessible, and cross-platform components that work seamlessly on iOS, macOS, and watchOS using Xcode. Get ready to master modal navigation in SwiftUI.
1. Why is it crucial to add a close button to a sheet in SwiftUI?
Before opening Xcode and starting to type Swift code, it is important to understand the UX design justification behind this practice. Why bother to add a close button to a sheet in SwiftUI if Apple already provides a native gesture to close it?
- Universal accessibility: Many users have motor difficulties that prevent them from performing precise swipe gestures. An explicit close button ensures everyone can navigate your app.
- The macOS paradigm: If you are building a cross-platform app, you must know that on macOS users cannot “swipe” a window away with a mouse. A “Close” or “Cancel” button is mandatory.
- Preventing accidental closures: In forms where the user inputs data, we often disable interactive dismissal (using
.interactiveDismissDisabled()) to prevent data loss. In these cases, a close button is the only escape route. - Visual clarity: A button in the top right (or left) corner is a universally recognized pattern that reduces the user’s cognitive load.
2. The Evolution: From presentationMode to dismiss
If you have been in Swift programming for a while, you might remember that in the early versions of SwiftUI (iOS 13 and 14), to close a view programmatically we used @Environment(\.presentationMode). While it still works, Apple considers it outdated for this specific task.
From iOS 15 onwards, the modern, clean, and recommended way to close any modal view is by using the dismiss environment value. It is much more direct and requires less code.
Let’s see how to configure the main view that will invoke our sheet:
import SwiftUI
struct ContentView: View {
// 1. State that controls the sheet's visibility
@State private var showSettingsSheet = false
var body: some View {
VStack {
Image(systemName: "gearshape.fill")
.imageScale(.large)
.foregroundColor(.blue)
.padding()
Text("Control Panel")
.font(.title)
.padding(.bottom, 20)
Button(action: {
showSettingsSheet = true
}) {
Text("Open Settings")
.font(.headline)
.foregroundColor(.white)
.padding()
.frame(maxWidth: .infinity)
.background(Color.blue)
.cornerRadius(10)
}
.padding(.horizontal)
}
// 2. Sheet Presentation
.sheet(isPresented: $showSettingsSheet) {
SettingsSheetView()
}
}
}
3. Standard Implementation: Using NavigationView and Toolbar
The most “native” way, and the one most respectful of Apple’s Human Interface Guidelines (HIG), to add a close button to a sheet in SwiftUI is to wrap your modal’s content in a NavigationView (or NavigationStack in iOS 16+) and place the button in the Toolbar.
Let’s build our SettingsSheetView using this approach:
import SwiftUI
struct SettingsSheetView: View {
// 1. We declare the environment variable to dismiss the view
@Environment(\.dismiss) var dismiss
var body: some View {
// On iOS 16+, prefer NavigationStack. We use NavigationView for backwards compatibility.
NavigationView {
Form {
Section(header: Text("Profile")) {
Text("Edit name")
Text("Change avatar")
}
Section(header: Text("Preferences")) {
Toggle("Push Notifications", isOn: .constant(true))
Toggle("Dark Mode", isOn: .constant(false))
}
}
.navigationTitle("Settings")
.navigationBarTitleDisplayMode(.inline)
// 2. We add the toolbar
.toolbar {
// 3. We place the button in the 'cancellationAction' placement
ToolbarItem(placement: .cancellationAction) {
Button(action: {
dismiss()
}) {
// We can use text or an icon (SF Symbol)
Text("Close")
.fontWeight(.semibold)
}
// Accessibility improvement
.accessibilityLabel("Close settings screen")
}
}
}
}
}
Why use .cancellationAction?
As an iOS Developer, you should leverage the semantics of SwiftUI. By placing the ToolbarItem with the .cancellationAction placement, the operating system automatically decides the best location for the button. On iOS, it will usually place it in the top left corner. If we use .confirmationAction (for example, for a “Save” button), it will place it on the right. This ensures your app feels consistent with the rest of the system.
4. Building a Custom Floating Close Button (“X” Style)
Sometimes, your app’s design doesn’t call for a full navigation bar. Maybe you are showing a full-screen image, a presentation card, or a highly visual modal where a NavigationStack would break the aesthetics. In these cases, Swift programming allows us to use a ZStack to overlay a floating button.
Here I show you how to add a close button to a sheet in SwiftUI with a custom floating style:
import SwiftUI
struct CustomFloatingSheetView: View {
@Environment(\.dismiss) var dismiss
var body: some View {
ZStack(alignment: .topTrailing) {
// 1. The main content of the sheet
Color.indigo.opacity(0.1)
.ignoresSafeArea()
VStack(spacing: 20) {
Image(systemName: "star.fill")
.font(.system(size: 60))
.foregroundColor(.yellow)
Text("Congratulations!")
.font(.largeTitle)
.bold()
Text("You have unlocked a new level.")
.font(.body)
.foregroundColor(.secondary)
}
// We ensure the content is centered
.frame(maxWidth: .infinity, maxHeight: .infinity)
// 2. The Floating Close Button
Button(action: {
dismiss()
}) {
Image(systemName: "xmark.circle.fill")
.font(.system(size: 30))
.foregroundColor(.gray.opacity(0.8))
.symbolRenderingMode(.hierarchical)
}
.padding() // Margin from the safe edges
.accessibilityLabel("Close")
.accessibilityHint("Closes this popup and returns to the previous screen.")
}
}
}
Visual Architecture Analysis
By using a ZStack(alignment: .topTrailing), we are telling SwiftUI to stack all elements on top of each other, and by default align them to the top right corner. This is incredibly useful because it positions our “X” button exactly where users expect to find it, without complex mathematical padding calculations.
5. The Cross-Platform Challenge: iOS vs. macOS vs. watchOS
The true magic of modern Swift programming lies in its cross-platform capabilities. However, a good iOS Developer knows that sharing code in Xcode doesn’t mean ignoring the conventions of each platform.
Let’s see how the concept of how to add a close button to a sheet in SwiftUI behaves on different operating systems:
- iOS/iPadOS: Sheets slide up from the bottom. The user expects close buttons at the top or to swipe down.
- macOS: Sheets appear as modal panels attached to the top of the main window. They cannot be swiped. A “Cancel” or “OK/Close” button in the bottom right corner is the historical standard, although top toolbars are also acceptable today.
- watchOS: Space is tiny. Often, SwiftUI automatically adds a cancel button in the top left corner if it detects a modal. Manually adding another can result in duplicate buttons.
Writing a Smart Cross-Platform Close Component
To create a truly professional codebase, we can create a reusable component that renders the correct button based on the operating system using compiler directives (#if os(...)).
import SwiftUI
// We create a custom ViewModifier
struct AdaptiveCloseButtonModifier: ViewModifier {
@Environment(\.dismiss) var dismiss
func body(content: Content) -> some View {
#if os(iOS)
// On iOS we use NavigationStack and Toolbar for a native experience
NavigationStack {
content
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button { dismiss() } label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(.secondary)
}
}
}
}
#elseif os(macOS)
// On macOS, we add the button at the bottom right
VStack(spacing: 0) {
content
Divider()
HStack {
Spacer()
Button("Close") {
dismiss()
}
.keyboardShortcut(.defaultAction) // Allows closing with the Enter key
.padding()
}
.background(Color(NSColor.windowBackgroundColor))
}
.frame(minWidth: 400, minHeight: 300) // Mac often requires explicit dimensions
#elseif os(watchOS)
// On watchOS we usually let the system handle native dismissal,
// or we place a very simple button at the end of the content.
ScrollView {
VStack {
content
Button("Close", role: .cancel) {
dismiss()
}
.padding(.top)
}
}
#endif
}
}
// Extension to make it easy to apply to any view
extension View {
func withAdaptiveCloseButton() -> some View {
self.modifier(AdaptiveCloseButtonModifier())
}
}
How to use this marvel of Swift programming? It’s very easy. Simply design your modal view focusing only on the content and add our modifier to the end of it:
struct MyCrossPlatformSheet: View {
var body: some View {
VStack {
Text("Important Content")
.font(.title)
Text("This view adapts perfectly to iPhone, Mac, and Apple Watch.")
.multilineTextAlignment(.center)
.padding()
}
// Cross-platform magic applied here:
.withAdaptiveCloseButton()
}
}
6. Best Practices and Error Prevention
To conclude this guide, as an iOS Developer, here is a summary of the best practices you should keep in mind when working with sheets in Xcode:
1. Beware of Data State
When a user closes a sheet (whether by swiping or pressing the button), ensure that unsaved data is handled correctly. If you are editing a form, you can intercept the dismissal using .interactiveDismissDisabled(hasUnsavedChanges) and show a confirmation alert (“Are you sure you want to discard your changes?”).
2. Accessibility First
As we saw in the previous examples, whenever you use an SF Symbols icon for your close button (like the typical “X”), you must provide an .accessibilityLabel("Close"). Otherwise, VoiceOver will read something confusing like “Filled circle with cross,” which will frustrate visually impaired users.
3. Don’t abuse modals
Although it is easy to open sheets in SwiftUI, the Human Interface Guidelines recommend using them sparingly. If a flow requires multiple steps, it is better to use conventional navigation (NavigationLink) instead of stacking sheets on top of each other, which creates a confusing architecture prone to visual bugs.
Conclusion
Knowing how to correctly add a close button to a sheet in SwiftUI is much more than simply drawing an “X” on the screen. It involves understanding the navigation flow, dependency injection via the @Environment, and the fundamental usability differences between Apple devices.
With the Swift programming tools and cross-platform design techniques we have explored in this article using Xcode, you are fully equipped to build robust, accessible interfaces with a top-tier professional finish.
If you have any questions about this article, please contact me and I will be happy to help you . You can contact me on my X profile or on my Instagram profile.