具有动态高度的行的基于视图的 NSTableView

我有一个基于视图的 NSTableView应用程序。在这个表视图中,有一些行的单元格的内容由启用了换行的多行 NSTextField组成。根据 NSTextField的文本内容,显示单元格所需的行大小将有所不同。

我知道我可以实现 NSTableViewDelegate方法 -tableView:heightOfRow:来返回高度,但是高度将根据在 NSTextField上使用的单词包装来确定。字包装的 NSTextField是类似的基础上如何广泛的 NSTextField是... 这是由宽度的 NSTableView决定。

所以... 我想我的问题是... 什么是一个好的设计模式?好像我做的每件事都是一团糟。由于 TableView 需要知道单元格的高度来布局,而 NSTextField需要知道它的布局来确定单词包装,而单元格需要知道单词包装来确定它的高度,它是一个圆形的混乱,它驱使着我 疯了

有什么建议吗?

如果这很重要,最终结果还将有可编辑的 NSTextFields,它将调整大小以适应其中的文本。我已经在视图级别上使用了这种方法,但是桌面视图还没有调整单元格的高度。我认为一旦我解决了高度问题,我将使用-noteHeightOfRowsWithIndexesChanged方法通知表视图高度改变了... ... 但它仍然会要求委托获得高度... ... 因此,这是我的难题。

先谢谢你!

39375 次浏览

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

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.

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

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.

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 ;
}

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;
}
}

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

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.

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.

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