To make SwiftUI text wrap inside an NSTableView cell, give the hosting view a definite width, connect its height to the cell with Auto Layout, and enable automatic row heights. This walkthrough builds a small Mac label list with that arrangement.
The task is specific: display synthetic labels of different lengths in your own app, with rows tall enough to show their contents as the available width changes. The example targets macOS 13 or later and uses a view-based table.
How do SwiftUI and NSTableView share sizing?
Three layers participate. The table allocates the cell’s rectangle. Auto Layout connects the cell to its embedded NSHostingView. SwiftUI determines how the label fits within the space proposed to it. Apple describes NSHostingView as the bridge that reports SwiftUI content size preferences to AppKit during layout. NSHostingView
For wrapping text, width must be established before height is meaningful. A label may need one line in a wide column and several in a narrow one. Setting a hosting view’s initial frame does not establish a lasting table-cell layout: Apple documents that the table sets the frame of the view returned by its cell delegate. tableView(_:viewFor:row:)
The implementation below therefore follows one sizing arrangement: the table supplies horizontal space, the host is constrained to the cell’s edges with horizontal insets, and the SwiftUI label preserves its ideal vertical size while accepting the proposed width. The table uses Auto Layout to calculate row heights when usesAutomaticRowHeights is enabled. usesAutomaticRowHeights
Do not treat intrinsicContentSize and fittingSize as interchangeable measurements. Apple defines fittingSize as the smallest size satisfying the view’s constraints and those of its subviews. Consequently, querying it without the intended width constraints does not answer the question of how tall the cell should be at the current column width. fittingSize
Build a complete label table for macOS 13 and later
Create a macOS App project in Xcode with the SwiftUI interface and a deployment target of macOS 13 or later. Replace the generated app declaration with the first file below, then add the other two Swift files to the same app target. Together, these three files form one complete program; keep only one @main declaration.
The macOS 13 requirement comes from the explicit use of NSHostingView.sizingOptions. The hosting view itself is available from macOS 10.15, and automatic table row heights are available from macOS 10.13. An older deployment target needs an availability check around sizingOptions or a version of the implementation that omits that customization. NSHostingView, sizingOptions, usesAutomaticRowHeights
Create the app and synthetic sample data
The first file, LabelSizingApp.swift, supplies three labels and a button that changes the final label. This gives you both an initial layout check and a content-update check.
import SwiftUI
@main
struct LabelSizingApp: App {
@State private var expanded = false
private var labels: [String] {
[
"Amber tile",
"Blue tile with a longer descriptive label that can wrap.",
expanded
? "Green tile with several descriptive words. This longer label makes it easier to inspect wrapping when the window becomes narrower."
: "Green tile"
]
}
var body: some Scene {
WindowGroup {
VStack(alignment: .leading, spacing: 12) {
Button("Toggle longer label") {
expanded.toggle()
}
LabelTable(labels: labels)
}
.padding()
.frame(minWidth: 300, minHeight: 240)
}
}
}
Connect the hosting view to the cell
In HostedLabelCell.swift, the SwiftUI content accepts the available width and requests its ideal height. The axis-specific fixedSize modifier matters: fixing the horizontal axis would ask text to preserve its ideal width and can make it exceed its parent’s bounds. fixedSize(horizontal:vertical:)
import AppKit
import SwiftUI
struct RowLabel: View {
let text: String
var body: some View {
Text(verbatim: text)
.font(.system(size: 14))
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, 8)
}
}
@MainActor
final class HostedLabelCell: NSTableCellView {
private let host = NSHostingView(
rootView: RowLabel(text: "")
)
override init(frame frameRect: NSRect) {
super.init(frame: frameRect)
host.translatesAutoresizingMaskIntoConstraints = false
host.sizingOptions = [.intrinsicContentSize]
addSubview(host)
NSLayoutConstraint.activate([
host.leadingAnchor.constraint(
equalTo: leadingAnchor, constant: 12
),
host.trailingAnchor.constraint(
equalTo: trailingAnchor, constant: -12
),
host.topAnchor.constraint(equalTo: topAnchor),
host.bottomAnchor.constraint(equalTo: bottomAnchor)
])
}
required init?(coder: NSCoder) {
fatalError("This sample creates cells programmatically.")
}
func configure(text: String) {
host.rootView = RowLabel(text: text)
}
}
The four edge constraints create the connection between the host and cell. Disabling autoresizing-mask translation on the embedded host lets these explicit constraints govern it, as Apple recommends for views whose constraints require dynamic adjustment. The table remains responsible for positioning the outer cell. translatesAutoresizingMaskIntoConstraints
Here, sizingOptions retains intrinsic-content sizing without requesting the host’s minimum and maximum size constraints. Apple documents these as separately selectable behaviors; setting the property to an empty option set creates none of those content-derived constraints. Keeping intrinsic-content sizing is an implementation choice for this content-driven cell, not a universal setting for every hosting view. sizingOptions
Enable automatic row heights and configure reused cells
Add LabelTable.swift to create the scroll view, table, and delegate. The program deliberately uses automatic sizing without implementing a manual row-height delegate.
import AppKit
import SwiftUI
@MainActor
struct LabelTable: NSViewRepresentable {
let labels: [String]
func makeCoordinator() -> Coordinator {
Coordinator(labels: labels)
}
func makeNSView(context: Context) -> NSScrollView {
let table = NSTableView()
let column = NSTableColumn(
identifier: Coordinator.cellID
)
column.title = "Label"
column.width = 360
column.minWidth = 120
column.resizingMask = .autoresizingMask
table.addTableColumn(column)
table.headerView = nil
table.columnAutoresizingStyle = .lastColumnOnlyAutoresizingStyle
table.intercellSpacing = NSSize(width: 0, height: 0)
table.usesAutomaticRowHeights = true
table.dataSource = context.coordinator
table.delegate = context.coordinator
let scrollView = NSScrollView()
scrollView.hasVerticalScroller = true
scrollView.documentView = table
return scrollView
}
func updateNSView(
_ scrollView: NSScrollView,
context: Context
) {
guard let table = scrollView.documentView as? NSTableView,
context.coordinator.labels != labels else {
return
}
context.coordinator.labels = labels
table.reloadData()
}
@MainActor
final class Coordinator: NSObject,
NSTableViewDataSource, NSTableViewDelegate {
static let cellID = NSUserInterfaceItemIdentifier("LabelCell")
var labels: [String]
init(labels: [String]) {
self.labels = labels
super.init()
}
func numberOfRows(in tableView: NSTableView) -> Int {
labels.count
}
func tableView(
_ tableView: NSTableView,
viewFor tableColumn: NSTableColumn?,
row: Int
) -> NSView? {
let cell = (
tableView.makeView(
withIdentifier: Self.cellID,
owner: self
) as? HostedLabelCell
) ?? HostedLabelCell(frame: .zero)
cell.identifier = Self.cellID
cell.configure(text: labels[row])
return cell
}
}
}
The delegate attempts reuse before creating a cell, then assigns the current label every time. This follows Apple’s recommended cell-view construction sequence. makeView(withIdentifier:owner:) can return a reused view or return nil when no matching view is available. tableView(_:viewFor:row:), makeView(withIdentifier:owner:)
The sample reloads only when its input array changes. A full reload is easy to follow for three rows, but Apple documents that it drops and reacquires visible row and cell views. In a larger application, choose a narrower update strategy when appropriate. Keep AppKit view construction and updates on the main actor; Swift documents MainActor as using an executor equivalent to the main dispatch queue. reloadData(), MainActor
How can you diagnose clipping or excessive row height?
Start with the width, then inspect height propagation, then inspect updates. This order follows the dependencies in the layout and helps avoid changing several unrelated settings at once.
| Symptom | First check | Useful correction |
|---|---|---|
| Long text runs horizontally beyond the cell | Is horizontal ideal sizing enabled? | Use fixedSize(horizontal: false, vertical: true) for this wrapping label. |
| Text wraps but its final lines are clipped | Does the host connect to both vertical cell edges? | Restore the top and bottom constraints and remove conflicting fixed heights. |
| Every row remains the same height | Is automatic sizing enabled, and is there a manual height implementation? | Check the table configuration and use one deliberate sizing strategy. |
| Short text appears in an unnecessarily tall cell | Does the content contain a fixed height or extra vertical space? | Inspect the SwiftUI hierarchy and its padding before adjusting the table. |
| A changed label displays old content | Is the cell configured again after the model changes? | Update the displayed root view or reload the affected content. |
Use a width budget to make the inspection concrete. If the cell is illustratively 320 points wide, the example’s two 12-point horizontal insets leave 296 points for the host. Its vertical padding contributes 16 points in total. The remaining required height depends on the actual text layout; those numbers do not predict how many lines a particular label will occupy.
The expanding SwiftUI frame establishes leading alignment within the available horizontal space. It does not assign an unlimited physical width: Apple’s frame documentation explains that the frame participates in proposals from its parent and applies the specified size constraints. frame(minWidth:idealWidth:maxWidth:minHeight:idealHeight:maxHeight:alignment:)
Run the sample with the long label at both a wide and narrow window size, then toggle back to the short label. Check that all text remains visible and that the row can shrink again. These are acceptance checks to perform on your supported macOS versions. The code was inspected against the retrieved API documentation; it was not compiled or run here because this environment has no Swift compiler or macOS runtime.
What changes when you calculate row heights manually?
Manual sizing is a separate implementation path. If you need it, calculate heights for the intended content width outside the height delegate and make the delegate return the prepared result. A useful cache key includes the item, available width, and any presentation inputs that change layout, such as font or padding.
Apple requires a manually returned row height to be greater than zero and to exclude intercell spacing. Its documentation also prohibits obtaining cell or row views inside tableView(_:heightOfRow:) and warns against geometry queries that can trigger recursive table layout. Measuring a visible cell from that callback is therefore a poor foundation for a manual implementation. tableView(_:heightOfRow:)
When prepared heights change, notify the table with noteHeightOfRows(withIndexesChanged:). Apple documents that this immediately retiles the table using the delegate’s heights when that delegate method is implemented. Column resizing must also invalidate width-dependent measurements; columnDidResizeNotification is the documented notification for that event. These are necessary considerations if you replace the automatic approach with a manual cache. noteHeightOfRows(withIndexesChanged:), columnDidResizeNotification
Should TextKit measure a SwiftUI label?
TextKit is useful when your app actually lays out text through a TextKit container. Its documented measurement has a narrower scope than an entire SwiftUI cell: NSLayoutManager.usedRect(for:) reports the bounds of glyphs already laid out in the specified container and does not itself generate glyphs or perform layout. Call ensureLayout(for:) before reading that measurement when layout has not been completed. usedRect(for:), ensureLayout(for:)
Account for the container configuration too. Apple documents a default lineFragmentPadding of 5 points at each end of a line fragment, affecting the available layout width. It also states that this property is not intended to express text margins. lineFragmentPadding
The practical inference is that a separately configured TextKit measurement cannot, by itself, establish the height of this SwiftUI hierarchy. The cell also includes SwiftUI layout behavior and padding. For this label table, keep the sizing path through the hosting view and Auto Layout, then verify wrapping, content changes, and shrinking at the widths your app supports.
Sources
- NSHostingView — Apple
- sizingOptions — Apple
- usesAutomaticRowHeights — Apple
- tableView(_:viewFor:row:) — Apple
- fittingSize — Apple
- fixedSize(horizontal:vertical:) — Apple
- translatesAutoresizingMaskIntoConstraints — Apple
- makeView(withIdentifier:owner:) — Apple
- reloadData() — Apple
- MainActor — Apple
- frame(minWidth:idealWidth:maxWidth:minHeight:idealHeight:maxHeight:alignment:) — Apple
- tableView(_:heightOfRow:) — Apple
- noteHeightOfRows(withIndexesChanged:) — Apple
- columnDidResizeNotification — Apple
- usedRect(for:) — Apple
- ensureLayout(for:) — Apple
- lineFragmentPadding — Apple