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;
}
Related
I have the following code to dismiss the keyboard if the user taps the background. It works fine if the scrollview is in the PointZero position, but if the user scrolls the view and then selects the textview, it doesn't call the "dismissKeyboard' method until the 2nd background tap.
On the first tap (for some reason) moves the scrollview offset to align with the scrollview frame to the screen bottom. The second tap will dismiss the keyboard and run the code below. I know it has to do with the scrollview. Any help would be appreciated.
Thanks
- (void)viewDidLoad {
UITapGestureRecognizer *tapGesture = [[UITapGestureRecognizer alloc] initWithTarget:self action:#selector(dismissKeyboard)];
tapGesture.cancelsTouchesInView = NO;
[_scrollView addGestureRecognizer:tapGesture];
}
-(void)dismissKeyboard {
[self.view endEditing:YES];
}
- (void)keyboardWasShown:(NSNotification *)notification {
scrollViewRect = _scrollView.contentOffset.y;
NSDictionary* info = [notification userInfo];
CGSize keyboardSize = [[info objectForKey:UIKeyboardFrameBeginUserInfoKey] CGRectValue].size;
keyboardSize.height += 10;
CGFloat viewBottom = CGRectGetMaxY(self.scrollView.frame);
if ([_itemNotes isFirstResponder]) {
CGFloat notesBottom = CGRectGetMaxY(_itemNotes.frame);
viewBottom -= notesBottom;
if (viewBottom < keyboardSize.height) {
keyboardSize.height -= viewBottom;
CGPoint scrollPoint = CGPointMake(0.0, keyboardSize.height);
[self.scrollView setContentOffset:scrollPoint animated:YES];
}
else {
[self.scrollView setContentOffset:CGPointZero animated:YES];
}
}
else {
[self.scrollView setContentOffset:CGPointZero animated:YES];
}
}
- (void)keyboardWillBeHidden:(NSNotification *)notification {
CGPoint scrollPoint = CGPointMake(0.0, scrollViewRect);
[self.scrollView setContentOffset:scrollPoint animated:YES];
}
EDIT:
So I figured out a solution but it seems like there must be a better way to handle this. The problem was because I was setting the contentOffset of the scrollView so that the contentSize was beyond the screen boundaries. Thus the first tap was moving the scrollView contentOffset back within the screen boundaries and the second was performing the tap gesture. I will post my solution below hoping that someone has a better answer.
I would recommend setting
_scrollView.layer.borderColor = [UIColor redColor].CGColor;
_scrollView.layer.borderWidth = 1;
This will show you exactly where your scrollview boundaries are, which may not be where you think they are, or may be covered by something else. Also, when I open the keyboard, I generally set the scrollview frame bottom to the top of the keyboard. Otherwise, you may have content below the keyboard you can't get to. Not sure if this is exactly related to your issues.
I am assuming there must be a better solution to this but I was able to solve the issue by extending the contentSize when the keyboard is displayed and then shrinking it back down when the keyboard is hidden.
Set a float (scrollViewHeight) to hold the original content size for the reset.
//add this right before setting the content offset
scrollViewHeight = _scrollView.contentSize.height;
_scrollView.contentSize = CGSizeMake(_scrollView.frame.size.width , scrollViewHeight + keyboardSize.height);
//add this right before reseting the content offset
_scrollView.contentSize = CGSizeMake(_scrollView.frame.size.width , scrollViewHeight);
It really seems like there must be a better way that I'm not aware of. I will have to go back through the documentation to see if there is another way.
Let's Say i loaded 100 rows in a table in awakeFromNib: , Now i want to call a method when the vertical scroller hits the bottom. Could anyone let me know how to handle the event of NSScroller hitting the bottom and calling a method when this happens.
// In awakeFromNib:
self.scrollView = [self.tableView enclosingScrollView];
// Register delegate to the scrollView content View:
// This will send notification whenever scrollView content view visible frame changes.
[NSNotificationCenter.defaultCenter addObserver:self selector:#selector(scrollViewDidScroll:) name:NSViewBoundsDidChangeNotification object:self.scrollView.contentView];
// This Method is called when content view frame changed
- (void)scrollViewDidScroll:(NSNotification *)notification {
NSScrollView *scrollView = self.scrollView;
// Test if bottom of content view is reached.
CGFloat currentPosition = CGRectGetMaxY([scrollView visibleRect]);
CGFloat contentHeight = [self.tableView bounds].size.height - 5;
if (currentPosition > contentHeight - 2.0) {
// YOUR ACTION
}
}
// Remove observer
- (void)dealloc {
[[NSNotificationCenter defaultCenter] removeObserver:self];
}
You can detect a scrollview at bottom by checking if tableView.enclosingScrollView.verticalScroller?.floatValue == 1 since the floatValue of vertical scroller varies between 0 and 1.
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.
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
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)
}
}
}
}