This blog has moved to rhult.github.com
Showing posts with label cocoa. Show all posts
Showing posts with label cocoa. Show all posts

Making an NSSlider snap to tick marks

Tuesday, May 26, 2009
Have you noticed how the sliders in the energy savings panel in the system preferences behave a little differently from your average slider? They have this nice touch of resistance when you drag the slider across a tick mark, making it easy to end up exactly on a mark. I wanted the same behavior for a slider in my app, and found out that it isn't builtin in the slider. The way I solved it was by connecting an action to the slider, where I manually restrict the value a bit:

- (IBAction)sliderValueChanged:(id)sender
{
double range = [sender maxValue] - [sender minValue];
double tickInterval = range / ([sender numberOfTickMarks] - 1);

double relativeValue = [sender doubleValue] - [sender minValue];

// Get the distance to the nearest tick.
int nearestTick = round(relativeValue / tickInterval);
double distance = relativeValue - nearestTick * tickInterval;

// Change the check here depending on how much resistance you
// want, or if you don't want it to depend on the tick interval.
if (fabs(distance) < tickInterval / 8) {
[sender setDoubleValue:[sender doubleValue] - distance];
}
}


Then you have to make the slider continously perform the action when moved, instead of just when releasing it. This can be done either by checking the "Continous" check button in Interface Builder or programmatically using:

- (void)setContinuous:(BOOL)flag


on the slider instance.

There might be a better to do this, if anyone knows about it I'm all ears :)

Adding a preference to launch your app on login using the Shared File List API

Thursday, April 16, 2009
I wanted to make my app launch automatically on login, and looked around for ways to do that programmatically. I first found two ways to do it, both a little bit less than ideal: either using Apple Events to talk to System Events, or by using NSUserDefaults (or CFPreferences) to tweak values in the persistent domain "loginwindow" (as opposed to the app's own persistent domain where the preferences for the app are stored). The Apple Events way seemed a bit hackish to me, as did the NSUserDefaults way, considering it meant poking at preferences you don't "own".

Nonetheless, I decided to try the latter, so I went ahead and implemented a simple controller that exposed only one property that I could then bind to a check box button in Interface Builder using Cocoa bindings:

@property BOOL launchOnLogin;


To show more clearly what I did, here's the part that reads the current login items:

NSUserDefaults *defaults;
NSDictionary *domain;

defaults = [NSUserDefaults standardUserDefaults];

domain = [defaults persistentDomainForName:@"loginwindow"];
if (domain) {
NSArray *items = [domain objectForKey:@"AutoLaunchedApplicationDictionary"];

for (NSDictionary *entry in items) {
NSString *entryPath = [entry objectForKey:@"Path"];
// ... do something with the entry ...
}
}


The writing part is similar, getting a copy of the existing array, then adding or removing our own item and then setting as the new array:

[domain setObject:updatedItems forKey:@"AutoLaunchedApplicationDictionary"];

// Update the setting.
[defaults setPersistentDomain:domain forName:@"loginwindow"];

// Write changes to disk so the system settings will be up to date
// if opened before the changes are automatically synced.
[defaults synchronize];


While this approach works in general, there's a problem: the login items list in the system settings panel is not updated when changing the setting in my app's preferences, and the other way around. Maybe not a big problem, but it made me search some more, which led me to the following note in the release notes for the Launch Services framework in Leopard:

The Shared File List API is new to Launch Services in Mac OS X Leopard. This API provides access to several kinds of system-global and per-user persistent lists of file system objects, such as recent documents and applications, favorites, and login items. For details, see the new interface file LSSharedFileList.h.


The mentioned header file is the only documentation for this new API, but it is quite straight-forward: you create an instance of the right list, in this case the login items list for the session. Then there is a bunch of things you can do with the list, what's interesting for our use case is to get a copy of the array in the list, to add or remove an item, or to register an observer callback for a list. The callback is called when the list changes, which makes it possible for the UI to update accordingly.

Since my app already depends on Leopard, I decided to rewrite my code using LSSharedFileList. The UI was already set up to use the simple controller described above, so a quick rewrite of the controller was enough. I create the list in my init method and add an observer:

loginItemsListRef = LSSharedFileListCreate(NULL,
kLSSharedFileListSessionLoginItems,
NULL);
if (loginItemsListRef) {
// Add an observer so we can update the UI if changed externally.
LSSharedFileListAddObserver(loginItemsListRef,
CFRunLoopGetMain(),
kCFRunLoopCommonModes,
loginItemsChanged,
self);
}


The matching tearing down is done in dealloc:

if (loginItemsListRef) {
LSSharedFileListRemoveObserver(loginItemsListRef,
CFRunLoopGetMain(),
kCFRunLoopCommonModes,
loginItemsChanged,
self);
CFRelease(loginItemsListRef);
}


The callback for the observer is a plain old C funtion:

static void
loginItemsChanged(LSSharedFileListRef listRef, void *context)
{
LoginItemsController *controller = context;

// Emit change notification for the bindnings. We can't do will/did
// around the change but this will have to do.
[controller willChangeValueForKey:@"launchOnLogin"];
[controller didChangeValueForKey:@"launchOnLogin"];
}


The rest of the code just needs to check if our app bundle is listed in the login items list in the getter for the property, and add it or remove it in the setter:

// Get an NSArray with the items.
- (NSArray *)loginItems
{
CFArrayRef snapshotRef = LSSharedFileListCopySnapshot(loginItemsListRef, NULL);

// Use toll-free bridging to get an NSArray with nicer API
// and memory management.
return [NSMakeCollectable(snapshotRef) autorelease];
}

// Return a CFRetained item for the app's bundle, if there is one.
- (LSSharedFileListItemRef)mainBundleLoginItemCopy
{
NSArray *loginItems = [self loginItems];
NSURL *bundleURL = [NSURL fileURLWithPath:[[NSBundle mainBundle] bundlePath]];

for (id item in loginItems) {
LSSharedFileListItemRef itemRef = (LSSharedFileListItemRef)item;
CFURLRef itemURLRef;

if (LSSharedFileListItemResolve(itemRef, 0, &itemURLRef, NULL) == noErr) {
// Again, use toll-free bridging.
NSURL *itemURL = (NSURL *)[NSMakeCollectable(itemURLRef) autorelease];
if ([itemURL isEqual:bundleURL]) {
CFRetain(item);
return (LSSharedFileListItemRef)item;
}
}
}

return NULL;
}

- (void)addMainBundleToLoginItems
{
// We use the URL to the app itself (i.e. the main bundle).
NSURL *bundleURL = [NSURL fileURLWithPath:[[NSBundle mainBundle] bundlePath]];

// Ask to be hidden on launch. The key name to use was a bit hard to find, but can
// be found by inspecting the plist ~/Library/Preferences/com.apple.loginwindow.plist
// and looking at some existing entries. Thanks to Anders for the hint!
NSDictionary *properties;
properties = [NSDictionary dictionaryWithObject:[NSNumber numberWithBool:YES]
forKey:@"com.apple.loginitem.HideOnLaunch"];

LSSharedFileListItemRef itemRef;
itemRef = LSSharedFileListInsertItemURL(loginItemsListRef,
kLSSharedFileListItemLast,
NULL,
NULL,
(CFURLRef)bundleURL,
(CFDictionaryRef)properties,
NULL);
if (itemRef) {
CFRelease(itemRef);
}
}

- (void)removeMainBundleFromLoginItems
{
// Try to get the item corresponding to the main bundle URL.
LSSharedFileListItemRef itemRef = [self mainBundleLoginItemCopy];
if (!itemRef)
return;

LSSharedFileListItemRemove(loginItemsListRef, itemRef);

CFRelease(itemRef);
}

#pragma mark Property accessor methods
- (BOOL)launchOnLogin
{
if (!loginItemsListRef)
return NO;

LSSharedFileListItemRef itemRef = [self mainBundleLoginItemCopy];
if (!itemRef)
return NO;

CFRelease(itemRef);
return YES;
}

- (void)setLaunchOnLogin:(BOOL)value
{
if (!loginItemsListRef)
return;

if (!value) {
[self removeMainBundleFromLoginItems];
} else {
[self addMainBundleToLoginItems];
}
}


That's all! Not only does this give you synchronization between your preferences panel and the system settings, but also feels more correct than poking at a different application's or subsystem's persistent domain.

Note: There is also a "seed" for each list that can be used to check if the list has changed. Using that you can make sure that any change notification is not emitted unless necessary (see LSSharedFileListGetSeedValue()). The example above doesn't do that, so there will be notification sent out when toggling the setting from the app's preferences.

Registering defaults for NSUserDefaults using a property list

Thursday, April 9, 2009
In a previous post about using property lists, I wrote a little about property lists and a use case I had for them. Another one I ran in to recently is related to user defaults:

NSUserDefaults is the system in Mac OS X that handles user preferences. Applications usually register default values at launch time, so that all preferences have a sane default value in case the user hasn't set one for a particular preference. Most examples I've found on the subject do something along the lines of:

// Register user defaults in the class initializer.
+ (void)initialize
{
NSDictionary *appDefaults = [NSDictionary dictionaryWithObjectsAndKeys:
@"YES", @"ShowToolBar",
@"NO", @"AutoSaveEnabled",
// ... lots of objects and keys here,
nil];

[[NSUserDefaults standardUserDefaults] registerDefaults:appDefaults];
}


However, if you have many keys, or just want to make it possible to change them without recompiling, they can be managed through a property list file instead of doing it programmatically. To do that, first create a property list by selecting File → New File... or pressing cmd-N in Xcode and selecting Property List in the Other category. Then add the default values using the property list editor.

Assuming the plist file is named UserDefaults.plist, the code can then be changed to:

+ (void)initialize
{
NSString *defaultsPath = [[NSBundle mainBundle] pathForResource:@"UserDefaults"
ofType:@"plist"];
NSDictionary *appDefaults = [NSDictionary dictionaryWithContentsOfFile:defaultsPath];

[[NSUserDefaults standardUserDefaults] registerDefaults:appDefaults];
}

Using property lists

Wednesday, April 8, 2009
Before entering the Cocoa world, whenever I needed to store and retrieve some small amount of data in an application, I usually handcrafted an XML format and wrote a matching XML parser using either DOM or SAX. This often meant a lot of uninspiring code duplication. On Mac OS X, there is a standardized format that is used throughout the system called property list or "plist". Not only are there APIs to parse plists into the familiar data structures in Cocoa, but there is also a builtin editor for plists in Xcode.

This came in handy recently in some code I was working on. I needed to store 20 or 30 pairs of strings and select one pair randomly from time to time. Instead of entering the strings in the code, they were put into a plist file.

Create and edit a plist
It's easy to create a property list. In Xcode, just select File → New File... or press cmd-N, and select the template Property List in the Other category. Then add the data you wish to, using the property list editor in Xcode. You can of course also handwrite the XML using any text editor.

Use the data
Assume that the property list is structured with a top-level key called Pairs, whose value is an array of our string pairs. Each pair in turn is also an array, with two strings in each. The code to read the list could then look as follows:

- (void)readStringPairs
{
NSString *path = [[NSBundle mainBundle] pathForResource:@"MyFileName"
ofType:@"plist"];
NSDictionary *toplevelDict =
[NSDictionary dictionaryWithContentsOfFile:path];

// Get the array of pairs, retain it as we need it later.
pairs = [[toplevelDict valueForKey:@"Pairs"] retain];
}

- (void)randomizeStrings
{
// Get a random pair, represented by an array.
NSArray *pair = [pairs objectAtIndex:arc4random() % [pairs count]];

// Get the two strings.
NSString *name = [pair objectAtIndex:0];
NSString *description = [pair objectAtIndex:1];

// Do something with the strings here.
}


If you need something a little bit more flexible or complex, you can nest dictionaries and arrays in the plist as well. As a matter of fact, my original code doesn't only have two strings per pair, but one string and an array of strings.