Post

Replies

Boosts

Views

Activity

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. "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. 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.
Topic: UI Frameworks SubTopic: SwiftUI
2
0
93
20h
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. "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. 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.
Topic: UI Frameworks SubTopic: SwiftUI
Replies
2
Boosts
0
Views
93
Activity
20h