Reputation: 7703
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
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
Reputation: 182
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.
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 NSString
s 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.
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:
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
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
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
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