4
votes

I have a NSTextView for which I want to use the find bar. The text is selectable, but not editable. I change the text in the text view programatically.

This setup can crash when NSTextFinder tries to select the next match after the text was changed. It seems NSTextFinder hold on to outdated ranges for incremental matches.

I tried several methods of changing the text:

[textView setString:@""];

or

NSTextStorage *newStorage = [[NSTextStorage alloc] initWithString:@""];

[textView.layoutManager replaceTextStorage:newStorage];

or

[textView.textStorage beginEditing];
[textView.textStorage setAttributedString:[[NSAttributedString alloc] initWithString:@""]];
[textView.textStorage endEditing];

Only replaceTextStorage: calls -[NSTextFinder noteClientStringWillChange]. None of the above invokes -[NSTextFinder cancelFindIndicator].

Even with NSTextFinder notified about the text change it can crash on Find Next (command-G).

I have also tried creating my own NSTextFinder instance as suggested in this post. Even though NSTextView does not implement the NSTextFinderClient protocol this works and fails just the same as without the NSTextFinder instance.

What is the correct way to use NSTextFinder with NSTextView?

2

2 Answers

3
votes

I had the same problem with the text view in my app, and what makes it even more annoying is that all "solutions" you find on the internet are either incorrect or at least incomplete. So here is my contribution.

When you set textView.useFindBar = YES in a NSTextView, this text view creates a NSTextFinder internally, and forwards the search/replace commands to it. Unfortunately, NSTextView does not seem to handle correctly the changes you make programmatically to its associated NSTextStorage, which causes the crashes you mention.

If you want to change this behavior, creating your private NSTextFinder is not enough: you also need to avoid the use by the text view of its default text finder, otherwise conflicts will occur and the new text finder won't be of much use.

To do this, you have to subclass NSTextView:

@interface MyTextView : NSTextView

- (void) resetTextFinder; // A method to reset the view's text finder when you change the text storage

@end

And in your text view, you have to override the responder methods used for controlling the text finder:

@interface MyTextView ()  <NSTextFinderClient>
{
    NSTextFinder* _textFinder; // define your own text finder
}

@property (readonly) NSTextFinder* textFinder;

@end

@implementation MyTextView

// Text finder command validation (could also be done in method validateUserInterfaceItem: if you prefer) 

- (BOOL) validateMenuItem:(NSMenuItem *)menuItem
{
    BOOL isValidItem = NO;

    if (menuItem.action == @selector(performTextFinderAction:)) {
        isValidItem = [self.textFinder validateAction:menuItem.tag];
    }
    // validate other menu items if needed
    // ...
    // and don't forget to call the superclass
    else {
        isValidItem = [super validateMenuItem:menuItem];
    }

    return isValidItem;
}

// Text Finder

- (NSTextFinder*) textFinder
{
    // Create the text finder on demand
    if (_textFinder == nil) {
        _textFinder = [[NSTextFinder alloc] init];
        _textFinder.client = self;
        _textFinder.findBarContainer = [self enclosingScrollView];
        _textFinder.incrementalSearchingEnabled = YES;
        _textFinder.incrementalSearchingShouldDimContentView = YES;
    }

    return _textFinder;
}

- (void) resetTextFinder
{
    if  (_textFinder != nil) {
        // Hide the text finder 
        [_textFinder cancelFindIndicator];
        [_textFinder performAction:NSTextFinderActionHideFindInterface];

        // Clear its client and container properties
        _textFinder.client = nil;
        _textFinder.findBarContainer = nil;

        // And delete it
        _textFinder = nil;
    }
}

// This is where the commands are actually sent to the text finder
- (void) performTextFinderAction:(id<NSValidatedUserInterfaceItem>)sender
{
    [self.textFinder performAction:sender.tag];
}

@end

In your text view, you still need to set properties usesFindBar and incrementalSearchingEnabled to YES.

And before changing the view's text storage (or text storage contents) you just need to call [myTextView resetTextFinder]; to recreate a brand new text finder for your new content the next time you will do a search.

If you want more information about NSTextFinder, the best doc I have seen is in the AppKit Release Notes for OS X 10.7

2
votes

The solution I had come up with seems rather similar to the one offered by @jlj. In both solutions NSTextView is used as client of NSTextFinder.

It seems that the main difference is that I don't hide the find bar on text change. I also hold onto my NSTextFinder instance. To do so I need to call [textFinder noteClientStringWillChange].

Changing text:

NSTextView *textView = self.textView;
NSTextFinder *textFinder = self.textFinder;

[textFinder cancelFindIndicator];
[textFinder noteClientStringWillChange];

[textView setString:@"New text"];

The rest of the view controller code looks like this:

- (void)viewDidLoad
{
    [super viewDidLoad];

    NSTextFinder *textFinder = [[NSTextFinder alloc] init];

    [textFinder setClient:(id < NSTextFinderClient >)textView];
    [textFinder setFindBarContainer:[textView enclosingScrollView]];

    [textView setUsesFindBar:YES];
    [textView setIncrementalSearchingEnabled:YES];

    self.textFinder = textFinder;
}

- (void)viewWillDisappear
{
    NSTextFinder *textFinder = self.textFinder;

    [textFinder cancelFindIndicator];

    [super viewWillDisappear];
}

- (id)supplementalTargetForAction:(SEL)action sender:(id)sender
{
    id target = [super supplementalTargetForAction:action sender:sender];

    if (target != nil) {
        return target;
    }

    if (action == @selector(performTextFinderAction:)) {
        target = self.textView;

        if (![target respondsToSelector:action]) {
            target = [target supplementalTargetForAction:action sender:sender];
        }

        if ((target != self) && [target respondsToSelector:action]) {
            return target;
        }
    }

    return nil;
}