When building iOS and Mac apps, it’s very common to want to present certain views either modally, or by pushing them onto the current navigation stack. For example, here we’re presenting a MessageDetailView
as a modal, using SwiftUI’s built-in sheet
modifier in combination with a local @State
property that keeps track of whether the detail view is currently being presented:
struct MessageView: View {
var message: Message
@State private var isShowingDetails = false
var body: some View {
ScrollView {
Text(message.body)
...
}
.navigationTitle(message.subject)
.navigationBarItems(trailing: Button("Details") {
isShowingDetails = true
})
.sheet(isPresented: $isShowingDetails) {
MessageDetailsView(message: message)
}
}
}
To learn more about @State
and the rest of SwiftUI’s state management system, check out this guide.
But now the question is — how do we dismiss that MessageDetailsView
once it’s been presented? One way to do that would be to inject our above isShowingDetails
property into our MessageDetailsView
as a binding, which the detail view could then set to false
to dismiss itself:
struct MessageDetailsView: View {
var message: Message
@Binding var isPresented: Bool
var body: some View {
VStack {
...
Button("Dismiss") {
isPresented = false
}
}
}
}
struct MessageView: View {
var message: Message
@State private var isShowingDetails = false
var body: some View {
...
.sheet(isPresented: $isShowingDetails) {
MessageDetailsView(
message: message,
isPresented: $isShowingDetails
)
}
}
}
While the above pattern certainly works, it requires us to manually implement that binding connection every time that we want to enable our users to dismiss a modal. So, there has to be a more convenient way, right?
The good news is that there is, and that’s to use the presentationMode
environment value, which gives us access to an object that can be used to dismiss any view, regardless of how it’s being presented:
struct MessageDetailsView: View {
var message: Message
@Environment(\.presentationMode) var presentationMode
var body: some View {
VStack {
...
Button("Dismiss") {
presentationMode.wrappedValue.dismiss()
}
}
}
}
With the above in place, we no longer have to manually inject our isShowingDetails
property as a binding — SwiftUI will automatically set that property to false
whenever our sheet gets dismissed. As an added bonus, the above pattern even works if we were to present our MessageDetailsView
by pushing it onto the navigation stack, rather than displaying it as a sheet. In that situation, our view would get “popped” from the navigation stack when the presentation mode’s dismiss
method is called. Neat!
However, there’s one thing that’s somewhat awkward about the above implementation, and that’s that we have to access our environment value’s wrappedValue
in order to be able to call its dismiss
method (because it’s actually a Binding
, not just a raw value). So, to address that, Apple is introducing a new version of the above API in iOS 15 (and the rest of their 2021 operating systems) which is simply called dismiss
. That new API gives us an action that can be invoked directly — like this:
struct MessageDetailsView: View {
var message: Message
@Environment(\.dismiss) private var dismiss
var body: some View {
VStack {
...
Button("Dismiss") {
dismiss()
}
}
}
}
Now, if you’ve been reading Swift by Sundell for a while, then you might think that the next thing that I will point out is that the above dismiss
closure can be directly passed to our Button
as its action
, but that’s actually not the case. It turns out that dismiss
is not a closure, but rather a struct that uses Swift’s relatively new call as function feature.
So, if we wanted to inject the dismiss
action directly into our button, then we’d have to pass a reference to its callAsFunction
method — like this:
struct MessageDetailsView: View {
var message: Message
@Environment(\.dismiss) private var dismiss
var body: some View {
VStack {
...
Button("Dismiss", action: dismiss.callAsFunction)
}
}
}
Of course, there’s nothing wrong with wrapping our call to dismiss
within a closure (in fact, I prefer doing that in this kind of situation), but I just thought I’d point it out, since it’s interesting to see SwiftUI adopt call as function for the above API and others like it.
So that’s three different ways to dismiss a SwiftUI modal or detail view — two of which that are backward compatible with iOS 14 and earlier, and a modern version that should ideally be used within apps that are targeting iOS 15 (or its sibling operating systems).
I hope that you found this article useful, and if you have any questions, comments, or feedback, then feel free to reach out via either Twitter or email.
Thanks for reading!