// // DCRoundSwitch.m // // Created by Patrick Richards on 28/06/11. // MIT License. // // http://twitter.com/patr // http://domesticcat.com.au/projects // http://github.com/domesticcatsoftware/DCRoundSwitch // #import "DCRoundSwitch.h" #import "DCRoundSwitchToggleLayer.h" #import "DCRoundSwitchOutlineLayer.h" #import "DCRoundSwitchKnobLayer.h" @interface DCRoundSwitch () @property(nonatomic, strong) DCRoundSwitchOutlineLayer *outlineLayer; @property(nonatomic, strong) DCRoundSwitchToggleLayer *toggleLayer; @property(nonatomic, strong) DCRoundSwitchKnobLayer *knobLayer; @property(nonatomic, strong) CAShapeLayer *clipLayer; @property (nonatomic, assign) BOOL ignoreTap; - (void)setup; - (void)useLayerMasking; - (void)removeLayerMask; - (void)positionLayersAndMask; @end @implementation DCRoundSwitch @synthesize outlineLayer, toggleLayer, knobLayer, clipLayer, ignoreTap; @synthesize on, onText, offText; @synthesize onTintColor; #pragma mark - #pragma mark Init & Memory Managment - (id)init { if ((self = [super init])) { self.frame = CGRectMake(0, 0, 77, 27); [self setup]; } return self; } - (id)initWithCoder:(NSCoder *)aDecoder { if ((self = [super initWithCoder:aDecoder])) { [self setup]; } return self; } - (id)initWithFrame:(CGRect)frame { if ((self = [super initWithFrame:frame])) { [self setup]; } return self; } + (Class)knobLayerClass { return [DCRoundSwitchKnobLayer class]; } + (Class)outlineLayerClass { return [DCRoundSwitchOutlineLayer class]; } + (Class)toggleLayerClass { return [DCRoundSwitchToggleLayer class]; } - (void)setup { // this way you can set the background color to black or something similar so it can be seen in IB self.backgroundColor = [UIColor clearColor]; // remove the flexible width/height autoresizing masks if they have been set UIViewAutoresizing mask = (int)self.autoresizingMask; if (mask & UIViewAutoresizingFlexibleHeight) self.autoresizingMask ^= UIViewAutoresizingFlexibleHeight; if (mask & UIViewAutoresizingFlexibleWidth) self.autoresizingMask ^= UIViewAutoresizingFlexibleWidth; // setup default texts NSBundle *uiKitBundle = [NSBundle bundleWithIdentifier:@"com.apple.UIKit"]; self.onText = uiKitBundle ? [uiKitBundle localizedStringForKey:@"ON" value:nil table:nil] : @"ON"; self.offText = uiKitBundle ? [uiKitBundle localizedStringForKey:@"OFF" value:nil table:nil] : @"OFF"; // the switch has three layers, (ordered from bottom to top): // // * toggleLayer * (bottom of the layer stack) // this layer contains the onTintColor (blue by default), the text, and the shadown for the knob. the knob shadow is // on this layer because it needs to go under the outlineLayer so it doesn't bleed out over the edge of the control. // this layer moves when the switch moves // * outlineLayer * (middle of the layer stack) // this is the outline of the control, it's inner shadow, and the inner gloss. the inner shadow is on this layer // because it must stay still while the switch animates. the inner gloss is also here because it doesn't move, and also // because it needs to go uner the knobLayer. // this layer appears to always stay in the same spot. // * knobLayer * (top of the layer stack) // this is the knob, and sits on top of the layer stack. note that the knob shadow is NOT drawn here, it is drawn on the // toggleLayer so it doesn't bleed out over the outlineLayer. self.toggleLayer = [[[[self class] toggleLayerClass] alloc] initWithOnString:self.onText offString:self.offText onTintColor:[UIColor colorWithRed:0.000 green:0.478 blue:0.882 alpha:1.0]]; self.toggleLayer.drawOnTint = NO; self.toggleLayer.clip = YES; [self.layer addSublayer:self.toggleLayer]; [self.toggleLayer setNeedsDisplay]; self.outlineLayer = [[[self class] outlineLayerClass] layer]; [self.toggleLayer addSublayer:self.outlineLayer]; [self.outlineLayer setNeedsDisplay]; self.knobLayer = [[[self class] knobLayerClass] layer]; [self.layer addSublayer:self.knobLayer]; [self.knobLayer setNeedsDisplay]; self.toggleLayer.contentsScale = self.outlineLayer.contentsScale = self.knobLayer.contentsScale = [[UIScreen mainScreen] scale]; // tap gesture for toggling the switch UITapGestureRecognizer *tapGestureRecognizer = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(tapped:)]; [tapGestureRecognizer setDelegate:self]; [self addGestureRecognizer:tapGestureRecognizer]; // pan gesture for moving the switch knob manually UIPanGestureRecognizer *panGestureRecognizer = [[UIPanGestureRecognizer alloc] initWithTarget:self action:@selector(toggleDragged:)]; [panGestureRecognizer setDelegate:self]; [self addGestureRecognizer:panGestureRecognizer]; [self setNeedsLayout]; // setup the layer positions [self positionLayersAndMask]; } #pragma mark - #pragma mark Setup Frame/Layout - (void)sizeToFit { [super sizeToFit]; NSString *onString = self.toggleLayer.onString; NSString *offString = self.toggleLayer.offString; CGFloat width = [onString sizeWithFont:self.toggleLayer.labelFont].width; CGFloat offWidth = [offString sizeWithFont:self.toggleLayer.labelFont].width; if(offWidth > width) width = offWidth; width += self.toggleLayer.bounds.size.width * 2.;//add 2x the knob for padding CGRect newFrame = self.frame; CGFloat currentWidth = newFrame.size.width; newFrame.size.width = width; newFrame.origin.x += currentWidth - width; self.frame = newFrame; //old values for sizeToFit; keep these around for reference // newFrame.size.width = 77.0; // newFrame.size.height = 27.0; } - (void)useLayerMasking { // turn of the manual clipping (done in toggleLayer's drawInContext:) self.toggleLayer.clip = NO; self.toggleLayer.drawOnTint = YES; [self.toggleLayer setNeedsDisplay]; // create the layer mask and add that to the toggleLayer self.clipLayer = [CAShapeLayer layer]; UIBezierPath *clipPath = [UIBezierPath bezierPathWithRoundedRect:self.bounds cornerRadius:self.bounds.size.height / 2.0]; self.clipLayer.path = clipPath.CGPath; self.toggleLayer.mask = self.clipLayer; } - (void)removeLayerMask { // turn off the animations so the user doesn't see the changing of mask/clipping [CATransaction setValue:(id)kCFBooleanTrue forKey:kCATransactionDisableActions]; // remove the layer mask (put on in useLayerMasking) self.toggleLayer.mask = nil; // renable manual clipping (done in toggleLayer's drawInContext:) self.toggleLayer.clip = YES; self.toggleLayer.drawOnTint = self.on; [self.toggleLayer setNeedsDisplay]; } - (void)positionLayersAndMask { // repositions the underlying toggle and the layer mask, plus the knob self.toggleLayer.mask.position = CGPointMake(-self.toggleLayer.frame.origin.x, 0.0); self.outlineLayer.frame = CGRectMake(-self.toggleLayer.frame.origin.x, 0, self.bounds.size.width, self.bounds.size.height); self.knobLayer.frame = CGRectMake(self.toggleLayer.frame.origin.x + self.toggleLayer.frame.size.width / 2.0 - self.knobLayer.frame.size.width / 2.0, -1, self.knobLayer.frame.size.width, self.knobLayer.frame.size.height); } #pragma mark - #pragma mark Interaction - (void)tapped:(UITapGestureRecognizer *)gesture { if (self.ignoreTap) return; if (gesture.state == UIGestureRecognizerStateEnded) [self setOn:!self.on animated:YES]; } - (void)toggleDragged:(UIPanGestureRecognizer *)gesture { CGFloat minToggleX = -self.toggleLayer.frame.size.width / 2.0 + self.toggleLayer.frame.size.height / 2.0; CGFloat maxToggleX = -1; if (gesture.state == UIGestureRecognizerStateBegan) { // setup by turning off the manual clipping of the toggleLayer and setting up a layer mask. [CATransaction setValue:(id)kCFBooleanTrue forKey:kCATransactionDisableActions]; [self useLayerMasking]; [self positionLayersAndMask]; self.knobLayer.gripped = YES; } else if (gesture.state == UIGestureRecognizerStateChanged) { CGPoint translation = [gesture translationInView:self]; // disable the animations before moving the layers [CATransaction setValue:(id)kCFBooleanTrue forKey:kCATransactionDisableActions]; // darken the knob if (!self.knobLayer.gripped) self.knobLayer.gripped = YES; // move the toggleLayer using the translation of the gesture, keeping it inside the outline. CGFloat newX = self.toggleLayer.frame.origin.x + translation.x; if (newX < minToggleX) newX = minToggleX; if (newX > maxToggleX) newX = maxToggleX; self.toggleLayer.frame = CGRectMake(newX, self.toggleLayer.frame.origin.y, self.toggleLayer.frame.size.width, self.toggleLayer.frame.size.height); // this will re-position the layer mask and knob [self positionLayersAndMask]; [gesture setTranslation:CGPointZero inView:self]; } else if (gesture.state == UIGestureRecognizerStateEnded) { // flip the switch to on or off depending on which half it ends at CGFloat toggleCenter = CGRectGetMidX(self.toggleLayer.frame); [self setOn:(toggleCenter > CGRectGetMidX(self.bounds)) animated:YES]; } // send off the appropriate actions (not fully tested yet) CGPoint locationOfTouch = [gesture locationInView:self]; if (CGRectContainsPoint(self.bounds, locationOfTouch)) [self sendActionsForControlEvents:UIControlEventTouchDragInside]; else [self sendActionsForControlEvents:UIControlEventTouchDragOutside]; } - (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event { if (self.ignoreTap) return; [super touchesBegan:touches withEvent:event]; self.knobLayer.gripped = YES; [self sendActionsForControlEvents:UIControlEventTouchDown]; } - (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event { [super touchesEnded:touches withEvent:event]; [self sendActionsForControlEvents:UIControlEventTouchUpInside]; } - (void)touchesCancelled:(NSSet *)touches withEvent:(UIEvent *)event { [super touchesCancelled:touches withEvent:event]; [self sendActionsForControlEvents:UIControlEventTouchUpOutside]; } #pragma mark UIGestureRecognizerDelegate - (BOOL)gestureRecognizerShouldBegin:(UIGestureRecognizer *)gestureRecognizer; { return !self.ignoreTap; } #pragma mark Setters/Getters - (void)setOn:(BOOL)newOn { [self setOn:newOn animated:NO]; } - (void)setOn:(BOOL)newOn animated:(BOOL)animated { [self setOn:newOn animated:animated ignoreControlEvents:NO]; } - (void)setOn:(BOOL)newOn animated:(BOOL)animated ignoreControlEvents:(BOOL)ignoreControlEvents { BOOL previousOn = self.on; on = newOn; self.ignoreTap = YES; [CATransaction setAnimationDuration:0.014]; self.knobLayer.gripped = YES; // setup by turning off the manual clipping of the toggleLayer and setting up a layer mask. [self useLayerMasking]; [self positionLayersAndMask]; // retain all our targets so they don't disappear before the actions get sent at the end of the animation [CATransaction setCompletionBlock:^{ [CATransaction begin]; if (!animated) [CATransaction setValue:(id)kCFBooleanTrue forKey:kCATransactionDisableActions]; else [CATransaction setValue:(id)kCFBooleanFalse forKey:kCATransactionDisableActions]; CGFloat minToggleX = -self.toggleLayer.frame.size.width / 2.0 + self.toggleLayer.frame.size.height / 2.0; CGFloat maxToggleX = -1; if (self.on) { self.toggleLayer.frame = CGRectMake(maxToggleX, self.toggleLayer.frame.origin.y, self.toggleLayer.frame.size.width, self.toggleLayer.frame.size.height); } else { self.toggleLayer.frame = CGRectMake(minToggleX, self.toggleLayer.frame.origin.y, self.toggleLayer.frame.size.width, self.toggleLayer.frame.size.height); } if (!self.toggleLayer.mask) { [self useLayerMasking]; [self.toggleLayer setNeedsDisplay]; } [self positionLayersAndMask]; self.knobLayer.gripped = NO; [CATransaction setCompletionBlock:^{ [self removeLayerMask]; self.ignoreTap = NO; // send the action here so it get's sent at the end of the animations if (previousOn != on && !ignoreControlEvents) [self sendActionsForControlEvents:UIControlEventValueChanged]; }]; [CATransaction commit]; }]; } - (void)setOnTintColor:(UIColor *)anOnTintColor { if (anOnTintColor != onTintColor) { onTintColor = anOnTintColor; self.toggleLayer.onTintColor = anOnTintColor; [self.toggleLayer setNeedsDisplay]; } } - (void)layoutSubviews; { CGFloat knobRadius = self.bounds.size.height + 2.0; self.knobLayer.frame = CGRectMake(0, 0, knobRadius, knobRadius); CGSize toggleSize = CGSizeMake(self.bounds.size.width * 2 - (knobRadius - 4), self.bounds.size.height); CGFloat minToggleX = -toggleSize.width / 2.0 + knobRadius / 2.0 - 1; CGFloat maxToggleX = -1; if (self.on) { self.toggleLayer.frame = CGRectMake(maxToggleX, self.toggleLayer.frame.origin.y, toggleSize.width, toggleSize.height); } else { self.toggleLayer.frame = CGRectMake(minToggleX, self.toggleLayer.frame.origin.y, toggleSize.width, toggleSize.height); } [self positionLayersAndMask]; } - (void)setOnText:(NSString *)newOnText { if (newOnText != onText) { onText = [newOnText copy]; self.toggleLayer.onString = onText; [self.toggleLayer setNeedsDisplay]; } } - (void)setOffText:(NSString *)newOffText { if (newOffText != offText) { offText = [newOffText copy]; self.toggleLayer.offString = offText; [self.toggleLayer setNeedsDisplay]; } } @end