Getting array elements with valueForKeyPath

自闭症网瘾萝莉.ら 提交于 2019-11-27 13:34:52
Alex

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.

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.

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.

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.

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)];
}
易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!