Search code examples
swiftswiftuiuiviewuiviewcontroller

Dynamically resize SwiftUI UIViewRepresentable with intrinsic content size / sizeThatFits


I have an ExpandingView like this:

struct ExpandingView: View {
    @State var isExpanded = false

    var body: some View {
        RoundedRectangle(cornerRadius: 16.0)
            .fill(Color.accentColor.opacity(0.1))
            .overlay {
                Button("Toggle expand") {
                    withAnimation {
                        isExpanded.toggle()
                    }
                }
            }
            .frame(height: isExpanded ? 500 : 100)
    }
}

struct ContentView: View {
    var body: some View {
        ExpandingView()
            .border(Color.red)
            .padding()
    }
}

Result:

Collapsed Expanded
Red border around collapsed blue rectangle Red border around expanded blue rectangle

This works fine. However, due to performance reasons, I am experimenting with embedding ExpandingView within a intermediary UIHostingController instead of having it inline. This way, I have more control over how it's rendered.

struct WrapperHostingControllerRepresentable<Content: View>: UIViewControllerRepresentable {
    @ViewBuilder var content: Content

    func makeUIViewController(context: Context) -> UIHostingController<Content> {
        UIHostingController(rootView: content)
    }

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

    func sizeThatFits(_ proposal: ProposedViewSize, uiViewController: UIHostingController<Content>, context: Context) -> CGSize? {
        let canvas = CGSize(width: proposal.width ?? 0, height: 5000)
        let size = uiViewController.sizeThatFits(in: canvas)
        print("Calculating size: \(size)")
        return size
    }
}

struct ContentView: View {
    var body: some View {
        WrapperHostingControllerRepresentable {
            ExpandingView()
        }
        .border(Color.red)
        .padding()
    }
}

Here's the result:

Collapsed Expanded
Red border around collapsed blue rectangle Expanded blue rectangle but red border stays the same

As you can see, the red border is not recalculated and Calculating size: (370.0, 100.0) is printed only once in the console. How do I get the UIViewRepresentable to resize along with its contents?


Solution

  • You should set sizingOptions to .preferredContentSize and/or .intrinsicContentSize:

    func makeUIViewController(context: Context) -> UIHostingController<Content> {
        let host = UIHostingController(rootView: content)
        host.sizingOptions = .preferredContentSize
        return host
    }
    

    Both options work in this case, because they both cause the hosting controller to track changes in the SwiftUI view's ideal size. The difference is whether the ideal size is reflected in the view controller's preferredContentSize or intrinsicContentSize. A change in either of these will cause sizeThatFits to be called.