View-based NSTableView with rows that have dynamic heights View-based NSTableView with rows that have dynamic heights objective-c objective-c

View-based NSTableView with rows that have dynamic heights


This is a chicken and the egg problem. The table needs to know the row height because that determines where a given view will lie. But you want a view to already be around so you can use it to figure out the row height. So, which comes first?

The answer is to keep an extra NSTableCellView (or whatever view you are using as your "cell view") around just for measuring the height of the view. In the tableView:heightOfRow: delegate method, access your model for 'row' and set the objectValue on NSTableCellView. Then set the view's width to be your table's width, and (however you want to do it) figure out the required height for that view. Return that value.

Don't call noteHeightOfRowsWithIndexesChanged: from in the delegate method tableView:heightOfRow: or viewForTableColumn:row: ! That is bad, and will cause mega-trouble.

To dynamically update the height, then what you should do is respond to the text changing (via the target/action) and recalculate your computed height of that view. Now, don't dynamically change the NSTableCellView's height (or whatever view you are using as your "cell view"). The table must control that view's frame, and you will be fighting the tableview if you try to set it. Instead, in your target/action for the text field where you computed the height, call noteHeightOfRowsWithIndexesChanged:, which will let the table resize that individual row. Assuming you have your autoresizing mask setup right on subviews (i.e.: subviews of the NSTableCellView), things should resize fine! If not, first work on the resizing mask of the subviews to get things right with variable row heights.

Don't forget that noteHeightOfRowsWithIndexesChanged: animates by default. To make it not animate:

[NSAnimationContext beginGrouping];[[NSAnimationContext currentContext] setDuration:0];[tableView noteHeightOfRowsWithIndexesChanged:indexSet];[NSAnimationContext endGrouping];

PS: I respond more to questions posted on the Apple Dev Forums than stack overflow.

PSS: I wrote the view based NSTableView


This got a lot easier in macOS 10.13 with .usesAutomaticRowHeights. The details are here: https://developer.apple.com/library/content/releasenotes/AppKit/RN-AppKit/#10_13 (In the section titled "NSTableView Automatic Row Heights").

Basically you just select your NSTableView or NSOutlineView in the storyboard editor and select this option in the Size Inspector:

enter image description here

Then you set the stuff in your NSTableCellView to have top and bottom constraints to the cell and your cell will resize to fit automatically. No code required!

Your app will ignore any heights specified in heightOfRow (NSTableView) and heightOfRowByItem (NSOutlineView). You can see what heights are getting calculated for your auto layout rows with this method:

func outlineView(_ outlineView: NSOutlineView, didAdd rowView: NSTableRowView, forRow row: Int) {  print(rowView.fittingSize.height)}


Based on Corbin's answer (btw thanks shedding some light on this):

Swift 3, View-Based NSTableView with Auto-Layout for macOS 10.11 (and above)

My setup: I have a NSTableCellView that is laid out using Auto-Layout. It contains (besides other elements) a multi-line NSTextField that can have up to 2 rows. Therefore, the height of the whole cell view depends on the height of this text field.

I update tell the table view to update the height on two occasions:

1) When the table view resizes:

func tableViewColumnDidResize(_ notification: Notification) {        let allIndexes = IndexSet(integersIn: 0..<tableView.numberOfRows)        tableView.noteHeightOfRows(withIndexesChanged: allIndexes)}

2) When the data model object changes:

tableView.noteHeightOfRows(withIndexesChanged: changedIndexes)

This will cause the table view to ask it's delegate for the new row height.

func tableView(_ tableView: NSTableView, heightOfRow row: Int) -> CGFloat {    // Get data object for this row    let entity = dataChangesController.entities[row]    // Receive the appropriate cell identifier for your model object    let cellViewIdentifier = tableCellViewIdentifier(for: entity)    // We use an implicitly unwrapped optional to crash if we can't create a new cell view    var cellView: NSTableCellView!    // Check if we already have a cell view for this identifier    if let savedView = savedTableCellViews[cellViewIdentifier] {        cellView = savedView    }    // If not, create and cache one    else if let view = tableView.make(withIdentifier: cellViewIdentifier, owner: nil) as? NSTableCellView {        savedTableCellViews[cellViewIdentifier] = view        cellView = view    }    // Set data object    if let entityHandler = cellView as? DataEntityHandler {        entityHandler.update(with: entity)    }    // Layout    cellView.bounds.size.width = tableView.bounds.size.width    cellView.needsLayout = true    cellView.layoutSubtreeIfNeeded()    let height = cellView.fittingSize.height    // Make sure we return at least the table view height    return height > tableView.rowHeight ? height : tableView.rowHeight}

First, we need to get our model object for the row (entity) and the appropriate cell view identifier. We then check if we have already created a view for this identifier. To do that we have to maintain a list with cell views for each identifier:

// We need to keep one cell view (per identifier) aroundfileprivate var savedTableCellViews = [String : NSTableCellView]()

If none is saved, we need to created (and cache) a new one. We update the cell view with our model object and tell it to re-layout everything based on the current table view width. The fittingSize height can then be used as the new height.