Modularized SwiftUI: Stacked sheet navigation with multiple modals
I don’t think this is a particularly rare use case, but recently I wanted to implement two modal sheets where the second one is displayed on top of the first one.
1
2
3
4
5
6
7
ContentView()
.sheet(isPresented: $showOnboarding) {
OnboardingView()
}
.sheet(isPresented: $showDownloads) {
DownloadView()
}
But when trying to present multiple sheets from a single root view as shown above, I’m met with this frustrating error message:
1
2
Currently, only presenting a single sheet is supported.
The next sheet will be presented when the currently presented sheet gets dismissed.
The quick solution here is to present the second sheet from within the first sheet as outlined in this Hacking With Swift post.
1
2
3
4
5
6
7
ContentView()
.sheet(isPresented: $showOnboarding) {
OnboardingView()
.sheet(isPresented: $showDownloads) {
DownloadView()
}
}
However, this limitation becomes particularly cumbersome for complex apps with features divided into separate modules, where it’s imperative for the navigation to be decoupled. Ideally, subsequent screens should seamlessly transition without needing knowledge of the presenting view. Yet, in the absence of a NavigationPath
equivalent for sheets, and considering the aforementioned error that arises when a sheet is already presented, finding a good solution in SwiftUI’s present state is not straightforward.
Solution
Our app has an onboarding view presented as a sheet at first launch. The onboarding view itself contains a button that presents a sheet with items the user may download. This is what we want to achieve:
The basic navigation concept of the example below with
Router
andSheetDestination
is inspired by the Icecubes Mastodon app.
It’s open-sourced on GitHub and contains many more interesting architectural ideas.
I highly recommend checking it out!
We define the destinations within an enum
inside a Navigation
module that also holds our Router
:
1
2
3
4
public enum SheetDestination: Identifiable {
case download
case onboarding
}
Inside our Router we only care about assigning the destinations to available sheets for presentation:
1
2
3
4
5
6
7
8
9
10
11
12
13
@Observable
public class Router {
public var presentedSheet: Sheet?
public var presentedSheet2: Sheet?
public func present(sheet: Sheet) {
if presentedSheet == nil {
presentedSheet = sheet
} else {
presentedSheet2 = sheet
}
}
}
In our root app, we wire up the Router
and sheet presentation, and decide which views to present for our destinations.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
import Downloads
import Navigation
import Onboarding
public struct MyApp: App {
@Bindable private var router = Router()
public var body: some Scene {
WindowGroup {
ContentView()
ˆ .sheet(item: $router.presentedSheet) { destination1 in
view(for: destination1)
.sheet(item: $router.presentedSheet2) { destination2 in
view(for: destination2)
}
}
}
.environment(router)
}
@ViewBuilder
private func view(for destination: SheetDestination) -> some View {
switch destination {
case .download: DownloadView()
case .onboarding: OnboardingView()
}
}
}
Wherever we want to present a view as a sheet from within our app we only need to import the Navigation
module. In our example the separated Onboarding
and Downloads
modules don’t need to know about each other. The only requirement is that they are both dependencies of the root app.
1
2
3
4
5
6
7
8
9
10
11
import Navigation
// ...
@Environment(Router.self) private var router
// ...
Button("Open Downloads") {
router.present(sheet: .download)
}