Skip to content

Public API to detach FlowStack presentation context when using SwiftUI .fullScreenCover / .sheet #113

Description

@kaitoshadow

Summary
When presenting a child coordinator that owns its own navigation state (FlowStack with a FlowPath / typed routes binding) via SwiftUI’s .fullScreenCover (or .sheet), the child inherits the parent FlowStack’s environment — including parentNavigationStackType and flowStackDataType. If the child then pushes screens, FlowStacks triggers the nesting assertion on iOS 16+ when the parent uses NavigationStack:

Unsupported nesting of FlowStacks. It is not possible to push from a child FlowStack when the navigation stack
is owned by the parent, where the child is also managing its own state...
FlowStacks already avoids this when using navigator.presentCover / presentSheet, because View+cover.swift clears the parent context:

content: { destination.environment(.parentNavigationStackType, nil) }
There is no public equivalent for app code that must use SwiftUI’s presentation modifiers (e.g. reusable ViewModifiers that are not inside a single parent coordinator and do not have access to FlowPathNavigator).

Use case
We have a reusable patient-picker modifier used across multiple screens. The UX requires:

Dismiss a bottom popup first
Then present a full-screen identity-verification flow in onDismiss
This choreography is natural with SwiftUI .fullScreenCover + @State, but cannot easily be replaced by router.presentCover because the modifier is attached to arbitrary host views and does not own a parent FlowPathNavigator.

Simplified reproduction pattern:

struct FFSelectPatientPopupModifier: ViewModifier {
    @Binding var isPresented: Bool
    @State private var isCreateRelativePresented = false
    func body(content: Content) -> some View {
        content
            .presentPopupView(
                isPresented: $isPresented,
                onDismiss: {
                    // After popup dismisses, present full-screen flow
                    isCreateRelativePresented = true
                },
                content: { /* patient picker */ }
            )
            .fullScreenCover(isPresented: $isCreateRelativePresented) {
                GIGlobalIdentityFlow(
                    onExit: {
                        isCreateRelativePresented = false
                    },
                    onCompleted: { patient in
                        onPatientPicked(patient)
                        isCreateRelativePresented = false
                    }
                )
            }
    }
}

GIGlobalIdentityFlow is a coordinator using FlowStack($path, withNavigation: true) (own FlowPath state) and calls pushScreen for subsequent screens (e.g. scan ID → review). That matches Approach 2 (independent child state) in Nesting FlowStacks, but the child is presented via SwiftUI cover instead of presentCover, so the parent navigation context is not cleared.

Expected behavior
A child coordinator with its own FlowPath / routes binding, presented modally via SwiftUI .fullScreenCover or .sheet, should be able to push screens without nesting assertions — equivalent to presenting via FlowStacks presentCover.

Actual behavior
When the host view is inside a parent FlowStack (especially with NavigationStack on iOS 16+ / default useNavigationStack policy on iOS 26+), the child inherits parentNavigationStackType == .navigationStack and parentFlowStackDataType != nil. On first push, FlowStacks fires assertionFailure in FlowStack.swift (condition includes !deferToParentFlowStack).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions