swiftcocoanstextfieldnspopover

NSTextField keep focus/first responder after NSPopover


The object of this app is to ensure the user has entered a certain text in an NSTextField. If that text is not in the field, they should not be allowed to leave the field.

Given a macOS app with a subclass text field, a button and another generic NSTextField. When the button is clicked, an NSPopover is shown which is 'attached' to the field which is controlled by an NSViewController called myPopoverVC.

For example, the user enters 3 in the top field and then clicks the Show Popover button which displays the popover and provides a hint: 'What does 1 + 1 equal'.

enter image description here

Note this popover has a field labelled 1st resp so when the popover shows, that field becomes the first responder. Nothing will be entered at this time - it's just for this question.

The user would click the Close button, which closes the popover. At that point what should happen if the user clicks or tabs away from the field with the '3' in it, the app should not permit that movement - perhaps emitting a Beep or some other message. But what happens when the popover closes and the user presses Tab

enter image description here

Even though that field with the '3' in it had a focus ring, which should indicate the first responder again in that window, the user can click or tab away from it as the textShouldEndEditing function is not called. In this case, I clicked the close button in the popover, the '3' field had a focus ring and I hit tab, which then went to the next field.

This is the function in the subclassed text field that works correctly after the text has been entered into the field. In this case, if the user types a 3 and then hits Tab, the cursor stays in that field.

override func textShouldEndEditing(_ textObject: NSText) -> Bool {

    if self.aboutToShowPopover == true {
       return true
    }

    if let editor = self.currentEditor() { //or use the textObject
        let s = editor.string

        if s == "2" {
            return true
        }

        return false
    }

The showPopover button code sets the aboutToShowPopover flag to true which will allow the subclass to show the popover. (set to false when the popover closes)

So the question is when the popover closes how to return the firstResponder status to the original text field? It appears to have first responder status, and it thinks it has that status although textShouldEndEditing is not called. If you type another char into the field, then everything works as it should. It's as if the window's field editor and the field with the '3' in it are disconnected so the field editor is not passing calls up to that field.

The button calls a function which contains this:

    let contentSize = myPopoverVC.view.frame
    theTextField.aboutToShowPopover = true
    parentVC.present(myPopoverVC, asPopoverRelativeTo: contentSize, of: theTextField, preferredEdge: NSRectEdge.maxY, behavior: NSPopover.Behavior.applicationDefined)
    NSApplication.shared.activate(ignoringOtherApps: true)

the NSPopover close is

parentVC.dismiss(myPopoverVC)

One other piece of information. I added this bit of code to the subclassed NSTextField control.

override func becomeFirstResponder() -> Bool {
    let e = self.currentEditor()
    print(e)
    return super.becomeFirstResponder()
}

When the popover closes and the textField becomes the windows first responder, that code executes but prints nil. Which indicates that while it is the first responder it has no connection to the window fieldEditor and will not receive events. Why?

If anything is unclear, please ask.


Solution

  • Here's my attempt with help from How can one programatically begin a text editing session in a NSTextField? and How can I make my NSTextField NOT highlight its text when the application starts?:

    The selected range is saved in textShouldEndEditing and restored in becomeFirstResponder. insertText(_:replacementRange:) starts an editing session.

    var savedSelectedRanges: [NSValue]?
    
    override func becomeFirstResponder() -> Bool {
        if super.becomeFirstResponder() {
            if self.aboutToShowPopover {
                if let ranges = self.savedSelectedRanges {
                    if let fieldEditor = self.currentEditor() as? NSTextView {
                        fieldEditor.insertText("", replacementRange: NSRange(location: 0, length:0))
                        fieldEditor.selectedRanges = ranges
                    }
                }
            }
            return true
        }
        return false
    }
    
    override func textShouldEndEditing(_ textObject: NSText) -> Bool {
        if super.textShouldEndEditing(textObject) {
            if self.aboutToShowPopover {
                let fieldEditor = textObject as! NSTextView
                self.savedSelectedRanges = fieldEditor.selectedRanges
                return true
            }
            let s = textObject.string
            if s == "2" {
                return true
            }
        }
        return false
    }
    

    Maybe rename aboutToShowPopover.