How to bind NSButton enabled state to a composed condition - cocoa

This is my situation in Xcode Interface Builder:
There is also an NSArrayController in entity mode which controls the content of the NSTableView. I want to enable the 'Create' button when the NSTableView is empty (as controlled by the NSSearchField) AND when the text in the NSSearchField is not empty. How do I achieve that? Is it possible without programming?
To what KVO compliant values can I bind the 2 enabled conditions of the 'Create' button?

I don't think there's a way to do it entirely in interface builder, but with a small amount of code you can get it working pretty easily. First, make sure your controller (or App Delegate) is set as the delegate of the search field, and that it has IBOutlet connections to the search field, the button and the array controller. Here's how I would implement it:
// This is an arbitrary pointer to indicate which property has changed.
void *kObjectsChangedContext = &kObjectsChangedContext;
- (void)awakeFromNib {
// Register as an observer so we're notified when the objects change, and initially at startup.
[arrayController addObserver:self
forKeyPath:#"arrangedObjects"
options:NSKeyValueObservingOptionInitial
context:kObjectsChangedContext];
}
// This updates the button state (based on your specs)
- (void)updateButton {
BOOL canCreate = (searchField.stringValue.length > 0 &&
0 == [arrayController.arrangedObjects count]);
[createButton setEnabled:canCreate];
}
// This delegate method is called whenever the text changes; Update the button.
- (void)controlTextDidChange:(NSNotification *)obj {
[self updateButton];
}
// Here's where we get our KVO notifications; Update the button.
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context {
if (kObjectsChangedContext == context)
[self updateButton];
// It's good practice to pass on any notifications we're not registered for.
else {
[super observeValueForKeyPath:keyPath ofObject:object change:change context:context];
}
}
If you're new to bindings some of that may look like Greek, hopefully the comments are clear enough.

I'm SOOO late for this, but came up with another method and just tested it in my app. It works, so I'm going to share it for anyone who will find this question in the future.
Basically what you want to do is to create a property WITHOUT a corresponding value in your controller
#property (readonly) BOOL enableProperty;
This means that there's actually no
BOOL enableProperty;
defined in the header file, or anywhere
then, rather than synthesize it, just write your own getter, and put there your condition
- (BOOL) enableProperty{
return (condition);
}
Third step: anytime there's the chance that your condition changes, notify it.
- (void) someMethod{
//.... Some code
[self willChangeValueForKey:#"enableProperty"];
[Thisline mightChange:theCondition];
[self didChangeValueForKey:#"enableProperty"];
//.... Some other code
}
fourth step: in IB, bind your control's enabled property to this "fake" property.
Enjoy! ;)

You seems to have a window, so presumably you have a controller object which is set as the File's Owner for the NIB file.
Why not declare a boolean property in this controller class, that returns a value based whatever conditions you want ?
#property(readonly) BOOL canCreate;
That you implement :
-(BOOL)canCreate {
// compute and return the value
}
Be sure to send KVO notifications appropriately when the conditions for the creation change.
The last step is to bind the button's enabled binding on the File's Owner canCreate key.

Related

Changing the selection behaviour of NSCollectionView

In my Mac app I have a NSCollectionView with multi select enabled. In my app being able to select more than one item is the norm, and having to press cmd while clicking to select multiple items is frustrating some users and most don't realise they can do it (I get a lot of feature requests asking for multi select).
So, I want to change the behaviour so that:
when a user clicks a second item, the first item remains selected (without the need for holding cmd)
When a user clicks a selected item, the item is deselected
I've tried overriding setSelected on my own subclass of NSCollectionViewItem like so:
-(void)setSelected:(BOOL)flag
{
[super setSelected:flag];
[(MyView*)[self view] setSelected: flag];
[(MyView*)[self view] setNeedsDisplay:YES];
}
Calling super setSelected is required to make sure the collection view functions correctly, but it also seems to be what is responsible for the default behaviour.
What should I do instead?
You could try intercepting all left-mouse-down events using a local events monitor. Within this block you'd then work out if the click happened on your collection view. If it did, create a new event which mimics the event you intercepted but add in the command key mask if it isn't already present. Then, at the end of the block return your event rather than the one you intercepted. Your collection view will behave as if the user had pressed the command key, even though they haven't!
I had a quick go with this in a very simple demo app and it looks like a promising approach - though I expect you'll have to negotiate a few gotchas along the way.
- (void)applicationDidFinishLaunching:(NSNotification *)aNotification {
[NSEvent addLocalMonitorForEventsMatchingMask:NSEventMaskFromType(NSLeftMouseDown)
handler:^NSEvent *(NSEvent *originalEvent) {
// Did this left down event occur on your collection view?
// If it did add in the command key
NSEvent *newEvent =
[NSEvent
mouseEventWithType: NSLeftMouseDown
location: originalEvent.locationInWindow
modifierFlags: NSCommandKeyMask // I'm assuming it's not already present
timestamp: originalEvent.timestamp
windowNumber: originalEvent.windowNumber
context: originalEvent.context
eventNumber: originalEvent.eventNumber
clickCount: originalEvent.clickCount
pressure:0];
return newEvent; // or originalEvent if it's nothing to do with your collection view
}];
}
Edit (by question author):
This solution is so heavily based on the original answer that this answer deserves credit (feel free to edit)
You can also intercept the mouse event by subclassing the NSCollectionView class and overriding mousedown like this:
#implementation MyCollectionView
-(void) mouseDown:(NSEvent *)originalEvent {
NSEvent *mouseEventWithCmd =
[NSEvent
mouseEventWithType: originalEvent.type
location: originalEvent.locationInWindow
modifierFlags: NSCommandKeyMask
timestamp: originalEvent.timestamp
windowNumber: originalEvent.windowNumber
context: originalEvent.context
eventNumber: originalEvent.eventNumber
clickCount: originalEvent.clickCount
pressure: originalEvent.pressure];
[super mouseDown: mouseEventWithCmd];
}
#end

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.

identify sender of controlTextDidEndEditing

I have a view with three NSTextFields connected to a View Controller that acts as delegate for all three of them.
I have successfully implemented the controlTextDidEndEditing to intercept the text entered by the user and change a property in my model. The method though is unique in the delegate and all the three textfields trigger it. The question is, how can I identify which one of them fired the notification? I can get the NSTextView from the key "NSFieldEditor" of the notification but that doesn't really tell me which one it is. At the beginning I thought of using the NSTextField placeholder but the the method returns me a NSTextView which doesn't seem to have a placeholder property.
Any idea?
You should be able to obtain the reference to the NSTextField via [notification object]. If all three of your NSTextFields are available as delegates, it is easy to check which one triggered the event.
Like this:
- (void)controlTextDidEndEditing:(NSNotification *)aNotification {
NSTextField* textField = (NSTextField *)[aNotification object];
if (textField == textField1) {
// textField1 triggered the event
} else if (textField == textField2) {
...
} else if (textField == textField3) {
...
}
}

Custom NSView in NSMenuItem not receiving mouse events

I have an NSMenu popping out of an NSStatusItem using popUpStatusItemMenu. These NSMenuItems show a bunch of different links, and each one is connected with setAction: to the openLink: method of a target. This arrangement has been working fine for a long time. The user chooses a link from the menu and the openLink: method then deals with it.
Unfortunately, I recently decided to experiment with using NSMenuItem's setView: method to provide a nicer/slicker interface. Basically, I just stopped setting the title, created the NSMenuItem, and then used setView: to display a custom view. This works perfectly, the menu items look great and my custom view is displayed.
However, when the user chooses a menu item and releases the mouse, the action no longer works (i.e., openLink: isn't called). If I just simply comment out the setView: call, then the actions work again (of course, the menu items are blank, but the action is executed properly). My first question, then, is why setting a view breaks the NSMenuItem's action.
No problem, I thought, I'll fix it by detecting the mouseUp event in my custom view and calling my action method from there. I added this method to my custom view:
- (void)mouseUp:(NSEvent *)theEvent {
NSLog(#"in mouseUp");
}
No dice! This method is never called.
I can set tracking rects and receive mouseEntered: events, though. I put a few tests in my mouseEntered routine, as follows:
if ([[self window] ignoresMouseEvents]) { NSLog(#"ignoring mouse events"); }
else { NSLog(#"not ignoring mouse events"); }
if ([[self window] canBecomeKeyWindow]) { dNSLog((#"canBecomeKeyWindow")); }
else { NSLog(#"not canBecomeKeyWindow"); }
if ([[self window] isKeyWindow]) { dNSLog((#"isKeyWindow")); }
else { NSLog(#"not isKeyWindow"); }
And got the following responses:
not ignoring mouse events
canBecomeKeyWindow
not isKeyWindow
Is this the problem? "not isKeyWindow"? Presumably this isn't good because Apple's docs say "If the user clicks a view that isn’t in the key window, by default the window is brought forward and made key, but the mouse event is not dispatched." But there must be a way do detect these events. HOW?
Adding:
[[self window] makeKeyWindow];
has no effect, despite the fact that canBecomeKeyWindow is YES.
Add this method to your custom NSView and it will work fine with mouse events
- (void)mouseUp:(NSEvent*) event {
NSMenuItem* mitem = [self enclosingMenuItem];
NSMenu* m = [mitem menu];
[m cancelTracking];
[m performActionForItemAtIndex: [m indexOfItem: mitem]];
}
But i'm having problems with keyhandling, if you solved this problem maybe you can go to my question and help me a little bit.
Add this to your custom view and you should be fine:
- (BOOL)acceptsFirstMouse:(NSEvent *)theEvent
{
return YES;
}
I added this method to my custom view, and now everything works beautifully:
- (void)viewDidMoveToWindow {
[[self window] becomeKeyWindow];
}
Hope this helps!
I've updated this version for SwiftUI Swift 5.3:
final class HostingView<Content: View>: NSHostingView<Content> {
override func viewDidMoveToWindow() {
window?.becomeKey()
}
}
And then use like so:
let item = NSMenuItem()
let contentView = ContentView()
item.view = HostingView(rootView: contentView)
let menu = NSMenu()
menu.items = [item]
So far, the only way to achieve the goal, is to register a tracking area manually in updateTrackingAreas - that is thankfully called, like this:
override func updateTrackingAreas() {
let trackingArea = NSTrackingArea(rect: bounds, options: [.enabledDuringMouseDrag, .mouseEnteredAndExited, .activeInActiveApp], owner: self, userInfo: nil)
addTrackingArea(trackingArea)
}
Recently I needed to show a Custom view for a NSStatusItem, show a regular NSMenu when clicking on it and supporting drag and drop operations on the Status icon.
I solved my problem using, mainly, three different sources that can be found in this question.
Hope it helps other people.
See the sample code from Apple named CustomMenus
In there you'll find a good example in the ImagePickerMenuItemView class.
It's not simple or trivial to make a view in a menu act like a normal NSMenuItem.
There are some real decisions and coding to do.

How to create a binding for NSApp.dockTile's

In IB it is easy to bind a label or text field to some controller's keyPath.
The NSDockTile (available via [[NSApp dockTile] setBadgeLabel:#"123"]) doesn't appear in IB, and I cannot figure out how to programmatically bind its "badgeLabel" property like you might bind a label/textfield/table column.
Any ideas?
NSDockTile doesn't have any bindings, so your controller will have to update the dock tile manually. You could do this using KVO which would have the same effect as binding it.
Create a context as a global:
static void* MyContext=(void*)#"MyContext";
Then, in your init method:
[objectYouWantToWatch addObserver:self forKeyPath:#"dockTileNumber" options:0 context:MyContext];
You then have to implement this method to be notified of changes to the key path:
- (void) observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context
{
if (context == MyContext) {
[[NSApp dockTile] setBadgeLabel:[object valueForKeyPath:keyPath]];
}
else {
[super observeValueForKeyPath:keyPath ofObject:object change:change context:context];
}
}
Make sure you remove the observer when the controller object goes away.
If NSDockTile does support bindings, you can use the method bind:toObject:withKeyPath:options: to set up bindings on the badgeLabel property. Check the documentation for details on which arguments to use. If it doesn't work, you could either implement key value observing in your controller class and update the label each time the value changes, or even override NSDockTile to create a bindings compatible subclass.
I've tried lots of variations of bind:toObject:withKeyPath:options: on NSDockTile, on a controller, on the data source. I can't figure out a combination that works. Alternately, is there a way of having a BatchController object that can be bound to the data source, and it then updates the badge? How do I take an NSObject and make it bindable?

Resources