iosswiftui

Wrap SwiftUI view inside a UIKit one while keeping original layout


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

screenshot

which depicts that the UIKit wrapper doesn't hug its subview. Any help appreciated.

Note, I need both:

  1. the UIKit wrapper to contain the SwiftUI subview
  2. for these 2 to match frames

Update 1:

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.

Update 2:

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.


Solution

  • 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)
                }
        }
    }