Summary
- A
TabViewwith.tabViewStyle(.page)builds its tabs withForEach; each tab presents afullScreenCover(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.loadingto.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 isSheetBridge.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.
- "Page list changes": tap Add a tab, then tap any photo.
- "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.
Groupadds no view of its own, so the tab is whicheverswitchbranch is active. Whenstateflips, 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
fullScreenCoverloses 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 onpresent/dismissin the sample). - With
.zoomthe 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 ofGroup { … }as the tab's root.ZStackis a real view, so the tab keeps its identity and theswitchonly 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
ForEachidentity across list diffs and root-identity changes, so a tab'sfullScreenCover/sheetnever 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.