BindingsJoystick/JoystickView.m
/* |
File: JoystickView.m |
Abstract: View that represents a joystick allowing angle and offset to be manipulated graphically. |
Version: 2.0 |
Disclaimer: IMPORTANT: This Apple software is supplied to you by Apple |
Inc. ("Apple") in consideration of your agreement to the following |
terms, and your use, installation, modification or redistribution of |
this Apple software constitutes acceptance of these terms. If you do |
not agree with these terms, please do not use, install, modify or |
redistribute this Apple software. |
In consideration of your agreement to abide by the following terms, and |
subject to these terms, Apple grants you a personal, non-exclusive |
license, under Apple's copyrights in this original Apple software (the |
"Apple Software"), to use, reproduce, modify and redistribute the Apple |
Software, with or without modifications, in source and/or binary forms; |
provided that if you redistribute the Apple Software in its entirety and |
without modifications, you must retain this notice and the following |
text and disclaimers in all such redistributions of the Apple Software. |
Neither the name, trademarks, service marks or logos of Apple Inc. may |
be used to endorse or promote products derived from the Apple Software |
without specific prior written permission from Apple. Except as |
expressly stated in this notice, no other rights or licenses, express or |
implied, are granted by Apple herein, including but not limited to any |
patent rights that may be infringed by your derivative works or by other |
works in which the Apple Software may be incorporated. |
The Apple Software is provided by Apple on an "AS IS" basis. APPLE |
MAKES NO WARRANTIES, EXPRESS OR IMPLIED, INCLUDING WITHOUT LIMITATION |
THE IMPLIED WARRANTIES OF NON-INFRINGEMENT, MERCHANTABILITY AND FITNESS |
FOR A PARTICULAR PURPOSE, REGARDING THE APPLE SOFTWARE OR ITS USE AND |
OPERATION ALONE OR IN COMBINATION WITH YOUR PRODUCTS. |
IN NO EVENT SHALL APPLE BE LIABLE FOR ANY SPECIAL, INDIRECT, INCIDENTAL |
OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF |
SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS |
INTERRUPTION) ARISING IN ANY WAY OUT OF THE USE, REPRODUCTION, |
MODIFICATION AND/OR DISTRIBUTION OF THE APPLE SOFTWARE, HOWEVER CAUSED |
AND WHETHER UNDER THEORY OF CONTRACT, TORT (INCLUDING NEGLIGENCE), |
STRICT LIABILITY OR OTHERWISE, EVEN IF APPLE HAS BEEN ADVISED OF THE |
POSSIBILITY OF SUCH DAMAGE. |
Copyright (C) 2012 Apple Inc. All Rights Reserved. |
*/ |
#import "JoystickView.h" |
@interface JoystickView () |
-(void)updateForMouseEvent:(NSEvent *)event; |
- (void)keyDown:(NSEvent *)event; |
- (void)updateXOffset:(float) xOffset yOffset:(float) yOffset withEvent:(NSEvent *)event; |
- (NSString *)angleValueTransformerName; |
- (BOOL)allowsMultipleSelectionForAngle; |
- (BOOL)allowsMultipleSelectionForOffset; |
- (id)observedObjectForAngle; |
- (NSString *)observedKeyPathForAngle; |
- (id)observedObjectForOffset; |
- (NSString *)observedKeyPathForOffset; |
@end |
@implementation JoystickView |
{ |
BOOL badSelectionForAngle, |
badSelectionForOffset, |
multipleSelectionForAngle, |
multipleSelectionForOffset; |
NSMutableDictionary *bindingInfo; |
BOOL mouseDown; |
} |
static char AngleObservationContext; |
static char OffsetObservationContext; |
#define ANGLE_BINDING_NAME @"angle" |
#define OFFSET_BINDING_NAME @"offset" |
- (id)initWithFrame:(NSRect)frameRect |
{ |
self = [super initWithFrame:frameRect]; |
if (self) |
{ |
_maxOffset = 15.0; |
_offset = 0.0; |
_angle = 28.0; |
multipleSelectionForAngle = NO; |
multipleSelectionForOffset = NO; |
bindingInfo = [[NSMutableDictionary alloc] init]; |
} |
return self; |
} |
- (void)bind:(NSString *)bindingName toObject:(id)observableController withKeyPath:(NSString *)keyPath options:(NSDictionary *)options |
{ |
if ([bindingName isEqualToString:ANGLE_BINDING_NAME]) |
{ |
if ([bindingInfo objectForKey:ANGLE_BINDING_NAME] != nil) |
{ |
[self unbind:ANGLE_BINDING_NAME]; |
} |
/* |
Observe the controller for changes -- note, pass binding identifier as the context, so we get that back in observeValueForKeyPath:... -- that way we can determine what needs to be updated. |
*/ |
[observableController addObserver:self forKeyPath:keyPath options:0 context:&AngleObservationContext]; |
NSDictionary *bindingsData = @{ NSObservedObjectKey:observableController, NSObservedKeyPathKey:[keyPath copy], NSOptionsKey:[options copy] }; |
[bindingInfo setObject:bindingsData forKey:ANGLE_BINDING_NAME]; |
} |
else |
{ |
if ([bindingName isEqualToString:OFFSET_BINDING_NAME]) |
{ |
if ([bindingInfo objectForKey:OFFSET_BINDING_NAME] != nil) |
{ |
[self unbind:OFFSET_BINDING_NAME]; |
} |
[observableController addObserver:self forKeyPath:keyPath options:0 context:&OffsetObservationContext]; |
NSDictionary *bindingsData = @{NSObservedObjectKey:observableController, NSObservedKeyPathKey:[keyPath copy], NSOptionsKey:[options copy] }; |
[bindingInfo setObject:bindingsData forKey:OFFSET_BINDING_NAME]; |
} |
else |
{ |
[super bind:bindingName toObject:observableController withKeyPath:keyPath options:options]; |
} |
} |
[self setNeedsDisplay:YES]; |
} |
- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary *)change context:(void *)context |
{ |
/* |
We passed a context when we added ourselves as an observer -- use that to decide what to update... should ask the dictionary for the value... |
*/ |
if (context == &AngleObservationContext) |
{ |
// Angle changed |
/* |
If we got a NSNoSelectionMarker or NSNotApplicableMarker, or if we got a NSMultipleValuesMarker and we don't allow multiple selections then note we have a bad angle. |
*/ |
id newAngle = [object valueForKeyPath:keyPath]; |
if ((newAngle == NSNoSelectionMarker) || (newAngle == NSNotApplicableMarker) |
|| ((newAngle == NSMultipleValuesMarker) && ![self allowsMultipleSelectionForAngle])) |
{ |
badSelectionForAngle = YES; |
} |
else |
{ |
/* |
Note we have a good selection. |
If we got a NSMultipleValuesMarker, note it but don't update value. |
*/ |
badSelectionForAngle = NO; |
if (newAngle == NSMultipleValuesMarker) |
{ |
multipleSelectionForAngle = YES; |
} |
else |
{ |
multipleSelectionForAngle = NO; |
NSString *angleValueTransformerName = [self angleValueTransformerName]; |
if (angleValueTransformerName != nil) |
{ |
NSValueTransformer *valueTransformer = |
[NSValueTransformer valueTransformerForName:angleValueTransformerName]; |
newAngle = [valueTransformer transformedValue:newAngle]; |
} |
[self setValue:newAngle forKey:ANGLE_BINDING_NAME]; |
} |
} |
} |
if (context == &OffsetObservationContext) |
{ |
// Offset changed. |
/* |
If we got a NSNoSelectionMarker or NSNotApplicableMarker, or if we got a NSMultipleValuesMarker and we don't allow multiple selections then note we have a bad selection. |
*/ |
id newOffset = [object valueForKeyPath:keyPath]; |
if ((newOffset == NSNoSelectionMarker) || (newOffset == NSNotApplicableMarker) |
|| ((newOffset == NSMultipleValuesMarker) && ![self allowsMultipleSelectionForOffset])) |
{ |
badSelectionForOffset = YES; |
} |
else |
{ |
// Note we have a good selection |
/* |
If we got a NSMultipleValuesMarker, note it but don't update value. |
*/ |
badSelectionForOffset = NO; |
if (newOffset == NSMultipleValuesMarker) |
{ |
multipleSelectionForOffset = YES; |
} |
else |
{ |
[self setValue:newOffset forKey:OFFSET_BINDING_NAME]; |
multipleSelectionForOffset = NO; |
} |
} |
} |
[self setNeedsDisplay:YES]; |
} |
- (void)unbind:bindingName |
{ |
if ([bindingName isEqualToString:ANGLE_BINDING_NAME]) |
{ |
id observedObjectForAngle = [self observedObjectForAngle]; |
NSString *observedKeyPathForAngle = [self observedKeyPathForAngle]; |
[observedObjectForAngle removeObserver:self forKeyPath:observedKeyPathForAngle]; |
[bindingInfo removeObjectForKey:ANGLE_BINDING_NAME]; |
} |
else |
{ |
if ([bindingName isEqualToString:OFFSET_BINDING_NAME]) |
{ |
id observedObjectForOffset = [self observedObjectForOffset]; |
NSString *observedKeyPathForOffset = [self observedKeyPathForOffset]; |
[observedObjectForOffset removeObserver:self forKeyPath:observedKeyPathForOffset]; |
[bindingInfo removeObjectForKey:OFFSET_BINDING_NAME]; |
} |
else |
{ |
[super unbind:bindingName]; |
} |
} |
[self setNeedsDisplay:YES]; |
} |
- (NSDictionary *)infoForBinding:(NSString *)bindingName |
{ |
NSDictionary *info = bindingInfo[bindingName]; |
if (info == nil) |
{ |
info = [super infoForBinding:bindingName]; |
} |
return info; |
} |
#pragma mark ---- accessing data from infoForBinding ---- |
/* |
Convenience methods to retrieve data from the infoForBinding dictionary |
*/ |
- (id)observedObjectForAngle |
{ |
return [self infoForBinding:ANGLE_BINDING_NAME][NSObservedObjectKey]; |
} |
- (NSString *)observedKeyPathForAngle |
{ |
return [self infoForBinding:ANGLE_BINDING_NAME][NSObservedKeyPathKey]; |
} |
- (id)observedObjectForOffset |
{ |
return [self infoForBinding:OFFSET_BINDING_NAME][NSObservedObjectKey]; |
} |
- (NSString *)observedKeyPathForOffset |
{ |
return [[self infoForBinding:OFFSET_BINDING_NAME] objectForKey:NSObservedKeyPathKey]; |
} |
- (NSString *)angleValueTransformerName |
{ |
NSDictionary *infoDictionary = [self infoForBinding:ANGLE_BINDING_NAME]; |
NSDictionary *optionsDictionary = infoDictionary[NSOptionsKey]; |
id name = optionsDictionary[NSValueTransformerNameBindingOption]; |
if ((name == [NSNull null]) || (name == nil)) |
{ |
return nil; |
} |
return (NSString *)name; |
} |
- (BOOL)allowsMultipleSelectionForAngle |
{ |
NSDictionary *options = [self infoForBinding:ANGLE_BINDING_NAME][NSOptionsKey]; |
NSNumber *allows = options[NSAllowsEditingMultipleValuesSelectionBindingOption]; |
return [allows boolValue]; |
} |
- (BOOL)allowsMultipleSelectionForOffset |
{ |
NSDictionary *options = [self infoForBinding:OFFSET_BINDING_NAME][NSOptionsKey]; |
NSNumber *allows = options[NSAllowsEditingMultipleValuesSelectionBindingOption]; |
return [allows boolValue]; |
} |
#pragma mark ---- responding to events ---- |
-(void)updateForMouseEvent:(NSEvent *)event |
{ |
// Update based on event location and selection state. |
if (badSelectionForAngle || badSelectionForOffset) |
{ |
// Don't do anything. |
return; |
} |
// Find out where the event is, offset from the view center. |
NSPoint p = [self convertPoint:[event locationInWindow] fromView:nil]; |
NSRect myBounds = [self bounds]; |
float xOffset = (p.x - (myBounds.size.width/2)); |
float yOffset = (p.y - (myBounds.size.height/2)); |
[self updateXOffset:xOffset yOffset:yOffset withEvent:event]; |
} |
- (void)keyDown:(NSEvent *)event |
{ |
float angleRadians = self.angle * (3.1415927/180.0); |
float x = sin(angleRadians) * self.offset; |
float y = cos(angleRadians) * self.offset; |
BOOL handled = NO; |
NSString *characters; |
// Get the pressed key. |
characters = [event charactersIgnoringModifiers]; |
// Is the "0" key pressed? |
if ([characters isEqualToString:@"0"]) |
{ |
x = 0; |
y = 0; |
handled = YES; |
} |
else |
{ |
unichar key = [characters characterAtIndex:0]; |
switch (key) |
{ |
case NSUpArrowFunctionKey : |
y += 1; |
handled = YES; |
break; |
case NSDownArrowFunctionKey : |
y -= 1; |
handled = YES; |
break; |
case NSLeftArrowFunctionKey : |
x -= 1; |
handled = YES; |
break; |
case NSRightArrowFunctionKey : |
x += 1; |
handled = YES; |
break; |
} |
} |
if (handled) |
{ |
[self updateXOffset:x yOffset:y withEvent:(NSEvent *)event]; |
} |
else |
{ |
[super keyDown:event]; |
} |
} |
- (void)updateXOffset:(float) xOffset yOffset:(float) yOffset withEvent:(NSEvent *)event |
{ |
float newOffset = hypot(xOffset, yOffset); |
if (newOffset > self.maxOffset) |
{ |
newOffset = self.maxOffset; |
} |
/* |
If we have a multiple selection for offset and Shift key is pressed then don't update the offset. |
This allows the offset to remain constant while the angle is changed. |
*/ |
if (!(multipleSelectionForOffset && ([event modifierFlags] & NSShiftKeyMask))) |
{ |
[self setOffset:newOffset]; |
// Update the observed controller, if it is set. |
if ([self observedObjectForOffset] != nil) |
{ |
[[self observedObjectForOffset] setValue:[NSNumber numberWithFloat:newOffset] forKeyPath:[self observedKeyPathForOffset]]; |
} |
} |
/* |
If we have a multiple selection for angle and Shift key is pressed then don't update the angle. |
This allows the angle to remain constant while the offset is changed. |
*/ |
if (!(multipleSelectionForAngle && ([event modifierFlags] & NSShiftKeyMask))) |
{ |
float newAngle = atan2(xOffset, yOffset); |
float newAngleDegrees = newAngle / (3.1415927/180.0); |
if (newAngleDegrees < 0) |
{ |
newAngleDegrees += 360; |
} |
[self setAngle:newAngleDegrees]; |
if (fabs(newAngle - self.angle) > 0.00001) |
{ |
// Update observed controller if set. |
if ([self observedObjectForAngle] != nil) |
{ |
NSNumber *newControllerAngle; |
// If there's a value transformer associated with the 'angle' binding, apply it to the value. |
if ([self angleValueTransformerName] != nil) |
{ |
NSValueTransformer *valueTransformer = [NSValueTransformer valueTransformerForName:[self angleValueTransformerName]]; |
newControllerAngle = (NSNumber *)[valueTransformer reverseTransformedValue:[NSNumber numberWithFloat:newAngleDegrees]]; |
} |
else |
{ |
newControllerAngle = [NSNumber numberWithFloat:self.angle]; |
} |
[[self observedObjectForAngle] setValue:newControllerAngle forKeyPath:[self observedKeyPathForAngle]]; |
} |
} |
} |
[self setNeedsDisplay:YES]; |
} |
/* |
For standard mouse events, invoke updateForMouseEvent: with the event. |
In the case of mouse down/up events, also record the down/up state. |
*/ |
-(void)mouseDown:(NSEvent *)event |
{ |
mouseDown = YES; |
[self updateForMouseEvent:event]; |
} |
-(void)mouseDragged:(NSEvent *)event |
{ |
[self updateForMouseEvent:event]; |
} |
-(void)mouseUp:(NSEvent *)event |
{ |
mouseDown = NO; |
[self updateForMouseEvent:event]; |
} |
- (BOOL)acceptsFirstMouse:(NSEvent *)theEvent |
{ |
return YES; |
} |
- (BOOL)acceptsFirstResponder |
{ |
return YES; |
} |
#pragma mark ---- drawing ---- |
- (void)drawRect:(NSRect)rect |
{ |
/* |
Basic goals: |
If either the angle or the offset has a "bad selection", then draw a gray rectangle, and that's it. |
Note: bad selection is set if there's a multiple selection but the "allows multiple selection" binding is NO. |
If there's a multiple selection for either angle or offset: then what you draw depends on what's multiple. |
- First, draw a white background to show all's OK. |
- If both are multiple, then draw a special symbol. |
- If offset is multiple, draw a line from the center of the view to the edge at the shared angle. |
- If angle is multiple, draw a circle of radius the shared offset centered in the view. |
If neither is multiple, draw a cross at the center of the view and a cross at distance 'offset' from the center at angle 'angle' |
*/ |
NSRect myBounds = [self bounds]; |
if (badSelectionForAngle || badSelectionForOffset) |
{ |
// "Disable" and exit. |
NSDrawDarkBezel(myBounds,myBounds); |
return; |
} |
/* |
The user can do something, so draw white background and clip in anticipation of future drawing. |
*/ |
NSDrawLightBezel(myBounds,myBounds); |
NSBezierPath *clipRect = |
[NSBezierPath bezierPathWithRect:NSInsetRect(myBounds,2.0,2.0)]; |
[clipRect addClip]; |
if (multipleSelectionForAngle || multipleSelectionForOffset) |
{ |
float originOffsetX = myBounds.size.width/2 + 0.5; |
float originOffsetY = myBounds.size.height/2 + 0.5; |
if (multipleSelectionForAngle && multipleSelectionForOffset) |
{ |
/* |
Draw a diagonal line and circle to denote multiple selections for angle and offset. |
*/ |
[NSBezierPath strokeLineFromPoint:NSMakePoint(0, 0) toPoint:NSMakePoint(myBounds.size.width, myBounds.size.height)]; |
NSRect circleBounds = NSMakeRect(originOffsetX-5, originOffsetY-5, 10, 10); |
NSBezierPath *path = [NSBezierPath bezierPathWithOvalInRect:circleBounds]; |
[path stroke]; |
return; |
} |
if (multipleSelectionForOffset) |
{ |
/* |
Draw a line from center to a point outside bounds in the direction specified by angle. |
*/ |
float angleRadians = self.angle * (3.1415927/180.0); |
float x = sin(angleRadians) * myBounds.size.width + originOffsetX; |
float y = cos(angleRadians) * myBounds.size.height + originOffsetX; |
[NSBezierPath strokeLineFromPoint:NSMakePoint(originOffsetX, originOffsetY) toPoint:NSMakePoint(x, y)]; |
return; |
} |
// |
if (multipleSelectionForAngle) |
{ |
/* |
Draw a circle with radius the shared offset don't draw radius < 1.0, else invisible. |
*/ |
float drawRadius = self.offset; |
if (drawRadius < 1.0) |
{ |
drawRadius = 1.0; |
} |
NSRect offsetBounds = NSMakeRect(originOffsetX-drawRadius, originOffsetY-drawRadius, drawRadius*2, drawRadius*2); |
NSBezierPath *path = [NSBezierPath bezierPathWithOvalInRect:offsetBounds]; |
[path stroke]; |
return; |
} |
// Shouldn't get here. |
return; |
} |
NSAffineTransform *transform = [NSAffineTransform transform]; |
[transform translateXBy:(myBounds.size.width/2 + 0.5) yBy:(myBounds.size.height/2 + 0.5)]; |
[transform concat]; |
NSBezierPath *path = [NSBezierPath bezierPath]; |
// Draw a "+" at the location to which the shadow extends. |
float angleRadians = self.angle * (3.1415927/180.0); |
float xOffset = sin(angleRadians) * self.offset; |
float yOffset = cos(angleRadians) * self.offset; |
[path moveToPoint:NSMakePoint(xOffset,yOffset-5)]; |
[path lineToPoint:NSMakePoint(xOffset,yOffset+5)]; |
[path moveToPoint:NSMakePoint(xOffset-5,yOffset)]; |
[path lineToPoint:NSMakePoint(xOffset+5,yOffset)]; |
[[NSColor lightGrayColor] set]; |
[path setLineWidth:1.5]; |
[path stroke]; |
// Draw + in center of view. |
path = [NSBezierPath bezierPath]; |
[path moveToPoint:NSMakePoint(0,-5)]; |
[path lineToPoint:NSMakePoint(0,5)]; |
[path moveToPoint:NSMakePoint(-5,0)]; |
[path lineToPoint:NSMakePoint(5,0)]; |
[[NSColor blackColor] set]; |
[path setLineWidth:1.0]; |
[path stroke]; |
} |
#pragma mark ---- accessor and accessor-related methods ---- |
- (void)setNilValueForKey:(NSString *)key |
{ |
/* |
We may get passed nil for angle or offset; Just use 0. |
*/ |
[self setValue:@0 forKey:key]; |
} |
-(BOOL)validateMaxOffset:(id *)ioValue error:(NSError **)outError |
{ |
if (*ioValue == nil) |
{ |
/* |
Trap this in setNilValueForKey; an alternative might be to create new NSNumber with value 0 here. |
*/ |
return YES; |
} |
if ([*ioValue floatValue] <= 0.0) |
{ |
NSString *errorString = |
NSLocalizedStringFromTable(@"Maximum Offset must be greater than zero", @"Joystick", @"validation: zero maxOffset error"); |
if (outError != NULL) { |
NSDictionary *userInfoDict = @{ NSLocalizedDescriptionKey:errorString }; |
NSError *error = [[NSError alloc] initWithDomain:@"JoystickView" code:1 userInfo:userInfoDict]; |
*outError = error; |
} |
return NO; |
} |
return YES; |
} |
#pragma mark ---- changing the view hierarchy ---- |
- (void)viewWillMoveToSuperview:(NSView *)newSuperview |
{ |
[super viewWillMoveToSuperview:newSuperview]; |
if (newSuperview == nil) |
{ |
[self unbind:ANGLE_BINDING_NAME]; |
[self unbind:OFFSET_BINDING_NAME]; |
} |
} |
@end |
Copyright © 2012 Apple Inc. All Rights Reserved. Terms of Use | Privacy Policy | Updated: 2012-04-09