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 usesmakeUIView
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 usesmakeUIView
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
|
3 Answers
Reset to default 2It's not that ContainerViewB
"blocks bindings". @Binding
s do work here. The @State
s 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 @State
s (the Text
s in ContainerViewB
) are not being updated when they should.
When SwiftUI detects a change, it will update your View
s by calling body
, and your UIViewRepresentable
s 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 Text
s). 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.
UIHostingController
to the view hierarchy of anotherUIViewController
without informing the parent view controller of the process. That's sth. you shouldn't do. Instead you should use aUIViewControllerRepresentable
instead of aUIViewRepresentable
and follow Apple's guide to Creating a custom container view controller to add the view of yourUIHostingController
instance to the view hierarchy. – ITGuy Commented Mar 31 at 18:36