jsd
jsd

Reputation: 7703

Getting array elements with valueForKeyPath

Is there any way to access an NSArray element with valueForKeyPath? Google's reverse geocoder service, for example, returns a very complex data structure. If I want to get the city, right now I have to break it into two calls, like this:

NSDictionary *address = [NSString stringWithString:[[[dictionary objectForKey:@"Placemark"] objectAtIndex:0] objectForKey:@"address"]];
NSLog(@"%@", [address valueForKeyPath:@"AddressDetails.Country.AdministrativeArea.SubAdministrativeArea.Locality.LocalityName"]);

Just wondering if there's a way to shoehorn the objectAtIndex: call into the valueForKeyPath string. I tried a javascript-esque formulation like @"Placemark[0].address" but no dice.

Upvotes: 23

Views: 26090

Answers (5)

jqgsninimo
jqgsninimo

Reputation: 7028

Create methods that supports array for NSObject:

@interface NSObject(ArraySupported)
-(id)valueForKeySupportedArray:(NSString*)path;
-(id)valueForKeyPathSupportedArray:(NSString*)fullPath;
@end

@implementation NSObject(ArraySupported)
-(id)valueForKeySupportedArray:(NSString*)path {
    id value = nil;
    if ([self isKindOfClass:[NSArray class]]) {
        NSArray *array = (NSArray *)self;
        NSUInteger index = path.integerValue;
        if (index >= 0 && index < array.count) {
            value = array[index];
        }
    } else {
        value = [self valueForKey:path];
    }
    return value;
}

-(id)valueForKeyPathSupportedArray:(NSString*)fullPath {
    NSArray* parts = [fullPath componentsSeparatedByString:@"."];
    id value = self;
    for (NSString* part in parts) {
        value = [value valueForKeySupportedArray:part];
        if (value == nil) {
            break;
        }
    }
    return value;
}
@end

How to use:

NSObject *object = @{@"Placemark":@[@{@"address":@"..."}]};
NSString *address = [object valueForKeyPathSupportedArray:@"Placemark.0.address"];
// address = "..."

Upvotes: 0

Sparky
Sparky

Reputation: 182

Subclass NSArrayController or NSDictionaryController

Use NSArrayController for this purpose, because NSObjectController does not include NSArrayController's provided handling of changes to bound array elements. If you use this same code with NSObjectController instead, then using Cocoa Bindings with your NSObjectController instance will only set the (bound interface element's) value at the time of binding but will not receive the messages from array elements in return. By using NSObjectController for this purpose, the user interface will not continue to update even though the contentObject is updated. Simply use the same code with NSArrayController to also include proper support for arrays -- which is the matter at hand.

#import <Cocoa/Cocoa.h>
@interface DelvingArrayController : NSArrayController
@end

#import "DelvingArrayController.h"
@implementation DelvingArrayController
-(id)valueForKeyPath:(NSString *)keyPath
{
    NSError *error = nil;
    NSRegularExpression *regex = [NSRegularExpression regularExpressionWithPattern:@"^(.+?)\\[(\\d+?)\\]$" options:NSRegularExpressionCaseInsensitive error:&error];
    NSArray<NSString*> *components = [keyPath componentsSeparatedByString:@"."];
    id currentObject = self;
    for (NSUInteger i = 0; i < components.count; i++)
    {
        if (![components[i] isEqualToString:@""])
        {
            NSTextCheckingResult *check_result = [regex firstMatchInString:components[i] options:0 range:NSMakeRange(0, components[i].length)];
            if (!check_result)
                currentObject = [currentObject valueForKey:components[i]];
            else
            {
                NSRange array_name_capture_range = [check_result rangeAtIndex:1];
                NSRange number_capture_range = [check_result rangeAtIndex:2];
                if (number_capture_range.location == NSNotFound)
                    currentObject = [currentObject valueForKey:components[i]];
                else if (array_name_capture_range.location != NSNotFound)
                {
                    NSString *array_name = [components[i] substringWithRange:array_name_capture_range];
                    NSUInteger array_index = [[components[i] substringWithRange:number_capture_range] integerValue];
                    currentObject = [currentObject valueForKey:array_name];
                    if ([currentObject count] > array_index)
                        currentObject = [currentObject objectAtIndex:array_index];
                }
            }
        }
    }
    return currentObject;
}
//at some point... also override setValueForKeyPath :-)
@end

This code uses NSRegularExpression, which is for macOS 10.7+. I leave it as an exercise for you to use the same approach to also override setValueForKeyPath, if you want write functionality.


Cocoa Bindings Example Usage

Say we want a little trivia game, with a window that shows a question and uses four buttons to display multiple-choice options. We have the questions and multiple-choice options as NSStrings in a plist, and also an NSNumber or optionally BOOL entries to indicate the correct answers. We want to bind the option buttons to options in the array, for each question also stored in an array.

Here is the example plist containing some trivia questions related to the game Halo. Notice that the options are located within nested arrays.

Trivia Property List

In this example, I use NSObjectController *stringsController as the controller for the entire plist file, and DelvingArrayController *triviaController as the controller for the trivia-related plist entries. You might simply use one DelvingArrayController instead, but I provide this for your understanding.

The trivia window is really simple, so I merely design it using Interface Builder in MainMenu.xib:

Trivia Window in Interface Builder

Trivia Interface Builder Bindings

A subclass of NSDocumentController is used for showing the trivia window via an NSMenuItem added in Interface Builder. The instance of this subclass is also in the .xib, so if we want to use the interface elements in the .xib, we have to wait for the Application Delegate instance's - (void)applicationDidFinishLaunching:(NSNotification *)aNotification method or otherwise wait until the .xib has finished loading...

#import <Cocoa/Cocoa.h>
#import "MenuInterfaceDocumentController.h"
@interface AppDelegate : NSObject <NSApplicationDelegate>
@property IBOutlet MenuInterfaceDocumentController *PrimaryInterfaceController;
@end

#import "AppDelegate.h"
@interface AppDelegate ()
@end
@implementation AppDelegate
@synthesize PrimaryInterfaceController;
- (void)applicationDidFinishLaunching:(NSNotification *)aNotification {
    if ([NSApp mainMenu])
    {
        [PrimaryInterfaceController configureTriviaWindow];
    }
}

#import <Cocoa/Cocoa.h>
@interface MenuInterfaceDocumentController : NSDocumentController
{
    IBOutlet NSMenuItem *MenuItemTrivia;    // shows the Trivia window
    IBOutlet NSWindow *TriviaWindow;
    IBOutlet NSTextView *TriviaQuestionField;
    IBOutlet NSButton *TriviaOption1, *TriviaOption2, *TriviaOption3, *TriviaOption4;
}
@property NSObjectController *stringsController;
-(void)configureTriviaWindow;
@end

#import "MenuInterfaceDocumentController.h"
@interface MenuInterfaceDocumentController ()
@property NSDictionary *languageDictionary;
@property DelvingArrayController *triviaController;
@property NSNumber *triviaAnswer;
@end

@implementation MenuInterfaceDocumentController
@synthesize stringsController, languageDictionary, triviaController, triviaAnswer;
// all this happens before the MainMenu is available, and before the AppDelegate is sent applicationDidFinishLaunching
-(instancetype)init
{
    self = [super init];
    if (self)
    {
        if (!stringsController)
            stringsController = [NSObjectController new];
        stringsController.editable = NO;
        // check for the plist file, eventually applying the following
        languageDictionary = [NSDictionary dictionaryWithContentsOfFile:[[NSBundle mainBundle] pathForResource:@"en" ofType:@"plist"]];
        if (languageDictionary)
            [stringsController setContent:languageDictionary];
        if (!triviaController)
        {
            triviaController = [DelvingArrayController new];
            [triviaController bind:@"contentArray" toObject:stringsController withKeyPath:@"selection.trivia" options:nil];
        }
        triviaController.editable = NO;
        if (!triviaAnswer)
        {
            triviaAnswer = @0;
            [self bind:@"triviaAnswer" toObject:triviaController withKeyPath:@"selection.answer" options:nil];
        }
    }
    return self;
}
// if we ever do something like change the plist file to a duplicate plist file that is in a different language, use this kind of approach to keep the same trivia entry active
-(IBAction)changeLanguage:(id)sender
{
    NSUInteger triviaQIndex = triviaController.selectionIndex;
    if (sender == MenuItemEnglishLanguage)
    {
        if ([self changeLanguageTo:@"en" Notify:YES])
        {
            [self updateSelectedLanguageMenuItemWithLanguageString:@"en"];
            if ([triviaController.content count] > triviaQIndex)    // in case the plist files don't match
                [triviaController setSelectionIndex:triviaQIndex];
        }
        else
            [self displayAlertFor:CUSTOM_ALERT_TYPE_LANGUAGE_CHANGE_FAILED];
    }
    else if (sender == MenuItemGermanLanguage)
    {
        if ([self changeLanguageTo:@"de" Notify:YES])
        {
            [self updateSelectedLanguageMenuItemWithLanguageString:@"de"];
            if ([triviaController.content count] > triviaQIndex)
                [triviaController setSelectionIndex:triviaQIndex];
        }
        else
            [self displayAlertFor:CUSTOM_ALERT_TYPE_LANGUAGE_CHANGE_FAILED];
    }
}
-(void)configureTriviaWindow
{
    [TriviaQuestionField bind:@"string" toObject:triviaController withKeyPath:@"selection.question" options:nil];
    [TriviaOption1 bind:@"title" toObject:triviaController withKeyPath:@"selection.options[0]" options:nil];
    [TriviaOption2 bind:@"title" toObject:triviaController withKeyPath:@"selection.options[1]" options:nil];
    [TriviaOption3 bind:@"title" toObject:triviaController withKeyPath:@"selection.options[2]" options:nil];
    [TriviaOption4 bind:@"title" toObject:triviaController withKeyPath:@"selection.options[3]" options:nil];
}
// this method is how you would manually set the value if you did not use binding:
-(void)updateTriviaAnswer
{
    triviaAnswer = [triviaController valueForKeyPath:@"selection.answer"];
}
-(IBAction)changeTriviaQuestion:(id)sender
{
    if (triviaController.selectionIndex >= [(NSArray*)triviaController.content count] - 1)
        [triviaController setSelectionIndex:0];
    else
        [triviaController setSelectionIndex:(triviaController.selectionIndex + 1)];
}
-(IBAction)showTriviaWindow:(id)sender
{
    [TriviaWindow makeKeyAndOrderFront:sender];
}
- (IBAction)TriviaOptionChosen:(id)sender
{
    // tag integers 0 through 3 are assigned to the option buttons in Interface Builder
    if ([sender tag] == triviaAnswer.integerValue)
        [self changeTriviaQuestion:sender];
    else
        NSBeep();
}
@end

Summary of Sequence

NSObjectController *stringsController = [[NSObjectController alloc] initWithContent:[NSDictionary dictionaryWithContentsOfFile:[[NSBundle mainBundle] pathForResource:@"en" ofType:@"plist"]]];
DelvingArrayController *triviaController = [DelvingArrayController new];
[triviaController bind:@"contentArray" toObject:stringsController withKeyPath:@"selection.trivia" options:nil];
NSNumber *triviaAnswer = @0;
[self bind:@"triviaAnswer" toObject:triviaController withKeyPath:@"selection.answer" options:nil];
// bind to .xib's interface elements after the nib has finished loading, else the IBOutlets are null
[TriviaQuestionField bind:@"string" toObject:triviaController withKeyPath:@"selection.question" options:nil];
[TriviaOption1 bind:@"title" toObject:triviaController withKeyPath:@"selection.options[0]" options:nil];
[TriviaOption2 bind:@"title" toObject:triviaController withKeyPath:@"selection.options[1]" options:nil];
[TriviaOption3 bind:@"title" toObject:triviaController withKeyPath:@"selection.options[2]" options:nil];
[TriviaOption4 bind:@"title" toObject:triviaController withKeyPath:@"selection.options[3]" options:nil];
// when the user chooses the correct option, go to the next question
if ([sender tag] == triviaAnswer.integerValue)
{
    if (triviaController.selectionIndex >= [(NSArray*)triviaController.content count] - 1)
        [triviaController setSelectionIndex:0];
    else
        [triviaController setSelectionIndex:(triviaController.selectionIndex + 1)];
}

Upvotes: 0

Alex
Alex

Reputation: 26859

Unfortunately, no. The full documentation for what's allowed using Key-Value Coding is here. There are not, to my knowledge, any operators that allow you to grab a particular array or set object.

Upvotes: 20

Graham Perks
Graham Perks

Reputation: 23390

You can intercept the keypath in the object holding the NSArray.

In your case the keypath would become Placemark0.address... Override valueForUndefinedKey; look for the index in the keypath; something like this:

-(id)valueForUndefinedKey:(NSString *)key
{
    // Handle paths like Placemark0, Placemark1, ...
    if ([key hasPrefix:@"Placemark"])
    {
        // Caller wants to access the Placemark array.
        // Find the array index they're after.
        NSString *indexString = [key stringByReplacingOccurrencesOfString:@"Placemark" withString:@""];
        NSInteger index = [indexString integerValue];

        // Return array element.
        if (index < self.placemarks.count)
            return self.placemarks[index];
    }

    return [super valueForUndefinedKey:key];
}

This works really well for model frameworks e.g. Mantle.

Upvotes: 3

psy
psy

Reputation: 2809

Here's a category I just wrote for NSObject that can handle array indexes so you can access a nested object like this: "person.friends[0].name"

@interface NSObject (ValueForKeyPathWithIndexes)
   -(id)valueForKeyPathWithIndexes:(NSString*)fullPath;
@end


#import "NSObject+ValueForKeyPathWithIndexes.h"    
@implementation NSObject (ValueForKeyPathWithIndexes)

-(id)valueForKeyPathWithIndexes:(NSString*)fullPath
{
    NSRange testrange = [fullPath rangeOfString:@"["];
    if (testrange.location == NSNotFound)
        return [self valueForKeyPath:fullPath];

    NSArray* parts = [fullPath componentsSeparatedByString:@"."];
    id currentObj = self;
    for (NSString* part in parts)
    {
        NSRange range1 = [part rangeOfString:@"["];
        if (range1.location == NSNotFound)          
        {
            currentObj = [currentObj valueForKey:part];
        }
        else
        {
            NSString* arrayKey = [part substringToIndex:range1.location];
            int index = [[[part substringToIndex:part.length-1] substringFromIndex:range1.location+1] intValue];
            currentObj = [[currentObj valueForKey:arrayKey] objectAtIndex:index];
        }
    }
    return currentObj;
}
@end

Use it like so

NSString* personsFriendsName = [obj valueForKeyPathsWithIndexes:@"me.friends[0].name"];

There's no error checking, so it's prone to breaking but you get the idea.

Upvotes: 19

Related Questions