How NSHostingView determines its sizing
By: Brian Webster |
I was recently banging my head against a problem trying to add a SwiftUI view into my existing AppKit using NSHostingView. The basic problem was that it seemed no matter what I did, I couldn’t get my SwiftUI view to expand to fill up the entire superview that the NSHostingView was being added to. The basic layout of the SwiftUI view looks like this.
struct IPReportDetailView: View {
var body: some View {
VStack(spacing: 20.0) {
Text(selectedReport.title)
.font(.headline)
IPReportView(reportDocument: selectedReport)
Spacer()
}
}
}
And the setup of the NSHostingView looked like this:
class IPReportListViewController: BWViewController {
lazy var reportListViewModel: IPReportListViewModel = IPReportListViewModel(reports: IPReportDocument.allStoredReports())
override func loadView() {
let reportListMainView = IPReportListSplitView(reportListViewModel: self.reportListViewModel, selectedReport: self.reportListViewModel.reports.first) let hostingView = NSHostingView(rootView: reportListMainView)
hostingView.translatesAutoresizingMaskIntoConstraints = false
//Green border so we can see the bounds of the hosting view
hostingView.layer?.borderWidth = 1.0
hostingView.layer?.borderColor = NSColor.green.cgColor
self.view = hostingView
}
}
Both the Text and IPReportView views have fixed heights, and the usual trick if you want a VStack to expand to fill the rest of its parent is to add a Spacer view to the end, which will gobble up any remaining space. However, this was not working at all, and ended up looking like this.
Despite the presence of the Spacer, the NSHostingView insists on shrinking down to fit the fixed size of the rest of the VStack. In the process of trying to figure out what’s going on, I came across this blog post from objc.io with a technique for printing out the size being proposed to your view by SwiftUI. You can read the blog post if you want to see the details of how it works, but the short version is that each time SwiftUI proposes a size to your view, this code will print out both the proposed size, then also the size that ends up being returned by SwiftUI that the view should actually use. I added their view modifier to my VStack and got output that looked like this (the first size is the proposed size, the second one is the size returned by SwiftUI).
Detail VStack: 0.00⨉0.00 656.00⨉48.00
Detail VStack: inf⨉inf 656.00⨉inf
Detail VStack: nil⨉nil 656.00⨉454.00
Detail VStack: 656.00⨉454.00 656.00⨉454.00
Detail VStack: 656.00⨉454.00 656.00⨉454.00
To be honest, I couldn’t really make heads or tails of this, since it seemed to be doing multiple size proposals every time my view refreshed. I took that as a dead end and started investigating other options.
The next thing I came across was a property on NSHostingView called ‘sizingOptions’, which is described in the documentation as “The options for how the hosting view creates and updates constraints based on the size of its SwiftUI content.” Well that sounds promising! The default setting is all three options, [.minSize, .intrinsicContentSize, .maxSize], so I tried setting it to just [.minSize] and lo and behold, it worked! The Spacer was now growing to take up the whole height of the superview! (setting [.minSize, .maxSize] also worked)
But, there’s just one problem… this property was introduced in macOS 13 and I’m still targeting macOS 12. 😭 But, after seeing this property and how it works, I think I now understand what was going on with the size proposals earlier. I believe what NSHostingView does is to probe its rootView once each so it can set up constraints for a minimum size, intrinsic content size, and maximum size. Proposing a size of 0x0 lets it determine the view’s minimum size, proposing infinity x infinity lets it determine a maximum size, and proposing nil x nil determines the intrinsic content size.
So what I need to do is basically reimplement what the sizingOptions property is doing, which is to ignore the intrinsic content size of the SwiftUI view. To do that, I created a subclass of NSHostingView and overrode the intrinsicContentSize property like so:
class IPReportHostingView<Content: View>: NSHostingView<Content> {
override var intrinsicContentSize: NSSize {
var size = super.intrinsicContentSize
size.height = NSView.noIntrinsicMetric
return size
}
}
That did the trick, and let me deploy on macOS 12. Hope this helps anyone out there who runs into similar issue.