How do I make an editable NSTextField wrap inside an NSTableView cell?

Hi, I’m pretty new to AppKit and I’m trying to make an NSTextField inside an NSTableView both:

  1. Editable
  2. 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
    }
}
Answered by DTS Engineer in 887620022

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

That does not occur with a stand alone TextField.

May be there is an issue with line 9:

        tableView.usesAutomaticRowHeights = true

Could you comment it out ?

Otherwise, could you try this:

  • make non editable
  • change to editable when you type on text

-> What happens then ? Does it change to single line ?

Then you can have this work around:

  • make it editable when yo type on it
  • when editing done, return to non editable

@Claude31

Thanks, I tried both suggestions.

If I comment out:

tableView.usesAutomaticRowHeights = true

then wrapping does not work correctly in either case because the row height stays fixed.

I also tried starting with:

tf.isEditable = false

and then toggling:

tf.isEditable = true

later after layout. As soon as it becomes editable, the row collapses back to a single visible line again.

One thing I noticed though: the text itself actually still seems to wrap internally when isEditable = true, but the cell height no longer updates to fit the wrapped content. So it looks like the issue is more that the automatic row height calculation stops accounting for the multi-line editable text field.

Yes, I think problem comes from height calculation during editing.

What I do in such a case:

  • make the text non editable
  • when typing on text, create a temporary NSTextField that overlays the cell
  • The text is edited there
  • when editing done, copy content in the cell text and delete the temporary textField.
Accepted Answer

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()
}
How do I make an editable NSTextField wrap inside an NSTableView cell?
 
 
Q