View-Based NSTableView - cocoa

I have been beating my head against the wall trying to come up with an understanding of tableView:heightOfRow: and I guess I need help. I want to do dynamic row heights for a textView inside the row, and cannot seem to get a handle on the approach. I've read everything I can find, which really isn't much. I can get the rows to size like I want using this method, but only if the table is resized. Rows that are not in view won't be sized right until they are visible and the table is resized.
I've added the tableView:didAddRowView:forRow method and using the same basic idea it ends up squishing the row size to a single line. Doesn't work the same as tableView:heightOfRow: at all, even though it's the same code. I'm guessing that the tableView:didAddRowView:forRow method setting the textView bounds is somehow getting scaled.
Here's my (hopefully relevant) code:
- (CGFloat)tableView:(NSTableView *)tv heightOfRow:(NSInteger)row {
if (tv == dataTableView) {
NSInteger valueCol = [tv columnWithIdentifier:#"value"];
NSTableCellView *valueView = [tv viewAtColumn:valueCol row:row makeIfNecessary:NO];
if (valueView) {
// Working on the interesting column and this row is visible
NSRect bounds = [[valueView textField] bounds];
id value = [[valueView textField] stringValue];
NSFont *fieldFont = [[valueView textField] font];
CGFloat adjustedHeight = [value heightForWidth:bounds.size.width font:fieldFont];
CGFloat rowHeight = [tv rowHeight];
if (adjustedHeight <= rowHeight) adjustedHeight = rowHeight;
return adjustedHeight;
}
}
return [tv rowHeight];
}
- (void)tableView:(NSTableView *)tv didAddRowView:(NSTableRowView *)rowView forRow:(NSInteger)row {
if (tv == dataTableView) {
NSInteger valueCol = [tv columnWithIdentifier:#"value"];
NSTableCellView *colView = [rowView viewAtColumn:valueCol];
NSRect textFieldViewBounds = [[colView textField] bounds];
NSTextField *colTextField = [colView textField];
NSFont *colFont = [colTextField font];
id value = [colTextField stringValue];
CGFloat newHeight = [value heightForWidth:textFieldViewBounds.size.width font:colFont];
NSSize colViewSize = colView.bounds.size;
colViewSize.height = newHeight;
textFieldViewBounds.size.height = newHeight;
[colTextField setBounds:textFieldViewBounds];
}
}
UPDATE: new code is working better, but still has glitches on initial load and sometimes on scroll:
- (CGFloat)tableView:(NSTableView *)tv heightOfRow:(NSInteger)row {
if (tv == dataTableView) {
NSInteger valueCol = [tv columnWithIdentifier:#"value"];
NSTableCellView *valueView = [tv viewAtColumn:valueCol row:row makeIfNecessary:NO];
if (valueView) {
// Working on the interesting column
NSRect bounds = [[valueView textField] bounds];
id value = [[valueView textField] stringValue];
NSFont *fieldFont = [[valueView textField] font];
CGFloat adjustedHeight = [value heightForWidth:bounds.size.width font:fieldFont];
CGFloat rowHeight = [tv rowHeight];
if (adjustedHeight <= rowHeight) adjustedHeight = rowHeight;
return adjustedHeight;
}
}
return [tv rowHeight];
}
- (void) tableView:(NSTableView *)tv didAddRowView:(NSTableRowView *)rowView forRow:(NSInteger)row {
if (tv == dataTableView) {
[dataTableView noteHeightOfRowsWithIndexesChanged:[NSIndexSet indexSetWithIndexesInRange:NSMakeRange(row, 1)]];
}
}

From your code, I'm guessing you want row height to change based on a the number of lines in a textfield cell, right? So the height should change if you resize a column or the table, or if you enter new text...?
IMO you don't need to do anything in -tableView:didAddRowViewForRow: It's too late for that and the row view should already have it's final height. Trying to modify view frame there may cause exceptions of bugs. Since your text field is in a NSTableCellView (assuming appropriate layout constraints, the default ones should be ok), it should display as intended given your design in IB and the row height returned by -tableView:heigthOfRow:
-tableView:heigthOfRow: is indeed the method where you need to determine row height. But you need to return an appropriate value for all rows for which to method is called or else rows won't display at the right height when they enter the visible rect during scrolling. So I would choose YES for the makeIfNecessary: argument of the viewAtColumn method.
Then you need to have the table update the row height when necessary (resizing a column, the view, maybe entering new text...). This could be done for instance in -tableViewColumnDidResize (using the userinfo dictionary to get the resized column) or when the text field is edited.
There, you need to call -noteHeightOfRowsWithIndexesChanged: on the table view. If the column is resized, you call it for all rows and the -tableView:heigthOfRow: will return all the heights. If the text field is edited, you may want to call it only for its row (but even calling the method for all rows should do it).
I hope it helped.

Related

How to scroll to row added to NSTableView after animation has finished

After adding a new row to an NSTableView I'd like to scroll to it.
When that row has been added to the end of table the scroll only scrolls to the row that was previously the last row. I initially thought that I had to wait for the animation to finish, but that hadn't solved my issue. Here's my code:
[NSAnimationContext beginGrouping];
[_tableView insertRowsAtIndexes:indexSet withAnimation:NSTableViewAnimationEffectGap];
[[NSAnimationContext currentContext] setCompletionHandler:^{
// Scroll to the first inserted row
NSUInteger firstIndex = [indexSet firstIndex];
[_tableView scrollRowToVisible:firstIndex];
}];
[NSAnimationContext endGrouping];
How can I do this?
I found a solution to his problem that I'm happy with:
[_tableView insertRowsAtIndexes:indexSet withAnimation:NSTableViewAnimationEffectGap];
[[NSOperationQueue mainQueue] addOperationWithBlock:^{
NSUInteger firstIndex = [indexSet firstIndex];
[_tableView scrollRowToVisible:firstIndex];
}];
I'm simply delaying the scroll request until the next run loop.
We had problems with this, so we ended up doing the scroll as the other animations happen, to keep the row on-screen. You’d call this code inside your animation grouping where you do the tableView modifications.
The code looks like this:
- (BOOL)scrollRowToVisible:(NSInteger)row animate:(BOOL)animate;
{
LIClipView *const clipView = (id)_sourceListOutlineView.enclosingScrollView.contentView;
const NSRect finalFrameOfRow = [_sourceListOutlineView rectOfRow:row];
const NSRect clipViewBounds = clipView.bounds;
if (NSIsEmptyRect(finalFrameOfRow) || _sourceListOutlineView.numberOfRows <= 1)
return NO;
const NSRect finalFrameOfLastRow = [_sourceListOutlineView rectOfRow:(_sourceListOutlineView.numberOfRows - 1)];
if (NSMaxY(finalFrameOfLastRow) <= NSHeight(clipViewBounds))
// The source list is shrinking to fully fit in its clip view (though it might still be larger while animating); no scrolling is needed.
return NO;
if (NSMinY(finalFrameOfRow) < NSMinY(clipViewBounds)) {
// Scroll top of clipView up to top of row
[clipView scrollToPoint:(NSPoint){0, NSMinY(finalFrameOfRow)} animate:animate];
return YES;
}
if (NSMaxY(finalFrameOfRow) > NSMaxY(clipViewBounds)) {
// Scroll bottom of clipView down to bottom of source, but not such that the top goes off-screen (i.e. repeated calls won't keep scrolling if the row is higher than visibleRect)
[clipView scrollToPoint:(NSPoint){0, MIN(NSMinY(finalFrameOfRow), NSMaxY(finalFrameOfRow) - NSHeight(clipViewBounds))} animate:animate];
return YES;
}
return NO;
}

UIPageViewController changes size?

I'm using the Page-Based Application template in Xcode 4 to load pages of a PDF and create a UITextView over hidden text boxes so the user can write notes.
So far I have it all working, but when I add the UITextView, it's in the wrong place in landscape mode (showing 2 pages).
// ModelController.m
- (id)init
{
self = [super init];
if (self) {
NSString *pathToPdfDoc = [[NSBundle mainBundle] pathForResource:#"My PDF File" ofType:#"pdf"];
NSURL *pdfUrl = [NSURL fileURLWithPath:pathToPdfDoc];
self.pageData = CGPDFDocumentCreateWithURL((__bridge CFURLRef)pdfUrl); // pageData holds the PDF file
}
return self;
}
- (DataViewController *)viewControllerAtIndex:(NSUInteger)index storyboard:(UIStoryboard *)storyboard
{
// Return the data view controller for the given index.
if( CGPDFDocumentGetNumberOfPages( self.pageData ) == 0 || (index >= CGPDFDocumentGetNumberOfPages( self.pageData )))
return nil;
// Create a new view controller and pass suitable data.
DataViewController *dataViewController = [storyboard instantiateViewControllerWithIdentifier:#"DataViewController"];
dataViewController.dataObject = CGPDFDocumentGetPage( self.pageData, index + 1 ); // dataObject holds the page of the PDF file
[dataViewController view]; // make sure the view is loaded so that all subviews can be accessed
UITextView *textView = [[UITextView alloc] initWithFrame:CGRectMake( 10, 20, 30, 40 )];
textView.layer.borderWidth = 1.0f;
textView.layer.borderColor = [[UIColor grayColor] CGColor];
[dataViewController.dataView addSubview:textView]; // dataView is a subview of dataViewController.view in the storyboard/xib
CGRect viewFrame = dataViewController.dataView.frame; // <- *** THIS IS THE WRONG SIZE IN LANDSCAPE ***
}
This behavior really surprised me, because viewControllerAtIndex isn't called when I rotate the iPad, so I have no way of knowing what the real size of the view frame is. I get the same view frame in both portrait and landscape:
# in Xcode console:
po [dataViewController view]
# result in either orientation:
(id) $4 = 0x0015d160 <UIView: 0x15d160; frame = (0 20; 768 1004); autoresize = RM+BM; layer = <CALayer: 0x15d190>>
#
Does anyone know if there is a transform I'm supposed to use to position the UITextView correctly? I'm concerned that I may have to store the locations of the elements independently and reposition them upon receiving shouldAutorotateToInterfaceOrientation messages.
It seems that Apple may have implemented UIPageViewController improperly, but all I could find was this partial workaround that I'm still trying to figure out:
UIPageViewController and off screen orientation changes
Thanks!
I think the trick here is to override viewDidLayoutSubviews in your DataViewController and manage the size of all your programmatically-inserted non-autosizing views, since you don't really know what the parent is going to do to its subviews until that time.
-(void)viewDidLayoutSubviews
{
[super viewDidLayoutSubviews];
self.textView.frame = CGRectMake(0, 0, self.view.frame.size.width, self.view.frame.size.height);
}

NSTextField in NSTableCellView

I have a view based NSTableView with a custom NSTableCellView. This custom NSTableCellView has several labels (NSTextField). The whole UI of the NSTableCellView is built in IB.
The NSTableCellView can be in a normal state and in a selected state. In the normal state all text labels should be black, in the selected state they should be white.
How can I manage this?
Override setBackgroundStyle: on the NSTableCellView to know when the background changes which is what affects what text color you should use in your cell.
For instance:
- (void)setBackgroundStyle:(NSBackgroundStyle)style
{
[super setBackgroundStyle:style];
// If the cell's text color is black, this sets it to white
[((NSCell *)self.descriptionField.cell) setBackgroundStyle:style];
// Otherwise you need to change the color manually
switch (style) {
case NSBackgroundStyleLight:
[self.descriptionField setTextColor:[NSColor colorWithCalibratedWhite:0.4 alpha:1.0]];
break;
case NSBackgroundStyleDark:
default:
[self.descriptionField setTextColor:[NSColor colorWithCalibratedWhite:1.0 alpha:1.0]];
break;
}
}
In source list table views the cell view's background style is set to Light, as is its textField's backgroundStyle, however the textField also draws a shadow under its text and haven't yet found exactly what is controlling that / determining that should it happen.
Probably the easiest way to accomplish this would be to subclass NSTextField and to override the drawRect: method in your subclass. There you can determine whether the NSTableCellView instance containing your NSTextField instances is currently selected by using this code (which I use with a NSOutlineView, but it should also work with NSTableView):
BOOL selected = NO;
id tableView = [[[self superview] superview] superview];
if ([tableView isKindOfClass:[NSTableView class]]) {
NSInteger row = [tableView selectedRow];
if (row != -1) {
id cellView = [tableView viewAtColumn:0 row:row makeIfNecessary:YES];
if ([cellView isEqualTo:[self superview]]) selected = YES;
}
}
Then draw the view like this:
if (selected) {
// set your color here
// draw [self stringValue] here in [self bounds]
} else {
// call [super drawRect]
}
This works no matter what style the table view has:
- (void)setBackgroundStyle:(NSBackgroundStyle)backgroundStyle {
[super setBackgroundStyle:backgroundStyle];
NSTableView *tableView = self.enclosingScrollView.documentView;
BOOL tableViewIsFirstResponder = [tableView isEqual:[self.window firstResponder]];
NSColor *color = nil;
if(backgroundStyle == NSBackgroundStyleLight) {
color = tableViewIsFirstResponder ? [NSColor lightGrayColor] : [NSColor darkGrayColor];
} else {
color = [NSColor whiteColor];
}
myTextField.textColor = color;
}
Swift 4
override var backgroundStyle: NSView.BackgroundStyle {
get {
return super.backgroundStyle
}
set {
self.yourCustomLabel.textColor = NSColor(calibratedWhite: 0.0, alpha: 1.0)//black
}
}

How to NSTableView cell add padding to cell?

I've got an NSTableView which stretches from edge to edge on my window, but the data in the cells on the edge of the table really need some padding. The window doesn't look good if I leave a gutter on the edges, so I'd like to try to add some padding inside some of the cells so the data isn't right up against the edge.
I can't find anything in Interface Builder or in the code documentation about adding padding or insets to the cells.
Any suggestions?
You can subclass NSTextFieldCell and override the drawInteriorWithFrame:inView: method to custom draw the string.
- (void)drawInteriorWithFrame:(NSRect)cellFrame inView:(NSView *)controlView
{
NSRect titleRect = [self titleRectForBounds:cellFrame];
NSAttributedString *aTitle = [self attributedStringValue];
if ([aTitle length] > 0) {
[aTitle drawInRect:titleRect];
}
}
where titleRectForBounds: adds some space
- (NSRect)titleRectForBounds:(NSRect)bounds
{
NSRect titleRect = bounds;
titleRect.origin.x += 5;
titleRect.origin.y += 5;
NSAttributedString *title = [self attributedStringValue];
if (title) {
titleRect.size = [title size];
} else {
titleRect.size = NSZeroSize;
}
// We don't want the width of the string going outside the cell's bounds
CGFloat maxX = NSMaxX(bounds);
CGFloat maxWidth = maxX - NSMinX(titleRect);
if (maxWidth < 0) {
maxWidth = 0;
}
titleRect.size.width = MIN(NSWidth(titleRect), maxWidth);
return titleRect;
}
There's a more full example at http://comelearncocoawithme.blogspot.com/2011/09/custom-cells-in-nstableview-part-1.html
Easy way: add spaces before your text strings.
Better way: embed a view inside your cell and put all other UI objects inside that.
in your cellforrowAtIndexPath you need to implement this.
cell.textLabel.text = [NSString stringWithFormat:#" %#",sometext];
or else
take a label and setframe to it.Then add it to cell

Respond to mouse events in text field in view-based table view

I have text fields inside a custom view inside an NSOutlineView. Editing one of these cells requires a single click, a pause, and another single click. The first single click selects the table view row, and the second single click draws the cursor in the field. Double-clicking the cell, which lets you edit in a cell-based table view, only selects the row.
The behavior I want: one click to change the selection and edit.
What do I need to override to obtain this behavior?
I've read some other posts:
The NSTextField flyweight pattern wouldn't seem to apply to view-based table views, where the cell views are all instantiated from nibs.
I tried subclassing NSTextField like this solution describes, but my overridden mouseDown method is not called. Overridden awakeFromNib and viewWillDraw (mentioned in this post) are called. Of course mouseDown is called if I put the text field somewhere outside a table view.
By comparison, a NSSegmentedControl in my cell view changes its value without first selecting the row.
Here's the working solution adapted from the accepted response:
In outline view subclass:
-(void)mouseDown:(NSEvent *)theEvent {
[super mouseDown:theEvent];
// Forward the click to the row's cell view
NSPoint selfPoint = [self convertPoint:theEvent.locationInWindow fromView:nil];
NSInteger row = [self rowAtPoint:selfPoint];
if (row>=0) [(CellViewSubclass *)[self viewAtColumn:0 row:row makeIfNecessary:NO]
mouseDownForTextFields:theEvent];
}
In table cell view subclass:
// Respond to clicks within text fields only, because other clicks will be duplicates of events passed to mouseDown
- (void)mouseDownForTextFields:(NSEvent *)theEvent {
// If shift or command are being held, we're selecting rows, so ignore
if ((NSCommandKeyMask | NSShiftKeyMask) & [theEvent modifierFlags]) return;
NSPoint selfPoint = [self convertPoint:theEvent.locationInWindow fromView:nil];
for (NSView *subview in [self subviews])
if ([subview isKindOfClass:[NSTextField class]])
if (NSPointInRect(selfPoint, [subview frame]))
[[self window] makeFirstResponder:subview];
}
Had the same problem. After much struggle, it magically worked when I selected None as against the default Regular (other option is Source List) for the Highlight option of the table view in IB!
Another option is the solution at https://stackoverflow.com/a/13579469/804616, which appears to be more specific but a little hacky compared to this.
I'll try to return the favor... Subclass NSOutlineView and override -mouseDown: like so:
- (void)mouseDown:(NSEvent *)theEvent {
[super mouseDown:theEvent];
// Only take effect for double clicks; remove to allow for single clicks
if (theEvent.clickCount < 2) {
return;
}
// Get the row on which the user clicked
NSPoint localPoint = [self convertPoint:theEvent.locationInWindow
fromView:nil];
NSInteger row = [self rowAtPoint:localPoint];
// If the user didn't click on a row, we're done
if (row < 0) {
return;
}
// Get the view clicked on
NSTableCellView *view = [self viewAtColumn:0 row:row makeIfNecessary:NO];
// If the field can be edited, pop the editor into edit mode
if (view.textField.isEditable) {
[[view window] makeFirstResponder:view.textField];
}
}
You really want to override validateProposedFirstResponder and allow a particular first responder to be made (or not) depending on your logic. The implementation in NSTableView is (sort of) like this (I'm re-writing it to be pseudo code):
- (BOOL)validateProposedFirstResponder:(NSResponder *)responder forEvent:(NSEvent *)event {
// We want to not do anything for the following conditions:
// 1. We aren't view based (sometimes people have subviews in tables when they aren't view based)
// 2. The responder to valididate is ourselves (we send this up the chain, in case we are in another tableview)
// 3. We don't have a selection highlight style; in that case, we just let things go through, since the user can't appear to select anything anyways.
if (!isViewBased || responder == self || [self selectionHighlightStyle] == NSTableViewSelectionHighlightStyleNone) {
return [super validateProposedFirstResponder:responder forEvent:event];
}
if (![responder isKindOfClass:[NSControl class]]) {
// Let any non-control become first responder whenever it wants
result = YES;
// Exclude NSTableCellView.
if ([responder isKindOfClass:[NSTableCellView class]]) {
result = NO;
}
} else if ([responder isKindOfClass:[NSButton class]]) {
// Let all buttons go through; this would be caught later on in our hit testing, but we also do it here to make it cleaner and easier to read what we want. We want buttons to track at anytime without any restrictions. They are always valid to become the first responder. Text editing isn't.
result = YES;
} else if (event == nil) {
// If we don't have any event, then we will consider it valid only if it is already the first responder
NSResponder *currentResponder = self.window.firstResponder;
if (currentResponder != nil && [currentResponder isKindOfClass:[NSView class]] && [(NSView *)currentResponder isDescendantOf:(NSView *)responder]) {
result = YES;
}
} else {
if ([event type] == NSEventTypeLeftMouseDown || [event type] == NSEventTypeRightMouseDown) {
// If it was a double click, and we have a double action, then send that to the table
if ([self doubleAction] != NULL && [event clickCount] > 1) {
[cancel the first responder delay];
}
...
The code here checks to see if the text field
cell had text hit. If it did, it attempts to edit it on a delay.
Editing is simply making that NSTextField the first responder.
...
}
I wrote the following to support the case for when you have a more complex NSTableViewCell with multiple text fields or where the text field doesn't occupy the whole cell. There a trick in here for flipping y values because when you switch between the NSOutlineView or NSTableView and it's NSTableCellViews the coordinate system gets flipped.
- (void)mouseDown:(NSEvent *)theEvent
{
[super mouseDown: theEvent];
NSPoint thePoint = [self.window.contentView convertPoint: theEvent.locationInWindow
toView: self];
NSInteger row = [self rowAtPoint: thePoint];
if (row != -1) {
NSView *view = [self viewAtColumn: 0
row: row
makeIfNecessary: NO];
thePoint = [view convertPoint: thePoint
fromView: self];
if ([view isFlipped] != [self isFlipped])
thePoint.y = RectGetHeight(view.bounds) - thePoint.y;
view = [view hitTest: thePoint];
if ([view isKindOfClass: [NSTextField class]]) {
NSTextField *textField = (NSTextField *)view;
if (textField.isEnabled && textField.window.firstResponder != textField)
dispatch_async(dispatch_get_main_queue(), ^{
[textField selectText: nil];
});
}
}
}
Just want to point out that if all that you want is editing only (i.e. in a table without selection), overriding -hitTest: seems to be simpler and a more Cocoa-like:
- (NSView *)hitTest:(NSPoint)aPoint
{
NSInteger column = [self columnAtPoint: aPoint];
NSInteger row = [self rowAtPoint: aPoint];
// Give cell view a chance to override table hit testing
if (row != -1 && column != -1) {
NSView *cell = [self viewAtColumn:column row:row makeIfNecessary:NO];
// Use cell frame, since convertPoint: doesn't always seem to work.
NSRect frame = [self frameOfCellAtColumn:column row:row];
NSView *hit = [cell hitTest: NSMakePoint(aPoint.x + frame.origin.x, aPoint.y + frame.origin.y)];
if (hit)
return hit;
}
// Default implementation
return [super hitTest: aPoint];
}
Here is a swift 4.2 version of #Dov answer:
override func mouseDown(with event: NSEvent) {
super.mouseDown(with: event)
if (event.clickCount < 2) {
return;
}
// Get the row on which the user clicked
let localPoint = self.convert(event.locationInWindow, from: nil)
let row = self.row(at: localPoint)
// If the user didn't click on a row, we're done
if (row < 0) {
return
}
DispatchQueue.main.async {[weak self] in
guard let self = self else {return}
// Get the view clicked on
if let clickedCell = self.view(atColumn: 0, row: row, makeIfNecessary: false) as? YourOutlineViewCellClass{
let pointInCell = clickedCell.convert(localPoint, from: self)
if (clickedCell.txtField.isEditable && clickedCell.txtField.hitTest(pointInCell) != nil){
clickedCell.window?.makeFirstResponder(clickedCell.txtField)
}
}
}
}

Resources