91

I have an application with a view-based NSTableView in it. Inside this table view, I have rows that have cells that have content consisting of a multi-row NSTextField with word-wrap enabled. Depending on the textual content of the NSTextField, the size of the rows needed to display the cell will vary.

I know that I can implement the NSTableViewDelegate method -tableView:heightOfRow: to return the height, but the height will be determined based on the word wrapping used on the NSTextField. The word wrapping of the NSTextField is similarly based on how wide the NSTextField is… which is determined by the width of the NSTableView.

Soooo… I guess my question is… what is a good design pattern for this? It seems like everything I try winds up being a convoluted mess. Since the TableView requires knowledge of the height of the cells to lay them out... and the NSTextField needs knowledge of it's layout to determine the word wrap… and the cell needs knowledge of the word wrap to determine it's height… it's a circular mess… and it's driving me insane.

Suggestions?

If it matters, the end result will also have editable NSTextFields that will resize to adjust to the text within them. I already have this working on the view level, but the tableview does not yet adjust the heights of the cells. I figure once I get the height issue worked out, I'll use the -noteHeightOfRowsWithIndexesChanged method to inform the table view the height changed… but it's still then going to ask the delegate for the height… hence, my quandry.

starball
  • 20,030
  • 7
  • 43
  • 238
Jiva DeVoe
  • 1,338
  • 1
  • 9
  • 10
  • If both the width and height of the view can change with their contents, then defining them is complicated because you have to iterate (but then you can cache). But your case seems to have a changing height for a constant width. If the width is known (i.e. based on the width of the window or view), then nothing here is complicated. I'm missing why the width is varible? – Rob Napier Sep 21 '11 at 18:20
  • Only the height can change with the contents. And I've already solved that problem. I have an NSTextField subclass that adjusts it's height automatically. The problem is in getting knowledge of that adjustment back to the table view's delegate, so it can update the height accordingly. – Jiva DeVoe Sep 21 '11 at 18:28
  • @Jiva: Wondering if you've solved this yet. – Joshua Nozzi Nov 06 '11 at 18:00
  • I'm trying to do something similar. I like the idea of a NSTextField subclass that adjusts it's own height. How about adding a (delegate) notification of height change to that subclass and monitoring that with some appropriate class (data source, outline delegate, ...) to get info to the outline? – John Velman Nov 07 '11 at 16:01
  • A highly related question, with answers that helped me more than those here: [NSTableView Row Height based on NSStrings](http://stackoverflow.com/q/3212279/2047122). – Ashley Jun 17 '14 at 07:46

10 Answers10

139

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

James Webster
  • 31,873
  • 11
  • 70
  • 114
corbin dunn
  • 2,647
  • 1
  • 18
  • 16
  • Thanks for the response. I hadn't seen it originally... I marked it as the correct answer. The dev forums are great, I use them too. – Jiva DeVoe Feb 23 '12 at 17:02
  • "figure out the required height for that view. Return that value." .. That's my problem :(. I am using view-based tableView, and I have no idea how to do that! – Mazyod Mar 17 '12 at 16:07
  • @corbin Thanks for the answer, this is what I had on my app. However, I found a problem with it regarding Mavericks. Since you are clearly versed on this subject, would you mind checking out my question? Here: http://stackoverflow.com/questions/20924141/mavericks-issue-noteheightofrowswithindexeschanged-does-not-animate – Alex Jan 04 '14 at 20:08
  • 1
    @corbin is there a canonical answer for this, but using auto-layout and constraints, with NSTableViews? There is a potential solution with auto-layout; wondering if that's good enough without the estimatedHeightForRowAtIndexPath APIs (that isnt in Cocoa) – Z S Apr 04 '14 at 06:06
  • 3
    @ZS: I'm using Auto Layout and implemented Corbin's answer. In `tableView:heightOfRow:`, I set up my spare cell view with the row's values (I only have one column), and return `cellView.fittingSize.height`. `fittingSize` is an NSView method that computes the minimal size of the view that satisfies its constraints. – Peter Hosey Oct 20 '15 at 20:31
  • @PeterHosey FittingSize is not bad, but how about this part: > Then set the view's width to be your table's width Looks like we would need a `fittingSizeWithWidth(...)`? – hnh Sep 12 '17 at 21:42
58

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)
}
Clifton Labrum
  • 13,053
  • 9
  • 65
  • 128
  • @tofutim: If works correctly with wrapped text cells if you use the correct constraints and when you make sure the Preferred Width setting of each NSTextField with dynamic height, is set to Automatic. See also https://stackoverflow.com/questions/46890144/how-to-update-row-heights-of-nstableview-with-usesautomaticrowheights-true-aft – Ely Oct 21 '18 at 08:01
  • I thing @tofutim is right, this will not work if you put NSTextView into TableView, it try to archive it but it not work. It work for other component though, example NSImage .. – Albert Apr 14 '19 at 15:05
  • I used a fixed height for a row but it doesn't work for me – Ken Zira Apr 23 '19 at 14:03
  • 2
    After few days of figuring out why it doesn't work for me I discovered: it works ONLY when `TableCellView` is NOT editable. Doesn't matter is checked in Attributes [Behavior:Editable] or Bindings Inspector [Conditionally Set Editable:Yes] – Łukasz Sep 14 '19 at 15:20
  • 1
    I just want to emphasize how important it is to have actual height constraints in your table cell: mine didn't, so the cell's calculated fittingSize was 0.0 and frustration ensued. – green_knight Jun 14 '20 at 14:44
12

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) around
fileprivate 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.

JanApotheker
  • 1,746
  • 1
  • 17
  • 23
  • This is the perfect answer. If you have multiple columns, you'll want to set bounds to column width, not table width. – Vojto Apr 26 '17 at 12:50
  • Also, as you set `cellView.bounds.size.width = tableView.bounds.size.width`, it's a good idea to reset height to a low number. If you plan to reuse this dummy view, setting it to low number will "reset" the fitting size. – Vojto Apr 28 '17 at 16:48
7

For anyone wanting more code, here is the full solution I used. Thanks corbin dunn for pointing me in the right direction.

I needed to set the height mostly in relation to how high a NSTextView in my NSTableViewCell was.

In my subclass of NSViewController I temporary create a new cell by calling outlineView:viewForTableColumn:item:

- (CGFloat)outlineView:(NSOutlineView *)outlineView heightOfRowByItem:(id)item
{
    NSTableColumn *tabCol = [[outlineView tableColumns] objectAtIndex:0];
    IBAnnotationTableViewCell *tableViewCell = (IBAnnotationTableViewCell*)[self outlineView:outlineView viewForTableColumn:tabCol item:item];
    float height = [tableViewCell getHeightOfCell];
    return height;
}

- (NSView *)outlineView:(NSOutlineView *)outlineView viewForTableColumn:(NSTableColumn *)tableColumn item:(id)item
{
    IBAnnotationTableViewCell *tableViewCell = [outlineView makeViewWithIdentifier:@"AnnotationTableViewCell" owner:self];
    PDFAnnotation *annotation = (PDFAnnotation *)item;
    [tableViewCell setupWithPDFAnnotation:annotation];
    return tableViewCell;
}

In my IBAnnotationTableViewCell which is the controller for my cell (subclass of NSTableCellView) I have a setup method

-(void)setupWithPDFAnnotation:(PDFAnnotation*)annotation;

which sets up all outlets and sets the text from my PDFAnnotations. Now I can "easily" calcutate the height using:

-(float)getHeightOfCell
{
    return [self getHeightOfContentTextView] + 60;
}

-(float)getHeightOfContentTextView
{
    NSDictionary *attributes = [NSDictionary dictionaryWithObjectsAndKeys:[self.contentTextView font],NSFontAttributeName,nil];
    NSAttributedString *attributedString = [[NSAttributedString alloc] initWithString:[self.contentTextView string] attributes:attributes];
    CGFloat height = [self heightForWidth: [self.contentTextView frame].size.width forString:attributedString];
    return height;
}

.

- (NSSize)sizeForWidth:(float)width height:(float)height forString:(NSAttributedString*)string
{
    NSInteger gNSStringGeometricsTypesetterBehavior = NSTypesetterLatestBehavior ;
    NSSize answer = NSZeroSize ;
    if ([string length] > 0) {
        // Checking for empty string is necessary since Layout Manager will give the nominal
        // height of one line if length is 0.  Our API specifies 0.0 for an empty string.
        NSSize size = NSMakeSize(width, height) ;
        NSTextContainer *textContainer = [[NSTextContainer alloc] initWithContainerSize:size] ;
        NSTextStorage *textStorage = [[NSTextStorage alloc] initWithAttributedString:string] ;
        NSLayoutManager *layoutManager = [[NSLayoutManager alloc] init] ;
        [layoutManager addTextContainer:textContainer] ;
        [textStorage addLayoutManager:layoutManager] ;
        [layoutManager setHyphenationFactor:0.0] ;
        if (gNSStringGeometricsTypesetterBehavior != NSTypesetterLatestBehavior) {
            [layoutManager setTypesetterBehavior:gNSStringGeometricsTypesetterBehavior] ;
        }
        // NSLayoutManager is lazy, so we need the following kludge to force layout:
        [layoutManager glyphRangeForTextContainer:textContainer] ;

        answer = [layoutManager usedRectForTextContainer:textContainer].size ;

        // Adjust if there is extra height for the cursor
        NSSize extraLineSize = [layoutManager extraLineFragmentRect].size ;
        if (extraLineSize.height > 0) {
            answer.height -= extraLineSize.height ;
        }

        // In case we changed it above, set typesetterBehavior back
        // to the default value.
        gNSStringGeometricsTypesetterBehavior = NSTypesetterLatestBehavior ;
    }

    return answer ;
}

.

- (float)heightForWidth:(float)width forString:(NSAttributedString*)string
{
    return [self sizeForWidth:width height:FLT_MAX forString:string].height ;
}
Sunkas
  • 9,542
  • 6
  • 62
  • 102
  • Where is heightForWidth:forString: implemented? – Z S Apr 07 '14 at 01:59
  • Sorry about that. Added it to the answer. Tried to find the original SO post I got this from but could not find it. – Sunkas Apr 07 '14 at 18:58
  • 1
    Thanks! It's almost working for me, but it gives me inaccurate results. In my case, the width that I'm feeding into the NSTextContainer seems wrong. Are you using auto layout to define the subviews of your NSTableViewCell? Where does the width of "self.contentTextView" come from? – Z S Apr 08 '14 at 06:58
  • I posted a question about my problem here: http://stackoverflow.com/questions/22929441/nstablecellview-subview-frames-inside-nsoutlineviews-outlineviewheightofrowbyi – Z S Apr 08 '14 at 06:58
3

I was looking for a solution for quite some time and came up with the following one, which works great in my case:

- (double)tableView:(NSTableView *)tableView heightOfRow:(long)row
{
    if (tableView == self.tableViewTodo)
    {
        CKRecord *record = [self.arrayTodoItemsFiltered objectAtIndex:row];
        NSString *text = record[@"title"];

        double someWidth = self.tableViewTodo.frame.size.width;
        NSFont *font = [NSFont fontWithName:@"Palatino-Roman" size:13.0];
        NSDictionary *attrsDictionary =
        [NSDictionary dictionaryWithObject:font
                                    forKey:NSFontAttributeName];
        NSAttributedString *attrString =
        [[NSAttributedString alloc] initWithString:text
                                        attributes:attrsDictionary];

        NSRect frame = NSMakeRect(0, 0, someWidth, MAXFLOAT);
        NSTextView *tv = [[NSTextView alloc] initWithFrame:frame];
        [[tv textStorage] setAttributedString:attrString];
        [tv setHorizontallyResizable:NO];
        [tv sizeToFit];

        double height = tv.frame.size.height + 20;

        return height;
    }

    else
    {
        return 18;
    }
}
John
  • 8,468
  • 5
  • 36
  • 61
  • In my case i wanted to expand a given column vertically so that the text i display on that label fits all in. So I get the column in question and grab the font and the width from it. – Klajd Deda Jan 31 '16 at 22:55
3

Since I use custom NSTableCellView and I have access to the NSTextField my solution was to add a method on NSTextField.

@implementation NSTextField (IDDAppKit)

- (CGFloat)heightForWidth:(CGFloat)width {
    CGSize size = NSMakeSize(width, 0);
    NSFont*  font = self.font;
    NSDictionary*  attributesDictionary = [NSDictionary dictionaryWithObject:font forKey:NSFontAttributeName];
    NSRect bounds = [self.stringValue boundingRectWithSize:size options:NSStringDrawingUsesLineFragmentOrigin|NSStringDrawingUsesFontLeading attributes:attributesDictionary];

    return bounds.size.height;
}

@end
Joshua Nozzi
  • 60,946
  • 14
  • 140
  • 135
Klajd Deda
  • 355
  • 2
  • 7
  • Thanks! You are my saviour! This is the only one making works correctly. I spent a whole day to figure this out. – Tommy Oct 21 '16 at 11:04
2

Here's what I have done to fix it:

Source: Look into XCode documentation, under "row height nstableview". You'll find a sample source code named "TableViewVariableRowHeights/TableViewVariableRowHeightsAppDelegate.m"

(Note: I'm looking at column 1 in table view, you'll have to tweak to look elsewhere)

in Delegate.h

IBOutlet NSTableView            *ideaTableView;

in Delegate.m

table view delegates control of row height

    - (CGFloat)tableView:(NSTableView *)tableView heightOfRow:(NSInteger)row {
    // Grab the fully prepared cell with our content filled in. Note that in IB the cell's Layout is set to Wraps.
    NSCell *cell = [ideaTableView preparedCellAtColumn:1 row:row];

    // See how tall it naturally would want to be if given a restricted with, but unbound height
    CGFloat theWidth = [[[ideaTableView tableColumns] objectAtIndex:1] width];
    NSRect constrainedBounds = NSMakeRect(0, 0, theWidth, CGFLOAT_MAX);
    NSSize naturalSize = [cell cellSizeForBounds:constrainedBounds];

    // compute and return row height
    CGFloat result;
    // Make sure we have a minimum height -- use the table's set height as the minimum.
    if (naturalSize.height > [ideaTableView rowHeight]) {
        result = naturalSize.height;
    } else {
        result = [ideaTableView rowHeight];
    }
    return result;
}

you also need this to effect the new row height (delegated method)

- (void)controlTextDidEndEditing:(NSNotification *)aNotification
{
    [ideaTableView reloadData];
}

I hope this helps.

Final note: this does not support changing column width.

laurent
  • 29
  • 1
  • 4
    Unfortunately that Apple sample code (which is currently marked as version 1.1) uses a cell-based table, not a view-based table (which this question is about). The code calls `-[NSTableView preparedCellAtColumn:row]`, which the docs say "is only available to NSCell-based table views". Using this method on a view-based table produces this log output at run time: "View Based NSTableView error: preparedCellAtColumn:row: was called. Please log a bug with the backtrace from this log, or stop using the method”. – Ashley Jun 16 '14 at 16:22
2

Have you had a look at RowResizableViews? It is quite old and I haven't tested it but it may nevertheless work.

Tim
  • 1,659
  • 1
  • 21
  • 33
1

Here is a solution based of JanApotheker's answer, modified as cellView.fittingSize.height was not returning the correct height for me. In my case I am using the standard NSTableCellView, an NSAttributedString for the cell's textField text, and a single column table with constraints for the cell's textField set in IB.

In my view controller, I declare:

var tableViewCellForSizing: NSTableCellView?

In viewDidLoad():

tableViewCellForSizing = tableView.make(withIdentifier: "My Identifier", owner: self) as? NSTableCellView

Finally, for the tableView delegate method:

func tableView(_ tableView: NSTableView, heightOfRow row: Int) -> CGFloat {
    guard let tableCellView = tableViewCellForSizing else { return minimumCellHeight }

    tableCellView.textField?.attributedStringValue = attributedString[row]
    if let height = tableCellView.textField?.fittingSize.height, height > 0 {
        return height
    }

    return minimumCellHeight
}

mimimumCellHeight is a constant set to 30, for backup, but never actually used. attributedStrings is my model array of NSAttributedString.

This works perfectly for my needs. Thanks for all the previous answers, which pointed me in the right direction for this pesky problem.

jbaraga
  • 656
  • 5
  • 16
  • 1
    Very good. One issue. Table cannot be editable or textfield.fittingSize.height will not work and will return zero. Set table.isEditable = false in viewdidload – jiminybob99 Jul 20 '17 at 00:21
  • This method will be called only once for each row before the tableview showed. It means that if you have many datas, it will take you a long time for showing the tableview. – user2027712 Aug 12 '22 at 04:07
0

This sounds a lot like something I had to do previously. I wish I could tell you that I came up with a simple, elegant solution but, alas, I did not. Not for lack of trying though. As you have already noticed the need of UITableView to know the height prior to the cells being built really make it all seem quite circular.

My best solution was to push logic to the cell, because at least I could isolate what class needed to understand how the cells were laid out. A method like

+ (CGFloat) heightForStory:(Story*) story

would be able to determine how tall the cell had to be. Of course that involved measuring text, etc. In some cases I devised ways to cache information gained during this method that could then be used when the cell was created. That was the best I came up with. It is an infuriating problem though as it seems there should be a better answer.

vagrant
  • 868
  • 1
  • 9
  • 23