Scrolling NSTextView to bottom

Found solution:

- (void)logMessage:(NSString *)message
{
    if (message) {
        [self appendMessage:message];
    }
}

- (void)appendMessage:(NSString *)message
{
    NSString *messageWithNewLine = [message stringByAppendingString:@"\n"];

    // Smart Scrolling
    BOOL scroll = (NSMaxY(self.textView.visibleRect) == NSMaxY(self.textView.bounds));

    // Append string to textview
    [self.textView.textStorage appendAttributedString:[[NSAttributedString alloc]initWithString:messageWithNewLine]];

    if (scroll) // Scroll to end of the textview contents
        [self.textView scrollRangeToVisible: NSMakeRange(self.textView.string.length, 0)];
}

As of OS 10.6 it's as simple as nsTextView.scrollToEndOfDocument(self).


Swift 4 + 5

let smartScroll = self.textView.visibleRect.maxY == self.textView.bounds.maxY

self.textView.textStorage?.append("new text")

if smartScroll{
    self.textView.scrollToEndOfDocument(self)
}


I've been messing with this for a while, because I couldn't get it to work reliably. I've finally gotten my code working, so I'd like to post it as a reply.

My solution allows you to scroll manually, while output is being added to the view. As soon as you scroll to the absolute bottom of the NSTextView, the automatic scrolling will resume (if enabled, that is).

First a category to #import this only when needed...

FSScrollToBottomExtensions.h:

@interface NSView (FSScrollToBottomExtensions)
- (float)distanceToBottom;
- (BOOL)isAtBottom;
- (void)scrollToBottom;
@end

FSScrollToBottomExtensions.m:

@implementation NSView (FSScrollToBottomExtensions)
- (float)distanceToBottom
{
    NSRect  visRect;
    NSRect  boundsRect;

    visRect = [self visibleRect];
    boundsRect = [self bounds];
    return(NSMaxY(visRect) - NSMaxY(boundsRect));
}

// Apple's suggestion did not work for me.
- (BOOL)isAtBottom
{
    return([self distanceToBottom] == 0.0);
}

// The scrollToBottom method provided by Apple seems unreliable, so I wrote this one
- (void)scrollToBottom
{
    NSPoint     pt;
    id          scrollView;
    id          clipView;

    pt.x = 0;
    pt.y = 100000000000.0;

    scrollView = [self enclosingScrollView];
    clipView = [scrollView contentView];

    pt = [clipView constrainScrollPoint:pt];
    [clipView scrollToPoint:pt];
    [scrollView reflectScrolledClipView:clipView];
}
@end

... create yourself an "OutputView", which is a subclass of NSTextView:

FSOutputView.h:

@interface FSOutputView : NSTextView
{
    BOOL                scrollToBottomPending;
}

FSOutputView.m:

@implementation FSOutputView

- (id)setup
{
    ...
    return(self);
}

- (id)initWithCoder:(NSCoder *)aCoder
{
    return([[super initWithCoder:aCoder] setup]);
}

- (id)initWithFrame:(NSRect)aFrame textContainer:(NSTextContainer *)aTextContainer
{
    return([[super initWithFrame:aFrame textContainer:aTextContainer] setup]);
}

- (void)dealloc
{
    [[NSNotificationCenter defaultCenter] removeObserver:self];
    [super dealloc];
}

- (void)awakeFromNib
{
    NSNotificationCenter    *notificationCenter;
    NSView                  *view;

    // viewBoundsDidChange catches scrolling that happens when the caret
    // moves, and scrolling caused by pressing the scrollbar arrows.
    view = [self superview];
    [notificationCenter addObserver:self
    selector:@selector(viewBoundsDidChangeNotification:)
        name:NSViewBoundsDidChangeNotification object:view];
    [view setPostsBoundsChangedNotifications:YES];

    // viewFrameDidChange catches scrolling that happens because text
    // is inserted or deleted.
    // it also catches situations, where window resizing causes changes.
    [notificationCenter addObserver:self
        selector:@selector(viewFrameDidChangeNotification:)
        name:NSViewFrameDidChangeNotification object:self];
    [self setPostsFrameChangedNotifications:YES];

}

- (void)handleScrollToBottom
{
    if(scrollToBottomPending)
    {
        scrollToBottomPending = NO;
        [self scrollToBottom];
    }
}

- (void)viewBoundsDidChangeNotification:(NSNotification *)aNotification
{
    [self handleScrollToBottom];
}

- (void)viewFrameDidChangeNotification:(NSNotification *)aNotification
{
    [self handleScrollToBottom];
}

- (void)outputAttributedString:(NSAttributedString *)aAttributedString
    flags:(int)aFlags
{
    NSRange                     range;
    BOOL                        wasAtBottom;

    if(aAttributedString)
    {
        wasAtBottom = [self isAtBottom];

        range = [self selectedRange];
        if(aFlags & FSAppendString)
        {
            range = NSMakeRange([[self textStorage] length], 0);
        }
        if([self shouldChangeTextInRange:range
            replacementString:[aAttributedString string]])
        {
            [[self textStorage] beginEditing];
            [[self textStorage] replaceCharactersInRange:range
                withAttributedString:aAttributedString];
            [[self textStorage] endEditing];
        }

        range.location += [aAttributedString length];
        range.length = 0;
        if(!(aFlags & FSAppendString))
        {
            [self setSelectedRange:range];
        }

        if(wasAtBottom || (aFlags & FSForceScroll))
        {
            scrollToBottomPending = YES;
        }
    }
}
@end

... You can add a few more convenience methods to this class (I've stripped it down), so that you can output a formatted string.

- (void)outputString:(NSString *)aFormatString arguments:(va_list)aArguments attributeKey:(NSString *)aKey flags:(int)aFlags
{
    NSMutableAttributedString   *str;

    str = [... generate attributed string from parameters ...];
    [self outputAttributedString:str flags:aFlags];
}

- (void)outputLineWithFormat:(NSString *)aFormatString, ...
{
    va_list         args;
    va_start(args, aFormatString);
    [self outputString:aFormatString arguments:args attributeKey:NULL flags:FSAddNewLine];
    va_end(args);
}