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?
=========== 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.
=========== 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)
}
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)