Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

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.

like image 263
Jay Avatar asked Feb 07 '19 01:02

Jay


Video Answer


2 Answers

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.

like image 123
Willeke Avatar answered Oct 18 '22 03:10

Willeke


If you subclass each of your NSTextField, you could override the method becomeFirstResponder and make it send self to a delegate class you will create, that will keep a reference of the current first responder:

NSTextField superclass:

override func becomeFirstResponder() -> Bool {
        self.myRespondersDelegate.setCurrentResponder(self)
        return super.becomeFirstResponder()
    }

(myRespondersDelegate: would optionally be your NSViewController)

Note: do not use the same superclass for your alerts TextFields and ViewController TextFields. Use this superclass with added functionality only for TextFields you would want to return to firstResponder after an alert is closed.

NSTextField delegate:

class MyViewController: NSViewController, MyFirstResponderDelegate {
    var currentFirstResponderTextField: NSTextField?

    func setCurrentResponder(textField: NSTextField) {
        self.currentFirstResponderTextField = textField
    }
}

Now, after your pop is dismissed, you could in viewWillAppear or create a delegate function that will be called on a pop up dismiss didDismisss (Depends how your pop up is implemented, I will show the delegate option) Check If a TextField has existed, and re-make it, the firstResponder.

Pop up delegate:

class MyViewController: NSViewController, MyFirstResponderDelegate, MyPopUpDismissDelegate {
    var currentFirstResponderTextField: NSTextField?

    func setCurrentResponder(textField: NSTextField) {
        self.currentFirstResponderTextField = textField
    }

    func didDismisssPopUp() {
        guard let isLastTextField = self.currentFirstResponderTextField else  {
            return
        }
        self.isLastTextField?.window?.makeFirstResponder(self.isLastTextField)
    }
}

Hope it works.

like image 30
MCMatan Avatar answered Oct 18 '22 04:10

MCMatan