Keep both NSSplitViewController's child controllers in first responder chain - macos

I've a document based app with an NSSplitViewController as the main window's content view controller.
The left pane contains a custom view with controller, which implements some menu commands.
The right pane contains a standard NSTableView with controller.
When the app starts the menu commands work as expected, but as soon as anything inside the right table view is selected, the menu commands get disabled.
How can I make sure that the view controller of the left pane remains inside the first responder chain?
I tried hooking up the menu commands directly to the correct view controller, but IB does not allow connections to another scene in a storyboard. I can only connect to objects in the same scene.
Regards,
Remco Poelstra

Connect to First Responder.
You can have all child view controllers respond to actions by implementing -[NSResponder supplementalTargetForAction:sender:] in your NSSplitViewController subclass:
- (id)supplementalTargetForAction:(SEL)action sender:(id)sender
{
id target = [super supplementalTargetForAction:action sender:sender];
if (target != nil) {
return target;
}
for (NSViewController *childViewController in self.childViewControllers) {
target = [NSApp targetForAction:action to:childViewController from:sender];
if (![target respondsToSelector:action]) {
target = [target supplementalTargetForAction:action sender:sender];
}
if ([target respondsToSelector:action]) {
return target;
}
}
return nil;
}

In Swift 4 you can do the following:
override func supplementalTarget(forAction action: Selector, sender: Any?) -> Any? {
for childViewController in childViewControllers {
if childViewController.responds(to: action) {
return childViewController
} else {
guard let supplementalTarget = childViewController.supplementalTarget(forAction: action, sender: sender) else {
continue
}
return supplementalTarget
}
}
return super.supplementalTarget(forAction: action, sender: sender)
}

Related

Enable or disable menu items on different view controllers in cocoa app?

I have 3 view controllers say main1, main2 and child. I have added a menu item, on click of that it should open child view controller as modal.
Whenever user is in main1 VC, menu item should be enabled. If user in main2 VC, menu should be disabled. Right now I’ve added modal segue between menu item and child VC.
I followed following approaches to disable, but they are not working.
Method 1:
In main2 VC, I’ve added
func validateUserInterfaceItem(_ anItem: NSValidatedUserInterfaceItem) -> Bool {
return false
}
override func validateMenuItem(_ menuItem: NSMenuItem) -> Bool {
return false
}
Method 2:
override func viewDidLoad() {
super.viewDidLoad()
let mainMenu = NSApplication.shared().mainMenu!
let appMenu = mainMenu.item(at: 0)!.submenu
appMenu?.item(withTitle: someMenuTitle)?.isEnabled = false
}
If you use a modal segue it will be always activated.
To enable/disable dependent on the presented view controller I would add an action to the view controller to open the view controller manualy as modal. The menu item has to be connected to the action (openModalViewController) with the first responder.
#IBAction func openModalViewController(_ sender: AnyObject) {
let storyboard = NSStoryboard(name: "Main", bundle: nil)
let viewController = storyboard.instantiateController(withIdentifier: "MyViewController") as! NSViewController
presentAsModalWindow(viewController)
}
Consider there must be at least one view able to get the first responder in main1/main2 that the menu item will activate. If this is not the case you would have to implement acceptsFirstResponder for the corresponding view.
override var acceptsFirstResponder: Bool{
return true
}
To implement validateUserInterfaceItem would be not required in this case, only if you want to control activation/deactivation dependent on an additional state as in the example below.
extension ViewController: NSMenuItemValidation {
func validateMenuItem(_ menuItem: NSMenuItem) -> Bool {
if menuItem.action == #selector(delete(_:)) {
return tableView.selectedRow < 0 ? false : true
}
return true
}
}

NSOutlineView how to show blue outline during right click

Everyone discussed about how to get rid of the blue outline during right click... but me.
Instead, I'm trying to display the blue outline.
I didn't get any outline when I right clicked my outline view row. The menu appeared but the outline wasn't. You can see that the blue outline is not visible in this picture below:
Below is what I'm trying to achieve.
Update
This is how I implemented the NSMenu. I subclassed the NSOutlineView and made a new protocol to override NSOutlineViewDelegate.
This idea was to make it simple by letting the NSOutlineView ask the NSMenu for each item, so we can implement different menu for each item. It works but the blue outline view doesn't show up during right click.
KRMenuOutlineView.swift
import Cocoa
#objc protocol KRMenuOutlineViewDelegate: NSOutlineViewDelegate {
// This method will ask NSMenu for each item in outline view
func outlineView(_ outlineView: KRMenuOutlineView, menuFor item: Any, event: NSEvent) -> NSMenu?
}
class KRMenuOutlineView: NSOutlineView {
override var delegate: NSOutlineViewDelegate? {
didSet {
if let newValue = delegate {
/*
* Swift doesn't support overriding inherited properties with different type
* like Objective C Does, therefore we need internal delegate.
*/
internalDelegate = unsafeBitCast(newValue, to: KRMenuOutlineViewDelegate.self)
} else {
internalDelegate = nil
}
}
}
private var internalDelegate: KRMenuOutlineViewDelegate?
override func menu(for event: NSEvent) -> NSMenu? {
let point = self.convert(event.locationInWindow, from: nil)
if let item = self.item(atRow: self.row(at: point)) {
return self.internalDelegate?.outlineView(self, menuFor: item, event: event)
}
return super.menu(for: event)
}
}
Then, I use it in my view controller like this:
KRTreeViewController.swift
extension KRTreeViewController: KRMenuOutlineViewDelegate {
func outlineView(_ outlineView: KRMenuOutlineView, menuFor item: Any, event: NSEvent) -> NSMenu? {
let menu = NSMenu(title: "Contextual Menu")
menu.delegate = self
let key = String(utf16CodeUnits: [unichar(NSBackspaceCharacter)], count: 1) as String
let deleteMenuItem = menu.addItem(withTitle: "Delete",
action: #selector(didClickMenuItem(_:)),
keyEquivalent: key)
deleteMenuItem.representedObject = myItem
deleteMenuItem.target = self
return menu
}
#objc fileprivate func didClickMenuItem(_ menuItem: NSMenuItem) {
// ...
}
}
How to properly show a context menu:
If you have created your menu using a storyboard:
First, go to the storyboard and add the menu to the viewController that contains the outlineView.
Then make it an #IBOutlet so you can reference it later.
In a method like viewDidLoad(), add the menu to the outlineView by calling
outlineView.menu = myMenu
where myMenu can either be the one you created in Interface Builder or in code.
You can run the app now and should see the blue outline around the cell.
The problem now is that you don't know which cell the user has clicked.
To fix this, set yourself as the delegate of myMenu and adopt the NSMenuDelegate protocol.
func menuNeedsUpdate(_ menu: NSMenu) {
let row = self.outlineView.clickedRow
guard row != -1 else { return }
for item in menu.items {
item.representedObject = row
}
}
Here you can do whatever you need. This implementation sets the rowIndex as the representedObject of each menu item. Keep in mind that this only works on static outlineViews (ones that don't change in the background) and menus which only go one level deep.
You could also store the index or object represented by the cell (if the outlineView is not static) in a local variable.

Conditionally showing NSViewController at app launch

I'm developing an OSX app where I show first a login/register window if the user hasn't logged in yet.
After login success I show my main view controller.
If the user is already logged in (a token is stored), then the app has to launch directly with the main view controller.
I'm new to OSX development, I googled for this kind of scenario but couldn't find anything.
So I went up with what I think should work. It works sometimes, sometimes I get a blank window.
In the storyboard I let the Main Menu and the Window Controller. I removed the "contains" segue to my main view controller.
In AppDelegate, I put this:
func applicationDidFinishLaunching(aNotification: NSNotification) {
if loggedIn {
self.showViewController(NSStoryboard.mainViewController())
} else {
let loginController = NSStoryboard.loginViewController()
loginController.delegate = self
self.showViewController(loginController)
}
}
private func showViewController(viewController: NSViewController) {
if let mainWindow = NSApplication.sharedApplication().mainWindow {
mainWindow.contentViewController = viewController
} else {
print("Error: No main window!")
}
}
About half of the times the window is empty and I see in the console "Error: No main window!". I thought maybe I can use applicationDidBecomeActive but this is called basically when it comes to the foreground and this is not what I need.
Further, the times when it works, and I log in, then I want to show the main view controller:
func onLoginSuccess() {
self.showViewController(NSStoryboard.mainViewController())
}
And here I also get "Error: No main window!" (always) and nothing happens.
The docs say following about mainWindow being nil:
The value in this property is nil when the app’s storyboard or nib file has not yet finished loading. It might also be nil when the app is inactive or hidden.
But why is the storyboard not finished loading or the app inactive when I'm launching it? And on login success the app is definitely active and in the foreground and the main window is always nil.
What am I doing wrong? How can I implement this workflow? Alternatively I could create a "parent" view controller, have that one connected to the window in the storyboard, and add the login or main view controller as nested view controllers to that. But don't really like having to add a do nothing view controller.
I'm using XCode 7(beta 4), Swift 2, OSX 10.10.4
Edit:
The NSStoryboard methods come from an extension, it looks like this:
extension NSStoryboard {
private class func mainStoryboard() -> NSStoryboard { return NSStoryboard(name: "Main", bundle: NSBundle.mainBundle()) }
private class func signupStoryboard() -> NSStoryboard { return NSStoryboard(name: "LoginRegister", bundle: NSBundle.mainBundle()) }
class func mainViewController() -> ViewController {
return self.mainStoryboard().instantiateControllerWithIdentifier("MainViewController") as! ViewController
}
class func loginViewController() -> LoginViewController {
return self.signupStoryboard().instantiateControllerWithIdentifier("LoginViewController") as! LoginViewController
}
class func registerViewController() -> RegisterViewController {
return self.signupStoryboard().instantiateControllerWithIdentifier("RegisterViewController") as! RegisterViewController
}
}
To put the solution we found in the comments as an answer:
Apparently NSApplication.sharedApplication().mainWindow is a different window than my main window in the storyboard.
So, I created an NSWindowController subclass and assigned it to the window in the storyboard, using the identity inspector.
Then I moved the logic I had in app delegate to this NSWindowController. It looks like this:
class MainWindowController: NSWindowController, LoginDelegate {
override func windowDidLoad() {
if loggedIn {
self.onLoggedIn()
} else {
let loginController = NSStoryboard.loginViewController()
loginController.delegate = self
self.contentViewController = loginController
}
}
func onLoggedIn() {
self.contentViewController = NSStoryboard.mainViewController()
}
func onLoginSuccess() {
self.onLoggedIn()
}
}
* Thanks Lucas Derraugh for pointing me in the right direction!
enum Storyboards: String {
case main = "Main"
case settings = "Settings"
func instantiateVC<T>(_ identifier: T.Type) -> T? {
let storyboard = NSStoryboard(name: rawValue, bundle: nil)
guard let viewcontroller = storyboard.instantiateController(withIdentifier: String(describing: identifier)) as? T else { return nil}
return viewcontroller
}
}
//Need to use like this
//Make sure Storyboard Id and class-name are the same
if let windowController = Storyboards.main.instantiateVC(IDMainController.self) {
windowController.showWindow(nil)
//----- OR -----
self.contentViewController = windowController
} else {
print("Cannot find IDMainController")
}

Move a NSWindow by dragging a NSView

I have a NSWindow, on which i apply this:
window.styleMask = window.styleMask | NSFullSizeContentViewWindowMask
window.titleVisibility = NSWindowTitleVisibility.Hidden;
window.titlebarAppearsTransparent = true;
I then add a NSView behind the titlebar to simulate a bigger one.
Now it looks like this:
I want to be able to move the window, by dragging the light-blue view. I have already tried to subclass NSView and always returning true for mouseDownCanMoveWindow using this code:
class LSViewD: NSView {
override var mouseDownCanMoveWindow:Bool {
get {
return true
}
}
}
This didn't work.
After some googling i found this INAppStoreWindow on GitHub. However it doesn't support OS X versions over 10.9, so it's completely useless for me.
Edit1
This is how it looks in the Interface Builder.
How can i move the window, by dragging on this NSView?
None of the answers here worked for me. They all either don't work at all, or make the whole window draggable (note that OP is not asking for this).
Here's how to actually achieve this:
To make a NSView control the window with it's drag events, simply subclass it and override the mouseDown as such:
class WindowDragView: NSView {
override public func mouseDown(with event: NSEvent) {
window?.performDrag(with: event)
}
}
That's it. The mouseDown function will transfer further event tracking to it's parent window.
No need for window masks, isMovableByWindowBackground or mouseDownCanMoveWindow.
Try setting the window's movableByWindowBackground property to true.
There are two ways to do this. The first one would be to set the NSTexturedBackgroundWindowMask as well as the windows background color to the one of your view. This should work.
Otherwise you can take a look at this Sample Code
I somehow managed to solve my problem, i don't really know how, but here are some screenshots.
In the AppDelegate file where i edit the properties of my window, i added an IBOutlet of my contentView. This IBOutlet is a subclass of NSView, in which i've overriden the variable mouseDownCanMoveWindow so it always returns false.
I tried this before in only one file, but it didn't work. This however solved the problem.
Thanks to Ken Thomases and Max for leading me into the right direction.
Swift3.0 Version
override func viewDidAppear() {
//for hide the TitleBar
self.view.window?.styleMask = .borderless
self.view.window?.titlebarAppearsTransparent = true
self.view.window?.titleVisibility = .hidden
//for Window movable with NSView
self.view.window?.isMovableByWindowBackground = true
}
Swift 3:
I needed this but dynamically. It's a little long but well worth it (IMHO).
So I decided to enable this only while the command key is down. This is achieved by registering a local key handler in the delegate:
// MARK:- Local key monitor
var localKeyDownMonitor : Any? = nil
var commandKeyDown : Bool = false {
didSet {
let notif = Notification(name: Notification.Name(rawValue: "commandKeyDown"),
object: NSNumber(booleanLiteral: commandKeyDown))
NotificationCenter.default.post(notif)
}
}
func keyDownMonitor(event: NSEvent) -> Bool {
switch event.modifierFlags.intersection(.deviceIndependentFlagsMask) {
case [.command]:
self.commandKeyDown = true
return true
default:
self.commandKeyDown = false
return false
}
}
which is enabled within the delegate startup:
func applicationDidFinishLaunching(_ aNotification: Notification) {
// Watch local keys for window movenment, etc.
localKeyDownMonitor = NSEvent.addLocalMonitorForEvents(matching: NSEventMask.flagsChanged) { (event) -> NSEvent? in
return self.keyDownMonitor(event: event) ? nil : event
}
}
and its removal
func applicationWillTerminate(_ aNotification: Notification) {
// Forget key down monitoring
NSEvent.removeMonitor(localKeyDownMonitor!)
}
Note that when the commandKeyDown value is changed by the key down handler. This value change is caught by the didset{} to post a notification. This notification is registered by any view you wish to have its window so moved - i.e., in the view delegate
override func viewDidLoad() {
super.viewDidLoad()
// Watch command key changes
NotificationCenter.default.addObserver(
self,
selector: #selector(ViewController.commandKeyDown(_:)),
name: NSNotification.Name(rawValue: "commandKeyDown"),
object: nil)
}
and discarded when the viewWillDisappear() (delegate) or the window controller windowShouldClose(); add this
<your-view>.removeObserver(self, forKeyPath: "commandKeyDown")
So sequence goes like this:
key pressed/release
handler called
notification posted
The view's window isMovableByWindowBackground property is changed by notification - placed within view controller / delegate or where you registered the observer.
internal func commandKeyDown(_ notification : Notification) {
let commandKeyDown : NSNumber = notification.object as! NSNumber
if let window = self.view.window {
window.isMovableByWindowBackground = commandKeyDown.boolValue
Swift.print(String(format: "command %#", commandKeyDown.boolValue ? "v" : "^"))
}
}
Remove the tracer output when happy. See it in action in SimpleViewer on github.

How can the context menu in WKWebView on the Mac be modified or overridden?

I'm using a WKWebView in a Mac OS X application. I want to override the contextual menu that appears when the user Control + clicks or right clicks in the WKWebView, but I cannot find a way to accomplish this.
It should be noted that the context menu changes depending on the state of the WKWebView and what element is under the mouse when the context menu is invoked. For example, the context menu only has a single "Reload" item when the mouse is over an "empty" part of the content, whereas right clicking a link presents the options "Open Link", "Open Link In New Window", and so on. It would be helpful to have granular control over these different menus if possible.
The older WebUIDelegate provides the - webView:contextMenuItemsForElement:defaultMenuItems:
method that allows you to customize the context menu for WebView instances; I'm essentially looking for the analog to this method for WKWebView, or any way to duplicate the functionality.
You can do this by intercepting the contextmenu event in your javascript, reporting the event back to your OSX container through a scriptMessageHandler, then popping up a menu from OSX. You can pass context back through the body field of the script message to show an appropriate menu, or use a different handler for each one.
Setting up callback handler in Objective C:
WKUserContentController *contentController = [[WKUserContentController alloc]init];
[contentController addScriptMessageHandler:self name:#"callbackHandler"];
config.userContentController = contentController;
self.mainWebView = [[WKWebView alloc] initWithFrame:self.view.frame configuration:config];
Javascript code using jquery:
$(nodeId).on("contextmenu", function (evt) {
window.webkit.messageHandlers.callbackHandler.postMessage({body: "..."});
evt.preventDefault();
});
Responding to it from Objective C:
-(void)userContentController:(WKUserContentController *)userContentController didReceiveScriptMessage:(WKScriptMessage *)message
{
if ([message.name isEqualToString:#"callbackHandler"]) {
[self popupMenu:message.body];
}
}
-(void)popupMenu:(NSString *)context {
NSMenu *theMenu = [[NSMenu alloc] initWithTitle:#"Context Menu"];
[theMenu insertItemWithTitle:#"Beep" action:#selector(beep:) keyEquivalent:#"" atIndex:0];
[theMenu insertItemWithTitle:#"Honk" action:#selector(honk:) keyEquivalent:#"" atIndex:1];
[theMenu popUpMenuPositioningItem:theMenu.itemArray[0] atLocation:NSPointFromCGPoint(CGPointMake(0,0)) inView:self.view];
}
-(void)beep:(id)val {
NSLog(#"got beep %#", val);
}
-(void)honk:(id)val {
NSLog(#"got honk %#", val);
}
You can intercept context menu items of the WKWebView class by subclassing it and implementing the willOpenMenu method like this:
class MyWebView: WKWebView {
override func willOpenMenu(_ menu: NSMenu, with event: NSEvent) {
for menuItem in menu.items {
if menuItem.identifier?.rawValue == "WKMenuItemIdentifierDownloadImage" ||
menuItem.identifier?.rawValue == "WKMenuItemIdentifierDownloadLinkedFile" {
menuItem.action = #selector(menuClick(_:))
menuItem.target = self
}
}
}
#objc func menuClick(_ sender: AnyObject) {
if let menuItem = sender as? NSMenuItem {
Swift.print("Menu \(menuItem.title) clicked")
}
}
}
Instead of this you can also simply hide the menu items with menuItem.isHidden = true
Detecting the chosen menu item is one thing, but knowing what the user actually clicked in the WKWebView control is the next challenge :)
It's also possible to add new menu items to the menu.items array.
Objective C solution. The best solution is to subclass WKWebView and intercept mouse clicks. It works great.
#implementation WKReportWebView
// Ctrl+click seems to send this not rightMouse
-(void)mouseDown:(NSEvent *)event
{
if(event.modifierFlags & NSEventModifierFlagControl)
return [self rightMouseDown:event];
[super mouseDown:event]; // Catch scrollbar mouse events
}
-(void)rightMouseDown:(NSEvent *)theEvent
{
NSMenu *rightClickMenu = [[NSMenu alloc] initWithTitle:#"Print Menu"];
[rightClickMenu insertItemWithTitle:NSLocalizedString(#"Print", nil) action:#selector(print:) keyEquivalent:#"" atIndex:0];
[NSMenu popUpContextMenu:rightClickMenu withEvent:theEvent forView:self];
}
#end
This answer builds on the excellent answers in this thread.
The challenges in working with the WKWebView's context menu are:
It can only be manipulated in a subclass of WKWebView
WebKit does not expose any information about the HTML element that the user right-clicked on. Thus, information about the element must be intercepted in JavaScript and plumbed back into Swift.
Intercepting and finding information about the element the user clicked on happens by injecting JavaScript into the page prior to rendering, and then by establishing a callback into Swift. Here is the class that I wrote to do this. It works on the WKWebView's configuration object. It also assumes that there is only one context menu available at a time:
class GlobalScriptMessageHandler: NSObject, WKScriptMessageHandler {
public private(set) static var instance = GlobalScriptMessageHandler()
public private(set) var contextMenu_nodeName: String?
public private(set) var contextMenu_nodeId: String?
public private(set) var contextMenu_hrefNodeName: String?
public private(set) var contextMenu_hrefNodeId: String?
public private(set) var contextMenu_href: String?
static private var WHOLE_PAGE_SCRIPT = """
window.oncontextmenu = (event) => {
var target = event.target
var href = target.href
var parentElement = target
while (href == null && parentElement.parentElement != null) {
parentElement = parentElement.parentElement
href = parentElement.href
}
if (href == null) {
parentElement = null;
}
window.webkit.messageHandlers.oncontextmenu.postMessage({
nodeName: target.nodeName,
id: target.id,
hrefNodeName: parentElement?.nodeName,
hrefId: parentElement?.id,
href
});
}
"""
private override init() {
super.init()
}
public func ensureHandles(configuration: WKWebViewConfiguration) {
var alreadyHandling = false
for userScript in configuration.userContentController.userScripts {
if userScript.source == GlobalScriptMessageHandler.WHOLE_PAGE_SCRIPT {
alreadyHandling = true
}
}
if !alreadyHandling {
let userContentController = configuration.userContentController
userContentController.add(self, name: "oncontextmenu")
let userScript = WKUserScript(source: GlobalScriptMessageHandler.WHOLE_PAGE_SCRIPT, injectionTime: .atDocumentStart, forMainFrameOnly: false)
userContentController.addUserScript(userScript)
}
}
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
if let body = message.body as? NSDictionary {
contextMenu_nodeName = body["nodeName"] as? String
contextMenu_nodeId = body["id"] as? String
contextMenu_hrefNodeName = body["hrefNodeName"] as? String
contextMenu_hrefNodeId = body["hrefId"] as? String
contextMenu_href = body["href"] as? String
}
}
Next, to enable this in your WKWebView, you must subclass it and call GlobalScriptMessageHandler.instance.ensureHandles in your constructor:
class WebView: WKWebView {
public var webViewDelegate: WebViewDelegate?
init() {
super.init(frame: CGRect(), configuration: WKWebViewConfiguration())
GlobalScriptMessageHandler.instance.ensureHandles(configuration: self.configuration)
}
Finally, (as other answers have pointed out,) you override the context menu handler. In this case I changed the action in target for the "Open Link" menu item. You can change them as you see fit:
override func willOpenMenu(_ menu: NSMenu, with event: NSEvent) {
for index in 0...(menu.items.count - 1) {
let menuItem = menu.items[index]
if menuItem.identifier?.rawValue == "WKMenuItemIdentifierOpenLink" {
menuItem.action = #selector(openLink(_:))
menuItem.target = self
And then, in your method to handle the menu item, use GlobalScriptMessageHandler.instance.contextMenu_href to get the URL that the user right-clicked:
#objc func openLink(_ sender: AnyObject) {
if let url = GlobalScriptMessageHandler.instance.contextMenu_href {
let url = URL(string: url)!
self.load(URLRequest(url: url))
}
}
Following the answers already given I was able to modify the menu and also found a way get the URL that was selected by the user. I suppose this approach can also be used to get an image or any other similar content selected, and I'm hoping this can help other folks.
This is written using Swift 5
This approach consists on performing the action from the menu item "Copy Link", so that the URL gets copied into the paste board, then retrieving the URL from the paste board to use it on a new menu item.
Note: Retrieving the URL from the pasteboard needs to be called on an async closure, allowing time for the URL to first be copied into it.
final class WebView: WKWebView {
override func willOpenMenu(_ menu: NSMenu, with: NSEvent) {
menu.items.first { $0.identifier?.rawValue == "WKMenuItemIdentifierCopyLink" }.map {
guard let action = $0.action else { return }
NSApp.sendAction(action, to: $0.target, from: $0)
DispatchQueue.main.async { [weak self] in
let newTab = NSMenuItem(title: "Open Link in New Tab", action: #selector(self?.openInNewTab), keyEquivalent: "")
newTab.target = self
newTab.representedObject = NSPasteboard.general.string(forType: .string)
menu.items.append(newTab)
}
}
}
#objc private func openInNewTab(_ item: NSMenuItem) {
print(item.representedObject as? String)
}
}

Resources