---
title: The sheet that closed itself
description: "62 commits in May, 203 Swift files and one night on one sheet: why SwiftUI sheets kept closing on iPad and iOS 26, and the policy that finally held."
url: https://secretocho.design/notes/the-sheet-that-closed-itself/
type: BlogPosting
published: 2026-10-11
---

# The sheet that closed itself

> 62 commits in May, 203 Swift files and one night on one sheet: why SwiftUI sheets kept closing on iPad and iOS 26, and the policy that finally held.

2026-10-11 · 11 min

*Part 3 of 7 in the [Building ProbaBaby](#the-series) series.*

For about seven weeks in the spring of 2026, the sheets in ProbaBaby had a mind of their own. These are the panels that slide up to log a feed, a symptom or a contraction. They flashed open and closed again. They vanished halfway through an edit. A confirmation alert dismissed itself before you could tap it. On iPad, the baby-prediction flow jumped back to its first step and threw away the photos you had chosen.

In May alone it took 62 commits touching 203 Swift files: a night and a morning on a single sheet, a workaround rolled out to 56 places within an hour and later pulled back from 83 to 9, and finally a written policy with a script to check it. This is that story, with code, for anyone fighting SwiftUI modals on iOS 26.

A note on certainty: what follows is the model the project settled on, backed by logs where I have them. Apple doesn’t document most of it, and the deepest case was never fully explained; the final fix made it irrelevant.

## How SwiftUI shows a sheet

In UIKit you call “present”. In SwiftUI you declare it: this screen shows sheet X *while* this value is set. SwiftUI re-reads that declaration every time the screen redraws, and a screen redraws whenever data it watches changes. If the screen redraws while a sheet is up, and the declaration is shaped badly, SwiftUI’s bookkeeping can decide the sheet shouldn’t be there and take it down.

iPad makes it worse. Sheets there are form sheets, so the screen underneath stays alive, keeps redrawing, and keeps re-asserting its modals.

1. **data changes** · after 3 May, every symptom tap wrote to the view-model
2. *publish* · **the host redraws** · it observes that view-model
3. *body runs* · **the modal is re-read** · its sheet declaration, again
4. *shaped badly* · **the sheet can be taken down** · the bookkeeping can decide it shouldn’t be there

and again, on the next publish

The diagram, step by step:

1. A tap writes to the view-model. After 3 May, every symptom tap did.
2. The view that hosts the sheet observes that view-model, so its body runs again.
3. SwiftUI re-reads the screen’s modal declaration on every redraw.
4. Shaped badly, the bookkeeping can decide the sheet shouldn’t be there, and take it down.

*on iPad the screen under a sheet stays alive, and keeps re-asserting its modals.*

## April: fixes without a name

The first traces are code comments from 1 April 2026, tucked into a documentation commit: a session manager and a tab view both noted re-renders “that dismiss sheets on iPad” (or full-screen covers). On 15 April another comment explained why two objects are reached through `.shared` instead of being observed: “@StateObject would subscribe to objectWillChange on every data refresh”. More `// iPad fix:` comments followed in bundled “sync WIP” commits through 28 April.

One of those, on 27 April, removed hidden subscriptions. An `@EnvironmentObject` declared only to pass an object further down still subscribes. A `@StateObject` on the `@main` app struct redraws the whole window. And one amplifier hit iPhone as well: presenting a sheet briefly flips the scene to inactive and back, and the app ran its whole “back to foreground” reload on that blip, a storm of data updates half a second after every sheet opened. Nobody called it a saga yet. It was being patched one view at a time.

## 4 May: 102 views in a day

On 3 May a small change, a quick-pick toggle for symptoms, made every tap write to the Planning view-model. The latent bug became the most visible thing in the app: any open log sheet vanished mid-edit, and a new “Period started today” alert dismissed itself.

The diagnosis: the view that hosted the sheet also observed a busy view-model. Every publish re-ran its body and re-applied its modal. An earlier search had missed it, because the view-model came in as a parameter (`@ObservedObject var viewModel`), not in the shape the search was looking for.

A static scan found 102 views with that shape across 117 files. Eighteen commits fixed most of them the same day. The fix is to split the screen in two: a stable outer host that owns the modal and observes nothing, and an inner view that observes. On 4 May the inner views got bindings to the host’s flags; the policy that came later prefers callbacks, which is the shape below.

|  | before: one view | after: a host and an inner view |
| --- | --- | --- |
| observes the view-model | yes | only the inner view |
| hosts the sheet | yes | only the outer host |
| on every publish | the whole view redraws | only the inner view redraws |
| the sheet declaration | re-applied, and an open sheet can be taken down | not re-run by a publish |

The diagram, step by step:

1. Before: one view observes the busy view-model and also hosts the sheet.
2. Every publish re-runs its body and re-applies the modal, and an open sheet can be taken down.
3. After: a stable outer host owns the sheet and observes nothing. An inner view observes.
4. A publish redraws only the inner view. The host’s sheet declaration isn’t re-run.

*a stable outer host owns the modal and observes nothing.*

Before, the host observes the view-model and also hosts the modal:

```
struct LogView: View {
    @ObservedObject var viewModel: TrackingViewModel
    @State private var activeSheet: LogSheet?

    var body: some View {
        LogContent(viewModel: viewModel)
            .sheet(item: $activeSheet) { SheetView($0) }
    }
}
```

After, the outer host is stable and only the inner view observes:

```
struct LogView: View {
    let viewModel: TrackingViewModel        // a reference, no subscription
    @State private var activeSheet: LogSheet?

    var body: some View {
        LogContent(viewModel: viewModel, open: { activeSheet = $0 })
            .sheet(item: $activeSheet) { SheetView($0) }
    }
}

private struct LogContent: View {
    @ObservedObject var viewModel: TrackingViewModel
    var open: (LogSheet) -> Void
    var body: some View { List { /* the log rows */ } }
}
```

## 6–7 May: the prediction flow on iPad

Four prediction steps had been deferred on 4 May as “lower practical risk”. Two days later I reported the iPad flow still resetting, and the plan’s log records that it was “still blocking publication”.

The commits that followed got more emphatic: “make flow rock-solid against view recreation”, then “make flow truly bulletproof”. What actually found the causes was instrumentation: body counters and prints at every layer, to see which ancestor was rebuilding. Three turned up. A permanently mounted `PhotosPicker` kept a connection to the system open, and its churn on iPadOS 26 coincided with the cover being rebuilt. The flow’s `.fullScreenCover` sat inside the home screen’s `NavigationStack`, where navigation kept re-applying it. And a photo-picker modifier used an `if/else` inside a `@ViewBuilder`, so the view’s structure flipped when the picker closed. By the commit messages’ account, two of the three came from code another agent, Codex, had written.

One fix from that hunt still shapes the app: a lock-screen overlay was removed from the prediction cover, and it stays removed, known gap and all. My standing instruction in the memory reads: “dont touch prediction flow, it created problem before.”

## 10–11 May: one sheet, nine commits

Then the contraction timer. After you stopped a contraction, the sheet that asks how strong it was flashed open and closed. Each commit that night fixed something real:

- **22:59.** Three chained sheets collapsed into one enum-driven sheet, and three card-level modals moved off a view that redraws ten times a second.
- **01:15.** A fresh ID per presentation. The sheet had always used the same ID, so stopping a second contraction while the first one’s sheet was still animating away looked like the same sheet. The commit called this “the real root cause”.
- **01:35.** A sibling host, a delayed write and logging: “Going nuclear with three combined defenses.”
- **01:53.** A flag that blocks data reloads while the sheet is up: “the actual root cause”.
- **10:18.** Interactive dismissal disabled. The sheet was now “ironclad”.

It still closed. At 10:40 the console showed the evidence that changed the approach. The app’s state said the intensity sheet was active, and it never went back to nil, while the sheet was visibly gone. SwiftUI still believed the sheet was presented. Something underneath it, at the UIKit level, had removed it, and no SwiftUI-side defence can protect against something SwiftUI doesn’t know happened.

| time | commit | the sheet |
| --- | --- | --- |
| 22:59 | one enum-driven sheet instead of three chained ones | still closes |
| 01:15 | a fresh ID per presentation: “the real root cause” | still closes |
| 01:35 | “going nuclear with three combined defenses” | still closes |
| 01:53 | no data reloads while it’s up: “the actual root cause” | still closes |
| 10:18 | interactive dismissal off: “ironclad” | still closes |
| 10:40 | the console: the app’s state says the sheet is open | visibly gone |
| 10:58 | no system sheet: draw it in a ZStack | stays |

The diagram, step by step:

1. 22:59: three chained sheets become one enum-driven sheet.
2. 01:15: a fresh ID per presentation. “The real root cause.”
3. 01:35: a sibling host, a delayed write and logging. “Going nuclear with three combined defenses.”
4. 01:53: no data reloads while the sheet is up. “The actual root cause.”
5. 10:18: interactive dismissal off. The sheet is “ironclad”. It still closes.
6. 10:40: the app’s state says the sheet is open, and never goes back to nil. The screen says it’s gone.
7. 10:58: stop using a system sheet. Draw the panel as content in a ZStack.

*every commit fixed something real. None was the cause.*

At 10:58: “We’ve exhausted every defense available in SwiftUI-land.” The fix stopped using a system sheet altogether, and drew the panel as ordinary content in a `ZStack`. Simplified:

```
ZStack {
    timerContent

    // Not a .sheet: regular content in the ZStack.
    // iOS can't dismiss it because it isn't a modal presentation.
    if let sheet = activeSheet,
       case let .intensity(duration, _) = sheet {
        Color.black.opacity(0.45)
            .ignoresSafeArea()
            .onTapGesture { }          // no-op: Save or Skip only
            .zIndex(1)

        ContractionIntensitySheet(
            duration: duration,
            /* … */
            onClose: { activeSheet = nil }   // not @Environment(\.dismiss)
        )
        .transition(.move(edge: .bottom).combined(with: .opacity))
        .zIndex(2)
    }
}
.animation(.spring(response: 0.4, dampingFraction: 0.85), value: activeSheet?.id)
```

The fresh ID from 01:15 is still there, too. Each presentation gets its own:

```
case .intensity(_, let presentationId):
    return "intensity-\(presentationId.uuidString)"
```

## The workaround becomes a component, then a problem

Within the hour the pattern became a design-system modifier, `.customModal`, and went out to 56 call sites across Pregnancy, Planning and Postpartum, because the same symptom was showing up on Log Symptoms, Add Weight, Add Appointment and Add Note, and I asked for the fix to be extended ahead of the reports. By 23 May it was in 83 places.

That was too many. `.customModal` draws inside whatever view hosts it, so on a chart card a full-screen panel rendered as a narrow column with the tab bar still showing. On 24 May a commit titled “undo over-application” cut it back to 9 sites. Its message is honest: the modifier had been “applied speculatively to ~80 other sites, most of which had no actual Tier-B trigger.” The undo went too far as well: within three days, three sites were back on `.customModal`, one of them a genuinely nested sheet.

- **11 May, within the hour**: 56
- **23 May**: 83
- **24 May, “undo over-application”**: 9
- **within three days**: 12 · three back

The diagram, step by step:

1. Within the hour, the workaround is a design-system modifier, at 56 call sites.
2. By 23 May it’s in 83 places, most of them with no real trigger.
3. 24 May: a commit titled “undo over-application” cuts it back to 9.
4. Within three days, three sites are back. One of them is a genuinely nested sheet.

*escape hatches cost something: escalate on a named trigger, not on fear.*

The workaround has real costs, and I paid them. No detents, no swipe-to-dismiss, no drag handle. `@Environment(\.dismiss)` inside it walks up and closes the parent cover instead, so content gets an `onClose` callback. And it wasn’t a modal to VoiceOver: no modal trait, no escape gesture, the screen behind still reachable. That gap shipped for four and a half months, until an audit caught it at the end of September. It was fixed in the code on 1 October and ships with the next release.

## The other shapes

Along the way the same symptom turned out to have several distinct causes. After the observing host, the two most common:

**Chained modifiers.** Several separate sheet declarations stacked on one view compete for its single presentation slot on every redraw. ProfileView had twelve modal modifiers. The fix is one enum and one modifier:

```
enum ActiveSheet: Identifiable {
    case editProfile, settings
    var id: Self { self }
}

content.sheet(item: $activeSheet) { sheet in
    switch sheet {
    case .editProfile: EditProfileView()
    case .settings: SettingsView()
    }
}
```

A `.sheet` and an `.alert` side by side on one view can collide the same way.

**Two writes where one will do.** Code that set “here is the file” and then “show the sheet” as two separate writes could redraw between them, so the sheet opened empty and bounced. Make the data the trigger:

```
// Before: two writes, and the sheet body has to unwrap
exportedFileURL = fileURL
showExportSheet = true

// After: one write; the item is the trigger
exportedFile = ExportedFile(url: fileURL)
// …
.sheet(item: $exportedFile) { file in ShareSheet(items: [file.url]) }
```

Then there were case-swaps, where a button inside one sheet replaces it with another and, on iPad and iOS 26, the second often never appears. And nested modals: a sheet opened from inside something that is itself a sheet or a full-screen cover.

## 21 May: a policy instead of another fix

At 02:47 on 21 May an eight-phase cleanup closed, and the scan that drove it was promoted to `scripts/audit_modal_chains.py`. At 11:08 a new feature re-created the pattern: the flow for ending a pregnancy, “My baby is born” or “I’m no longer pregnant”, swapped one sheet for another on purpose. At 13:20 the policy commit opened with: “The modal-chain anti-pattern keeps regressing despite the existing canonical rule.”

That flow, and that policy, landed in the week our own baby was born. Part 4 is about that week.

The policy has two tiers:

- **Tier A, the default, for about 95% of modals:** `.sheet(item:)`, `.fullScreenCover(item:)` or `.alert(presenting:)`, keyed on one enum per host. The host is stable and observes nothing, and its inner view asks for modals through callbacks. Detents, swipe-to-dismiss and VoiceOver come for free.
- **Tier B, `.customModal`, mandatory when any of four triggers fires:** the host is itself inside a sheet or cover; the flow swaps one modal for another mid-presentation; an async task writes the modal’s binding; or the device shows the UIKit-level dismissal, where the binding never goes nil.

1. **a new modal** · pick the tier first
2. **any of four triggers?** · inside a sheet or cover · a case-swap · an async write · a UIKit-level dismissal
3. **no: Tier A** · an item-keyed sheet, cover or alert, one enum per host; about 95% of modals · **yes: Tier B** · .customModal, drawn in a ZStack

The diagram, step by step:

1. Before any new modal, pick the tier.
2. Four triggers: a host inside a sheet or cover, a mid-presentation swap, an async write to the binding, or the UIKit-level dismissal.
3. None fires: Tier A, a native sheet keyed on one enum, on a host that observes nothing. About 95% of modals.
4. Any fires: Tier B, .customModal, drawn as content in a ZStack.

*four yes/no triggers and a script beat “be careful with sheets”.*

Escalation is now a yes/no question, not a feeling. In September I added one exemption: a feature module shown as one stable full-screen cover from its Home card counts as top level. Moving its twenty or so log sheets to Tier B would have traded native sheet behaviour for a risk the stable host already removes.

## The script, and its limits

The audit is regular expressions and brace matching, not a Swift parser. For each type it counts modal modifiers and subscriptions, flags async writes, and sorts views into classes. Three classes must be empty before a merge: B (two or more modals on a host that observes), E (nested modals) and F (case-swaps). Today they are: 0, 0 and 0.

It has missed things, and each miss taught it something: a cover routed through a helper function, nesting through a `TabView`, a cover that lived in another package. And it isn’t in the pre-commit hook or in CI. It’s enforced through `CLAUDE.md` and the memory, which means through instructions to the agent, and five months on the drift shows. One non-gating class has grown from one view to five, and one Tools view now hosts five modals. Putting B, E and F into the hook would turn the policy into a gate.

## Why it kept coming back

- **The fragile form is the natural one.** `.sheet(isPresented: $showX)` is what the tutorials show, and adding one more is the path of least resistance. Before the 4 May sweep the app had 243 `isPresented:` sheets and covers against 58 `item:` ones. Today it’s 62 against 84.
- **AI writes the common idiom.** Every commit in the saga carries an AI co-author, and so does the one that regressed on 26 May: an alert added next to an existing sheet, fixed 23 minutes later. Without the rule in front of it, an agent writes what most SwiftUI code looks like.
- **The detectors were narrower than the bug.** Each one missed a spelling.
- **“Fixed” was declared before the device test.** The bug is intermittent and device-dependent, so a green build proved nothing. One plan says it best: “Build-success ≠ user-flow-correct.”

## If you’re fighting this now

1. Keep the view that declares modals boring: it holds state, observes nothing, and hosts as few modals as possible, each keyed on an enum.
2. Make opening a sheet one atomic write. Let the data be the trigger.
3. Get logs before theories. The contraction night turned on one console line, and the prediction flow on instrumentation.
4. Be suspicious of “the root cause”. Intermittent UI bugs often have several.
5. Escape hatches cost something. Escalate on a named trigger, not on fear, and check accessibility the day you ship one.
6. Write the rule so a machine can apply it. Four yes/no triggers and a script beat “be careful with sheets”.
7. Test where the bug lives: on an iPad, on a real device.

The road not taken: moving the view-models from `ObservableObject` to Swift’s `@Observable`, where only the views that read a changed property redraw, would remove most of this class of bug at the source. Two plans in May listed it, and both deferred it as too large. On 30 September the Planning view-model behind the 4 May crisis finally moved to `@Observable`; about 98 `ObservableObject` types remain.

## The series

1. [Four platforms in four months](https://secretocho.design/notes/four-platforms-in-four-months/)
2. [Rules born from mistakes](https://secretocho.design/notes/rules-born-from-mistakes/)
3. The sheet that closed itself
4. [A newborn app, with a newborn](https://secretocho.design/notes/a-newborn-app-with-a-newborn/)
5. [What Apple, Postgres and 32 languages taught me](https://secretocho.design/notes/apple-postgres-and-32-languages/)
6. [Twins found us](https://secretocho.design/notes/twins-found-us/)
7. [A spotless website nobody could find](https://secretocho.design/notes/a-spotless-website-nobody-could-find/)

[cd ..](https://secretocho.design/notes/)
