swiftswiftuiviewios17viewbuilder

How to force to create a new UIViewControllerRepresentable | updating views on makeUIViewController


I have a problem with creating a new UIViewControllerRepresentable, as the makeUIViewController method runs only once in UIViewControllerRepresentable, preventing updates to the new view. What would be the optimal way to modify this code while maintaining the privacy of MyView within ControllerView?

private struct ControllerView<Content: View>: View {
    struct MyView<ContentM: View>: UIViewControllerRepresentable {
        let rootView: ContentM
        
        init(rootView: ContentM) {
            self.rootView = rootView
            print("init MyView")
        }
        
        func makeUIViewController(context: Context) -> UIViewController {
            print("makeUI")
            /// create my custom VC
            return UIHostingController(rootView: rootView)
        }
        func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) {
            print("updateUI")
        }
    }
    
    
    let rootView: () -> Content
    @Binding var isGreen: Bool
    
    init(isGreen: Binding<Bool>,@ViewBuilder rootView: @escaping () -> Content) {
        self.rootView = rootView
        self._isGreen = isGreen
    }
    
    var body: some View {
        ZStack {
            MyView(rootView: rootView())
            
            Button {
                isGreen.toggle()
            } label: {
                Text("Change")
            }
        }
    }
}

private struct GreenView: View  {
    var body: some View {Color.green.ignoresSafeArea()}
}
private struct OrangeView: View  {
    var body: some View {Color.orange.ignoresSafeArea()}
}




struct SwiftUIView21: View {
    @State var isGreen: Bool = true
    
    var body: some View {
        ControllerView(isGreen: $isGreen) {
            if isGreen {
                GreenView()
            } else {
                OrangeView()
            }
        }
    }
}

#Preview {
    SwiftUIView21()
}


Solution

  • While makeUIViewController is not called, updateUIViewController is. You can change the view that is displayed in the UIHostingController there.

    func makeUIViewController(context: Context) -> UIHostingController<ContentM> {
        print("makeUI")
        return UIHostingController(rootView: rootView)
    }
    func updateUIViewController(_ uiViewController: UIHostingController<ContentM>, context: Context) {
        uiViewController.rootView = rootView
        print("updateUI")
    }
    

    Note that this will break any animations you want to do when you toggle between the two views. SwiftUI can't animate uiViewController.rootView = rootView.


    Alternatively, you can change MyView's id every time a toggle happens:

    MyView(rootView: rootView()).id(isGreen)
    

    This recreate a new UIHostingController every time you change views, and can animate the change. However, this only animates the change between two MyViews, which just so happens to look similar to the animation of changing between OrangeView and GreenView (both a cross-fade). If you have other animations like animating the scale of something:

    Text("Foo").scaleEffect(isGreen ? 1 : 2)
    

    Then the animation will still be a cross-fade, not a scale animation.


    A third way I found was to use an @Observable wrapper to wrap the Bool, then put it in the Environment.

    @Observable
    class BoolWrapper: ExpressibleByBooleanLiteral {
        var bool: Bool
        required init(booleanLiteral value: Bool) {
            bool = value
        }
    }
    
    private struct ControllerView<Content: View>: View {
        struct MyView<ContentM: View>: UIViewControllerRepresentable {
            let rootView: () -> ContentM
            
            init(@ViewBuilder rootView: @escaping () -> ContentM) {
                self.rootView = rootView
                print("init MyView")
            }
            
            func makeUIViewController(context: Context) -> UIHostingController<ContentM> {
                print("makeUI")
                return UIHostingController(rootView: rootView())
            }
            func updateUIViewController(_ uiViewController: UIHostingController<ContentM>, context: Context) {
                print("updateUI")
            }
        }
        
        
        let rootView: () -> Content
        let isGreen: BoolWrapper
        
        init(isGreen: BoolWrapper,@ViewBuilder rootView: @escaping () -> Content) {
            self.rootView = rootView
            self.isGreen = isGreen
        }
        
        var body: some View {
            ZStack {
                MyView(rootView: rootView)
                
                Button {
                    withAnimation {
                        isGreen.bool.toggle()
                    }
                } label: {
                    Text("Change")
                }
            }
        }
    }
    
    struct ContentView: View {
        @State var isGreen: BoolWrapper = true
        
        var body: some View {
            ControllerView(isGreen: isGreen) {
                GreenOrangeWrapper().environment(isGreen)
            }
        }
    }
    
    struct GreenOrangeWrapper: View {
        @Environment(BoolWrapper.self) var boolWrapper
        
        var body: some View {
            if boolWrapper.bool {
                GreenView()
            } else {
                OrangeView()
            }
        }
    }
    

    Now the animations are all preserved.