I gave this a shot using GeometryReader and Anchor Preferences, this is how the code looks:
/// PreferenceKey to capture the bounds of the content
struct BoundsPreferenceKey: PreferenceKey {
typealias Value = Anchor<CGRect>?
static var defaultValue: Anchor<CGRect>? = nil
static func reduce(value: inout Anchor<CGRect>?, nextValue: () -> Anchor<CGRect>?) {
value = value ?? nextValue()
}
}
/// Wraps any SwiftUI `Content` in a `UIHostingController`
/// and calls you back with its `.view` so you can use it in UIKit.
struct ViewProvider<Content: View>: UIViewRepresentable {
let content: Content
@Binding var uiView: UIView
let bounds: CGRect
func makeUIView(context: Context) -> UIView {
let host = UIHostingController(rootView: content)
host.view.backgroundColor = .clear
host.view.bounds = bounds
DispatchQueue.main.async {
uiView = host.view
}
return host.view
}
func updateUIView(_ uiView: UIView, context: Context) {
uiView.bounds = bounds // doesn't have any effect
}
}
struct UIKitWrapperModifier: ViewModifier {
@Binding var uiView: UIView
@State var bounds = CGRect.zero
func body(content: Content) -> some View {
ViewProvider(
content: contentMeasured(content: content),
uiView: $uiView,
bounds: bounds
)
}
@ViewBuilder
func contentMeasured(content: Content) -> some View {
content
.anchorPreference(key: BoundsPreferenceKey.self, value: .bounds) { $0 }
.backgroundPreferenceValue(BoundsPreferenceKey.self) { anchor in
GeometryReader { geometry in
if let anchor {
print(geometry[anchor]) // prints correct bounds
bounds = geometry[anchor]
}
return Color.clear
}
}
}
}
extension View {
func uiKitWrapped(_ uiView: Binding<UIView>) -> some View {
modifier(UIKitWrapperModifier(uiView: uiView))
}
}
But using it
struct ContentView: View {
@State private var sourceUIView = UIView()
var body: some View {
Color.blue
.frame(width: 200, height: 150)
.uiKitWrapped($sourceUIView)
.background(Color.yellow)
}
}
gives me
which depicts that the UIKit wrapper doesn't hug its subview. Any help appreciated.
Note, I need both:
Using Sweeper's solution I managed to make it work (thanks!), but there's one problem remaining - I've omitted a detail about my view hierarchy, the view I'm interested is placed inside a ScrollView and has an aspect ratio applied, which makes the width of the view really small (10 points - SwiftUI's default intrinsic size), and debugging confirmed that the proposed size coming into sizeThatFits
is indeed nil.
This is a simplified version of my view hierarchy that exhibits the issue:
struct ContentView: View {
@State private var sourceUIView: UIView?
var body: some View {
ScrollView {
Rectangle()
.fill(Color.blue)
.aspectRatio(2, contentMode: .fit)
.uiKitWrapped($sourceUIView)
}
}
}
Note, it lays out properly stretching to the entire screen if I move the uiKitWrapped
modifier above the aspectRatio. But in my code that doesn't suit, because this view with the aspect ratio is embedded inside other views and I need to take the uiKitWrapped
from one of its ancestors.
Managed to make it fully work by modifying @Sweeper's original solution like so:
func sizeThatFits(_ proposal: ProposedViewSize, uiViewController: UIHostingController<Content>, context: Context) -> CGSize? {
if let size {
return size
} else {
return proposal.replacingUnspecifiedDimensions(by: CGSize(width: .max, height: .max))
}
}
With the caveat that if the aspect ratio is not set on the view, it might grow infinitely high. But not in my case, so that works.
It is undefined behaviour to set the center
, bounds
, frame
, and transform
properties of the UIView
represented by a UIViewRepresentable
. See the warning in the documentation.
You can return the measured size in sizeThatFits
instead, e.g.
struct UIViewWrapper<Content: View>: UIViewControllerRepresentable {
@Binding var uiView: UIView?
let content: Content
let size: CGSize?
func makeUIViewController(context: Context) -> UIHostingController<Content> {
let host = UIHostingController(rootView: content)
return host
}
func sizeThatFits(_ proposal: ProposedViewSize, uiViewController: UIHostingController<Content>, context: Context) -> CGSize? {
if let size {
return size
} else {
return proposal.replacingUnspecifiedDimensions()
}
}
func updateUIViewController(_ uiViewController: UIHostingController<Content>, context: Context) {
uiViewController.rootView = content
DispatchQueue.main.async {
uiView = uiViewController.view
}
}
}
struct UIKitWrapperModifier: ViewModifier {
@Binding var uiView: UIView?
@State private var size: CGSize?
func body(content: Content) -> some View {
UIViewWrapper(
uiView: $uiView,
content: content.onGeometryChange(for: CGSize.self, of: \.size) {
size = $0
},
size: size
)
}
}
In fact, you don't need to measure any size at all. There is already a sizeThatFits
method available on UIHostingController
:
func sizeThatFits(_ proposal: ProposedViewSize, uiViewController: UIHostingController<Content>, context: Context) -> CGSize? {
let intrinsicSize = uiViewController.view.intrinsicContentSize
return uiViewController.sizeThatFits(in: CGSize(
width: (proposal.width ?? intrinsicSize.width),
height: proposal.height ?? intrinsicSize.height
))
}
struct UIKitWrapperModifier: ViewModifier {
@Binding var uiView: UIView?
func body(content: Content) -> some View {
UIViewWrapper(uiView: $uiView, content: content)
}
}
If before iOS 16, you can use UIViewWrapper
as an overlay
of an identical but hidden view,
struct UIKitWrapperModifier: ViewModifier {
@Binding var uiView: UIView?
func body(content: Content) -> some View {
content
.hidden()
.overlay {
UIViewWrapper(uiView: $uiView, content: content)
}
}
}