53

I have rewritten my sign in view controller as a SwiftUI View. The SignInView is wrapped in a UIHostingController subclass (final class SignInViewController: UIHostingController<SignInView> {}), and is presented modally, full screen, when sign in is necessary.

Everything is working fine, except I can't figure out how to dismiss the SignInViewController from the SignInView. I have tried adding:

@Environment(\.isPresented) var isPresented

in SignInView and assigning it to false when sign in is successful, but this doesn't appear to interop with UIKit. How can I dismiss the view?

jjatie
  • 5,152
  • 5
  • 34
  • 56
  • This seems like incorrect behaviour to me on the part of SwiftUI. I've opened an item in Apple's Feedback Assistant about this [here](https://feedbackassistant.apple.com/feedback/9116652). – Adil Hussain May 24 '21 at 11:45
  • 1
    It just occurred to me that bug reports which I create in Apple's Feedback Assistant are visible to me only and not visible to other developers. That's a shame. The bug report which I created describes how a `UIHostingController` object presented modally from a `UIViewController` cannot be dismissed programmatically by means of `presentationMode.wrappedValue.dismiss()`. I've created a small, minimal UIKit application which demonstrates the problem [here](https://github.com/adil-hussain-84/SwiftUIExperiments/tree/master/App2). – Adil Hussain May 25 '21 at 13:07
  • This issue is resolved in Xcode 13.0 beta 5. I tested it against an iPhone 15.0 simulator. – Adil Hussain Aug 27 '21 at 11:13

13 Answers13

53

I found another approach that seems to work well and which feels a little cleaner than some of the other approaches. Steps:

  1. Add a dismissAction property to the SwiftUI view:
struct SettingsUIView: View {
    var dismissAction: (() -> Void)
    ...
}    
  1. Call the dismissAction when you want to dismiss the view:
Button(action: dismissAction ) {
    Text("Done")
}
  1. When you present the view, provide it with a dismissal handler:
let settingsView = SettingsUIView(dismissAction: {self.dismiss( animated: true, completion: nil )})
let settingsViewController = UIHostingController(rootView: settingsView )

present( settingsViewController, animated: true )
Sean McMains
  • 57,907
  • 13
  • 47
  • 54
  • 2
    I'm not sure what best practice is, but I really like this answer and people who scroll down for it will be happy they did IMHO. In my usage I've renamed dismissAction to onComplete and it takes an optional error. This makes it very similar to how I like to handle dialogs that may complete with success or failure and allow the presenting host to dismiss it and handle the result appropriately. – biomiker Apr 19 '20 at 17:56
  • 4
    With that approach you won't be able to dismiss the `settingsViewController`, only the parent view controller since it's using `self` inside the `dismissAction`. – ricardopereira Jul 02 '20 at 16:45
  • Unsure why this is not the accepted answer. It's precise, brief and in line with how a closure is normally used in swift in general to dismiss vc's. –  Apr 12 '21 at 16:05
50

UPDATE: From the release notes of iOS 15 beta 1:

isPresented, PresentationMode, and the new DismissAction action dismiss a hosting controller presented from UIKit. (52556186)


I ended up finding a much simpler solution than what was offered:


final class SettingsViewController: UIHostingController<SettingsView> {
    required init?(coder: NSCoder) {
        super.init(coder: coder, rootView: SettingsView())
        rootView.dismiss = dismiss
    }

    func dismiss() {
        dismiss(animated: true, completion: nil)
    }
}

struct SettingsView: View {
    var dismiss: (() -> Void)?
    
    var body: some View {
        NavigationView {
            Form {
                Section {
                    Button("Dimiss", action: dismiss!)
                }
            }
            .navigationBarTitle("Settings")
        }
    }
}
jjatie
  • 5,152
  • 5
  • 34
  • 56
  • 2
    Why make the dismiss closure optional and then force unwrap it... – Luis Jun 06 '20 at 19:06
  • 1
    It's unreasonable to expect production ready code on SO. `dismiss` must be assigned after initialization because of `UIHostingController`'s requirements. The correct thing to do (mapping the optional to a button) is probably less readable for those newer to Swift and SwiftUI. – jjatie Jun 08 '20 at 15:45
  • I understand. You could also just call the completed closure from within the closure of the button handler. – Luis Jun 08 '20 at 20:13
  • The problem with that is you can have a button that does nothing. – jjatie Jun 12 '20 at 10:06
  • 2
    @jjatie How do you create the `SettingsViewController` since it's expecting the `NSCoder`? – ricardopereira Jul 02 '20 at 16:46
  • @ricardopereira If you're using a storyboard, the you can use a segue action. – Doug Jul 05 '20 at 14:04
  • 1
    How do you call it programmatically? Not sure how to use the NSCoder – fphelp Jul 10 '20 at 01:30
  • @fphelp you'll have to be more specific. – jjatie Jul 10 '20 at 13:09
  • @fphelp, if you want to do it all programmatically (no storyboards, no nibs), then you'd have to do something like this: ``` private final class SettingsViewController: UIHostingController { init() { super.init(rootView: SettingsView()) rootView.dismiss = dismiss } @objc required dynamic init?(coder aDecoder: NSCoder) { fatalError("init(coder:) has not been implemented") } func dismiss() { dismiss(animated: true, completion: nil) } } ``` – willtherussian Jul 12 '21 at 01:34
16

All the provided answers here didn't work for me, probably because of some weak reference. This is the solution I came up with:

Creating the view and UIHostingController:

let delegate = SheetDismisserProtocol()
let signInView = SignInView(delegate: delegate)
let host = UIHostingController(rootView: AnyView(signInView))
delegate.host = host
// Present the host modally 

SheetDismisserProtocol:

class SheetDismisserProtocol: ObservableObject {
    weak var host: UIHostingController<AnyView>? = nil

    func dismiss() {
        host?.dismiss(animated: true)
    }
}

The view that has to be dismissed:

struct SignInView: View {
    @ObservedObject var delegate: SheetDismisserProtocol

    var body: some View {
        Button(action: {
            self.delegate.dismiss()
        })
    }
}
milo
  • 936
  • 7
  • 18
10

Another approach (relatively easier in my opinion) would be to have an optional property type of UIViewController in your SwiftUI view and then set it to the viewController that will present UIHostingController which will be wrapping your SwiftUI view.

A simple SettingsView:

struct SettingsView: View {
    
    var presentingVC: UIViewController?
    
    var body: some View {
        Button(action: {
            self.presentingVC?.presentedViewController?.dismiss(animated: true)
        }) {
            Text("Dismiss")
        }
    }
}

Then when you present this view from a view controller using UIHostingController:

class ViewController: UIViewController {

    private func presentSettingsView() {
        var view = SettingsView()
        view.presentingVC = self
        let hostingVC = UIHostingController(rootView: view)
        present(hostingVC, animated: true, completion: nil)
    }
}

Now as you can see in the action of the Button in SettingsView, we are going to talk to ViewController to dismiss the view controller it is presenting, which in our case will be the UIHostingController that wraps SettingsView.

emrepun
  • 2,496
  • 2
  • 15
  • 33
  • I get the error: Instance member 'presentingVC' cannot be used on type 'SettingsView' when typing: SettingsView().presentingVC = self – Abv May 01 '21 at 15:51
  • This will cause the controller to leak memory as it'll have a cyclic dependency with the child view. – thecoolwinter Dec 24 '22 at 04:49
  • But how? `ViewController` does not have a reference to `SettingsView`, or the `UIHostingController` it presents. Then, `SettingsView` only keeps a reference to `ViewController`, not the `UIHostingController` it was added as a root view. Did you try this out and observed the leak in the memory debugger? @thecoolwinter – emrepun Dec 24 '22 at 09:40
9

You could just use notifications.

Swift 5.1

In the SwiftUI button handler:

NotificationCenter.default.post(name: NSNotification.Name("dismissSwiftUI"), object: nil)

In the UIKit view controller:

NotificationCenter.default.addObserver(forName: NSNotification.Name("dismissSwiftUI"), object: nil, queue: nil) { (_) in
    hostingVC.dismiss(animated: true, completion: nil)
}
Jayden Irwin
  • 921
  • 9
  • 14
  • Does it matter where you put the observer? Whether it's in the classes init() or viewDidLoad()? – Brody Higby May 29 '20 at 05:29
  • 1
    Not sure. I used viewDidLoad() – Jayden Irwin Jun 10 '20 at 02:05
  • 4
    Please don't use NotificationCenter for this kind of situations. You can easily lose track of notifications you observe. It's an easy mechanism to broadcast information from one class to registered listeners and it's perfect for system wide notifications not for specific implementations like this. – ricardopereira Jul 02 '20 at 17:08
  • simple way before fullScreenCover at iOS 14 – ytp92 Jul 08 '20 at 05:21
  • Agree with @ricardopereira a global notification is not the best tool to implement a a callback. – malhal Sep 13 '20 at 13:12
  • hmmm dont like this pattern, since in new UI , you can present more two views at the same time – justicepenny Dec 16 '20 at 22:19
7
let rootView = SignInView();
let ctrl = UIHostingController(rootView: rootView);
ctrl.rootView.dismiss = {[weak ctrl] in
    ctrl?.dismiss(animated: true)
}
present(ctrl, animated:true, completion:nil);

pay attention: ctrl.rootView.dismiss not rootView.dismiss

john07
  • 562
  • 6
  • 16
6

What about extend environment values with hosting controller presenter? It allows to be used like presentationMode, from any view in the hierarchy and it is easily reusable and scalable. Define your new environment value:

struct UIHostingControllerPresenter {
    init(_ hostingControllerPresenter: UIViewController) {
        self.hostingControllerPresenter = hostingControllerPresenter
    }
    private unowned var hostingControllerPresenter: UIViewController
    func dismiss() {
        if let presentedViewController = hostingControllerPresenter.presentedViewController, !presentedViewController.isBeingDismissed { // otherwise an ancestor dismisses hostingControllerPresenter - which we don't want.
            hostingControllerPresenter.dismiss(animated: true, completion: nil)
        }
    }
}

private enum UIHostingControllerPresenterEnvironmentKey: EnvironmentKey {
    static let defaultValue: UIHostingControllerPresenter? = nil
}

extension EnvironmentValues {
    /// An environment value that attempts to extend `presentationMode` for case where
    /// view is presented via `UIHostingController` so dismissal through
    /// `presentationMode` doesn't work.
    var uiHostingControllerPresenter: UIHostingControllerPresenter? {
        get { self[UIHostingControllerPresenterEnvironmentKey.self] }
        set { self[UIHostingControllerPresenterEnvironmentKey.self] = newValue }
    }
}

Then pass the value when needed like:

let view = AnySwiftUIView().environment(\.uiHostingControllerPresenter, UIHostingControllerPresenter(self))
let viewController = UIHostingController(rootView: view)
present(viewController, animated: true, completion: nil)
...

And enjoy using

@Environment(\.uiHostingControllerPresenter) private var uiHostingControllerPresenter
...
uiHostingControllerPresenter?.dismiss()

where you otherwise go with

@Environment(\.presentationMode) private var presentationMode
...
presentationMode.wrappedValue.dismiss() // .isPresented = false
Stanislav Smida
  • 1,565
  • 17
  • 23
4

I had the same problem, and thanks to this post, I could write a mixed solution, to improve usability of the solutions of this post :

final class RootViewController<Content: View>: UIHostingController<AnyView> {
    init(rootView: Content) {
        let dismisser = ControllerDismisser()
        let view = rootView
            .environmentObject(dismisser)

        super.init(rootView: AnyView(view))

        dismisser.host = self
    }

    @objc required dynamic init?(coder aDecoder: NSCoder) {
        super.init(coder: aDecoder)
    }
}

final class ControllerDismisser: ObservableObject {
    var host: UIHostingController<AnyView>?

    func dismiss() {
        host?.dismiss(animated: true)
    }
}

This way, I can just initialize this controller as a normal UIHostingController

let screen = RootViewController(rootView: MyView())

Note : I used an .environmentObject to pass the object to my views that needed it. This way no need to put it in the initializer, or pass it through all the view hierarchy

zarghol
  • 478
  • 5
  • 19
4

This was a bug in Xcode 12 (and most probably earlier versions of Xcode also). It has been resolved in Xcode 13.0 beta 5 and hopefully will continue to be resolved in the stable release of Xcode 13.0. That said, if you're able to build with Xcode 13 and target iOS 15 (or higher), then prefer the EnvironmentValues.dismiss property over the deprecated EnvironmentValues.presentationMode property, as follows:

struct MyView: View {
    
    @Environment(\.dismiss) var dismiss
    
    var body: some View {
        Button("Dismiss") { dismiss() }
    }
}

If you're not able to build with Xcode 13 and target iOS 15, then opt for one of the workarounds proposed in this thread.

Adil Hussain
  • 30,049
  • 21
  • 112
  • 147
  • The first part is correct, this was a bug pre-Xcode 13. `PresentationMode` is now deprecated however. The accepted answer is updated with this information. – jjatie Aug 28 '21 at 01:28
  • Thanks for pointing out that the [EnvironmentValues.presentationMode](https://developer.apple.com/documentation/swiftui/environmentvalues/presentationmode) property and the [PresentationMode](https://developer.apple.com/documentation/swiftui/presentationmode) structure are deprecated in iOS 15. I did not realise that. I've updated my answer accordingly. – Adil Hussain Aug 28 '21 at 14:44
1

I'm not sure whether isPresented will be connected to View's UIHostingController in a future version. You should submit feedback about it.

In the meantime, see this answer for how to access a UIViewController from your Views.

Then, you can just do self.viewController?.dismiss(...).

arsenius
  • 12,090
  • 7
  • 58
  • 76
1

I had a similar issue presenting an instance of UIDocumentPickerViewController.

In this scenario, the UIDocumentPickerViewController is presented modally (sheet), which slightly differs from yours -- but the approach may work for you as well.

I could make it work by conforming to the UIViewControllerRepresentable protocol and adding a callback to dismiss the View Controller inside the Coordinator.

Code example:

SwiftUI Beta 5

struct ContentProviderButton: View {
    @State private var isPresented = false

    var body: some View {
        Button(action: {
            self.isPresented = true
        }) {
            Image(systemName: "folder").scaledToFit()
        }.sheet(isPresented: $isPresented) { () -> DocumentPickerViewController in
            DocumentPickerViewController.init(onDismiss: {
                self.isPresented = false
            })
        }
    }
}

/// Wrapper around the `UIDocumentPickerViewController`.
struct DocumentPickerViewController {
    private let supportedTypes: [String] = ["public.image"]

    // Callback to be executed when users close the document picker.
    private let onDismiss: () -> Void

    init(onDismiss: @escaping () -> Void) {
        self.onDismiss = onDismiss
    }
}

// MARK: - UIViewControllerRepresentable

extension DocumentPickerViewController: UIViewControllerRepresentable {

    typealias UIViewControllerType = UIDocumentPickerViewController

    func makeUIViewController(context: Context) -> DocumentPickerViewController.UIViewControllerType {
        let documentPickerController = UIDocumentPickerViewController(documentTypes: supportedTypes, in: .import)
        documentPickerController.allowsMultipleSelection = true
        documentPickerController.delegate = context.coordinator
        return documentPickerController
    }

    func updateUIViewController(_ uiViewController: DocumentPickerViewController.UIViewControllerType, context: Context) {}

    // MARK: Coordinator

    func makeCoordinator() -> Coordinator {
        Coordinator(self)
    }

    class Coordinator: NSObject, UIDocumentPickerDelegate {
        var parent: DocumentPickerViewController

        init(_ documentPickerController: DocumentPickerViewController) {
            parent = documentPickerController
        }

        func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
            // TODO: handle user selection
        }

        func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) {
            parent.onDismiss()
        }
    }
}
backslash-f
  • 7,923
  • 7
  • 52
  • 80
1

I believe you can use the Environment Variable directly to dismiss:

@Environment(\.presentationMode) var presentationMode

var body: some View {
    Button("Dismiss") {
        presentationMode.wrappedValue.dismiss()
    }
}
Mike Bedar
  • 632
  • 5
  • 14
1

iOS 15 and above

struct MyView: View {
@Environment(\.dismiss) var dismiss

    var body: some View {
        NavigationView {
            Text("Hello World")
                .toolbar {
                    ToolbarItem(placement: .navigationBarTrailing) {
                        Button("Dismiss") {
                            dismiss()
                        }
                    }
                }
       }
   }
}
Jevon718
  • 354
  • 4
  • 10