最新消息:雨落星辰是一个专注网站SEO优化、网站SEO诊断、搜索引擎研究、网络营销推广、网站策划运营及站长类的自媒体原创博客

ios - Custom SwiftUI container view using UIViewRepresentable - How to do it correctly? - Stack Overflow

programmeradmin2浏览0评论

The SwiftUI ScrollView lacks some features I need, so I used UIViewRepresentable to create a custom container based on UIScrollView. I found different tutorials showing how to create custom container views. However, while one solution works without any problem, another solution seems to block Bindings some how and I do not understand why.

So the question is, why does ContainerViewA work properly while ContainerViewB blocks bindings?


To keep things simple, the following example creates a simple UIView container instead of using UIScrollView, but the problem is the same:

  • Version A creates the UIHostingController within the Coordinator, while Version B uses makeUIView to do the same.
  • The TestView uses both versions, each containing a TextField, bound to a @State property.
  • Text changes within ContainerViewA are properly shown in A and B.
  • Text changes within ContainerViewB are only shown in A.
  • Thus "incoming" bindings are not properly handled in ContainerViewB. Why?

struct ContainerViewA<Content: View>: UIViewRepresentable {
    let content: Content
    
    @inlinable init(@ViewBuilder content: () -> Content) {
        self.content = content()
    }
    
    func makeUIView(context: Context) -> UIView {
        let view = UIView()
        
        let hostingController = context.coordinator.hostingController
        hostingController.view.frame = view.bounds
        hostingController.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
        
        view.addSubview(hostingController.view)
        
        return view
    }
    
    func updateUIView(_ uiView: UIView, context: Context) {
        context.coordinator.hostingController.rootView = self.content
    }
    
    func makeCoordinator() -> Coordinator {
        return Coordinator(hostingController: UIHostingController(rootView: content))
    }
    
    class Coordinator: NSObject {
        var hostingController: UIHostingController<Content>
        
        init(hostingController: UIHostingController<Content>) {
            self.hostingController = hostingController
        }
    }
}


struct ContainerViewB<Content: View>: UIViewRepresentable {
    var content: Content
    
    init(@ViewBuilder content: () -> Content) {
        self.content = content()
    }

    func makeUIView(context: Context) -> UIView {
        let view = UIView()

        let hostingController = UIHostingController(rootView: content)
        hostingController.view.frame = view.bounds
        hostingController.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
        
        view.addSubview(hostingController.view)

        return view
    }
    
    func updateUIView(_ uiView: UIView, context: Context) {
        
    }

    func makeCoordinator() -> Coordinator {
        Coordinator(parent: self)
    }
    
    class Coordinator: NSObject, UIScrollViewDelegate {
        private var parent: ContainerViewB

        init(parent: ContainerViewB) {
            self.parent = parent
            super.init()
        }
    }
}


struct ContainerTestView: View {
    @State private var textA: String = ""
    @State private var textB: String = ""
    
    var body: some View {
        VStack {
            ContainerViewA {
                TextField("TextA", text: $textA)
                Text("Typed A: \(textA)")
                Text("Typed B: \(textB)")
            }
            
            ContainerViewB {
                TextField("TextB", text: $textB)
                Text("Typed A: \(textA)")
                Text("Typed B: \(textB)")
            }
        }
    }
}

The SwiftUI ScrollView lacks some features I need, so I used UIViewRepresentable to create a custom container based on UIScrollView. I found different tutorials showing how to create custom container views. However, while one solution works without any problem, another solution seems to block Bindings some how and I do not understand why.

So the question is, why does ContainerViewA work properly while ContainerViewB blocks bindings?


To keep things simple, the following example creates a simple UIView container instead of using UIScrollView, but the problem is the same:

  • Version A creates the UIHostingController within the Coordinator, while Version B uses makeUIView to do the same.
  • The TestView uses both versions, each containing a TextField, bound to a @State property.
  • Text changes within ContainerViewA are properly shown in A and B.
  • Text changes within ContainerViewB are only shown in A.
  • Thus "incoming" bindings are not properly handled in ContainerViewB. Why?

struct ContainerViewA<Content: View>: UIViewRepresentable {
    let content: Content
    
    @inlinable init(@ViewBuilder content: () -> Content) {
        self.content = content()
    }
    
    func makeUIView(context: Context) -> UIView {
        let view = UIView()
        
        let hostingController = context.coordinator.hostingController
        hostingController.view.frame = view.bounds
        hostingController.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
        
        view.addSubview(hostingController.view)
        
        return view
    }
    
    func updateUIView(_ uiView: UIView, context: Context) {
        context.coordinator.hostingController.rootView = self.content
    }
    
    func makeCoordinator() -> Coordinator {
        return Coordinator(hostingController: UIHostingController(rootView: content))
    }
    
    class Coordinator: NSObject {
        var hostingController: UIHostingController<Content>
        
        init(hostingController: UIHostingController<Content>) {
            self.hostingController = hostingController
        }
    }
}


struct ContainerViewB<Content: View>: UIViewRepresentable {
    var content: Content
    
    init(@ViewBuilder content: () -> Content) {
        self.content = content()
    }

    func makeUIView(context: Context) -> UIView {
        let view = UIView()

        let hostingController = UIHostingController(rootView: content)
        hostingController.view.frame = view.bounds
        hostingController.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
        
        view.addSubview(hostingController.view)

        return view
    }
    
    func updateUIView(_ uiView: UIView, context: Context) {
        
    }

    func makeCoordinator() -> Coordinator {
        Coordinator(parent: self)
    }
    
    class Coordinator: NSObject, UIScrollViewDelegate {
        private var parent: ContainerViewB

        init(parent: ContainerViewB) {
            self.parent = parent
            super.init()
        }
    }
}


struct ContainerTestView: View {
    @State private var textA: String = ""
    @State private var textB: String = ""
    
    var body: some View {
        VStack {
            ContainerViewA {
                TextField("TextA", text: $textA)
                Text("Typed A: \(textA)")
                Text("Typed B: \(textB)")
            }
            
            ContainerViewB {
                TextField("TextB", text: $textB)
                Text("Typed A: \(textA)")
                Text("Typed B: \(textB)")
            }
        }
    }
}
Share Improve this question asked Mar 31 at 7:25 Andrei HerfordAndrei Herford 18.8k24 gold badges108 silver badges257 bronze badges 1
  • Note that with your implementation you're effectively adding the view of a UIHostingController to the view hierarchy of another UIViewController without informing the parent view controller of the process. That's sth. you shouldn't do. Instead you should use a UIViewControllerRepresentable instead of a UIViewRepresentable and follow Apple's guide to Creating a custom container view controller to add the view of your UIHostingController instance to the view hierarchy. – ITGuy Commented Mar 31 at 18:36
Add a comment  | 

3 Answers 3

Reset to default 2

It's not that ContainerViewB "blocks bindings". @Bindings do work here. The @States are indeed being updated. This can be shown using the following code:

VStack {
    ContainerViewB {
        TextField("TextB", text: $textB)
    }
    Text(textB)
}

The Text outside of ContainerViewB is properly updated.

What went wrong in your code is that the views displaying the @States (the Texts in ContainerViewB) are not being updated when they should.

When SwiftUI detects a change, it will update your Views by calling body, and your UIViewRepresentables by calling updateUIView. In body, you are supposed to construct a new View that reflects the new state. In updateUIView, you are supposed to configure the UIView that you are wrapping, so that it reflects the new state.

Here is what happens when you type in the second text field. SwiftUI detects a change in the state textB, so calls ContainerTestView.body. In there, you create a new ContainerViewB with an updated content (a TupleView containing a TextField and 2 Texts). Since ContainerTestView is not Equatable, SwiftUI assumes that it has changed, and it calls ContainerViewB.updateUIView, where you do nothing.

So the new content you created in the view builder closure of ContainerViewB are just stored in the content property, and that's it. They are not displayed in the hosting controller.

Compare this to ContainerViewA, where you do update the hosting controller in updateUIView, so that it displays the new content. This is exactly what you are supposed to do in updateUIView.

The bindings in ContainerViewA work because you're updating the rootView of the UIHostingController inside updateUIView. This method is triggered when the state of ContainerTestView changes, as outlined in the documentation:

When the state of your app changes, SwiftUI updates the portions of your interface affected by those changes. SwiftUI calls this method for any changes affecting the corresponding UIKit view. Use this method to update the configuration of your view to match the new state information provided in the context parameter.

If you remove the manual rootView update from updateUIView, ContainerViewA will behave the same as ContainerViewB, the state updates won’t be reflected as expected.

ContainerViewA & ContainerViewB are wrapping the content without the state properties, so the view doesn't know when its state changes which is why UIHostingController's rootView isn't getting updated automatically & why you need to manually update them in updateUIView.

However, If you create a new view that wraps the content along with the Binding properties & use that as the content for ContainerViewB, the view would update as expected:

struct ContainerTestView: View {
    @State private var textA: String = ""
    @State private var textB: String = ""
    
    var body: some View {
        VStack {
            ContainerViewA {
                TextField("TextA", text: $textA)
                Text("Typed A: \(textA)")
                Text("Typed B: \(textB)")
            }
            
            ContainerViewB {
                TestViewB(textA: $textA, textB: $textB)
            }
        }
    }
}

struct TestViewB: View {
    @Binding var textA: String // The Binding here is used to let the view know about the state of textA
    @Binding var textB: String
    var body: some View {
        TextField("TextB", text: $textB)
        Text("Typed A: \(textA)")
        Text("Typed B: \(textB)")
    }
}

Use UIViewControllerRepresentable instead and you need the update implemented, eg.

func updateUIViewController(_ uiViewController: UIHostingController, context: Context) {
        uiViewController.rootView = content
    }

When you have ContainerViewB { in your body the representable's init is always called, make is called the first time and update is called whenever the params passed in change (or internal States or Environments change), which in your case is every time because a closure param cannot be compared.

发布评论

评论列表(0)

  1. 暂无评论