SwiftUI Button to Selector action - appkit

I need to have a SwiftUI button perform an Objective-C selector on the currently focused component. The following works:
let button = Button(action: {
let command = #selector(NSStandardKeyBindingResponding.selectAll(_:))
NSApplication.shared.currentEvent?.window?.firstResponder?.doCommand(by: command)
}, label: { Text("Select All") })
…but it is obviously really ugly. SwiftUI has a Command struct that can wrap a Selector, but it isn't clear how the selector can actually be invoked once the command is created.
What is the recommended way to issue an arbitrary Command when a button is pressed?

If you want to dispatch an action along the responder chain, you can use the -[NSApplication sendAction:to:from:] method, passing nil for the target.
You'd do this if you want to work with the AppKit responder chain, which might include your SwiftUI NSHostingView along with the rest of the responder chain (its superviews, and window, and so on). In theory if you wanted to handle a selector-based action from SwiftUI content, you can use the onCommand modifier to recognize a Command wrapping that selector. However, your NSHostingView needs to be in the AppKit responder chain, and the onCommand SwiftUI view needs to be focused (or an ancestor of the focused view) for the command to be recognized. The main benefit to this is that you can also handle menubar actions using the exact same code, since menubar actions usually dispatch through the responder chain.
If you're trying to modify state related to some other SwiftUI view, it might be more straightforward to pass a Binding or a closure parameter to your view struct as an argument to its initializer. For example, you could maintain some state somewhere else (i.e. "the set of selected objects") and then pass a closure to the button that modifies the state ("put every object in the set of selected objects") without having to directly teach the button about that state.

Related

How do I set the initial first responder per view in a cocoa app that switches between different views?

My Cocoa App uses one ViewController. I do not use the InterfaceBuilder On app launch a view will be created and the user can do stuff. When clicking a specific button the VC (as the view's delegate) receives a message and then replaces the view with another.
In this new view I want a specific UI element to be the first responder. So far I have not been successful.
The new view has a reference to the desired element (a subview), so the VC can pass it to the window's makeFirstResponder(:_) method.
I tried to do that in the following places:
at the end of the view's init
in the view controller's viewWillAppear()
in the VCs viewDidAppear()
in the latter two I tried:
if let myView = self.view as? MyView {
... here I try to set the UI element as firstResponder ...
}
But in any case I get the following Message:
[General] ERROR: Setting <NSTableView: 0x7f8c1f840600> as the first responder for window <NSWindow: 0x7f8c1ef0efc0>, but it is in a different window ((null))! This would eventually crash when the view is freed. The first responder will be set to nil.
So it appears that at the time I try to set the firstResponder the new view has not yet been attached to the window.
What I also tried is to override the MyView's becomeFirstResponder()method, assuming that when the view is finally presented in the window it will receive that command, but unfortunately this method does not get called.
Is there an easy way to specify an entry point for the responder chain / key view loop per view?

Rich, Window-like Context Menu (like Interface Builder)

I need a context menu that is similar in capabilities to what Interface Builder presents when right(control)-clicking on a view, view controller etc.:
At first sight, it looks like an NSPanel with its style attribute set to "HUD Panel", containing some sort of outline view.
The window itself shouldn't be difficult to implement, but the usual way of presenting a context menu on right(control)-click is by overriding the method:
func menu(for event: NSEvent) -> NSMenu?
...which takes an NSMenu as the return value; can't pass an NSWindow / NSPanel instead.
Perhaps I could do something like this:
override func menu(for event: NSEvent) -> NSMenu? {
// Create our popup window (HUD Panel) and present it
// at the location of event:
// (...actual code omitted...)
// Prevent any actual menu from being displayed:
return nil
}
...but it feels like a hack; I am tricking the system into giving away the timing of the right(control)-click event by pretending to care about presenting an actual NSMenu (i.e., overriding a method explicitly intended for that), but using that timing to do something different.
I would also need to place some logic to dismiss the window when the user clicks somewhere else (context menues have this functionality built in).
I don't think that subclassing NSMenu and NSMenuItem to obtain the above behaviour and appearance is feasible either...
Is there a better way?
Does anybody know (or is able to guess) what Interface Builder actually does?
EDIT: As pointed out in the comment by #Willeke, the conditions for the panel to be shown are not exactly the same as a context menu, so it most surely is not one. This means that hijacking the method above in order to display the window is not just unelegant, but wrong. The question stands as to How to Display the Window (i.e., detect static, non-dragging right click).
You'll want to override rightMouseDown(with:) from your view subclass and use that to trigger showing the panel. NSView's implementation of that method is what calls menu(for:) and presents the returned menu, so your custom subclass can use it to show its custom menu panel instead of calling super.
For full behavioral consistency with both standard context menus and the menus in Interface Builder, you'll also want to handle ctrl-left-clicks by overriding mouseDown(with:) and check for if the event's modifierFlags includes .control.

Perform segue after unwinding

I have a main screen wrapped in a navigation controller.
The main screen has several buttons that trigger segues to other views. One button goes to a table view.
A selection in this table should trigger a segue that is normally performed by one of the buttons on the main view.
I was assuming that I need to unwind to the main screen first, and then trigger a segue from the unwind segue programmatically, but what happens when I do that is that it performs the programmatic segue first, then unwinds, and ends up on the main screen again.
What is the correct way to deal with this situation? I don't want to be able to go back to the table view after the programmatically called segue, the back button should then go to the main view.
In case it helps to explain my use case a little more: The table view is a list of levels. A selection should launch my game view with the level picked in the table view.
My unwind segue in my main view:
#IBAction func backFromLevelSelectionUnwindSegue(segue:UIStoryboardSegue) {
performSegueWithIdentifier("playSegue", sender: self)
}
obviously playSegue is the segue to the game view controller.
An answer to a similar question suggests setting a boolean flag and then performing the segue in viewDidAppear, but it seems like that viewDidAppear should not have to know about an unwind segue that has occurred. Is there a "correct" solution that I haven't come across?
In your backFromLevelSelectionUnwindSegue, your are still in a unwind segue context. So call performSegueWithIdentifier after the context finished using dispatch_async or performSelector:withObject:afterDelay like below.
#IBAction func
backFromLevelSelectionUnwindSegue( segue:UIStoryboardSegue ) {
dispatch_async( dispatch_get_main_queue() ) {
self.performSegueWithIdentifier( "playSegue", sender: self )
}
}
Would it be possible for you to perform a segue to the game view without unwinding it to the main screen? Then when you unwind the segue, unwind it to the main view instead of back to table view? If I remember correctly, we are able to unwind past the presenting view controller.
This is a matter of opinion, but in your case I think you should simply have a segue between the tableview and the game, with any extra data necessary being passed to the tableview VC from the main VC.
If you need go back multiple viewControllers in a UINavigationVC, I would look at using popToRootViewController or use an unWind between them. e.g. call unwind from the 3rd viewController, with the handler in the 1st viewController
It's possible to have multiple unwind segues that go back to different places. In the scenario given the view controllers should be arranged Main->LevelSelect->Game
and then when game ends you have buttons for 2 or 3 unwind segues. First one is exitToGameStart that allows the player to restart the same level. exitToLevelSelect allows player to choose a new level. And optionally exitToMainMenu goes all the way back to the start. For a full example, see Apple's UnwindSegue sample, and in particular the "Start Over" button on the last table that performs the exitToQuizStart unwind segue and the "Return To The Menu Screen" that performs the exitToHomeScreen unwind segue. Code for the receiving methods are below:
QuestionViewController.m
//! Unwinds from the ResultsViewController back to the first
//! QuestionViewController when the user taps the 'Start Over' button.
//
// This is an unwind action. Note that the sender parameter is a
// 'UIStoryboardSegue*' instead of the usual 'id'. Like all unwind actions,
// this method is invoked early in the unwind process, before the visual
// transition. Note that the receiver of this method is the
// destinationViewController of the segue. Your view controller should use
// this callback to update its UI before it is redisplayed.
//
- (IBAction)exitToQuizStart:(UIStoryboardSegue *)sender
{
// The user has restarted the quiz.
[self.currentQuiz resetQuiz];
}
MainMenuViewController.m
//! Unwinds from the ResultsViewController back to the MainMenuViewController
//! when the user taps the 'Return to the Home Screen' button.
//
// This is an unwind action. Note that the sender parameter is a
// 'UIStoryboardSegue*' instead of the usual 'id'. Like all unwind actions,
// this method is invoked early in the unwind process, before the visual
// transition. Note that the receiver of this method is the
// destinationViewController of the segue. Your view controller should use
// this callback to retrieve information from the sourceViewController. Used
// properly, this method can replace existing delegation techniques for
// passing information from a detail view controller to a previous view
// controller in the navigation hierarchy.
//
- (IBAction)exitToHomeScreen:(UIStoryboardSegue *)unwindSegue
{
// Retrieve the score from the ResultsViewController and update the high
// score.
ResultsViewController *resultVC = (ResultsViewController*)unwindSegue.sourceViewController;
self.highScore = MAX(resultVC.currentQuiz.percentageScore, self.highScore);
}
Note: to build the old project in the latest Xcode, open the storyboard, file inspector, builds for, pick a newer iOS version.

NSMenuItem KeyEquivalent " "(space) bug

I want to set key equivalent " "(space) without any modifiers for NSMenuItem (in App Main Menu).
As follows from documentation:
For example, in an application that plays media, the Play command may be mapped to just “ ” (space), without the command key. You can do this with the following code:
[menuItem setKeyEquivalent:#" "];
[menuItem setKeyEquivalentModifierMask:0];
Key Equivalent sets successfully, but it don't work. When I press "Space" key without modifiers nothing happens, but it's works when i press "Space" with "Fn" modifier key.
I need to use "Space" without modifiers. Any help please!
This is a tricky question. Like many answers suggest, intercepting the event at the application or window level is a solid way to force the menu item to work. At the same time it is likely to break other things, for example, if you have a focused NSTextField or NSButton you'd want them to consume the event, not the menu item. This might also fail if the user redefines the key equivalent for that menu item in system preferences, i.e., changes Space to P.
The fact that you're using the space key equivalent with the menu item makes things even trickier. Space is one of the special UI event characters, along with the arrow keys and a few others, that the AppKit treats differently and in certain cases will consume before it propagates up to the main menu.
So, there are two things to keep in mind. First, is the standard responder chain:
NSApplication.sendEvent sends event to the key window.
Key window receives the event in NSWindow.sendEvent, determines if it is a key event and invokes performKeyEquivalent on self.
performKeyEquivalent sends it to the current window's firstResponder.
If the responder doesn't consume it, the event gets recursively sent upwards to the nextResponder.
performKeyEquivalent returns true if one of the responders consumes the event, false otherwise.
Now, the second and tricky part, if the event doesn't get consumed (that is when performKeyEquivalent returns false) the window will try to process it as a special keyboard UI event – this is briefly mentioned in Cocoa Event Handling Guide:
The Cocoa event-dispatch architecture treats certain key events as commands to move control focus to a different user-interface object in a window, to simulate a mouse click on an object, to dismiss modal windows, and to make selections in objects that allow selections. This capability is called keyboard interface control. Most of the user-interface objects involved in keyboard interface control are NSControl objects, but objects that aren’t controls can participate as well.
The way this part works is pretty straightforward:
The window converts the key event in a corresponding action (selector).
It checks with the first responder if it respondsToSelector and invokes it.
If the action was invoked the event gets treated as consumed and the event propagation stops.
So, with all that in mind, you must ensure two things:
The responder chain is correctly set up.
Responders consumes only what they need and propagate events otherwise.
The first point rarely gives troubles. The second one, and this is what happens in your example, needs taking care of – the AVPlayer would typically be the first responder and consume the space key event, as well as a few others. To make this work you need to override keyUp and keyDown methods to propagate the event up the responder chain as would happen in the default NSView implementation.
// All player keyboard gestures are disabled.
override func keyDown(with event: NSEvent) {
self.nextResponder?.keyDown(with: event)
}
// All player keyboard gestures are disabled.
override func keyUp(with event: NSEvent) {
self.nextResponder?.keyUp(with: event)
}
The above forwards the event up the responder chain and it will eventually be received by main menu. There's one gotcha, if first responder is a control, like NSButton or any custom NSControl-inheriting object, it WILL consume the event. Typically you do want this to happen, but if not, for example when implementing custom controls, you can override respondsToSelector:
override func responds(to selector: Selector!) -> Bool {
if selector == #selector(performClick(_:)) { return false }
return super.responds(to: selector)
}
This will prevent the window from consuming the keyboard UI event, so the main menu can receive it instead. However, if you want to intercept ALL keyboard UI events, including when the first responder is able to consume it, you do want to override your window's or application's performKeyEquivalent, but without duplicating it as other answers suggest:
override func performKeyEquivalent(with event: NSEvent) -> Bool {
// Attempt to perform the key equivalent on the main menu first.
if NSApplication.shared.mainMenu?.performKeyEquivalent(with: event) == true { return true }
// Continue with the standard implementation if it doesn't succeed.
return super.performKeyEquivalent(with: event)
}
If you invoke performKeyEquivalent on the main menu without checking for result you might end up invoking it twice – first, manually, and second, automatically from the super implementation, if the event doesn't get consumed by the responder chain. This would be the case when AVPlayer is the first responder and keyDown and keyUp methods not overwritten.
P.S. Snippets are Swift 4, but the idea is the same! ✌️
P.P.S. There's a brilliant WWDC 2010 Session 145 – Key Event Handling in Cocoa Applications that covers this subject in depth with excellent examples. WWDC 2010-11 is no longer listed on Apple Developer Portal but the full session list can be found here.
I had the same problem. I haven't investigated very hard, but as far as I can tell, the spacebar doesn't "look" like a keyboard shortcut to Cocoa so it gets routed to -insertText:. My solution was to subclass the NSWindow, catch it as it goes up the responder chain (presumably you could subclass NSApp instead), and send it off to the menu system explicitly:
- (void)insertText:(id)insertString
{
if ([insertString isEqual:#" "]) {
NSEvent *fakeEvent = [NSEvent keyEventWithType:NSKeyDown
location:[self mouseLocationOutsideOfEventStream]
modifierFlags:0
timestamp:[[NSProcessInfo processInfo] systemUptime]
windowNumber:self.windowNumber
context:[NSGraphicsContext currentContext]
characters:#" "
charactersIgnoringModifiers:#" "
isARepeat:NO
keyCode:49];
[[NSApp mainMenu] performKeyEquivalent:fakeEvent];
} else {
[super insertText:insertString];
}
}
I have just been experiencing the same problem with a twist...
The spacebar key equivalent works fine in my app while the NSMenuItem's linked IBAction is located in the App Delegate.
If I move the IBAction into a dedicated controller it fails. All other menu item key equivalents continue to work but the spacebar does not respond (it is ok with a modifier key, but unmodified #" " will not work).
I have tried various workarounds, like linking directly to the controller vs. linking via the responder chain, to no avail. I tried the code way:
[menuItem setKeyEquivalent:#" "];
[menuItem setKeyEquivalentModifierMask:0];
and the Interface Builder way, the behaviour is the same
I have tried subclassing NSWindow, as per Justin's answer, but so far have failed to get that to work.
So for now I have surrendered and relocated this one IBAction to the App Delegate where it works. I don't regard this as a solution, just making do... perhaps it's a bug, or (more likely) I just don't understand event messaging and the responder chain well enough.
Up this post because i need to use space too but no of those solutions work for me.
So, I subclass NSApplication and use the sendEvent: selector with the justin k solution :
- (void)sendEvent:(NSEvent *)anEvent
{
[super sendEvent:anEvent];
switch ([anEvent type]) {
case NSKeyDown:
if (([anEvent keyCode] == 49) && (![anEvent isARepeat])) {
NSPoint pt; pt.x = pt.y = 0;
NSEvent *fakeEvent = [NSEvent keyEventWithType:NSKeyDown
location:pt
modifierFlags:0
timestamp:[[NSProcessInfo processInfo] systemUptime]
windowNumber: 0 // self.windowNumber
context:[NSGraphicsContext currentContext]
characters:#" "
charactersIgnoringModifiers:#" "
isARepeat:NO
keyCode:49];
[[NSApp mainMenu] performKeyEquivalent:fakeEvent];
}
break;
default:
break;
}
}
Hope it will help
Quick Swift 4-5 method:
In view controller:
// Capture space and call main menu
override func keyDown(with event: NSEvent) {
if event.keyCode == 49 && !event.isARepeat{
NSApp.mainMenu?.performKeyEquivalent(with: event)
}
super.keyDown(with: event)
}

When an NSWindow object has a delegate that is a NSWindow subclass, who is responsible to act on received events?

So I'm building a program that features the use of the IKImageBrowserView component as a subview in an NSWindow. As a side note, I have a controller object called ImageBrowserController which subclasses NSWindow and is set as the delegate of the NSWindow object of my app.
I have sent IKImageBrowserView the message setCanControlQuickLookPanel:YES to enable it to automatically use the QuickLook functionality to preview image files when the IKImageBrowserView is a first responder to receive key events. Then it took me a while to figure out how to make the IKImageBrowserView a first responder which I finally got working by overriding acceptsFirstResponder inside my ImageBrowserController.
Now I understand that as the delegate to the NSWindow, ImageBrowserController has a place in the responder chain after the event gets triggered on NSWindow. And I understand that as a subview of NSWindow, IKImageBrowserView is in line to be passed events for event handling. What I don't get is where the connection is between the ImageBrowserController being a first responder and the event somehow making it to the IKImageBrowserView. I didn't set NSWindow or IKImageBrowserView as first responders explicitly. So why isn't it necessary for me to implement event handling inside my ImageBrowserController?
EDIT: So after reading the accepted answer and going back to my code I tried removing the acceptsFirstResponder override in my ImageBrowserController and the QuickLook functionality still triggered just like the accepted answer said it would. Commenting out the setCanControlQuickLookPanel:YES made the app beep at me when I tried to invoke QuickLook functionality via the spacebar. I'm getting the feeling that my troubles were caused by user error of XCode in hitting the RUN button instead of the BUILD button after making changes to my code (sigh).
Some of what you are saying regarding the interactions between your objects does not make sense, and it is hard to address your stated question without some background.
As you say, your window delegate has a place at the end of the responder chain, after the window itself. The key point I think you are missing is that GUI elements, such as your IKImageBrowserView, will be at the beginning of the chain, and any one of them in a given window could be the current firstResponder.
When your application gets an event, it passes it off to the key window (which is just the window which currently accepts "key" (i.e., "keystroke") events). That window begins by asking its firstResponder to handle the event. If that object refuses, it passes the event to its own nextResponder, usually its superview, which either handles it or passes it on, until the event has either been handled or passed all the way up to the window object itself. Only then will the window (if it does not handle the event itself) ask its delegate to handle the event.
This means that the connection between the window delegate and the IKImageBrowserView is only through the Responder Chain, and its nature is simply that if the view declines to handle any given event, the delegate may eventually be asked to handle it, if no other object in between them handles it first.
Your window delegate does not need to be a firstResponder. Nor does overriding acceptsFirstResponder on the window delegate have any effect on one of the window's subviews.*
Your window delegate also does not need to (and, indeed should not) be a subclass of NSWindow. All it needs is to be a subclass of NSObject which implements whatever methods from the NSWindowDelegate Protocol you are interested in, and methods to handle any events you might want to catch if they are not handled by other objects.
So, the answer to your explicit question at the end is (and I do not mean this sarcastically): you only need to implement event handling in your window delegate if you want it to handle events itself.
*: IKImageBrowserView already responds YES to acceptsFirstResponder. If there are no other subviews in your window, it will automatically be the firstResponder when your application starts. You can set this initialFirstResponder explicitly in Interface Builder by connecting that outlet on the window to whatever object you want.

Resources