简体   繁体   中英

Cocoa Storyboard Responder Chain

Storyboards for Cocoa apps seems like a great solution as I prefer the methodology you find in iOS. However, while breaking things up into separate view controllers makes a lot of logical sense, I'm not clear as to how to pass window control (toolbar buttons) or menu interaction down to the view controllers that care. My app delegate is the first responder and it receives the the menu or toolbar actions, however, how can I access the view controller that I need to get that message to? Can you just drill down into the view controllers hierarchy. If so, how do you get there from the app delegate since it's the first responder? Can you make the window controller the first responder instead. If so, how? In the storyboard? Where?

Since this is a high level question it may not matter, however, I am using Swift for this project if you're wondering.

I'm not sure if there is a " proper " way to solve this, however, I have come up with a solution that I'll use for now. First a couple of details

  • My app is a document based application so each window has an instance of the document.

  • The document the app uses can act as the first responder and forward any actions I've connected

  • The document is able to get a hold of the top level window controller and from there I am able to drill down through the view controller hierarchy to get to the view controller I need.

So, in my windowDidLoad on the window controller, I do this:

override func windowDidLoad() {
    super.windowDidLoad()

    if self.contentViewController != nil {
        var vc = self.contentViewController! as NSSplitViewController
        var innerSplitView = vc.splitViewItems[0] as NSSplitViewItem
        var innerSplitViewController = innerSplitView.viewController as NSSplitViewController
        var layerCanvasSplitViewItem = innerSplitViewController.splitViewItems[1] as NSSplitViewItem
        self.layerCanvasViewController = layerCanvasSplitViewItem.viewController as LayerCanvasViewController
    }
}

Which gets me the view controller (which controls the view you see outlined in red below) and sets a local property in the window view controller.

在此输入图像描述

So now, I can forward the toolbar button or menu item events directly in the document class which is in the responder chain and therefore receives the actions I setup in the menu and toolbar items. Like this:

class LayerDocument: NSDocument {

    @IBAction func addLayer(sender:AnyObject) {
        var windowController = self.windowControllers[0] as MainWindowController
        windowController.layerCanvasViewController.addLayer()
    }

    // ... etc.
}

Since the LayerCanvasViewController was set as a property of the main window controller when it got loaded, I can just access it and call the methods I need.

For the action to find your view controllers, you need to implement -supplementalTargetForAction:sender: in your window and view controllers.

You could list all child controllers potentially interested in the action, or use a generic implementation:

- (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;
}

I had the same Storyboard problem but with a single window app with no Documents. It's a port of an iOS app, and my first OS X app. Here's my solution.

First add an IBAction as you did above in your LayerDocument. Now go to Interface Builder. You'll see that in the connections panel to First Responder in your WindowController, IB has now added a Sent Action of addLayer. Connect your toolBarItem to this. (If you look at First Responder connections for any other controller, it will have a Received Action of addLayer. I couldn't do anything with this. Whatever.)

Back to windowDidLoad. Add the following two lines.

//  This is the top view that is shown by the window

NSView *contentView = self.window.contentView;

//  This forces the responder chain to start in the content view
//  instead of simply going up to the chain to the AppDelegate.

[self.window makeFirstResponder: contentView];

That should do it. Now when you click on the toolbarItem it will go directly to your action.

I've been struggling with this question myself.

I think the 'correct' answer is to lean on the responder chain. For example, to connect a tool bar item action, you can select the root window controller's first responder. And then show the attributes inspector. In the attributes inspector, add your custom action (see photo).

创建自定义响应程序操作

Then connect your toolbar item to that action. (Control drag from your Toolbar item to the first responder and select the action you just added.)

Finally, you can then go to the ViewController (+ 10.10) or other object, so long as its in the responder chain, where you want to receive this event and add the handler.

Alternatively, instead of defining the action in the attributes inspector. You can simply write your IBAction in your ViewController. Then, go to the toolbar item, and control drag to the window controller's first responder -- and select the IBAction you just added. The event will then travel thru the responder chain until received by the view controller.

I think this is the correct way to do this without introducing any additional coupling between your controllers and/or manually forwarding the call.

The only challenge I've run into -- being new to Mac dev myself -- is sometimes the Toolbar item disabled itself after receiving the first event. So, while I think this is the correct approach, there are still some issues I've run into myself.

But I am able to receive the event in another location without any additional coupling or gymnastics.

As i'm a very lazy person i came up with the following solution based on Pierre Bernard 's version

#include <objc/runtime.h>
//-----------------------------------------------------------------------------------------------------------

IMP classSwizzleMethod(Class cls, Method method, IMP newImp)
{
    auto methodReplacer = class_replaceMethod;
    auto methodSetter = method_setImplementation;

    IMP originalImpl = methodReplacer(cls, method_getName(method), newImp, method_getTypeEncoding(method));

    if (originalImpl == nil)
        originalImpl = methodSetter(method, newImp);

    return originalImpl;
}
// ----------------------------------------------------------------------------

@interface NSResponder (Utils)
@end
//------------------------------------------------------------------------------

@implementation NSResponder (Utils)
//------------------------------------------------------------------------------

static IMP originalSupplementalTargetForActionSender;
//------------------------------------------------------------------------------

static id newSupplementalTargetForActionSenderImp(id self, SEL _cmd, SEL action, id sender)
{
    assert([NSStringFromSelector(_cmd) isEqualToString:@"supplementalTargetForAction:sender:"]);

    if ([self isKindOfClass:[NSWindowController class]] || [self isKindOfClass:[NSViewController class]]) {
        id target = ((id(*)(id, SEL, SEL, id)) originalSupplementalTargetForActionSender)(self, _cmd, action, sender);

        if (target != nil)
            return target;

        id childViewControllers = nil;

        if ([self isKindOfClass:[NSWindowController class]])
            childViewControllers = [[(NSWindowController*) self contentViewController] childViewControllers];
        if ([self isKindOfClass:[NSViewController class]])
            childViewControllers = [(NSViewController*) self childViewControllers];

        for (NSViewController *childViewController in childViewControllers) {
            target = [NSApp targetForAction:action to:childViewController from:sender];

            if (NO == [target respondsToSelector:action])
                target = [target supplementalTargetForAction:action sender:sender];

            if ([target respondsToSelector:action])
                return target;
        }
    }
    return nil;
}
// ----------------------------------------------------------------------------

+ (void) load
{
    Method m = nil;

    m = class_getInstanceMethod([NSResponder class], NSSelectorFromString(@"supplementalTargetForAction:sender:"));
    originalSupplementalTargetForActionSender = classSwizzleMethod([self class], m, (IMP)newSupplementalTargetForActionSenderImp);
}
// ----------------------------------------------------------------------------

@end
//------------------------------------------------------------------------------

This way you do not have to add the forwarder code to the window controller and all the viewcontrollers (although subclassing would make that a bit easier), the magic happens automatically if you have a viewcontroller for the window contentview.

Swizzling always a bit dangerous so it is far not a perfect solution, but I've tried it with a very complex view/viewcontroller hierarchy that using container views, worked fine.

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM