19

I'm using an NSSlider control, and I've configured it to use continuous mode so that I can continually update an NSTextField with the current value of the slider while the user is sliding it around. The issue I have is that I don't want to 'commit' the value until the user lets go of the knob, i.e I don't want my application to take account of the value unless the user lets go of the slider to signify it's at the desired value. At the moment, I have no way of knowing when that's the case; the action method is just getting called continuously with no indication of when the slider has been released.

If possible, I need a solution which will cover edge cases such as the user interacting the with slider with the keyboard or accessibility tools (if there is such a thing). I'd started to look into using mouse events, but it didn't seem like an optimum solution for the reasons I've just outlined.

dbr
  • 487
  • 4
  • 10

8 Answers8

32

This works for me (and is easier than subclassing NSSlider):

- (IBAction)sizeSliderValueChanged:(id)sender {
    NSEvent *event = [[NSApplication sharedApplication] currentEvent];
    BOOL startingDrag = event.type == NSLeftMouseDown;
    BOOL endingDrag = event.type == NSLeftMouseUp;
    BOOL dragging = event.type == NSLeftMouseDragged;

    NSAssert(startingDrag || endingDrag || dragging, @"unexpected event type caused slider change: %@", event);

    if (startingDrag) {
        NSLog(@"slider value started changing");
        // do whatever needs to be done when the slider starts changing
    }

    // do whatever needs to be done for "uncommitted" changes
    NSLog(@"slider value: %f", [sender doubleValue]);

    if (endingDrag) {
        NSLog(@"slider value stopped changing");
        // do whatever needs to be done when the slider stops changing
    }
}
marcprux
  • 9,845
  • 3
  • 55
  • 72
  • Great way of getting the changes, simple and clean – Alan MacGregor Sep 19 '12 at 16:35
  • 3
    On some MacBook Pros, tapping a location on the slider via the trackpad (without actually dragging) will never give you the mouseup -- meaning you'll never commit that value change. – peterflynn Nov 24 '15 at 02:54
  • 2
    btw, you must first check in interface the NSSlider "continous" value for this to work, – Silviu St Oct 13 '16 at 19:40
  • I had to replace `NSLeftMouseDown` with `NSEventTypeLeftMouseDown` and so on. – hi im zvaehn Feb 13 '17 at 13:40
  • is NSEventTypeLeftMouseDown helps to solve "On some MacBook Pros, tapping a location on the slider via the trackpad (without actually dragging) will never give you the mouseup" problem ??? – Sangram Shivankar Jun 21 '17 at 12:01
7

You could also simply check the type of the current event in the action method:

- (IBAction)sliderChanged:(id)sender
{
    NSEvent *currentEvent = [[sender window] currentEvent];
    if ([currentEvent type] == NSLeftMouseUp) {
        // the slider was let go
    }
}
fjoachim
  • 906
  • 6
  • 11
  • Did you try this? Doesn't work for me - always getting a `NSLeftMouseDragged` type.. – Jay Jan 06 '16 at 07:07
  • Yes I did. And it's very similar to the solution described by @mprudhom. – fjoachim Jan 11 '16 at 23:22
  • 1
    Have you tried it on various laptops with track pads? Never getting the up event there as already mentioned by @peterflynn in another comment – Jay Jan 13 '16 at 17:04
  • Yes I tried on various trackpads. Note that if you have a breakpoint in the action method, you will not get the NSLeftMouseUp event since the key application will change to Xcode before the event would be sent to your app. – fjoachim Jan 18 '16 at 19:45
4

Took me a little while to find this thread, but the accepted answer (although old) is great for detecting NSSlider state changes (slider value stopped changing being the main one I was looking for)!

Answer in Swift (Swift 4.1):

let slider = NSSlider(value: 1,
                      minValue: 0,
                      maxValue: 4,
                      target: self,
                      action: #selector(sliderValueChanged(sender:)))

. . .

@objc func sliderValueChanged(sender: Any) {

    guard let slider = sender as? NSSlider, 
          let event = NSApplication.shared.currentEvent else { return }

    switch event.type {
    case .leftMouseDown, .rightMouseDown:
        print("slider value started changing")
    case .leftMouseUp, .rightMouseUp:
        print("slider value stopped changing: \(slider.doubleValue)")
    case .leftMouseDragged, .rightMouseDragged:
        print("slider value changed: \(slider.doubleValue)")
    default:
        break
    }
}

Note: the right event types account for someone who has reversed their mouse buttons .

jason z
  • 1,377
  • 13
  • 19
3

Using current application or window event as suggested by other answers might be simpler to some degree, but not bulletproof – tracking can be stopped programmatically + check related comments for other issues. Subclassing both slider and slider cell is by far more reliable and straightforward, however, updating classes in interface builder is a drawback:

// This is Swift 3.

import AppKit

class Slider: NSSlider
{
    fileprivate(set) var tracking: Bool = false
}

class SliderCell: NSSliderCell
{
    override func startTracking(at startPoint: NSPoint, in controlView: NSView) -> Bool {
        (self.controlView as? Slider)?.tracking = true
        return super.startTracking(at: startPoint, in: controlView)
    }

    override func stopTracking(last lastPoint: NSPoint, current stopPoint: NSPoint, in controlView: NSView, mouseIsUp flag: Bool) {
        super.stopTracking(last: lastPoint, current: stopPoint, in: controlView, mouseIsUp: flag)
        (self.controlView as? Slider)?.tracking = false
    }
}
Ian Bytchek
  • 8,804
  • 6
  • 46
  • 72
  • 1
    Thanks for `NSCell.controlView`! I knew about the approach but didn't know how to talk to `NSControl` from corresponding `NSCell`. – legends2k Feb 07 '20 at 11:29
2

Subclass NSSlider and implement

- (void)mouseDown:(NSEvent *)theEvent

it's called mouseDown:, but its called when the know interaction ends

- (void)mouseDown:(NSEvent *)theEvent {
    [super mouseDown:theEvent];
    NSLog(@"Knob released!");
}
Peter Lapisu
  • 19,915
  • 16
  • 123
  • 179
2

Just found an elegant way to have a slider continuously updating a label, and storing the slider's value only when the user releases all the mouse buttons.

class YourViewController: NSViewController {
    @IBOutlet weak var slider: NSSlider!
    @IBOutlet weak var label: NSTextField!

    @objc var sliderValue = 0

    override func awakeFromNib() {
        sliderValue = 123 // init the value to whatever you like

        slider.bind(NSBindingName("value"), to: self, withKeyPath: "sliderValue")
        label.bind(NSBindingName("value"),  to: self, withKeyPath: "sliderValue")
    }

    @IBAction func sliderMoved(_ sender: NSSlider) {
        // return if a mouse button is pressed
        guard NSEvent.pressedMouseButtons == 0 else { return }

        // do something with the value
    }
}
Pavel Lobodinský
  • 1,028
  • 1
  • 12
  • 25
1

Unfortunately the two needs are contradictory due to the way basic Cocoa controls are designed. If you're using the target/action mechanism, you're still firing the action in continuous or non-continuous mode. If you're using Bindings, you're still triggering KVO.

One "quick" solution to this might be to subclass NSSlider/NSSliderCell and override the mouse dragging machinery to call super then post a custom "NSSliderDidChangeTemporaryValue" (or whatever name you choose) notification with self as the object. Leave it set to NOT be continuous so the change is only "committed for realz" when the user's done dragging but you can still observe the "user-proposed value" notification and update your UI however you wish.

No need to watch for mouse up or implement complicated "don't-change-yet-im-still-draggin" logic that way.

Joshua Nozzi
  • 60,946
  • 14
  • 140
  • 135
  • Thanks for the tips. From what you say, it sounds like I'm trying to fight the system a bit. Maybe a bit of a UI rethink is in order to find something that goes with the flow a little better. I can't imagine that what I'm trying to do is that uncommon though. – dbr Feb 23 '12 at 17:24
  • Fairly uncommon, in my experience. Setting a (non-text) control (whether continuous or not) value sets its value. Always worked that way. The slider is uniquely positioned to need something like what you want but I don't see it requested often. This solution would be a pretty easy way to do it though. – Joshua Nozzi Feb 23 '12 at 19:11
0

NSSlider fires a private notification after ending the continuous tracking: "NSControlDidEndContinuousTrackingNotification". This notification is used in the onEditingChanged callback of a slider in SwiftUI. So the first change of the value fires the onEditingChanged(true) and the notification fires the onEditingChanged(false).

Stephan Michels
  • 952
  • 4
  • 18