Search code examples
iosswiftuiuikit

NavigationStack with path doesn't work properly inside UIHostingController


When UIHostingController's rootView is wrapped in a NavigationStack, navigation by means of NavigationStack's path doesn't work: Even though a value is appended to the NavigationPath, the corresponding view is not pushed.

Curiously enough, if NavigationStack is initialized with a non-empty path, it shows the stack correctly:

Incorrect behavior of NavigationStack inside UIHostingController

(On the animation, the screen with the "Show first" button is a UIViewController, which modally presents a SwiftUI view wrapped in a UIHostingController (the view with the button "Open next".) "Open next" modifies path of the view, trying to push another SwiftUI view, with the "Next" text label.

This is a minimal reproducible example, stripped of all irrelevant code:

class HomeViewController: UIViewController {

  class NavigationModel: ObservableObject {

    enum Path {
      case second
    }

    @Published var navigationPath = NavigationPath()

  }

  @ObservedObject var navigationModel = NavigationModel()

  override func viewDidLoad() {
    super.viewDidLoad()

    view.backgroundColor = .white

    let button = UIButton(type: .system)
    button.translatesAutoresizingMaskIntoConstraints = false
    button.setTitle("Show first", for: .normal)
    button.addTarget(self, action: #selector(presentSecondViewController), for: .touchUpInside)

    view.addSubview(button)
    NSLayoutConstraint.activate([button.centerXAnchor.constraint(equalTo: view.centerXAnchor), button.centerYAnchor.constraint(equalTo: view.centerYAnchor)])
  }

  @objc func presentSecondViewController() {
    let view = NavigationStack(path: $navigationModel.navigationPath) {
      Button("Open next") { self.navigationModel.navigationPath.append(NavigationModel.Path.second) }
        .navigationDestination(for: NavigationModel.Path.self) {
          switch $0 {
            case .second:
              SecondView()
          }
        }
    }

    let vc = UIHostingController(rootView: view)
    present(vc, animated: true)
  }

}

struct SecondView: View {
  var body: some View {
    Text("Next")
  }
}

However, the same approach works in the pure SwiftUI: If the root view modally presents another view wrapped in a NavigationStack, all modifications of the corresponding path result in the expected behavior:

Correct behavior of NavigationStack in pure SwiftUI

Here's the code of the working example:

@main struct SwiftUIPlaygroundApp: App {

  class NavigationModel: ObservableObject {

    enum Path {
      case second
    }

    @Published var navigationPath = NavigationPath()

  }

  var body: some Scene {
    WindowGroup {
      Button("Show first") { firstShown = true }
        .sheet(isPresented: $firstShown) {
          NavigationStack(path: $navigationModel.navigationPath) {
            Button("Open next") { navigationModel.navigationPath.append(NavigationModel.Path.second) }
              .navigationDestination(for: NavigationModel.Path.self) {
                switch $0 {
                  case .second:
                    SecondView()
                }
              }
          }
        }
    }
  }
  @State var firstShown = false
  @ObservedObject var navigationModel = NavigationModel()

}

struct SecondView: View {
  var body: some View {
    Text("Next")
  }
}

How could I make it work properly inside UIHostingController?

Any guidance is appreciated.


Solution

  • As lorem ipsum correctly pointed out for me, the problem is in @ObservedObject, which doesn't work outside a SwiftUI view. If NavigationModel is injected to the UIHostingController's root view, navigation works as expected.

    However, it also forces us to move NavigationStack and the corresponding navigationDestination view modifier inside the view as well. This breaks the idea of separating views from navigation, and SwiftUI views become "aware of each other."

    To partially solve the problem of hardcoded navigation, we can inject the destination block from the outside, like this:

    struct FirstView<Destination: View>: View {
    
      var body: some View {
        NavigationStack(path: $navigationModel.navigationPath) {
          Button("Open next") {
             self.navigationModel.navigationPath.append(HomeViewController.NavigationModel.Path.second)
          }
            .navigationDestination(for: HomeViewController.NavigationModel.Path.self, destination: navigationDestination)
        }
      }
      @ViewBuilder private let navigationDestination: (HomeViewController.NavigationModel.Path) -> Destination
      @ObservedObject private var navigationModel: HomeViewController.NavigationModel
    
      init(navigationModel: HomeViewController.NavigationModel, @ViewBuilder navigationDestination: @escaping (HomeViewController.NavigationModel.Path) -> Destination) {
        self.navigationModel = navigationModel
        self.navigationDestination = navigationDestination
      }
    
    }
    

    This is not a perfect solution, of course, but it solves the problem of the views being "aware" of each other.

    This is the updated code related to the UIHostingController:

    @objc func presentSecondViewController() {
      let view = FirstView(navigationModel: navigationModel) {
        switch $0 {
          case .second:
            SecondView()
        }
      }
    
      let vc = UIHostingController(rootView: view)
      present(vc, animated: true)
    }