TabView auto-dismisses fullScreenCover after tab list changes (crash with .zoom animation)

Summary

  • A TabView with .tabViewStyle(.page) builds its tabs with ForEach; each tab presents a fullScreenCover(item:) from inside.
  • Once the TabView is on screen, either of these breaks every later presentation: a tab is added or removed, or a tab's root view changes identity (Group { switch state } going from .loading to .loaded, with no tab added or removed).
  • From then on each cover is dismissed by SwiftUI itself 15–70 ms after the present. With the standard transition it opens and closes once. With .navigationTransition(.zoom) the dismissal reaches UIKit while the cover is still animating in, and the app crashes: NSInternalInconsistencyException: Cannot morph from a view that is not in the hierarchy.
  • Reproduces on iOS 26.0, 26.5 and 27.1 simulators and on devices on iOS 27.0.x. In our shipping app it is the top crash, ~2,000 events/day.

How we got here

  • The crash appeared when we adopted .navigationTransition(.zoom) for this cover. Every report is SheetBridge.dismiss/present → dismissViewControllerAnimated: → -[UIView(UIMorphableInternal) _morphPreviewFromCurrentState:…].
  • A second tap while the cover was still zooming in reproduced it; we guarded against that. Crashes continued.
  • Disassembling UIKit showed that a dismissal during a zoom presentation preempts it and morphs from the presented view; if that view is not in the hierarchy, UIKit asserts instead of falling back. Any premature dismissal is enough.
  • Our crash breadcrumbs showed every user navigating between tabs of a page-style TabView shortly before the crash, so we went looking for what dismisses a cover that was just presented.
  • A lab with our topology (~1,700 automated presents) found the two triggers above. Both reproduce on iOS 26.0, 26.5 and 27.1, so the SwiftUI side is not new; the zoom transition is what makes it fatal.

Steps to reproduce
Run the PagedTabViewCoverRepro scheme (https://github.com/matheusmdutra03/PagedTabViewCoverRepro). The bottom tab bar selects the scenario; each screen explains the cause and shows the steps.

  1. "Page list changes": tap Add a tab, then tap any photo.
  2. "Page content changes": tap Reload tab content, then tap any photo.

Expected
The photo opens and stays until Close.

Actual
Zoom on: the app crashes with "Cannot morph from a view that is not in the hierarchy." Zoom off: the photo closes by itself about 1 s later. The on-screen counter shows "Dismissed by system".

Why we think it happens

  • The page-style TabView tracks the presentations made from its tabs, keyed by each tab's identity in the ForEach.
  • Scenario 1 changes that identity directly: the tab list is diffed.
  • Scenario 2 changes it indirectly. Group adds no view of its own, so the tab is whichever switch branch is active. When state flips, the branch changes type and SwiftUI sees a different view in that slot. For the TabView that is the same as removing the tab and inserting a new one.
  • Either way the tracking is rebuilt and the tab's fullScreenCover loses the presentation it owns. Every present after that is treated as orphaned: SwiftUI dismisses it, presents a second copy, and dismisses that too (captured with method hooks on present/dismiss in the sample).
  • With .zoom the final dismissal lands while the cover is still animating in and its view is not in the window yet; UIKit's zoom-out has no fallback for that and asserts.
  • The broken state lives in the TabView subtree: recreating only the TabView clears it, and a cover attached outside the TabView is never affected.

What fixed it in the sample

  • Scenario 1: .id(tabs.map(\.title)) on the TabView, so a list change recreates the TabView instead of diffing it. 20/20 clean. Does not help scenario 2.
  • Scenario 2: ZStack { switch state { … } } instead of Group { … } as the tab's root. ZStack is a real view, so the tab keeps its identity and the switch only changes its content. 20/20 clean. Does not help scenario 1.
  • Both are app-side workarounds, one per trigger. A standard TabView, a paging ScrollView, or a cover attached outside the TabView never reproduce.

Suggested fix

  • Keep each tab's presentation bookkeeping bound to its ForEach identity across list diffs and root-identity changes, so a tab's fullScreenCover/sheet never loses the presentation it owns.
  • If SwiftUI still has to dismiss a presentation it no longer tracks, do it without animation while the presented view is not in the window, so the UIKit zoom transition cannot assert.

Results (iOS 27.1, 20 presents each)

  • Scenario 1: 0 clean · 9 dismissed by system · 11 crashes. With .id(tabs): 20 clean.
  • Scenario 2: 0 clean · 0 dismissed · 20 crashes. With ZStack: 20 clean.

Attachments
Evidence/ has crash logs (iOS 27.1 and 26.5), a screen recording, and the raw trial logs. The ZoomDismissCrashRepro scheme in the same project reproduces the UIKit assertion with five public-API calls and no SwiftUI.

Thanks for putting together such a detailed repro. One possible workaround to try in the shipping hierarchy is to move the fullScreenCover(item:) modifier and the selected-photo state above the page-style TabView, so the presentation is owned by a stable parent instead of a tab that can be replaced during a diff. Your repro already suggests that placing the cover outside the TabView avoids the failure. Apple's API describes the item binding as the source of truth for the presentation: https://developer.apple.com/documentation/swiftui/view/fullscreencover(item:ondismiss:content:)

The .id(...) and stable ZStack changes you tested look like useful targeted workarounds for the two separate identity changes. I'd keep the regression test around each trigger and file the minimal reproducer with Feedback Assistant, including the crash logs across OS versions. I wouldn't treat the UIKit assertion as proof of a documented SwiftUI contract, but the deterministic repro and crash volume should make a strong bug report.

@Pokpong Thanks for the reply!

  • Moving the presentation above the TabView is exactly what we validated as the long-term fix: with a single fullScreenCover owned by the parent and the selected item lifted out of the tabs, none of the triggers reproduce in a lab with our production hierarchy, and the zoom still animates from the tapped cell using the tab’s namespace.

  • We shipped the .id and ZStack workarounds first because they’re one-line. Lifting the cover is an architectural change for us: today each tab embeds a reusable grid component that owns its own presentation (view model, zoom namespace, present/dismiss callbacks), so the parent would have to take over that ownership, consolidate the per-tab state into one, and coordinate with the sheets it already presents. That’s the follow-up.

  • Agreed that these are two separate reports, one per framework. FB25287840 covers the SwiftUI side: the page-style TabView dismisses a fullScreenCover on its own after its tabs change, with the item binding never set to nil. I’m filing a second one for UIKit: dismiss(animated:) during a queued zoom presentation asserts with “Cannot morph from a view that is not in the hierarchy” instead of falling back, reproducible with five public-API calls and no SwiftUI. I’ll cross-reference them.