Search code examples
swiftuilifecycleredrawuiviewcontrollerrepresentable

Generic UIViewControllerRepresentable implementation not reloading based on published property


I'm trying to use a generic wrapper for different UIViewControllers (screens implemented in legacy part of the app in obj-c) and I hit a problem where SwiftUI doesn't redraw the view when the wrapped UIViewController is coming from a published property of my view model while it will redraw the view when it comes from local static property combined with a changing state.

Wrapper view:

struct WrappedViewController: UIViewControllerRepresentable {
    let viewController: UIViewController

    typealias UIViewControllerType = UIViewController

    func makeUIViewController(context: Self.Context) -> UIViewController {
        viewController
    }

    func updateUIViewController(_ uiViewController: UIViewController, context: Self.Context) {}

}

View Model with published property to expose currently selected view controller:



class ViewModel: ObservableObject {
    @Published
    var selectedViewController: UIViewController
    
    private static let redVC = {
        let controller = UIViewController()
        controller.view.backgroundColor = .red
        return controller
    }()
    
    private static let blueVC = {
        let controller = UIViewController()
        controller.view.backgroundColor = .blue
        return controller
    }()
    
    init() {
        self.selectedViewController = Self.redVC
    }
    
    func toggle() {
        if selectedViewController == Self.redVC {
            selectedViewController = Self.blueVC
        } else {
            selectedViewController = Self.redVC
        }
    }
}

View where toggling doesn't reload the UI:

struct ContentView: View {
    @StateObject private var viewModel = ViewModel()
    
    var body: some View {
        Button("Switch") { viewModel.toggle() }
        
        WrappedViewController(viewController: viewModel.selectedViewController)
    }
}

If I remove the view model layer with published property, it works as expected:

struct ContentView: View {
    @State private var viewSwitch: Bool = true
    
    let blueView: UIViewController = {
        let blueViewController = UIViewController()
        blueViewController.view.backgroundColor = .blue
        return blueViewController
    }()
    
    let redView: UIViewController = {
        let redViewController = UIViewController()
        redViewController.view.backgroundColor = .red
        return redViewController
    }()
    
    var body: some View {
        Button("Switch") { viewSwitch.toggle() }
        
        if viewSwitch {
            WrappedViewController(viewController: blueView)
        } else {
            WrappedViewController(viewController: redView)
        }
    }
}

But I need to have the view controllers wrapped in view model. My specific use case is a screen with multiple view controllers and tab bar where tabs are defined as view controllers inside a view model and passed at construction.

Any thoughts on why SwiftUI doesn't re-render the screen? I can see that the published property change triggers reevaluating of the hierarchy (breakpoint in 'body' is hit) but the screen is not re-rendered.


Solution

  • I found out that when clicking the button and updating published property a new WrappedViewController object is created with the new (correct) view controller but then makeUIViewController doesn't get called. Instead only the updateUIViewController gets called and it gets passed the UIViewController instance which I returned from makeUIViewController of the first view:)

    From there, I came up with an ugly but working solution where the view controller I want as input of the representable I need to wrap as a child:

    struct WrappedViewController: UIViewControllerRepresentable {
        let viewController: UIViewController
        
        typealias UIViewControllerType = UIViewController
        
        func makeUIViewController(context: Context) -> UIViewController {
            return UIViewController()
        }
        
        func updateUIViewController(_ uiViewController: UIViewController, context: Context) {
            uiViewController.children.forEach { childController in
                childController.willMove(toParent: nil)
                childController.view.removeFromSuperview()
                childController.removeFromParent()
            }
            
            viewController.willMove(toParent: uiViewController)
            uiViewController.addChild(viewController)
            uiViewController.view.addSubview(viewController.view)
            viewController.view.constrainToSuperview()
        }
    }
    

    This kind of make sense - the UIViewControllerRepresentable is probably trying to create view controllers in the same way as views are created - providing a builder function and update function and SwiftUI infra would decide whether it wants to construct a new view controller instance or reuse the previous one.