When I developed MenuTemperature 1.0, I ran into the following UI design problem: I wanted people to input the frequency of polling the CPU’s temperature, and I wanted to allow for a rather wide range of intervals. Polling twice a second should be just as possible as only polling, say, once every minute.
Three obvious options are there: you could have a text field allowing for numeral values to be input, hardcoded to be treated as seconds (MenuTemperature 1.5, for instance, does this, though it also adds a stepper to enter values more easily). Or you could have a popup menu with several predefined values, e.g. “twice a second”, “every 10 seconds”, “every minute” (for example, Safari does this for its RSS check interval, and Twitterrific for its refresh interval). Or you could even combine the two, so the user can type in “10″, then choose between “seconds”, “minutes” or “hours” (this was briefly considered for MenuTemperature but deemed needlessly complex). And then, there’s the aforementioned combination with a stepper. But, stepper aside, none of those are very “graphical”. Even the stepper doesn’t really give a visual representation of how your currently chosen amount relates to the possible minimum and maximum values; indeed, none of those controls even necessarily define such limits.
Enter the slider: this control has clearly defined and visible limits of a minimum and a maximum; typically the left and right “end”. It has a knob, then, to scroll around and pick something that roughly matches the user’s desired value. It’s imprecise, in fact, but this doesn’t really matter. Who cares if it’s every 30 seconds, or every 31? The user does not. And when it does matter, the slider control even allows for predefined “markers”, which “lock” the value. That is, if the knob is near the marker, the marker’s value will take precedent over the “actually” selected one.
So this is one very simple and flexible control. As of 10.3(?), OS X actually supplies a circular variant that mimics the familiar volume knob on your Hi-Fi, but many argue this to be a bad idea, because you’ll typically interact with the slider using a mouse or trackpad, neither of which are designed for such “circular” input: the behaviour is quite unpredictable. (A few years ago, I had an audio app use such a control for pitch and speed, and I couldn’t figure out for a long time how to actually increase or decrease the value reliably, unless I realized that, even though the “tick” will go around the “knob”, your mouse has to go up and down. The highest value was therefore achieved by going all the way up, even though the “tick” was, then, in the bottom right. Extremely unintuitive. OS X’s implementation makes more sense, in that you do actually make the circular motion, but it’s still rather awkward to use.)
The rationale of the circular slider, beyond familiar with the aforementioned volume knobs, is that it takes up a lot less space. Compared to a linear slider with roughly the same length, a circular slider allows for far more “condensed” precision, i.e. many more values that can be chosen with this method.
I realize, of course, that much of this precision is superfluous. As already said, you don’t really need to be able to select 35 versus 36 seconds; you wouldn’t really notice the different anyway. But you would notice the difference between three or four seconds. In other words, your perception is of a percentual nature. Whereas four seconds take a third longer, the difference between 36 and 35 amounts to less than 3 percents. This is reflected as well in Safari: possible values for the interval of checking for RSS feed updates are 30 minutes, an hour (100% more) and an entire day (4700% more). What user would care to set it to seven hours?
A popup menu is still a suboptimal solution for this, I’d argue. It just wasn’t intended to be used to select values, but for, say, different behavioral modes. Apple’s three choices are rather arbitrary, and while the flexibility would be mostly lost for users, they arguably go overboard with their simplification. (Two hours?)
Going back to the slider control – the linear one – , it would therefore make sense if the values one can input with it reflect the actual needs of users. By the title, you can perhaps see where I’m going with this. Instead of a relationship of realValue(inputValue) = inputValue, a preferable solution would be realValue(inputValue) = einputValue, e being Euler’s number. Internally, then, we would also need to invert the conversion back, i.e. inputValue(realValue) = ln realValue. (I’m sure someone will reprimand me now for sucky math!) This would easily allow for wide ranges of values. With higher inputValues, the realValue would grow very quickly, and with lower ones, the realValue would be far more specific. Win-win.
There’s two considerations here: first, how do we implement this best? And second, seeing as users aren’t very used to such behaviour, how do we make it somewhat intuitive?
Thanks to Cocoa Bindings, the actual implementation is surprisingly simple. In my particular case, it was even simpler as I already was using Bindings for that particular slider. If you’re not, you need to switch to them; you want to anyway. (10.2 and older don’t support Bindings, but I would wager to say that supporting 10.2 users, by now, really costs more money that it brings in now. If you disagree, this post isn’t for you; implementing this without Bindings is certainly possible, but not without significantly more code. Bindings does things for you. Let it!) I’m also only writing about doing this programmatically, but you should figure out the Interface Builder part yourself.
Let’s go!
The first thing you want to do is create a value transformer. Even though this will do the meat of our conversion, it’s an extremely simple class. Here’s the header:
#import <Cocoa/Cocoa.h>
@interface ExponentialValueTransformer : NSValueTransformer {}
@end
And here’s the code:
#import "ExponentialValueTransformer.h"
@implementation ExponentialValueTransformer
+ (Class) transformedValueClass {
return [NSNumber class];
}
+ (BOOL) allowsReverseTransformation {
return YES;
}
- (id) transformedValue: (id) value {
return [NSNumber numberWithFloat:log([value floatValue])];
}
- (id) reverseTransformedValue: (id) value {
return [NSNumber numberWithFloat:exp([value floatValue])];
}
@end
(Yes, that’s it.)
Fortunately for us, C provides log() and exp(), but naturally, they cannot work with NSNumber objects, only with C floats. No problem, though; we simply cast to a float using floatValue, run the C function on that, and create (and return, and have it autorelease when appropriate) a new NSNumber object.
I'm assuming you use this with your userDefaults, i.e. you wish to store a setting. In that case, transformedValue gets called for pulling the value out of the NSUserDefaults database (more specifically, normally your app's property list file in Library/Preferences), so we need to transform it back to display the slider correctly. (You will typically call log ln instead, or "logarithmus naturalis", or "natural logarithm" for the English-inclined.)
And once the slider gets dragged, reverseTransformedValue stores the new value: "e to the power of x", which, lucky for us, happens to be the exact inverse function. So even though we internally store and use a completely different value, the user will never know; he or she interacts with the direct slider value only.
To hook up the transformer, you will want to register it, typically somewhere in your application delegate ([NSApp delegate]), or at some other part in the code that gets called early enough. I'm using NSApplication's +load method for a number of reasons, and this probably can't hurt either, but it's a little unusual, to say the least. Of course, you should import the header of your new value transformer first:
#import "NearExponentialValueTransformer.h"
Registration, then, looks like this:
ExponentialValueTransformer * exponentialValueTransformer = [[[ExponentialValueTransformer alloc] init] autorelease];
[NSValueTransformer setValueTransformer:exponentialValueTransformer forName:@"ExponentialValueTransformer"];
If you're using Interface Builder, you should be able to just create a slider or choose an existing one, bind its value to some key path, and choose this value transformer from a popup menu. Programmatically, it looks something like this:
NSSlider * refreshSlider = [[NSSlider alloc] initWithFrame:sliderFrame];
[refreshSlider setMinValue:log(60.0)];
[refreshSlider setMaxValue:log(3600.0)];
[refreshSlider bind:@"value" toObject:[NSUserDefaultsController sharedUserDefaultsController]
withKeyPath:@"values.refreshInterval"
options:[NSDictionary dictionaryWithObject:@"ExponentialValueTransformer" forKey:NSValueTransformerNameBindingOption]];
[view addSubview:refreshSlider];
Two gotchas: first, the min and max values above need to be prepended by the log() function, as shown. (You could also put the result of that right into the code, but that's just plain ugly.) And second, I lost a needless amount of time thinking that this argument to options: should also work:
[NSDictionary dictionaryWithObject:@"ExponentialValueTransformer" forKey:NSValueTransformerNameBindingOption]
In some cases, Apple allows both interchangeably; here they didn't, so nothing ever happened because I changed the wrong setting.
Congratulations: you now have a slider which allows the user to get more specific with smaller values, and more wide-ranged with bigger ones. I would wager to say that this is the behaviour many users would like.
Only one thing left: add a text label so the user actually knows what value the slider represents. Fairly simple, though, so I'll leave this up to the reader. This should fix the lack of intuitiveness, especially when you turn on "Continuously send action while sliding"; when in doubt, the user can just look at the bare number instead.
To give you an idea of the range achieved internally compared to the range perceived by the user: above, I set the minValue to ln 60, and the maxValue to ln 3600. That's a big internal range; 3600 is 60 times 60! Yet, for the slider, the values actually only go from about 4.094345 to roughly 8.188689, i.e. only twice that. Nifty!