Handling multiple sheets in SwiftUI

Manuel Kunz
StepUp Development
Published in
5 min readJun 10, 2020

In order for a view to be displayed modally in SwiftUI, the sheet modifier can be used. In its simplest form a sheet is being presented if a given condition is true.

struct ContentView: View {@State var presentSheet = falsevar body: some View {
VStack {
Button("Show Settings") {
self.presentSheet = true
}
}.sheet(isPresented: $presentSheet) {
SettingsView()
}
}
}

In this example, the sheet modifier is applied to the VStack. However, it could also be applied to the Button directly.

What if we wanted to display more than one sheet from a specific view, though?

The most logical choice would be to just place another sheet modifier below the existing one, since it is possible to chain modifiers as we please. Unfortunately this does not work. In order for us to be able to use multiple sheets, each modifier has to be applied to the button directly. Using two or more consecutive sheet modifiers does not work in SwiftUI, only the last sheet modifier will be applied and can be used. As a result, we could design our view like this.

@State var showSettingsView = false
@State var showProfileView = false
var body: some View {
VStack {
Button("Show Settings") {
self.showSettingsView = true
}.sheet(isPresented: $showSettingsView) {
SettingsView()
}
Button("Show Profile") {
self.showProfileView = true
}.sheet(isPresented: $showProfileView) {
ProfileView()
}
}
}

But as you can imagine in a working app, this view won’t be as compact. If you need to handle multiple different sheets, the code gets blown up very quickly. Additionally, each sheet needs its own @State to be presented.

To avoid massive Views, it would be great if there was a way to separate this navigation logic from our view into a dedicated file. There are different alternatives that simplify the handling of multiple sheet modifiers. This article shows one of them.

Moving the presentation logic into an observable ViewModel

First of all we are going to separate the logic of which sheet will be shown to a separate view model.

class SheetNavigator: ObservableObject {
@Published var showSettingsView = false
@Published var showProfileView = false
}

This SheetNavigator class has to conform to the ObservableObject protocol and will be observed by the view itself using the @ObservedObject property wrapper.

@ObservedObject var sheetNavigator = SheetNavigator()
var body: some View {
VStack {
Button("Show Settings") {
self.sheetNavigator.showSettingsView = true
}.sheet(isPresented: self.$sheetNavigator.showSettingsView) {
SettingsView()
}
Button("Show Profile") {
self.sheetNavigator.showProfileView = true
}.sheet(isPresented: self.$sheetNavigator.showProfileView) {
ProfileView()
}
}
}

After we moved our presentation state variables into the view model, the code did not really improve. The state handling is not scalable and we still have multiple sheet modifiers cluttering our view. Let‘s reduce those at first to just one sheet modifier.

VStack {
Button("Show Settings") {
self.sheetNavigator.showSettingsView = true
}
Button("Show Profile") {
self.sheetNavigator.showProfileView = true
}
}.sheet(isPresented: ???) {
???
}

At this point we only have one sheet modifier, but which view should be shown and to which state variable should we bind the sheet? The answer is our SheetNavigator.

class SheetNavigator: ObservableObject {
var showSettingsView = false
var showProfileView = false
@Published var showSheet = false

func sheetView() -> AnyView {
if showFirstView {
return Text("SettingsView").eraseToAnyView()
} else if showSecondView {
return Text("ProfileView").eraseToAnyView()
}
return Text("Empty").eraseToAnyView()
}
}
extension View {
func eraseToAnyView() -> AnyView {
AnyView(self)
}
}

We add an additional bool state to handle the binding for all sheets. The function sheetView returns the View we want to show for the selected button. All this logic happens in the navigator and our view is way simpler than before.

Since we are dealing with different views that are displayed in our sheets, we have to type erase said views to AnyView. For the sake of readability, we added a little View extension to simplify wrapping a view inside of AnyView.

As there might be performance implications when using AnyView, you could also consider wrapping your views inside a Group.

VStack {
Button("Show Settings") {
self.sheetNavigator.showSettingsView = true
self.sheetNavigator.showSheet = true
}
Button("Show Profile") {
self.sheetNavigator.showProfileView = true
self.sheetNavigator.showSheet = true
}
}.sheet(isPresented: self.$sheetNavigator.showSheet) {
self.sheetNavigator.sheetView()
}

But it’s not perfect, since we need to set two different booleans in the action of a button, one for the sheet that should be shown and one for the state that is bound to the sheet modifier. Additionally, the amount of state variables required, will grow for each new sheet we want to show. Let‘s use a different approach, that is more scalable and also improves readability: Enums.

Enums to the rescue!

Now there is only one published parameter left, the showSheet state that is bound to the sheet modifier. For the different sheet views we want to present, we added a SheetDestination enum.

class SheetNavigator: ObservableObject {@Published var showSheet = false
var sheetDestination: SheetDestination = .none

enum SheetDestination {
case none
case settings
case profile
}

func sheetView() -> AnyView {
switch sheetDestination {
case .none:
return Text("None").eraseToAnyView()
case .settings:
return SettingsView().eraseToAnyView()
case .profile:
return ProfileView().eraseToAnyView()
}
}
}

With these changes the View will now look like this.

@ObservedObject var sheetNavigator = SheetNavigator()
var body: some View {
VStack {
Button("Show Settings") {
self.sheetNavigator.sheetDestination = .settings
self.sheetNavigator.showSheet = true
}
Button("Show Profile") {
self.sheetNavigator.sheetDestination = .profile
self.sheetNavigator.showSheet = true
}
}.sheet(isPresented: self.$sheetNavigator.showSheet) {
self.sheetNavigator.sheetView()
}
}

At this point, we are still calling showSheet ourselves. Actually, setting the sheetDestination should really be enough. So we remove setting showSheet to true from the view and handle this with a property observer.

var sheetDestination: SheetDestination = .none {
didSet {
showSheet = true
}
}

Every time the sheetDestination is being set, we set showModal to true.

VStack {
Button("Show Settings") {
self.sheetNavigator.sheetDestination = .settings
}
Button("Show Profile") {
self.sheetNavigator.sheetDestination = .profile
}
}.sheet(isPresented: self.$sheetNavigator.showSheet) {
self.sheetNavigator.sheetView()
}

What‘s next?

In using our navigator, we managed to condense our view and moved most of our navigation logic out of it. But there is still room for improvement. We can take advantage of the fact that Swift enums support associated values and pass additional data to the presented sheet views.

For instance, we can make the profile enum case accept a String as parameter, if we want to pass the name of the user to the profile view.

enum SheetDestination {
case none
case settings
case profile(name: String)
}

When setting the sheetDestination to the profile case, our call site would look like this.

Button("Show Profile") {
self.sheetNavigator.sheetDestination = .profile(name: "Christoph")
}

The profile case of the switch statement within the sheetView function, has to be adjusted as well. Here, we can use the given parameter and pass it to the ProfileView.

case .profile(name: let userName):
return ProfileView(name: userName).eraseToAnyView()

Keep in mind to avoid too many destinations in one Navigator class. In fact, each view you are using should have its own Navigator.

To establish a consistent style, you could use a Navigator Protocol each Navigator can confirm to.

protocol NavigatorProtocol: ObservableObject {
associatedtype SheetDestination
func sheetView() -> AnyView
}

I hope you enjoyed this little concept of handling multiple sheets. How do you handle navigating to different views in SwiftUI? Do you have similar approaches? Let us know! We‘d love to hear your ideas and hope this article helped to StepUp your developer game.

--

--