Post

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 and SheetDestination 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)
}
This post is licensed under CC BY 4.0 by the author.