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?
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
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;
}
Related
I have a list of files. Next to it I have a QLPreviewView which shows the currently selected file.
Unfortunately QLPreviewView loads a web view to preview bookmark files. Some web pages can grab keyboard focus. E.g. the Gmail login form places the insertion point into the user name field.
This breaks the flow of my application. I want to navigate my list using arrow keys. This is disrupted when keyboard focus is taken away from the table view.
So far the best I could come up with is to override - [NSWindow makeFirstResponder:] and not call super for instances of classes named with a QL prefix. Yuck.
Is there a more reasonable way to
Prevent unwanted changes of first responder?
or prevent user interaction on QLPreviewView and its subviews?
I ended up using a NSWindow subclass that allows QLPreviewViews and its private subviews to become first responder on user interaction, but prevents these views from simply stealing focus.
- (BOOL)makeFirstResponder:(NSResponder *)aResponder
{
NSString *classname = NSStringFromClass([aResponder class]);
// This is a hack to prevent Quick Look from stealing first responder
if ([classname hasPrefix:#"QL"]) {
BOOL shouldMakeFirstRespnder = NO;
NSEvent *currentEvent = [[NSApplication sharedApplication] currentEvent] ;
NSEventType eventType = currentEvent.type;
if ((eventType == NSLeftMouseDown) || (eventType == NSRightMouseDown) || (eventType == NSMouseEntered)) {
if ([aResponder isKindOfClass:[NSView class]]) {
NSView *view = (NSView *)aResponder;
NSPoint locationInWindow = currentEvent.locationInWindow;
NSPoint locationInView = [view convertPoint:locationInWindow fromView:nil];
BOOL pointInRect = NSPointInRect(locationInView, [view bounds]);
shouldMakeFirstRespnder = pointInRect;
}
}
if (!shouldMakeFirstRespnder) {
return NO;
}
}
return [super makeFirstResponder:aResponder];
}
Maybe you can subclass QLPreviewView and override its becomeFirstResponder so that you can either enable or disable it when your application should allow it to accept focus.
Header
#interface MyQLPreviewView : QLPreviewView
#end
Implementation
#implementation
- (BOOL)becomeFirstResponder
{
return NO;
}
#end
I'm trying to get my NSTextField to have its height grow (much like in iChat or Adium) once the user types enough text to overflow the width of the control (as asked on this post)
I've implimented the accepted answer yet I can't seem to get it to work. I have uploaded my attempt at http://scottyob.com/pub/autoGrowingExample.zip
Ideally, when the text grows, the containing window should grow with it, but I'm trying baby steps here.
Solved it! (Inspired by https://github.com/jerrykrinock/CategoriesObjC/blob/master/NS(Attributed)String%2BGeometrics/NS(Attributed)String%2BGeometrics.m )
Reading the Apple Documentation is usually helpful. Apple has engineered all this text layout stuff to be powerful enough to handle all sorts of complicated edge cases which is sometimes extremely helpful, and sometimes not.
Firstly, I set the text field to wrap lines on word break, so we actually get multiple lines. (Your example code even had an if statement so it did nothing at all when wrapping was turned off).
The trick to this one was to note that when text is being edited, it’s printed by a ‘field editor’ – a heavy weight NSTextView object, owned by an NSWindow, that’s reused by whatever NSTextField is currently the ‘first responder’ (selected). The NSTextView has a single NSTextContainer (rectangle where text goes), which has a NSLayoutManager to layout the text. We can ask the layout manager how much space it wants to use up, to get the new height of our text field.
The other trick was to override the NSText delegate method - (void)textDidChange:(NSNotification *)notification to invalidate the intrinsic content size when the text is changed (so it doesn’t just wait to update when you commit changed by pressing return).
The reason I didn’t use cellSizeForBounds as you originally suggested was I couldn’t solve your problem – even when invalidating the intrinsic content size of the cell, cellSizeForBounds: continued to return the old size.
Find the example project on GitHub.
#interface TSTTextGrowth()
{
BOOL _hasLastIntrinsicSize;
BOOL _isEditing;
NSSize _lastIntrinsicSize;
}
#end
#implementation TSTTextGrowth
- (void)textDidBeginEditing:(NSNotification *)notification
{
[super textDidBeginEditing:notification];
_isEditing = YES;
}
- (void)textDidEndEditing:(NSNotification *)notification
{
[super textDidEndEditing:notification];
_isEditing = NO;
}
- (void)textDidChange:(NSNotification *)notification
{
[super textDidChange:notification];
[self invalidateIntrinsicContentSize];
}
-(NSSize)intrinsicContentSize
{
NSSize intrinsicSize = _lastIntrinsicSize;
// Only update the size if we’re editing the text, or if we’ve not set it yet
// If we try and update it while another text field is selected, it may shrink back down to only the size of one line (for some reason?)
if(_isEditing || !_hasLastIntrinsicSize)
{
intrinsicSize = [super intrinsicContentSize];
// If we’re being edited, get the shared NSTextView field editor, so we can get more info
NSText *fieldEditor = [self.window fieldEditor:NO forObject:self];
if([fieldEditor isKindOfClass:[NSTextView class]])
{
NSTextView *textView = (NSTextView *)fieldEditor;
NSRect usedRect = [textView.textContainer.layoutManager usedRectForTextContainer:textView.textContainer];
usedRect.size.height += 5.0; // magic number! (the field editor TextView is offset within the NSTextField. It’s easy to get the space above (it’s origin), but it’s difficult to get the default spacing for the bottom, as we may be changing the height
intrinsicSize.height = usedRect.size.height;
}
_lastIntrinsicSize = intrinsicSize;
_hasLastIntrinsicSize = YES;
}
return intrinsicSize;
}
#end
As a last note, I’ve never actually used auto layout myself – the demos look amazing, but whenever I actually try it myself, I can’t get it to work quite right and it makes things more complicated. However, in this case, I think it actually did save a bunch of work – without it, -intrinsicContentSize wouldn’t exist, and you’d possibly have to set the frame yourself, calculating the new origin as well as the new size (not too difficult, but just more code).
The solution by DouglasHeriot only works for fixed width text fields. In my app, I have text fields that I want to grow both horizontally and vertically. Therefore I modified the solution as follows:
AutosizingTextField.h
#interface AutosizingTextField : NSTextField {
BOOL isEditing;
}
#end
AutosizingTextField.m
#implementation AutosizingTextField
- (void)textDidBeginEditing:(NSNotification *)notification
{
[super textDidBeginEditing:notification];
isEditing = YES;
}
- (void)textDidEndEditing:(NSNotification *)notification
{
[super textDidEndEditing:notification];
isEditing = NO;
}
- (void)textDidChange:(NSNotification *)notification
{
[super textDidChange:notification];
[self invalidateIntrinsicContentSize];
}
-(NSSize)intrinsicContentSize
{
if(isEditing)
{
NSText *fieldEditor = [self.window fieldEditor:NO forObject:self];
if(fieldEditor)
{
NSTextFieldCell *cellCopy = [self.cell copy];
cellCopy.stringValue = fieldEditor.string;
return [cellCopy cellSize];
}
}
return [self.cell cellSize];
}
#end
There's a minor issue remaining: When typing spaces, the text jumps a bit to the left. However, that's not a problem in my app, because the text fields shouldn't contain spaces in most cases.
The solution of DouglasHeriot works great for me.
Here is the same code on Swift 4
class GrowingTextField: NSTextField {
var editing = false
var lastIntrinsicSize = NSSize.zero
var hasLastIntrinsicSize = false
override func textDidBeginEditing(_ notification: Notification) {
super.textDidBeginEditing(notification)
editing = true
}
override func textDidEndEditing(_ notification: Notification) {
super.textDidEndEditing(notification)
editing = false
}
override func textDidChange(_ notification: Notification) {
super.textDidChange(notification)
invalidateIntrinsicContentSize()
}
override var intrinsicContentSize: NSSize {
get {
var intrinsicSize = lastIntrinsicSize
if editing || !hasLastIntrinsicSize {
intrinsicSize = super.intrinsicContentSize
// If we’re being edited, get the shared NSTextView field editor, so we can get more info
if let textView = self.window?.fieldEditor(false, for: self) as? NSTextView, let textContainer = textView.textContainer, var usedRect = textView.textContainer?.layoutManager?.usedRect(for: textContainer) {
usedRect.size.height += 5.0 // magic number! (the field editor TextView is offset within the NSTextField. It’s easy to get the space above (it’s origin), but it’s difficult to get the default spacing for the bottom, as we may be changing the height
intrinsicSize.height = usedRect.size.height
}
lastIntrinsicSize = intrinsicSize
hasLastIntrinsicSize = true
}
return intrinsicSize
}
}
}
This solution also works when setting the string value of the text field and when it's resized by AutoLayout. It just uses the attributed text property to calculate the intrinsic content size whenever it's needed.
class AutoGrowingTextField: NSTextField {
var maximumHeight: CGFloat = 100
override var intrinsicContentSize: NSSize {
let height = attributedStringValue.boundingRect(
with: NSSize(width: bounds.width - 8, height: maximumHeight),
options: [NSString.DrawingOptions.usesLineFragmentOrigin]
).height + 5
return NSSize(width: NSView.noIntrinsicMetric, height: height)
}
override func textDidChange(_ notification: Notification) {
super.textDidChange(notification)
invalidateIntrinsicContentSize()
}
override func layout() {
super.layout()
invalidateIntrinsicContentSize()
}
}
And if you want to limit the size of the TextField (e.g.):
if (intrinsicSize.height > 100)
{
intrinsicSize = _lastIntrinsicSize;
}
else
{
_lastIntrinsicSize = intrinsicSize;
_hasLastIntrinsicSize = YES;
}
(Diff)
One thing I’m having trouble with is getting the NSTextField embedded in an NSScrollView and having it work properly (especially within an NSStackView). Going to look at whether it wouldn’t be easier with NSTextView instead.
I came up with an alternative solution that works well for me:
- (NSSize)intrinsicContentSize {
return [self sizeThatFits:NSMakeSize(self.frame.size.width, CGFLOAT_MAX)];
}
- (void)textDidChange:(NSNotification *)notification {
[super textDidChange:notification];
[self invalidateIntrinsicContentSize];
}
Basically we're just constraining the width to the given width of the element and based on that calculate the fitting height.
I'm using an NSTextField and customizing the fieldEditor using the setupFieldEditorAttributes: method. This allows me to set custom foreground and background colors for the selected text, which is important because my textField has a black background and white text. Generally, this works fine. However, my settings seem to be overridden when I deactivate the application and the window is no longer key. The fieldEditor NSTextView remains there, but drawing changes to a white text color and light gray selection color (the defaults). Does anyone have suggestions for how I can customize this drawing?
You can override [NSWindow willReturnFieldEditor:toObject:] and return there custom NSTextView with changed selection color.
Inspired by the answer to this question, the solution is to create an override of the NSLayoutManager that customizes the way in which the highlighting is performed based on the first responder state of the NSText view that owns it.
If the text view associated with this custom layout manager is the first responder, then it draws the selection using the color provided by macOS. If the text view is not the first responder, it uses the text view's background color as the selection color unless a custom color is provided via the setCustomInactiveColor method.
// ---------------------------------------------------------------------------
// IZLayoutManager CLASS
// ---------------------------------------------------------------------------
// Override NSLayoutManager to change how the currently selected text is
// highlighted when the owning NSTextView is not the first responder.
#interface IZLayoutManager : NSLayoutManager
{
}
-(instancetype)initWithOwningTextView:(NSTextView*)inOwningTextView;
#property (nullable, assign, nonatomic) NSTextView* owningTextView;
#property (nullable, strong, nonatomic) NSColor* customInactiveColor;
#end
#implementation IZLayoutManager
- (instancetype)initWithOwningTextView:(NSTextView*)inOwningTextView
{
self = [super init];
if (self) {
self.owningTextView = inOwningTextView;
}
return self;
}
- (void) dealloc
{
// my project is non-ARC; so we maually release any custom color
// we received; in non-ARC projects this is probably not necessary
if (self.customInactiveColor != NULL) {
[self.customInactiveColor release];
self.customInactiveColor = NULL;
}
[super dealloc];
}
// see extensive description of fillBackgroundRectArray in NSLayoutManager.h
// TL;DR: if you change the background color here, you must restore it before
// returning from this call
- (void) fillBackgroundRectArray:(const NSRect *)rectArray count:(NSUInteger)rectCount forCharacterRange:(NSRange)charRange color:(NSColor *)color
{
BOOL needToReestoreColor = NO;
if (self.owningTextView != NULL && [[self.owningTextView window] firstResponder] != self.owningTextView) {
if (self.customInactiveColor != NULL) {
[self.customInactiveColor setFill];
} else {
[[self.owningTextView backgroundColor] setFill];
}
needToReestoreColor = true;
}
[super fillBackgroundRectArray:rectArray count:rectCount forCharacterRange:charRange color:color];
if (needToReestoreColor) {
[color setFill];
}
}
#end
Then, after you've allocated the NSTextView, you need to do this:
NSTextView* myTextView = ... // get a reference to your text view
// allocate our custom layout manager
IZLayoutManager* layoutManager = [[[IZLayoutManager alloc] initWithOwningTextView:self] autorelease];
// if you want to use a color other than the background for
// the selected text, uncomment the following line and
// supply your desired color
// [layoutManager setCustomInactiveColor:[NSColor redColor]];
[[myTextView textContainer] replaceLayoutManager:layoutManager];
I'm trying to write an application that allows the user to drag files from the Finder and drop them onto an NSStatusItem. So far, I've created a custom view that implements the drag and drop interface. When I add this view as a subview of an NSWindow it all works correctly -- the mouse cursor gives appropriate feedback, and when dropped my code gets executed.
However, when I use the same view as an NSStatusItem's view it doesn't behave correctly. The mouse cursor gives appropriate feedback indicating that the file can be dropped, but when I drop the file my drop code never gets executed.
Is there something special I need to do to enable drag and drop with an NSStatusItem?
I finally got around to testing this and it works perfectly, so there's definitely something wrong with your code.
Here's a custom view that allows dragging:
#implementation DragStatusView
- (id)initWithFrame:(NSRect)frame
{
self = [super initWithFrame:frame];
if (self) {
//register for drags
[self registerForDraggedTypes:[NSArray arrayWithObjects: NSFilenamesPboardType, nil]];
}
return self;
}
- (void)drawRect:(NSRect)dirtyRect
{
//the status item will just be a yellow rectangle
[[NSColor yellowColor] set];
NSRectFill([self bounds]);
}
//we want to copy the files
- (NSDragOperation)draggingEntered:(id<NSDraggingInfo>)sender
{
return NSDragOperationCopy;
}
//perform the drag and log the files that are dropped
- (BOOL)performDragOperation:(id <NSDraggingInfo>)sender
{
NSPasteboard *pboard;
NSDragOperation sourceDragMask;
sourceDragMask = [sender draggingSourceOperationMask];
pboard = [sender draggingPasteboard];
if ( [[pboard types] containsObject:NSFilenamesPboardType] ) {
NSArray *files = [pboard propertyListForType:NSFilenamesPboardType];
NSLog(#"Files: %#",files);
}
return YES;
}
#end
Here's how you'd create the status item:
NSStatusItem* item = [[[NSStatusBar systemStatusBar] statusItemWithLength:NSSquareStatusItemLength] retain];
DragStatusView* dragView = [[DragStatusView alloc] initWithFrame:NSMakeRect(0, 0, 24, 24)];
[item setView:dragView];
[dragView release];
Since Yosemite, the method for setting a view on NSStatusItem is deprecated but fortunately there is a much nicer way using the new NSStatusItemButton property on NSStatusItem:
- (void)applicationDidFinishLaunching: (NSNotification *)notification {
NSImage *icon = [NSImage imageNamed:#"iconName"];
//This is the only way to be compatible to all ~30 menu styles (e.g. dark mode) available in Yosemite
[normalImage setTemplate:YES];
statusItem.button.image = normalImage;
// register with an array of types you'd like to accept
[statusItem.button.window registerForDraggedTypes:#[NSFilenamesPboardType]];
statusItem.button.window.delegate = self;
}
- (NSDragOperation)draggingEntered:(id<NSDraggingInfo>)sender {
return NSDragOperationCopy;
}
- (BOOL)performDragOperation:(id<NSDraggingInfo>)sender {
//drag handling logic
}
Please be aware that the button property is only available starting in 10.10 and you might have to keep your old solution if you support 10.9 Mavericks or below.
This is a really strange problem I'm seeing in my app. I have an NSTextField bound to an attribute of an NSManagedObject, but whenever the object is saved the textfield loses focus. I'm continuously updating the value of the binding, so this is far from ideal.
Has anyone seen anything like this before, and (hopefully) found a solution?
I encountered the issue recently and fixed it by changing the way the NSTextField was bound to the NSManagedObject attribute. Instead of binding the value of the text field to the selection.[attribute] key path of the NSArrayController, I bound the arrayController.selection.[attribute] keyPath of the view controller that had a proper outlet pointing to the controller.
For some reason, the NSTextField doesn't loose focus when the NSManagedObjectContext is saved if bound this way.
I want to share my solution. It will work for all fields without modification.
I have optimized it for this posting and removed some error checking, logging and thread safety.
- (BOOL)saveChanges:(NSError **)outError {
BOOL result = YES;
#try {
NSError *error = nil;
if ([self hasChanges]) {
// Get field editor
NSResponder *responder = [[NSApp keyWindow] firstResponder];
NSText *editor = [[NSApp keyWindow] fieldEditor: NO forObject: nil];
id editingObject = [editor delegate];
BOOL isEditing = (responder == editor);
NSRange range;
NSInteger editedRow, editedColumn;
// End editing to commit the last changes
if (isEditing) {
// Special case for tables
if ([editingObject isKindOfClass: [NSTableView class]]) {
editedRow = [editingObject editedRow];
editedColumn = [editingObject editedColumn];
}
range = [editor selectedRange];
[[NSApp keyWindow] endEditingFor: nil];
}
// The actual save operation
if (![self save: &error]) {
if (outError != nil)
*outError = error;
result = NO;
} else {
result = YES;
}
// Now restore the field editor, if any.
if (isEditing) {
[[NSApp keyWindow] makeFirstResponder: editingObject];
if ([editingObject isKindOfClass: [NSTableView class]])
[editingObject editColumn: editedColumn row: editedRow withEvent: nil select: NO];
[editor setSelectedRange: range];
}
}
} #catch (id exception) {
result = NO;
}
return result;
}
OK, so thanks to Martin for pointing out that I should read the docs a little more closely. This is expected behaviour, and here's what I did to get around it (use your judgement as to whether this is appropriate for you):
I save my context once every 3 seconds, checking at the start if the context has any changes before I bother executing the actual save: method on my NSManagedObjectContext. I added a simple incrementing/decrementing NSUInteger (_saveDisabler) to my Core Data controller class that is modified via the following methods:
- (void)enableSaves {
if (_saveDisabler > 0) {
_saveDisabler -= 1;
}
}
- (void)disableSaves {
_saveDisabler += 1;
}
Then all I do in my custom saveContext method is do a simple check at the top:
if (([moc hasChanges] == NO) || (_saveDisabler > 0)) {
return YES;
}
This prevents the save from occurring, and means that the focus is not stolen from any of my custom textfield subclasses. For completeness, I also subclassed NSTextField and enable/disable saves in my Core Data controller from the following methods:
- (void)textDidBeginEditing:(NSNotification *)notification;
- (void)textDidEndEditing:(NSNotification *)notification;
It might be a little messy, but it works for me. I'm keen to hear of cleaner/less convoluted methods if anyone has done this successfully in another way.