SwiftUI provides us a nice way of integrating SwiftUI into UIKit — UIHostingConfiguration
:
let config = UIHostingConfiguration {
Text("Hello world!")
}
We can turn this into a UIView like so:
let contentView: (UIView & UIContentView) = config.makeContentView()
We can then apply constraints:
addSubview(contentView)
contentView.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([ ... ])
But here is the thing — if the SwiftUI view resizes internally, our constraints won't be invalidated.
The missing callback
I found out through experimenation, that we can leverage onGeometryChange
as a callback into the UIKit world:
final class HostingView<T: View>: UIView {
private var contentView: (UIView & UIContentView)?
private var heightConstraint: NSLayoutConstraint?
init(@ViewBuilder content: () -> T) {
super.init(frame: .zero)
let config = makeConfiguration(content: content())
contentView = config.makeContentView()
layout()
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
func update(_ content: T) {
let config = makeConfiguration(content: content)
contentView?.configuration = config
}
private func layout() {
guard let contentView else { return }
addSubview(contentView)
contentView.translatesAutoresizingMaskIntoConstraints = false
// 100 can be initial value
let heightConstraint = heightAnchor.constraint(equalToConstant: 100)
self.heightConstraint = heightConstraint
NSLayoutConstraint.activate([
contentView.topAnchor.constraint(equalTo: topAnchor),
contentView.leadingAnchor.constraint(equalTo: leadingAnchor),
contentView.trailingAnchor.constraint(equalTo: trailingAnchor),
// Allow SwiftUI to expand height arbitrarily
contentView.heightAnchor.constraint(equalToConstant: UIView.layoutFittingExpandedSize.height),
heightConstraint,
])
}
private func heightDidChange(_ newHeight: CGFloat) {
heightConstraint?.constant = newHeight
self.setNeedsLayout()
}
private func makeConfiguration(content: T) -> some UIContentConfiguration {
UIHostingConfiguration {
VStack(spacing: .zero) {
content
.onGeometryChange(for: CGFloat.self) { g in
g.size.height
} action: { [weak self] newHeight in
self?.heightDidChange(newHeight)
}
Spacer(minLength: .zero)
}
}
}
}
This is really nice, because onGeometryChange
triggers in the same layout pass — which means, we can resize our UIKit constraints in the same layout pass as SwiftUI itself — theres no need for scheduling the UIKit layout pass on a future run loop iteration.
Note: the SwiftUI UIHostingConfiguration
needs enough space to grow — you might need to give it a large height that exceeds your HostingView's bounds, and then manually adjust the height of your HostingView based on the new size.