Managing sheet presentations in SwiftUI can quickly become complex, especially when your app requires multiple sheets across different screens. The default sheet
view modifier provides a simple way to present modals, but as your app grows, using multiple sheet modifiers or manually controlling their display can lead to scattered, redundant code that’s hard to maintain.
What if there was a way to centralize sheet management, creating a cleaner, more maintainable solution with a user-friendly API? In this article, we’ll explore the Global Sheets Pattern in SwiftUI—a method that simplifies the management of sheets by centralizing their logic. We’ll examine various approaches to displaying and managing sheets and demonstrate how adopting concepts from other platforms can improve your app’s architecture, making sheet handling more efficient and scalable.
If you’re interested in iOS development courses and live workshops, check out https://azamsharp.school.
There are various ways of displaying sheets in SwiftUI. The simplest approach is to use the isPresented
argument to control when the sheet is shows. In the code below we are displaying a sheet when the isPresented
property is changed.
struct ContentView: View {
@State private var isPresented: Bool = false
var body: some View {
Button("Show Sheet") {
isPresented = true
}.sheet(isPresented: $isPresented, content: {
Text("Sheet")
})
}
}
If you want to display multiple sheets then you can use multiple sheet modifiers, dependent on separate properties. This is shown in the implementation below:
struct ContentView: View {
@State private var isPresented: Bool = false
@State private var isChatPresented: Bool = false
var body: some View {
VStack {
Button("Show Sheet") {
isPresented = true
}
Button("Show Chat") {
isChatPresented = true
}
}.sheet(isPresented: $isPresented, content: {
Text("Sheet")
})
.sheet(isPresented: $isChatPresented, content: {
Text("Chat Presented")
})
}
}
Managing multiple sheets can become cumbersome over time, even if the current approach works. To simplify this complexity, you can introduce a private enum that encapsulates all the sheet types that can be presented within a screen. This allows for cleaner, more organized code and makes it easier to manage and extend the available sheet types.
Instead of using multiple sheet view modifiers, we can make use of an enum with all the different options for a particular screen. The enum can be set to private since it will be for a particular screen. This is shown below:
struct CustomerListScreen: View {
private enum Sheet: Identifiable {
case add
case update(Customer)
var id: String { String(describing: self) }
}
}
The Sheet
enum conforms to the Identifiable
protocol to participate in binding operations, ensuring each case can be uniquely identified within SwiftUI views. Now we can start using our Sheet enum as shown below:
struct CustomerListScreen: View {
// Sheet definition
@State private var activeSheet: Sheet?
var body: some View {
VStack {
Button("Add Customer") {
activeSheet = .add
}
Button("Update Customer") {
let customer = Customer(name: "John Doe")
activeSheet = .update(customer)
}
}.sheet(item: $activeSheet) { activeSheet in
switch activeSheet {
case .add:
Text("Add Sheet")
case .update(let customer):
Text("Updating \(customer.name)")
}
}
}
}
Instead of managing multiple sheets, we’re now using a single sheet
modifier bound to the activeSheet
enum. When the activeSheet
value changes, the sheet modifier is automatically triggered. This approach allows for dynamic view presentation based on the active sheet, simplifying management and reducing complexity.
While this method is effective, it still involves multiple sheet
modifiers for each screen. Although this isn’t necessarily a problem, there may be opportunities for further improvement in managing sheets more efficiently.
React Hooks are functions that enable you to use state and other React features in functional components, which were previously available only in class components.
A similar approach in SwiftUI involves using custom environment values. This method allows us to achieve a comparable result, enabling more flexible and centralized management of state and actions across different views.
struct ContentView: View {
@Environment(\.showSheet) private var showSheet
var body: some View {
VStack {
Button("Show Settings Screen") {
showSheet(.settings)
}
Button("Show Contact Screen") {
showSheet(.contact("John Doe"))
}
}
.padding()
}
}
Don’t worry if the above code doesn’t work right now due to the absence of showSheet
.
Now that we have a clear vision of the final implementation, we can focus on the steps needed to achieve it.
We’ll start by creating a Sheet
enum and a SheetAction
struct. The Sheet
enum will represent the various sheets in the application, while the SheetAction
struct will handle the actions related to presenting or managing these sheets within a SwiftUI application.
enum Sheet: Identifiable, Hashable {
case settings
case contact(String)
var id: Self { self }
}
struct SheetAction {
typealias Action = (Sheet) -> Void
let action: Action
func callAsFunction(_ sheet: Sheet) {
action(sheet, dismiss)
}
}
In the above implementation, the
onDismiss
behavior of the sheet is not handled. This will be addressed later in the article.
Next, we will implement SheetEnvironmentKey
by conforming to the EnvironmentKey
protocol and add a new custom property, showSheet
, to the EnvironmentValues
struct.
struct ShowSheetKey: EnvironmentKey {
static var defaultValue: SheetAction = SheetAction { _ in }
}
extension EnvironmentValues {
var showSheet: (SheetAction) {
get { self[ShowSheetKey.self] }
set { self[ShowSheetKey.self] = newValue }
}
}
In iOS 18, you can use the
@Entry
macro to eliminate the boilerplate code required to create a custom environment value.
Finally, we inject the showSheet
environment value into the root view of the application, as shown below:
@main
struct LearnApp: App {
@State private var selectedSheet: Sheet?
var body: some Scene {
WindowGroup {
NavigationStack {
ContentView()
.environment(\.showSheet, SheetAction(action: { sheet in
selectedSheet = sheet
}))
.sheet(item: $selectedSheet) { sheet in
switch sheet {
case .settings:
Text("Settings")
case .contact(let name):
Text("Contacting \(name)")
}
}
}
}
}
}
Now, we can leverage the showSheet
environment value in our views to simplify sheet presentation. Here’s how you can use it:
struct ContentView: View {
@Environment(\.showSheet) private var showSheet
var body: some View {
VStack {
Button("Show Settings Screen") {
showSheet(.settings)
}
Button("Show Contact Screen") {
showSheet(.contact("John Doe"))
}
}
.padding()
}
}
Once you call showSheet
, it sets the selectedSheet
in the App file, triggering a binding update that causes the sheet
modifier to display the corresponding sheet.
Since you’re using a single
sheet
view modifier, this approach ensures that only one sheet is displayed at a time, preventing multiple sheets from overlapping.
Additionally, you can refactor the switch case in the App file by moving it into a dedicated view.
struct SheetView: View {
let sheet: Sheet
var body: some View {
switch sheet {
case .settings:
Text("Settings")
case .contact(let name):
Text("Contacting \(name)")
}
}
}
ContentView()
.environment(\.showSheet, SheetAction(action: { sheet in
selectedSheet = sheet
}))
.sheet(item: $selectedSheet) { sheet in
SheetView(sheet: sheet)
}
You can also conform
Sheet
to theView
protocol and implement thebody
property to return the appropriate view for each sheet.
In some cases, you might need to handle the onDismiss
event of the sheet
modifier. Our current implementation doesn’t support onDismiss
, but this can be addressed by adding another closure. The SheetAction
and ShowSheetKey
have been updated as shown in the implementation below:
struct SheetAction {
typealias Action = (Sheet, (() -> Void)?) -> Void
let action: Action
func callAsFunction(_ sheet: Sheet, _ dismiss: (() -> Void)? = nil) {
action(sheet, dismiss)
}
}
struct ShowSheetKey: EnvironmentKey {
static var defaultValue: SheetAction = SheetAction { _, _ in }
}
We also need to update the App file and handle the new dismiss
closure.
@main
struct LearnApp: App {
@State private var selectedSheet: Sheet?
@State private var onSheetDismiss: (() -> Void)?
var body: some Scene {
WindowGroup {
NavigationStack {
ContentView()
.environment(\.showSheet, SheetAction(action: { sheet, dismiss in
selectedSheet = sheet
onSheetDismiss = dismiss
}))
.sheet(item: $selectedSheet, onDismiss: onSheetDismiss) { sheet in
SheetView(sheet: sheet)
}
}
}
}
}
Similar to the selectedSheet
property, we introduce a separate property for onSheetDismiss
. The onSheetDismiss
closure is triggered when the sheet is dismissed.
The usage is shown below:
struct ContentView: View {
@Environment(\.showSheet) private var showSheet
private func settingsScreenDismissed() {
print("settingsScreenDismissed")
}
var body: some View {
VStack {
Button("Show Settings Screen") {
showSheet(.settings, settingsScreenDismissed)
}
}
.padding()
}
}
The showSheet
function now supports a second argument for passing a dismiss closure. This allows each view to handle its own dismissal logic, as different views may require specific actions when they are dismissed.
In rare cases where you need to display one sheet on top of another, you can still use the default sheet
modifier to present sheets in a stacked manner.
You can download the complete source here.
The Global Sheets Pattern in SwiftUI provides a powerful and streamlined approach to managing multiple sheets across your application. By centralizing sheet management with a single SheetAction
struct and leveraging custom environment values, you can reduce redundancy, simplify your codebase, and make your sheet presentation logic more scalable.
This pattern not only enhances maintainability but also allows for a more flexible architecture, accommodating various sheets without cluttering your views with multiple modifiers. As demonstrated, adopting ideas from other platforms and implementing them in SwiftUI can lead to a cleaner and more efficient app design. Whether you’re managing simple sheets or more complex workflows, this pattern offers a user-friendly API that ensures a smooth and cohesive user experience.