SwiftUI opaque View type compilation error

Hi everyone, wanted to subclass UIHostController or add new initialization for it using extension, but can't compile it. The strange thing is it compiles when used in code.

compiles:

if #available(iOS 13.0, *) {
                let menuModel = MenuModel()
                let menuView = MenuView().environmentObject(menuModel)
                let menuHostVC = UIHostingController(rootView: menuView)
                presenter.viewController.present(menuHostVC, animated: true)

doesn't compile, Cannot convert value of type 'some View' to expected argument type 'MenuView':

#if canImport(SwiftUI)
import SwiftUI
#endif

@available(iOS 13.0, *)
extension UIHostingController where Content == MenuView {
    static func create(withMenu model: MenuModel) -> UIHostingController {
        let menuView = MenuView().environmentObject(model)
        return UIHostingController(rootView: menuView)
    }
}

in code below, the super.init expects MenuView, but viewWithModel has some View type and it's not possible to apply force cast to it, because it can't be casted to MenuView.

@available(iOS 13.0, *)
final class MenuViewController: UIHostingController<MenuView> {
    init(model: MenuModel) {
        let viewWithModel = MenuView().environmentObject(model)
        super.init(rootView: viewWithModel)
    }
    
    @objc required dynamic init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

So that, basically the question is why it works in 1st code snippet?

That error is expected as you're using environmentObject modifier which wraps/nests your MenuView into ModifiedContent<MenuView, _EnvironmentKeyWritingModifier<Optional<MenuModel>>>.

extension View {
  @inlinable 
  public func modifier<T>(_ modifier: T) -> ModifiedContent<Self, T> {
    .init(content: self, modifier: modifier)
  }
}

extension View {
  @inlinable 
  public func environment<V>(
    _ keyPath: WritableKeyPath<EnvironmentValues, V>, 
    _ value: V
  ) -> some View {
    modifier(_EnvironmentKeyWritingModifier(keyPath: keyPath, value: value))
  }
}

extension View {
  @inlinable 
  public func environmentObject<B>(
    _ bindable: B
  ) -> some View where B: Combine.ObservableObject {
    environment(B.environmentStore, bindable)
  }
}

The first example however compiles because it's Content type is inferred by the compiler, which again isn't just MenuView.

PS: The above code is taken from arm64.swiftinterface file which is available somewhere deep inside the Frameworks folder of your Xcode.app.

1 Like

yes, I saw in debugger that environmentObject returns some complex wrapper type, but why in extension it can't infer type? e.g. in next snippet type constraint is removed, but now compiler shows different error Cannot convert value of type 'some View' to expected argument type 'Content':

extension UIHostingController {
    static func create(menu model: MenuModel) -> UIHostingController {
        let menuView = MenuView().environmentObject(model)
        return UIHostingController(rootView: menuView)
    }

so, is it possible to accomplish successful compilation for extension? I even tried to set Content constraint to that complex wrapper type, but it showed same error.

Well you kinda want the compiler to infer the returning type for the result, which is what opaque result types are meant for, but I don’t think they are extended to support classes other types than protocol existentials yet. In your case you either want some UIHostingController or UIHostingController<some View> as the result type.

I think you can workaround your issue by nesting your view.

struct WorkaroundMenuView: View {
  let model: MenuModel
  var body: some View {
    MenuView().environmentObject(model)
  }
}

extension UIHostingController where Content == WorkaroundMenuView {
  static func create(withMenu model: MenuModel) -> UIHostingController {
    let menuView = WorkaroundMenuView(model: model)
    return UIHostingController(rootView: menuView)
  }
}

I haven’t tested if it compiles, but it should if there are no typos.

1 Like

wow, big thank you, this is actually a good solution, I can make original view type as fileprivate

1 Like

If you can modify MenuView why not make it hold the model directly and inject the model from within MenuView where it’s needed?

It's because I'm not sure how to call .environmentObject inside body var body: some View, because obviously subviews are using model.

var body: some View {
        NavigationView {
            List {
                Toggle(isOn: $model.isDohEnabled) {
                    Text(verbatim: .dohMenuTitle)
                }
            }
            .navigationBarTitle(Text(verbatim: .menuTtl))
            .navigationBarItems(trailing: Button<Text>(String.dismissBtn, action: model.dismissAction))
        }
        
    }

if I append/call it after NavigationView, the app will crash on Toggle(isOn:

Okay I see. You could still make something like this:

struct MenuView: View {
  @ObservedObject
  var model: MenuModel
  var body: some View { ... }
}

ObservedObject will provide a projected value as $model and it theoretically should use dynamic key-path lookup to produce a binding, so you will be able to keep $model.isDohEnabled.

Is there any other place where you'd need the MenuModel in the view hierarchy? Judging from your code snippet, the answer will be "no". That said, you don't really need to inject it though the environment, instead it's totally fine to pass it through the initializer and wrap it inside ObservedObject for automatic update invalidations.