Articles, podcasts and news about Swift development, by John Sundell.

Dismissing a SwiftUI modal or detail view

Published on 29 Jun 2021
Discover page available: SwiftUI

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!