swiftuiuikituihostingcontroller

DismissAction is not working in a SwiftUI view inside a UIHostingController


I have a SwiftUI view that will be presented inside a UIHostingController in a larger app. I want the SwiftUI view to be able to dismiss itself when the user taps a "Done" button in its toolbar. As I understand it, this can be done with the DismissAction environment variable.

Unfortunately, dismiss seems to be a no-op for me. I created an isolated example in a fresh project to re-create the problem. Here it is:

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

  var body: some View {
    Text("Hello, World!")
      .toolbar {
        ToolbarItem(placement: .topBarTrailing) {
          Button("Done") {
            dismiss()
          }
        }
      }
  }
}

class ViewController: UIViewController {
  override func viewDidAppear(_ animated: Bool) {
    super.viewDidAppear(animated)
    
    let navController = UINavigationController(rootViewController: UIHostingController(rootView: TestView()))
    self.present(navController, animated: true)
  }
}

I'm using an example XCode storyboard project, which creates ViewController as the window's root controller.

When I run the app, the viewDidAppear method is called on ViewController, and it shows a navigation controller containing my SwiftUI view.

However, nothing at all happens when I tap the "Done" button. What am I missing here?


Solution

  • The dismiss action of a hosting VC embedded in a UINavigationController would try to pop the currently presented VC. Since your hosting VC is already the root, nothing happens. The hosting VC won't look up its hierarchy and see that it is being presented modally.

    Instead of using a UINavigationController, embed the SwiftUI view in a SwiftUI NavigationStack, and present that directly.

    struct Root: View {
        var body: some View {
            NavigationStack {
                TestView()
            }
        }
    }
    
    self.present(UIHostingController(rootView: Root()), animated: true)
    

    If you must use a UINavigationController, you can inject your own dismiss environment value:

    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        let navController = UINavigationController()
        navController.viewControllers = [UIHostingController(
            rootView: TestView()
                .environment(\.customDismiss) {
                    navController.dismiss(animated: true)
                }
        )]
        self.present(navController, animated: true)
    }
    
    extension EnvironmentValues {
        @Entry var customDismiss: @MainActor () -> Void = {}
    }
    
    // In the SwiftUI view you need to read customDimiss,
    @Environment(\.customDismiss) var dismiss
    

    If you also want to reuse TestView in a SwiftUI environment, it might be desirable to have customDismiss do whatever dismiss would have done if customDismiss is not set. In that case, write a custom EnvironmentValues property like this, instead of using @Entry,

    struct CustomDismissKey: EnvironmentKey {
        static let defaultValue: (@MainActor () -> Void)? = nil
    }
    
    extension EnvironmentValues {
        var customDismiss: @MainActor () -> Void {
            get {
                self[CustomDismissKey.self] ?? self.dismiss.callAsFunction
            }
            set {
                self[CustomDismissKey.self] = newValue
            }
        }
    }