Stupid SwiftUI Tricks: Debugging Sheet Dismissal

Last week, I spent some time solving an odd bug with the WriteFreely client's iOS app.

When you're looking at the list of posts, you can tap the gear button to get to the settings screen, which presents you with a form for logging into your WriteFreely instance:

"Screenshot of settings screen on an iPhone"

As you might expect, you can tap the close button in the upper-right (ⓧ) to dismiss the sheet.

Except… well, if you tapped into one of the login form's fields, you end up in a state where tapping on the close button didn't seem to have any effect.

Okay, so that's not entirely true — if you added a print statement to the button's action, you'd find that the first tap does register, toggling presenting view's the isPresentingSettingsView flag correctly; it just doesn't have any effect.

The workaround, while I'd been testing the app, was to dismiss the sheet is by swiping down on it — a standard (if somewhat undiscoverable) system gesture.

Interestingly, when you'd tap in any form field, you'd also receive the following warning in Xcode's console:

2020-09-11 09:56:01.927435-0400 WriteFreely-MultiPlatform[37593:6860302] [Presentation] Attempt to present <_TtGC7SwiftUI22SheetHostingControllerVS_7AnyView_: 0x7fb24a7297f0> on <_TtGC7SwiftUI19UIHostingControllerGVS_15ModifiedContentVS_7AnyViewVS_12RootModifier__: 0x7fb24c905ac0> (from <_TtGC7SwiftUI19UIHostingControllerGVS_15ModifiedContentVVS_22_VariadicView_Children7ElementGVS_18StyleContextWriterVS_23ContentListStyleContext___: 0x7fb24a711ec0>) which is already presenting <_TtGC7SwiftUI22SheetHostingControllerVS_7AnyView_: 0x7fb24c80eaf0>.

There's a lot of cruft there, but it hints that SwiftUI is trying to present a view that's already being presented. This suggested to me that the hosting view is getting re-rendered when a login form field becomes the first responder, finds that the isPresentingSettingsView flag is set, and tries to present the sheet again.

Okay! This is something we can test! Here's what the settings view looked like:

import SwiftUI

struct SettingsView: View {
    @EnvironmentObject var model: WriteFreelyModel

    @Binding var isPresented: Bool

    var body: some View {
        VStack {
            HStack {
                Text("Settings")
                    .font(.largeTitle)
                    .fontWeight(.bold)
                Spacer()
                Button(action: {
                    self.isPresented = false
                }, label: {
                    Image(systemName: "xmark.circle")
                })
            }
            .padding()
            Form {
                Section(header: Text("Login Details")) {
                    AccountView()
                }
                Section(header: Text("Appearance")) {
                    PreferencesView(preferences: model.preferences)
                }
            }
        }
    }
}

(For debugging purposes, I've simplified this a tiny bit: originally that HStack was in a separate SettingsHeaderView struct.)

To test the hypothesis, I started by commenting out the entire Form. Everything then worked fine in presenting and dismissing the sheet, but of course, it's not a very useful sheet without that form. 😅

If I just included the appearance form, that works fine too. That narrows things down here — or so I thought.


There are two ways to dismiss a sheet. The first is to pass the hosting view's presentation state as a binding to the presented sheet, which is what you see in the above listing. Simplified, the SettingsView is presented from the PostListView like this:

Button(action: {
    self.isPresentingSettingsView = true
}, label: {
    Image(systemName: "gear")
})
.sheet(
    isPresented: $isPresentingSettingsView,
    content: {
        SettingsView(isPresented: self.$isPresentingSettingsView)
    }
)

You can also use @Environment(.presentationMode) in the SettingsView to dismiss itself. You declare the property wrapper at the top of the struct like so:

@Environment(\.presentationMode) var presentationMode

…and call its dismiss() method in a button action, like so:

Button(action: {
    presentationMode.wrappedValue.dismiss()
}, label: {
    Image(systemName: "xmark.circle")
})

Interestingly enough, using this method to dismiss the sheet no longer triggered the console warning when I tapped into any login form field. Could it be? Was the problem solved? 😃

Nope. 😬

If you filled out the form and logged in, then that same warning was logged three times in the console. If you logged out, the warning was logged again. But this looked like progress! It seemed likely that something in the account views was triggering this, so I explored that a little deeper.

The AccountView swaps between an AccountLoginView and an AccountLogoutView based on the state of an isLoggedIn flag in the AccountModel. My prime suspect was the AccountLoginView, which has an .alert(isPresented:) modifier attached to it. If there's an error logging in, this is triggered and an alert is presented depending on which of the three AccountError cases are present. Because the .alert(isPresented:) and .sheet(isPresented:) modifiers work similarly, maybe some wires were getting crossed there? This is, of course, a beta framework running on a beta operating system in a beta IDE!

So, I started with an easy test: commenting out the .alert(isPresented:) modifier, and see what happens on login.

You guessed it: this doesn't change the behaviour — the warnings are still logged, and the sheet can't be dismissed.

Digging further and further, setting breakpoints and stepping through code, commenting out blocks to see if they were the culprit, got me nowhere. I finally started searching DuckDuckGo for SwiftUI "Attempt to present" "which is already presenting" and eventually found this year-old forum comment on Swift.org:

Is it the current recommendation, to put modal views & the triggers outside NavigationView, or is it only to circumvent an existing bug?

🤦

Yep. Taking the .sheet(isPresented:) modifier out of the PostListView and attaching it to an EmptyView outside of the NavigationView solved the issue. Nothing in the docs on NavigationView, View Modifiers, or sheet suggests this could be a thing.


So, yeah, the title of this post is a bit misleading — it turns out that I spent a couple of hours trying to figure out what was happening, when an undocumented bug in the framework was the cause.

Again: this is a beta framework, on a beta operating system, and frankly the amount of SwiftUI documentation that's already out there is surprisingly good. But it's a little frustrating to have spent a couple of hours debugging a warning that could have been avoided with a one-line disclaimer in the documentation. Hopefully, this will be helpful to anyone that searches for a similar issue!

For those of you that want to see the code, here's the fix in the app.


Enter your email to subscribe to updates:

You can also subscribe via RSS or follow @angelo@write.as on Mastodon.