NSTextField keep focus/first responder after NSPopover
Asked Answered
F

3

8

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.

Fatherhood answered 7/2, 2019 at 1:42 Comment(12)
Not relevant to the issue but in textShouldEndEditing, use textObject instead of self.currentEditor.Vermiculation
Did you try to get the field editor for the text field and make it first responder again after the popover closes?Vermiculation
@Vermiculation Thanks for the input - the textObject is obviously the better choice there so I updated the question.Fatherhood
@Vermiculation So I've gone through a lot of gyrations to try to reestablish the fields connection to the fieldEditor. Using the controls window to make it first responder... self.window.makeFirstResponder(self) etc. I updated the question to address your question.Fatherhood
What happens if you first make the window its own first responder (self.window.makeFirstResponder(nil)) and then change it back to the text field?Multifarious
@KenThomases Thanks - I thought of that. While removing the first responder and reassigning it does again make the NSTextField the first responder, that text field does not receive any events or messages until you actually type something in the field.Fatherhood
How does the text field end editing if textShouldEndEditing returns false?Vermiculation
@Vermiculation Hmmm. Not sure I understand that question. When the popover is dismissed and the focus returns to the NSTextField, the field can be clicked or tabbed out of as textShouldEndEditing is never called, so it doesn't have an opportunity to return false (or true). That function is only called if there's a manual keypress in the field - then the function is called as you would expect. It almost seems like the field has focus but the window editor doesn't 'activate' or have focus until there's a key down event.Fatherhood
I tried to reproduce the issue but the text field doesn't end editing when I show a popover. When the popover closes, the text field is still editing and textShouldEndEditing is called.Vermiculation
@Vermiculation Thank you for taking a look. I updated and clarified the question as it appears I left out a couple of details. You are 100% correct in your observation - the initial field maintains focus in the case you present. What I left out was there is a text field on the popover, so when it opens, that field becomes first responder and then when the popover closes the original field looks like it's again the first responder but no calls are sent to it to fire the textShouldEndEditing function to prevent leaving the field.Fatherhood
self.currentEditor() will return the field editor after super.becomeFirstResponder().Vermiculation
@Vermiculation Hmm. If you take a look a the last part of the question, override func becomeFirstResponder() returns a bool, so there isn't a way to call self.currentEditor() after the return in that function. Or do you mean calling that in another function that follows becomeFirstResponder? If so, where would you get the current editor? The other issue is that even if that returns the current field editor, it's not the windows field editor so therefore the events are not being passed from the window's field editor to it's delegate, which should be the fields editor.Fatherhood
V
3

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.

Vermiculation answered 14/2, 2019 at 14:32 Comment(4)
Question on if self.aboutToShowPopover { I assume you were not meaning to test it for nil but checking it for false as in if self.aboutToShowPopover == false {?Fatherhood
Go you. This works and captures and keep the focus in the field and causes the textShouldEndEditing to fire after the popover closes. The solution was actually simpler as I think fieldEditor.insertText("", replacementRange: NSRange(location: 0, length:0)) was the key. It appears that call 'activates' the windows field editor and sets this field as its delegate as it should be.Fatherhood
I didn't know your aboutToShowPopover is optional , mine isn't.Vermiculation
It's not optional but I see what you are saying in your code. It's a class var set to false initially var aboutToShowPopover = false and is set to true when the user clicks the button to show the popover, which then allows textShouldEndEditing to return true so the popover can be shown. When the popover closes, that var is set to false so the textShouldEndEditing can determine if it contains a valid value. I added an answer with that info to show the final solution. It works great by the way, thanks again.Fatherhood
G
3

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.

Georgetta answered 9/2, 2019 at 20:52 Comment(4)
Thanks for putting time into that answer. Unfortunately, it has the same result. The issue is not with the NSTextField becoming the first responder; when the NSPopver closes, a call to thatTextField.window.makeFirstResponder(thatTextField) accomplishes that. However, the problem is that even though it has first responder status, it's not receiving events so the textField functions like textShouldEndEditing are not called. The only way for those events to be routed to the field is by typing a key into the field - and then everything works like it did before the popover.Fatherhood
I have clarified the question further if you want to take a look.Fatherhood
@Fatherhood I think I’m missing something, since not able to reproduceGeorgetta
There probably wasn't enough info in my original question to reproduce as I omitted there was a textField on the Popover which, when the popover opens take first responder status away from the field in the main view. See my updated question with screen shots - I think it brings more clarity to the issue.Fatherhood
V
3

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.

Vermiculation answered 14/2, 2019 at 14:32 Comment(4)
Question on if self.aboutToShowPopover { I assume you were not meaning to test it for nil but checking it for false as in if self.aboutToShowPopover == false {?Fatherhood
Go you. This works and captures and keep the focus in the field and causes the textShouldEndEditing to fire after the popover closes. The solution was actually simpler as I think fieldEditor.insertText("", replacementRange: NSRange(location: 0, length:0)) was the key. It appears that call 'activates' the windows field editor and sets this field as its delegate as it should be.Fatherhood
I didn't know your aboutToShowPopover is optional , mine isn't.Vermiculation
It's not optional but I see what you are saying in your code. It's a class var set to false initially var aboutToShowPopover = false and is set to true when the user clicks the button to show the popover, which then allows textShouldEndEditing to return true so the popover can be shown. When the popover closes, that var is set to false so the textShouldEndEditing can determine if it contains a valid value. I added an answer with that info to show the final solution. It works great by the way, thanks again.Fatherhood
F
0

Huge thanks to Willeke for the help and an answer that lead to a pretty simple solution.

The big picture issue here was that when the popover closed, the 'focused' field was the original field. However, it appears (for some reason) that the windows field editor delegate disconnected from that field so functions such as control:textShouldEndEditing were not being passed to the subclassed field in the question.

Executing this line when the field becomes the first reponder seems to re-connect the windows field editor with this field so it will receive delegate messages

fieldEditor.insertText("", replacementRange: range)

So the final solution was a combination of the following two functions.

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

    if self.aboutToShowPopover == true {
        return true
    }

    let s = textObject.string

    if s == "2" {
        return true
    }

    return false
}

override func becomeFirstResponder() -> Bool {

    if super.becomeFirstResponder() == true {
        if let myEditor = self.currentEditor() as? NSTextView {
            let range = NSMakeRange(0, 0)
            myEditor.insertText("", replacementRange: range)
        }
        return true
    }

    return false
}
Fatherhood answered 15/2, 2019 at 20:58 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.