How to get the firstResponder-to-be when an NSView is asked to resign as first responder?

1.2k views Asked by At

I've created a custom subclass of NSControl which accepts a small amount of text. I'm using the window's field editor for any editing purposes (just like how NSTextField does). When I lose first responder status, I'd obviously like to send a -commitEditing: message, but if you're well-versed in the area of OS X's text system, you know that a -resignFirstResponder message is sent to the control before appointing the field editor as the new first responder.

So I was thinking that if I could find out whether the field editor is to be the new first responder when the -resignFirstResponder method is called, I could make sure -commitEditing: isn't called.

With that said, is there a way to find out which object will become the new first responder?

2

There are 2 answers

1
Keith Knauber On

subclass NSApplication That way you can catch preprocess NSEvents, collect the information you need, and then your NSControl subclass can retrieve that information.

In my case, I use this method to avoid dangling field editors in my very large multi-screen UI.

@interface NSApplicationEventCatcher : NSApplication 
{
}
- (void)sendEventDirectly:(NSEvent *)event;
+(void)setExcludedResponder:(NSResponder *)iResponder;
@end


- (void)sendEvent:(NSEvent *)event
{
    // do some checking here (see example code below)
    [super sendEvent:event];
}

in main(), instantiate NSApplicationEventCatcher first,

[NSApplicationEventCatcher sharedApplication];

before calling NSApplicationMain()

NSApplicationMain(argc,  (const char **) argv);

now, here's some of the checking that I do in NSApplicationEventCatcher sendEvent override.

However, this is only one small part of that solution.

   if ( [event type] == NSLeftMouseDown )
   {
      gVAppCancelAction = kVAppCancelOtherWindow;
      //NSLog( @"before mouse down window %@ first responder %@", [[event window] description], [[[event window] firstResponder] description] );
      if ( [event window] )
      {      
         gVAppCancelAction = kVAppCancelMouseDown;         
         NSTextView *theFirstResponder = (NSTextView *)[[event window] firstResponder];
         if ( theFirstResponder && sExcludedResponder != theFirstResponder )
            sExcludedResponder = nil; // reset

         if ( [theFirstResponder isKindOfClass:[NSTextView class]] )
         {
            NSPoint clickLocation;

            // convert the mouse-down location into the view coords
            clickLocation = [theFirstResponder convertPoint:[event locationInWindow]
                                         fromView:nil];
            // did the mouse-down occur in the item?
            BOOL itemHit = NSPointInRect(clickLocation, [theFirstResponder bounds]);

            id delegate = [(NSTextView *)theFirstResponder delegate];
            if ( [delegate isKindOfClass: [NSComboBox class]] )
            {
               itemHit |= NSPointInRect(clickLocation, [delegate bounds]);
            }

            if (itemHit) 
            {
               VLog::Log( kLogDbgNoteType, @"clicked on first responder %@", [[[event window] firstResponder] description] );
               excludeResponder = theFirstResponder;               
            }
            else 
            {
               NSView *theContentView = [[event window] contentView];
               if (  [theContentView isKindOfClass:[NSView class]] )
               {
                  NSView *theHitView = [theContentView hitTest:[event locationInWindow]];
                  if ( theHitView == nil || theHitView == theContentView )  
                  {
                     gVAppCancelAction = kVAppCancelLayerView;
                  }
                  else
                  {
                     gVAppCancelAction = kVAppCancelMouseDown;
                     if ( sExcludedResponder == theFirstResponder )
                        excludeResponder = theFirstResponder; 
                     /*
                     if ( [theHitView isKindOfClass:[LayerView class]] )
                     {
                        NSView *theSuperview = [theHitView superview];
                        if ( theSuperview && [theSuperview isKindOfClass:[LayerView class]] )
                        {
                           // ignore VNumericKeypad-like views which are like pop-up dialog views on
                           // top of a LayerView superview.
                           gVAppCancelAction = kVAppCancelMouseDown;
                           if ( sExcludedResponder == theFirstResponder )
                              excludeResponder = theFirstResponder; 
                        }
                        else
                           gVAppCancelAction = kVAppCancelLayerView;

                     }
                     else 
                     {
                        if ( sExcludedResponder == theFirstResponder )
                           excludeResponder = theFirstResponder;  
                     }
                        */                      
                  }
               }
            }
         }
      }  
   } 

And here is a related part:

  for ( NSWindow *theWindow in [self windows] )
  {     
     NSResponder *theResponder = [theWindow firstResponder];
     if ( theResponder != theWindow && theResponder && theResponder != excludeResponder )
     {
        // tbd could also check for [theResponder isKindOfClass:[NSControl class]] and call abortEditing
        if ( [theResponder isKindOfClass:[NSTextView class]] && [(NSTextView *)theResponder isFieldEditor] )
        {
           NSWindow *evwindow = [event window];
           NSArray *childwindows = [theWindow childWindows];
           if ( evwindow && [childwindows containsObject:evwindow] )
           {
              // pass through clicks on attached NSMenu or NSComboBox
              VLog::Log( kLogDbgNoteType, @"clicked child event window %@, my window %@", evwindow, theWindow );
              break;
           }

           VLog::Log( kLogDbgNoteType, @"NSApplicationEventCatcher before cancel first responder %@", [theResponder description] ); 
           BOOL cancelSucceeded;
           if ( evwindow != theWindow && gVAppCancelAction == kVAppCancelMouseDown )
           {
              gVAppCancelAction = kVAppCancelOtherWindow;
              cancelSucceeded = [theWindow makeFirstResponder:theWindow];                  
              gVAppCancelAction = kVAppCancelMouseDown;
           }
           else
              cancelSucceeded =[theWindow makeFirstResponder:theWindow]; 

           if ( !cancelSucceeded )
           {
              VLog::Log( kLogDbgNoteType, @"Application about to FORCE cancel field editor %@", [[theWindow firstResponder] description] );                                      
              [theWindow endEditingFor:nil];
           }
           VLog::Log( kLogDbgNoteType, @"NSApplicationEventCatcher after cancel first responder %@", [[theWindow firstResponder] description] );                    
        }          
     }
  }
0
Keith Knauber On

You might also find this class relevant.

I've tried to encapsulate some of this functionality in a helper class which is used by all of my controller classes.

//
//  VEditableTextDelegate.mm
//
//  Created by Keith Knauber on 8/6/14.
//
//

#import "VEditableTextDelegate.h"
#import "VEditableTextField.h"

// Since obj-c doesn't have multiple inheritance,
// VEditableTextDelegate provides static functions instead.
// Controller classes who want to use these functions simply
// need to cut and paste the following example code into their controller class:
#ifdef VEditableTextDelegate_EXAMPLE_CODE

#pragma mark - NSControl editing delegate methods ( NSTableView / NSTextField )

- (BOOL)control:(NSControl *)control isValidObject:(id)object
{
   return [VEditableTextDelegate control: control
                           isValidObject: object];
}

- (BOOL)control:(NSControl *)control textView:(NSTextView *)textView doCommandBySelector:(SEL)command
{
   return [VEditableTextDelegate control: control
                                textView: textView
                     doCommandBySelector: command];
}

#endif // end VEditableTextDelegate_EXAMPLE_CODE


static NSTableView *sSuppressSortWhileNavigating;


@implementation VEditableTextDelegate

#pragma mark - NSControl editing delegate methods ( NSTableView / NSTextField )

// gets called when user clicks outside of control.
// this happens when NSApplicationEventCatcher does "cancel first responder"
+ (BOOL)control:(NSControl *)control isValidObject:(id)object
{
   NSText *textView = [control currentEditor] ;
   if ( ![textView isKindOfClass:[NSText class]] )
      return YES;

   if ( [control isKindOfClass: [NSTableView class]] )
      return YES; // let tableview handle normally

   //NSLog( @"isValidObject %@ %@ %@", control, object, [control currentEditor] );
   //if ( [control respondsToSelector:@selector(validateString:)] )
   //    [(VNumericTextField *)control validateString:[textView string]];
   //else
   {
      [control validateEditing];
      [control sendAction:[control action] to:[control target]];
      if ( [control respondsToSelector:@selector(abortEditing)] )
         [control abortEditing];     // end editing session
   }
   return YES;
}

+ (ValueEditorCmdType)cmdTypeForSelector:(SEL)command
{
   ValueEditorCmdType cmdType = kCmdTypeNone;
   if ( command == @selector(insertLineBreak:) || command == @selector(insertNewline:) || command == @selector(insertNewlineIgnoringFieldEditor:) || command == @selector(insertParagraphSeparator:))
      cmdType = kCmdTypeAccept;
   else if (  command == @selector(insertTab:) || command == @selector(selectNextKeyView:)  || command == @selector(insertTabIgnoringFieldEditor:))
      cmdType = kCmdTypeNext;
   else if ( command == @selector(insertBacktab:) || command == @selector(selectPreviousKeyView:))
      cmdType = kCmdTypePrev;
   else if ( command == @selector(cancelOperation:) )
      cmdType = kCmdTypeCancel;
   return cmdType;
}

+ (void) keypressEndedEditing: (NSControl *)control
{
   sSuppressSortWhileNavigating = nil;
   [control abortEditing];

   // but tableview should remain first responder
   if ( [control isKindOfClass: [NSTableView class]] )
   {
      [[control window] makeFirstResponder: control];
   }
}

+ (BOOL)control:(NSControl *)control textView:(NSTextView *)textView doCommandBySelector:(SEL)command
{
   ValueEditorCmdType cmdType = [VEditableTextDelegate cmdTypeForSelector:command];

   sSuppressSortWhileNavigating = nil;
   if ( [control isKindOfClass: [NSTableView class]] )
   {
      // http://stackoverflow.com/questions/612805/arrow-keys-with-nstableview
      // "This only works while editing a table cell."
      // spreadsheet style navigation cursor left/right, tab to next/prev column
      NSTableView *tableView = (NSTableView *)control;
      NSUInteger row, column;

      row = [tableView editedRow];
      column = [tableView editedColumn];

      // Trap down arrow key
      if (  [textView methodForSelector:command] == [textView methodForSelector:@selector(moveDown:)] )
      {
         NSUInteger newRow = row+1;
         if (newRow>=[tableView numberOfRows]) return YES; //check if we're already at the end of the list
         if (column>= [tableView numberOfColumns]) return YES; //the column count could change

         sSuppressSortWhileNavigating = tableView;
         [control validateEditing];
         [tableView selectRowIndexes:[NSIndexSet indexSetWithIndex:newRow] byExtendingSelection:NO];
         [tableView editColumn:column row:newRow withEvent:nil select:YES];
         return YES;
      }

      // Trap up arrow key
      else if (  [textView methodForSelector:command] == [textView methodForSelector:@selector(moveUp:)] )
      {
         if (row==0) return YES; //already at the beginning of the list
         NSUInteger newRow = row-1;

         if (newRow>=[tableView numberOfRows]) return YES;
         if (column>= [tableView numberOfColumns]) return YES;

         sSuppressSortWhileNavigating = tableView;
         [control validateEditing];
         [tableView selectRowIndexes:[NSIndexSet indexSetWithIndex:newRow] byExtendingSelection:NO];
         [tableView editColumn:column row:newRow withEvent:nil select:YES];
         return YES;
      }

      // Trap tab keys
      else if ( cmdType == kCmdTypeNext )
      {
         NSInteger newColumn = column+1;
         NSInteger newRow = row;

         for ( ; newColumn < [tableView numberOfColumns]; newColumn++ )
         {
            NSTableColumn *tc = [[tableView tableColumns] objectAtIndex:newColumn];
            if ( [tc isEditable] && ![tc isHidden] )
               break;
         }

         if (newColumn >= [tableView numberOfColumns])
         {
            if ( row+1 < [tableView numberOfRows] )
            {
               newRow = row+1;

               newColumn = 0;
               for ( ; newColumn < [tableView numberOfColumns]; newColumn++ )
               {
                  NSTableColumn *tc = [[tableView tableColumns] objectAtIndex:newColumn];
                  if ( [tc isEditable] && ![tc isHidden] )
                     break;
               }
            }
         }

         if ( newColumn >= [tableView numberOfColumns] )
            return YES;

         sSuppressSortWhileNavigating = tableView;
         [control validateEditing];
         [tableView editColumn:newColumn row:newRow withEvent:nil select:YES];
         return YES;
      }

      // Trap tab keys
      else if ( cmdType == kCmdTypePrev )
      {
         NSInteger newColumn = column-1;
         NSInteger newRow = row;

         for ( ; newColumn >= 0; newColumn-- )
         {
            NSTableColumn *tc = [[tableView tableColumns] objectAtIndex:newColumn];
            if ( [tc isEditable] && ![tc isHidden] )
               break;
         }

         if (newColumn < 0 )
         {
            if ( row-1 > 0 )
            {
               newRow = row-1;

               newColumn = [tableView numberOfColumns] - 1;
               for ( ; newColumn >= 0; newColumn-- )
               {
                  NSTableColumn *tc = [[tableView tableColumns] objectAtIndex:newColumn];
                  if ( [tc isEditable] && ![tc isHidden] )
                     break;
               }
            }
         }

         if ( newColumn < 0 )
            return YES;

         sSuppressSortWhileNavigating = tableView;
         [control validateEditing];
         [tableView editColumn:newColumn row:newRow withEvent:nil select:YES];
         return YES;
      }

      // Let TableView handle Accept through normal pathway
      if ( cmdType == kCmdTypeAccept )
         return NO;
   }


   //{ NSLog( @"doCommandBySelector command %@", self, control, NSStringFromSelector(command) );}
   if ( cmdType == kCmdTypeNone )
   {
      // do nothing
      // try {throw(1);} catch(...){ NSLog( @"doCommandBySelector command %@ %@ %@", self, control, NSStringFromSelector(command) );}
   }
   else if ( cmdType == kCmdTypeCancel )
   {
      [VEditableTextDelegate keypressEndedEditing: control ];
   }
   else
   {
      //if ( [control respondsToSelector:@selector(validateString:)] )
      //    [(VNumericTextField *)control validateString:[textView string]];
      //else
      {
         BOOL valid = YES;
         if ([control isKindOfClass: [VEditableTextField class]] &&
             [control formatter] )
         {
            id obj = nil;
            NSString *err = nil;
            NSString *strVal = [textView string];
            NSNumberFormatter *formatter = [control formatter];
            valid = [formatter getObjectValue:&obj forString:strVal errorDescription:&err];
            if ( err && [formatter isKindOfClass:[NSNumberFormatter class]] )
            {
               float floatVal = [strVal floatValue];
               if ( floatVal <= [[[control formatter] minimum] floatValue] )
                  [control setFloatValue: [[[control formatter] minimum] floatValue]];
               else if ( floatVal >= [[[control formatter] maximum] floatValue] )
               {
                  if ( [[[control formatter] multiplier] floatValue] == 100.0 )
                  {
                     floatVal /= 100.0; // workaround Apple bug with simple Percent field.
                     if ( floatVal >= [[[control formatter] maximum] floatValue] ||
                          floatVal <= [[[control formatter] minimum] floatValue] )
                        [control setFloatValue: [[[control formatter] maximum] floatValue]];
                     else
                     {
                        [control setFloatValue: floatVal];
                     }
                  }
                  else
                     [control setFloatValue: [[[control formatter] maximum] floatValue]];


               }
            }
            else
               [control validateEditing];
         }

         if ( valid )
         {
            [control validateEditing];
            if ( ( cmdType == kCmdTypeAccept || cmdType == kCmdTypeNext || cmdType == kCmdTypePrev ) &&
                [control currentEditor] )
            {
               BOOL sendAction = YES;
               if ( cmdType == kCmdTypeNext || cmdType == kCmdTypePrev )
               {
                  if ( [control isKindOfClass: [VEditableTextField class]] && ![[textView undoManager] canUndo] )
                  {
                     //DLog( @"tab key not sending action... textview undo buffer empty (user didn't type anything)" );
                     sendAction = NO;
                  }
               }

               if (sendAction)
                  [control sendAction:[control action] to:[control target]];
               [VEditableTextDelegate keypressEndedEditing: control ];
            }
         }
      }


      if ( cmdType == kCmdTypeNext || cmdType == kCmdTypePrev )
      {
         id nextView = control;
         int i = 0;

         do
         {
            nextView = ( cmdType == kCmdTypeNext ) ? [nextView nextKeyView] : [nextView previousKeyView];
            if ( [nextView isKindOfClass:[VEditableTextField class]] && [nextView visibleRect].size.width != 0 )
            {
               [VEditableTextDelegate keypressEndedEditing: control ];
               DLog( @"control %@\n  next %@", control, [nextView stringValue] );
               [[control window] makeFirstResponder: nextView];
               [(VEditableTextField *)nextView selectText:nil];
               break;
            }

         }while (nextView && nextView != control && i++ < 100 );
      }
   }

   //NSLog( @"doCommandBySelector command %@ %@ %@", self, control, NSStringFromSelector(command) );
   if ( cmdType == kCmdTypeNone )
      return NO;
   else
      return YES;

}

//+ (BOOL)control:(NSControl *)control didFailToFormatString:(NSString *)string errorDescription:(NSString *)error
//{
//    if ( [control formatter] )
//    {
//        if ( [string floatValue] <= [[[control formatter] minimum] floatValue] )
//            [control setFloatValue: [[[control formatter] minimum] floatValue]];
//        else if ( [string floatValue] >= [[[control formatter] maximum] floatValue] )
//            [control setFloatValue: [[[control formatter] maximum] floatValue]];
//    }
//    return NO;
//}


+ (void) editableField: (VEditableTextField *)editableField
              selector: (SEL)iSelector
              delegate: (id <NSTextFieldDelegate>)delegate
{
   [editableField setTarget:delegate];
   [editableField setDelegate:delegate];
   [editableField setAction:iSelector];
   // [editableField setDrawsBorder:YES];
   [editableField setFocusRingType: NSFocusRingTypeExterior];
   NSRect r = [editableField editingAlignmentRect];
   if ( [editableField frame].size.height >= 24 )
   {
      r.origin.y += 4; r.size.height -= 4;
      r.origin.x += 2;
      r.size.width -= 4;

   }
   else
   {
      r.origin.y += 2; r.size.height -= 2;
      r.origin.x += 2;
      r.size.width -= 4;
   }
   [editableField setEditingAlignmentRect:r];
}

+ (BOOL) suppressSortWhileNavigating:(NSTableView *)iTableView
{
   if ( iTableView == sSuppressSortWhileNavigating )
   {
      return YES;
   }
   return NO;
}

+ (BOOL) periodicUpdateSuppressSort:(NSTableView *)iTableView
{
   if ( iTableView == sSuppressSortWhileNavigating && ![iTableView currentEditor] )
   {
      sSuppressSortWhileNavigating = nil;
   }
   return [VEditableTextDelegate suppressSortWhileNavigating:iTableView];
}
@end