NSButton with Mouse Down/Up and Key Down/Up - macos

I need a NSButton that gives me 2 events, one when the button is pressed down (NSOnState) and one when the button is released (NSOffState) and so far i've got it working with the mouse (intercepting the mouseDown: event). But using a keyboard shortcut doesn't work, it fires a NSOnState once and then after a delay really often. Is there any way to get a button that fires NSOnState when pressed and NSOffState when released?
My current subclass of NSButton looks like this and unfortunately works using a delegate:
-(void)awakeFromNib {
[self setTarget:self];
[self setAction:#selector(buttonAction:)];
}
-(void)mouseDown:(NSEvent *)theEvent {
[_delegate button:self isPressed:YES];
[super mouseDown:theEvent];
}
-(void)buttonAction:(id)sender {
[_delegate button:self isPressed:NO];
}

The button can be set to send mouse events at both times by using -sendActionOn::
[self.button sendActionOn: NSLeftMouseDownMask | NSLeftMouseUpMask];
Handling keyboard events similarly seems more difficult. If you don't need the event exactly at the same time the highlight is removed from the button, you could override NSButton's -performKeyEquivalent: so that it will e.g. send the action twice.
- (BOOL) performKeyEquivalent: (NSEvent *) anEvent
{
if ([super performKeyEquivalent: anEvent])
{
[self sendAction: self.action to: self.target];
return YES;
}
return NO;
}
If you do need the event at the same time, I think you need to use a custom button cell (by creating a subclass of NSButtonCell and setting the button's cell in the initializer) and override its -highlight:withFrame:inView::
- (void)highlight:(BOOL)flag
withFrame:(NSRect)cellFrame
inView:(NSView *)controlView
{
[super highlight: flag withFrame:cellFrame inView:controlView];
if (flag)
{
// Action hasn't been sent yet.
}
else
{
// Action has been sent.
}
}

Related

Set selectedRange when NSTextField becomes focused

I'm overriding becomeFirstResponder to know when my NSTextField is focused. Once focused, I'm trying to move the cursor to the end. The following snippet does not work:
#interface MyTextField : NSTextField
#end
#implementation MyTextField
- (BOOL)becomeFirstResponder
{
if ([super becomeFirstResponder]) {
self.currentEditor.selectedRange = NSMakeRange(self.stringValue.length, 0);
return YES;
}
return NO;
}
#end
By overriding textView:didChangeSelection:, I found that the selection is made, but it's then overwritten by some internal code that runs in response to the NSEventTypeLeftMouseDown event.
The logs look like this:
location=0, length=25
location=25, length=0 // The desired selection.
location=0, length=0
location=5, length=0 // Where the user clicked.
Override the mouseDown: method in your NSTextField subclass.
Then, set selectedRange after calling super.
- (void)mouseDown:(NSEvent *)event
{
[super mouseDown:event];
self.currentEditor.selectedRange = NSMakeRange(self.stringValue.length, 0);
}
NSTextField only has its mouseDown: method called when its "field editor" is not yet focused, so the user can still change the selection after the NSTextField gains focus.
This isn't a perfect solution, because the user may have focused the NSTextField indirectly (eg: with the Tab key). You can always set selectedRange in both mouseDown: and becomeFirstResponder though.
Use performSelector:withObject:afterDelay: from inside becomeFirstResponder to ensure the selectedRange is set after the NSEventTypeLeftMouseDown event is handled.
- (BOOL)becomeFirstResponder
{
if ([super becomeFirstResponder]) {
[self performSelector:#selector(textFieldDidFocus) withObject:nil afterDelay:0.0];
return YES;
}
return NO;
}
- (void)textFieldDidFocus
{
self.currentEditor.selectedRange = NSMakeRange(self.stringValue.length, 0);
}

Create a selectable square button, that open with double click in Xcode (Cocoa)

How to create a Square Button, that once clicked on once it gets selected (focus ring) and once its clicked on twice it performs an action.
Is it possible to do that in XIB file?
In AppDelegate(or some other class):
#property BOOL alreadyClickedButton;
Then initialize the property:
- (void)applicationDidFinishLaunching:(NSNotification *)aNotification {
// Insert code here to initialize your application
[self setAlreadyClickedButton:NO];
}
Then in an action:
-(IBAction)onclickButton:(id)sender {
if ([self alreadyClickedButton] ) {
NSLog(#"Do something...");
[self setAlreadyClickedButton:NO]; //Reset the button to initial state?
}
else {
[[self window] makeFirstResponder:sender];
[self setAlreadyClickedButton:YES];
}
}
Every two clicks of that button, you will see the message.

How to deal with first responder and a NSPopover

I’m trying to replicate the behaviour of the search field in iTunes, for looking up stock symbols and names. Specifically, as you start typing in the search field a popover appears with the filtered items. For the most part I have this working however what I can’t replicate is the way it handles first responder
I have my popover appear after three characters are entered. At this point the NSSearchField would lose first responder status and therefore I could no longer continue typing. The behaviour I would like is
the ability to continue typing after the popover appears
if scrolling through the items with the arrow keys, and then resume typing, you would continue from the last character in the Search field.
What I tried is subclassing NSTextView (use this as the custom field editor for the NSSearchField) and overriding
- (BOOL)resignFirstResponder
By simply returning NO, I can continue typing once the popover appears, but obviously I can’t select any of the items in the popover. So i tried the following, which returns YES if the down arrow or a mousedown event occurs.
#interface SBCustomFieldEditor ()
{
BOOL resignFirstRepond;
}
#end
#implementation SBCustomFieldEditor
- (id)initWithFrame:(NSRect)frame
{
self = [super initWithFrame:frame];
if (self) {
// Initialization code here.
resignFirstRepond = NO;
}
return self;
}
- (BOOL)resignFirstResponder
{
return resignFirstRepond;
}
- (void)keyDown:(NSEvent *)theEvent
{
if ([theEvent keyCode] == 125) {
resignFirstRepond = YES;
[self resignFirstResponder];
}
[super keyDown:theEvent];
}
- (void)mouseDown:(NSEvent *)theEvent
{
resignFirstRepond = YES;
[self resignFirstResponder];
}
This works for the mousedown event, but not the keydown event, furthermore this doesn’t address the issue, when the user resumes typing.
Any suggestions?
In the meantime I found an easy fix. Subclass your text view and implement - (BOOL)canBecomeKeyView. Always return NO there. It will be called only once when the popover is shown. You can work with the text view any time still.

NSButton with delayed NSMenu - Objective-C/Cocoa

I want to create an NSButton that sends an action when it is clicked, but when it is pressed for 1 or two seconds it show a NSMenu. Exactly the same as this question here, but since that answer doesn't solve my problem, I decided to ask again.
As an example, go to Finder, open a new window, navigate through some folders and then click the back button: you go to the previous folder. Now click and hold the back button: a menu is displayed. I don't know how to do this with a NSPopUpButton.
Use NSSegmentedControl.
Add a menu by sending setMenu:forSegment: to the control (connecting anything to the menu outlet in IB won't do the trick). Have an action connected to the control (this is important).
Should work exactly as you described.
Create a subclass of NSPopUpButton and override the mouseDown/mouseUp events.
Have the mouseDown event delay for a moment before calling super's implementation and only if the mouse is still being held down.
Have the mouseUp event set the selectedMenuItem to nil (and therefore selectedMenuItemIndex will be -1) before firing the button's target/action.
The only other issue is to handle rapid clicks, where the timer for one click might fire at the moment when the mouse is down for some future click. Instead of using an NSTimer and invalidating it, I chose to have a simple counter for mouseDown events and bail out if the counter has changed.
Here's the code I'm using in my subclass:
// MyClickAndHoldPopUpButton.h
#interface MyClickAndHoldPopUpButton : NSPopUpButton
#end
// MyClickAndHoldPopUpButton.m
#interface MyClickAndHoldPopUpButton ()
#property BOOL mouseIsDown;
#property BOOL menuWasShownForLastMouseDown;
#property int mouseDownUniquenessCounter;
#end
#implementation MyClickAndHoldPopUpButton
// highlight the button immediately but wait a moment before calling the super method (which will show our popup menu) if the mouse comes up
// in that moment, don't tell the super method about the mousedown at all.
- (void)mouseDown:(NSEvent *)theEvent
{
self.mouseIsDown = YES;
self.menuWasShownForLastMouseDown = NO;
self.mouseDownUniquenessCounter++;
int mouseDownUniquenessCounterCopy = self.mouseDownUniquenessCounter;
[self highlight:YES];
float delayInSeconds = [NSEvent doubleClickInterval];
dispatch_time_t popTime = dispatch_time(DISPATCH_TIME_NOW, delayInSeconds * NSEC_PER_SEC);
dispatch_after(popTime, dispatch_get_main_queue(), ^(void){
if (self.mouseIsDown && mouseDownUniquenessCounterCopy == self.mouseDownUniquenessCounter) {
self.menuWasShownForLastMouseDown = YES;
[super mouseDown:theEvent];
}
});
}
// if the mouse was down for a short enough period to avoid showing a popup menu, fire our target/action with no selected menu item, then
// remove the button highlight.
- (void)mouseUp:(NSEvent *)theEvent
{
self.mouseIsDown = NO;
if (!self.menuWasShownForLastMouseDown) {
[self selectItem:nil];
[self sendAction:self.action to:self.target];
}
[self highlight:NO];
}
#end
If anybody still needs this, here's my solution based on a plain NSButton, not a segmented control.
Subclass NSButton and implement a custom mouseDown that starts a timer within the current run loop. In mouseUp, check if the timer has not fired. In that case, cancel it and perform the default action.
This is a very simple approach, it works with any NSButton you can use in IB.
Code below:
- (void)mouseDown:(NSEvent *)theEvent {
[self setHighlighted:YES];
[self setNeedsDisplay:YES];
_menuShown = NO;
_timer = [NSTimer scheduledTimerWithTimeInterval:0.3 target:self selector:#selector(showContextMenu:) userInfo:nil repeats:NO];
[[NSRunLoop currentRunLoop] addTimer:_timer forMode:NSDefaultRunLoopMode];
}
- (void)mouseUp:(NSEvent *)theEvent {
[self setHighlighted:NO];
[self setNeedsDisplay:YES];
[_timer invalidate];
_timer = nil;
if(!_menuShown) {
[NSApp sendAction:[self action] to:[self target] from:self];
}
_menuShown = NO;
}
- (void)showContextMenu:(NSTimer*)timer {
if(!_timer) {
return;
}
_timer = nil;
_menuShown = YES;
NSMenu *theMenu = [[NSMenu alloc] initWithTitle:#"Contextual Menu"];
[[theMenu addItemWithTitle:#"Beep" action:#selector(beep:) keyEquivalent:#""] setTarget:self];
[[theMenu addItemWithTitle:#"Honk" action:#selector(honk:) keyEquivalent:#""] setTarget:self];
[theMenu popUpMenuPositioningItem:nil atLocation:NSMakePoint(self.bounds.size.width-8, self.bounds.size.height-1) inView:self];
NSWindow* window = [self window];
NSEvent* fakeMouseUp = [NSEvent mouseEventWithType:NSLeftMouseUp
location:self.bounds.origin
modifierFlags:0
timestamp:[NSDate timeIntervalSinceReferenceDate]
windowNumber:[window windowNumber]
context:[NSGraphicsContext currentContext]
eventNumber:0
clickCount:1
pressure:0.0];
[window postEvent:fakeMouseUp atStart:YES];
[self setState:NSOnState];
}
I've posted a working sample on my GitHub.
Late to the party but here is a bit different approach, also subclassing NSButton:
///
/// #copyright © 2018 Vadim Shpakovski. All rights reserved.
///
import AppKit
/// Button with a delayed menu like Safari Go Back & Forward buttons.
public class DelayedMenuButton: NSButton {
/// Click & Hold menu, appears after `NSEvent.doubleClickInterval` seconds.
public var delayedMenu: NSMenu?
}
// MARK: -
extension DelayedMenuButton {
public override func mouseDown(with event: NSEvent) {
// Run default implementation if delayed menu is not assigned
guard delayedMenu != nil, isEnabled else {
super.mouseDown(with: event)
return
}
/// Run the popup menu if the mouse is down during `doubleClickInterval` seconds
let delayedItem = DispatchWorkItem { [weak self] in
self?.showDelayedMenu()
}
DispatchQueue.main.asyncAfter(deadline: .now() + .milliseconds(Int(NSEvent.doubleClickInterval * 1000)), execute: delayedItem)
/// Action will be set to nil if the popup menu runs during `super.mouseDown`
let defaultAction = self.action
// Run standard tracking
super.mouseDown(with: event)
// Restore default action if popup menu assigned it to nil
self.action = defaultAction
// Cancel popup menu once tracking is over
delayedItem.cancel()
}
}
// MARK: - Private API
private extension DelayedMenuButton {
/// Cancels current tracking and runs the popup menu
func showDelayedMenu() {
// Simulate mouse up to stop native tracking
guard
let delayedMenu = delayedMenu, delayedMenu.numberOfItems > 0, let window = window, let location = NSApp.currentEvent?.locationInWindow,
let mouseUp = NSEvent.mouseEvent(
with: .leftMouseUp, location: location, modifierFlags: [], timestamp: Date.timeIntervalSinceReferenceDate,
windowNumber: window.windowNumber, context: NSGraphicsContext.current, eventNumber: 0, clickCount: 1, pressure: 0
)
else {
return
}
// Cancel default action
action = nil
// Show the default menu
delayedMenu.popUp(positioning: nil, at: .init(x: -4, y: bounds.height + 2), in: self)
// Send mouse up when the menu is on screen
window.postEvent(mouseUp, atStart: false)
}
}

How to force NSToolBar validation?

I'm geting this strange behavior. I'm using a panel with text to show to the user when the app is waiting for some info. This panel is show modally to prevent the user to click something.
When the loading panel is hidden all the items on the toolbar are disabled and the validateToolbarItem method is not called.
I'm showing the panel in this way:
- (void)showInWindow:(NSWindow *)mainWindow {
sheetWindow = [self window];
[self sheetWillShow];
[NSApp beginSheet:sheetWindow modalForWindow:mainWindow modalDelegate:nil didEndSelector:nil contextInfo:nil];
[NSApp runModalForWindow:sheetWindow];
[NSApp endSheet:sheetWindow];
[sheetWindow orderOut:self];
}
- (void)dismissModal {
[sheetWindow close];
[NSApp stopModal];
}
How can I force the toolbar to validate in this case?
Edit after comment:
I have already tried:
[[[NSApp mainWindow] toolbar] validateVisibleItems]
[[NSApp mainWindow] update];
[NSApp updateWindows];
[NSApp setWindowsNeedUpdate:YES];
All after call dismissModal. I'm thinking that the problem is elsewhere....
The problem is that NSToolbar only sends validation messages to NSToolbarItem's that are of Image type, which none of mine were. In order to validate any or all NSToolbarItems's, create a custom subclass of NSToolBar and override the validateVisibleItems: method. This will send validation messages to ALL visible NSToolbarItem's. The only real difference is that instead of having the Toolbar class enable or disable the item with the returned BOOL, you need to enable or disable the item in the validation method itself.
#interface CustomToolbar : NSToolbar
#end
#implementation CustomToolbar
-(void)validateVisibleItems
{
for (NSToolbarItem *toolbarItem in self.visibleItems)
{
NSResponder *responder = toolbarItem.view;
while ((responder = [responder nextResponder]))
{
if ([responder respondsToSelector:toolbarItem.action])
{
[responder performSelector:#selector(validateToolbarItem:) withObject:toolbarItem];
}
}
}
}
#end
Now, assume you have a controller with an IBAction method that handles Actions for a NSSegmentedControl in your toolbar:
- (IBAction)backButton:(NSSegmentedControl*)sender
{
NSInteger segment = sender.selectedSegment;
if (segment == 0)
{
// Action for first button segment
}
else if (segment == 1)
{
// Action for second button segment
}
}
Place the following in the same controller that handles the toolbar item's Action:
-(BOOL)validateToolbarItem:(NSToolbarItem *)toolbarItem
{
SEL theAction = [toolbarItem action];
if (theAction == #selector(backButton:))
{
[toolbarItem setEnabled:YES];
NSSegmentedControl *backToolbarButton = (NSSegmentedControl *)toolbarItem.view;
[backToolbarButton setEnabled:YES forSegment:0];
[backToolbarButton setEnabled:NO forSegment:1];
}
return NO;
}
The result is that you have complete control over which segments are enabled or disabled.
This technique should be applicable to almost any other type of NSToolbarItem as long as the item's Received Action is being handled by a controller in the responder chain.
I hope this helps.
NSToolbar *toolbar; //Get this somewhere. If you have the window it is in, call [window toolbar];
[toolbar validateVisibleItems];

Resources