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.
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.