swiftswiftuiuikit

NavigationStack in a custom presented view is connected to NavigationStack of the presenting view


I need to present a view with custom presentation style. I have implemented CustomPresentationView that presents UIHostingController using a plain UIViewController:

struct CustomPresentationView<ViewContent: View>: UIViewControllerRepresentable {
    typealias UIViewControllerType = UIViewController
    
    class Coordinator: NSObject, UIAdaptivePresentationControllerDelegate {
        @Binding private var isPresented: Bool
        
        init(isPresented: Binding<Bool>) {
            self._isPresented = isPresented
            super.init()
        }
        
        func presentationControllerDidDismiss(_ presentationController: UIPresentationController) {
            isPresented = false
        }
    }
    
    @Binding private var isPresented: Bool
    @ViewBuilder private let content: () -> ViewContent
    
    init(isPresented: Binding<Bool>, @ViewBuilder content: @escaping () -> ViewContent) {
        self._isPresented = isPresented
        self.content = content
    }
    
    func makeUIViewController(context: Context) -> UIViewControllerType {
        UIViewController()
    }
    
    func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) {
        if isPresented {
            if uiViewController.presentedViewController == nil {
                let view = content()
                    .environment(\.close, CloseAction(isPresented: $isPresented))
                let hostingViewController = UIHostingController(rootView: view)
                hostingViewController.presentationController?.delegate = context.coordinator
                uiViewController.present(hostingViewController, animated: true)
            }
        } else if
            let presentedViewController = uiViewController.presentedViewController,
            !presentedViewController.isBeingDismissed
        {
            presentedViewController.dismiss(animated: true)
        }
    }
    
    func makeCoordinator() -> Coordinator {
        Coordinator(isPresented: $isPresented)
    }
}

and a modifier looks like this:

struct CustomPresentationModifier<ViewContent: View>: ViewModifier {
    @Binding private var isPresented: Bool
    @ViewBuilder private let viewContent: () -> ViewContent
    
    public init(isPresented: Binding<Bool>, @ViewBuilder viewContent: @escaping () -> ViewContent) {
        self._isPresented = isPresented
        self.viewContent = viewContent
    }
    
    func body(content: Content) -> some View {
        content.background(CustomPresentationView(isPresented: _isPresented, content: viewContent))
    }
}

public extension View {
    func customPresentation<ViewContent: View>(isPresented: Binding<Bool>, @ViewBuilder viewContent: @escaping () -> ViewContent) -> some View {
        modifier(CustomPresentationModifier(isPresented: isPresented, viewContent: viewContent))
    }
}

Everything seems to work fine except one thing: it works weird if content is wrapped in NavigationStack. However it works fine with native .sheet() modifier.

Pay attention at the red button in the top right corner. With custom presentation it appears on the parent view.

If i remove the top level NavigationStack or move .customPresentation() out of it, it is got fixed. But this is not an option for me due to navigation architecture.

Here is the full project https://github.com/claustrofob/PresentedNavigationStack

How can i fix it?

Demo of a problem

=========== UPDATE ===========

The thing looks even more weird if i try to push in presented view. With custom presenter it pushes to the parent navigation stack.

enter image description here

=========== UPDATE 2 with solution ===========

@Immanuel solution works but not exactly the way i wanted it to have. But it gave me a confidence that it is possible to get an easy fix on that.

And the solution is simple, i just need to wrap the UIHostingController creation in DispatchQueue.main.async. It is somehow breaks that weird connection between 2 navigation stacks.

DispatchQueue.main.async {
    let hostingViewController = UIHostingController(rootView: view)
    hostingViewController.presentationController?.delegate = context.coordinator
    uiViewController.present(hostingViewController, animated: true)
}

Solution

  • Issues - 1) custom presentation appears on the parent view, 2) pushes view to the parent navigation stack. The both issues are happening when trying to present the controller in updateUIViewController function.

    The solution was to move the button inside the view controller and present the controller on button action. And remove the present logic from updateUIViewController function. Because of this you have to manually set the size for 'CustomPresentationView' and 'UIButton'.

    struct CustomPresentationView<ViewContent: View>: UIViewControllerRepresentable {
    
        typealias UIViewControllerType = UIViewController
        
        @Binding var isPresented: Bool
        private let content: () -> ViewContent
        
        init(isPresented: Binding<Bool>, @ViewBuilder content: @escaping () -> ViewContent) {
            self._isPresented = isPresented
            self.content = content
        }
        
        func makeCoordinator() -> Coordinator {
            Coordinator(controller: self)
        }
        
        func makeUIViewController(context: Context) -> UIViewControllerType {
            let controller = CustomUIViewController(isPresented: $isPresented, content: content)
            controller.coordinator = context.coordinator
            return controller
        }
        
        func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) {}
        
        class Coordinator: NSObject, UIAdaptivePresentationControllerDelegate {
            
            let controller: CustomPresentationView<ViewContent>
            
            init(controller: CustomPresentationView<ViewContent>) {
                self.controller = controller
            }
            
            func presentationControllerDidDismiss(_ presentationController: UIPresentationController) {
                controller.isPresented = false
            }
            
        }
        
        class CustomUIViewController: UIViewController {
            
            @Binding var isPresented: Bool
            private let content: () -> ViewContent
            weak var coordinator: Coordinator?
            
            init(isPresented: Binding<Bool>, @ViewBuilder content: @escaping () -> ViewContent) {
                self._isPresented = isPresented
                self.content = content
                super.init(nibName: nil, bundle: nil)
            }
            
            required init?(coder: NSCoder) {
                fatalError("init(coder:) has not been implemented")
            }
            
            override func viewDidLoad() {
                super.viewDidLoad()
                configureButton()
            }
            
            func configureButton() {
                var configuration = UIButton.Configuration.gray()
                configuration.title = "Present custom"
                configuration.cornerStyle = .capsule
                configuration.baseForegroundColor = UIColor.red
                configuration.buttonSize = .large
                
                let button = UIButton(type: .custom)
                button.configuration = configuration
                view.addSubview(button)
                
                button.translatesAutoresizingMaskIntoConstraints = false
                button.widthAnchor.constraint(equalToConstant: 150).isActive = true
                button.heightAnchor.constraint(equalToConstant: 80).isActive = true
                button.centerXAnchor.constraint(equalTo: view.centerXAnchor).isActive = true
                button.centerYAnchor.constraint(equalTo: view.centerYAnchor).isActive = true
                
                button.addAction(UIAction(
                    handler: { [weak self] _ in
                        self?.onButtonTap()
                    }
                ),for: .touchUpInside)
            }
            
            func onButtonTap() {
                let view = content()
                    .environment(\.close, CloseAction(isPresented: $isPresented))
                let hostingViewController = UIHostingController(rootView: view)
                hostingViewController.presentationController?.delegate = coordinator
                present(hostingViewController, animated: true, completion: { [weak self] in
                    self?.isPresented = true
                })
            }
            
        }
        
    }
    

    Usage in view

     CustomPresentationView(isPresented: $isPresentedCustom) {
        NavigationStack {
             PresentedView(title: "Presented custom")
         }
     }
     .frame(width: 150, height: 80)