Stupid SwiftUI Tricks: Debugging Sheet Dismissal
Tags: swiftui, swift, ios, showdev
Published: 2020-09-14 18:49:01

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"](https://i.snap.as/nLAzPbZ.png)

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:

```swift
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:

```swift
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:

```swift
@Environment(\.presentationMode) var presentationMode
```

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

```swift
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](https://forums.swift.org/t/actionsheet-and-modal-seems-have-bug-in-navigationview/29590/10):

> 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](https://developer.apple.com/documentation/swiftui/navigationview), [View Modifiers](https://developer.apple.com/documentation/swiftui/navigationview-view-modifiers), or [sheet](https://developer.apple.com/documentation/swiftui/navigationview/sheet(ispresented:ondismiss:content:)) 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](https://github.com/writeas/writefreely-swiftui-multiplatform/commit/6ea10fb434393fd5a19f34f35a9c768617eb32cc) in the app.