Hi, I’m pretty new to AppKit and I’m trying to make an NSTextField inside an NSTableView both:
- Editable
- Multi-line / wrapping
Right now, wrapping works fine until I set:
tf.isEditable = true
Then the text becomes a single line.
How do I make it editable while still wrapping correctly?
import AppKit
final class ViewController: NSViewController, NSTableViewDataSource, NSTableViewDelegate {
let tableView = NSTableView()
let text = String(repeating: "A", count: 500)
override func viewDidLoad() {
super.viewDidLoad()
view = tableView
tableView.addTableColumn(NSTableColumn())
tableView.usesAutomaticRowHeights = true
tableView.dataSource = self
tableView.delegate = self
}
func numberOfRows(in tableView: NSTableView) -> Int { 1 }
func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? {
let cell = NSTableCellView()
let tf = NSTextField(wrappingLabelWithString: text)
tf.lineBreakMode = .byCharWrapping
if let tableColumn {
tf.preferredMaxLayoutWidth = tableColumn.width
}
tf.isEditable = true // comment this out and wrapping works
cell.addSubview(tf)
tf.translatesAutoresizingMaskIntoConstraints = false
NSLayoutConstraint.activate([
tf.leadingAnchor.constraint(equalTo: cell.leadingAnchor),
tf.trailingAnchor.constraint(equalTo: cell.trailingAnchor),
tf.topAnchor.constraint(equalTo: cell.topAnchor),
tf.bottomAnchor.constraint(equalTo: cell.bottomAnchor),
])
return cell
}
}
This is a tricky aspect of NSTextField in table views. usesAutomaticRowHeights relies on the text field's intrinsicContentSize to determine row height. When isEditable is true, NSTextField internally changes its sizing behavior and stops reporting a multi-line intrinsic height, so the row collapses to a single line. Your observation that "the text still wraps internally but the cell height doesn't update" is exactly right.
The fix is to stop using usesAutomaticRowHeights for editable text fields and calculate row heights manually using tableView(_:heightOfRow:). Use a separate non-editable text field as a measurement prototype — since non-editable text fields calculate wrapping height correctly — and call noteHeightOfRows(withIndexesChanged:) when the text changes so the row resizes as someone types.
Here is a working example:
// Prototype field for height measurement (non-editable, so wrapping height is correct)
let measureField: NSTextField = {
let tf = NSTextField(wrappingLabelWithString: "")
tf.isEditable = false
tf.maximumNumberOfLines = 0
return tf
}()
func heightForContent(_ text: String, columnWidth: CGFloat) -> CGFloat {
measureField.stringValue = text
measureField.preferredMaxLayoutWidth = columnWidth
return max(measureField.fittingSize.height, 22)
}
// Return the measured height for each row
func tableView(_ tableView: NSTableView, heightOfRow row: Int) -> CGFloat {
guard let column = tableView.tableColumn(withIdentifier: columnID) else { return 22 }
return heightForContent(rows[row], columnWidth: column.width)
}
In your tableView(_:viewFor:row:), set the text field's delegate and tag so you can identify the row:
tf.tag = row
tf.delegate = self
Implement controlTextDidChange to update the stored text and notify the table view:
func controlTextDidChange(_ obj: Notification) {
guard let tf = obj.object as? NSTextField else { return }
let row = tf.tag
rows[row] = tf.stringValue
NSAnimationContext.beginGrouping()
NSAnimationContext.current.duration = 0
tableView.noteHeightOfRows(withIndexesChanged: IndexSet(integer: row))
NSAnimationContext.endGrouping()
}
The NSAnimationContext wrapper with duration 0 suppresses the default row-resize animation so the height change feels immediate while editing.
Remove tableView.usesAutomaticRowHeights = true from your setup code — it conflicts with manual height management and does not work correctly with editable text fields.
If you need row heights to update when someone resizes a column, observe columnDidResizeNotification in your viewDidLoad:
NotificationCenter.default.addObserver(
self,
selector: #selector(columnDidResize),
name: NSTableView.columnDidResizeNotification,
object: tableView
)
And recalculate all row heights:
@objc func columnDidResize(_ notification: Notification) {
let allRows = IndexSet(integersIn: 0..<rows.count)
NSAnimationContext.beginGrouping()
NSAnimationContext.current.duration = 0
tableView.noteHeightOfRows(withIndexesChanged: allRows)
NSAnimationContext.endGrouping()
}