TGPControls/TGPControls/TGPDiscreteSlider7.m

651 lines
24 KiB
Objective-C

// @file: TGPDiscreteSlider7.m
// @project: TGPControls
//
// @history: Created July 4th, 2014 (Independence Day)
// @author: Xavier Schott
// mailto://xschott@gmail.com
// http://thegothicparty.com
// tel://+18089383634
//
// @license: http://opensource.org/licenses/MIT
// Copyright (c) 2014, Xavier Schott
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.
#import "TGPDiscreteSlider7.h"
static CGFloat iOSThumbShadowRadius = 4.0;
static CGSize iosThumbShadowOffset = (CGSize){0, 3};
@interface TGPDiscreteSlider7 () {
int _intValue;
int _intMinimumValue;
}
@property (nonatomic) NSMutableArray * ticksAbscisses;
@property (nonatomic, assign) CGFloat thumbAbscisse;
@property (nonatomic) CALayer * thumbLayer;
@property (nonatomic) CALayer * colorTrackLayer;
@property (nonatomic) CGRect trackRectangle;
@property (nonatomic) BOOL touchedInside;
@end
@implementation TGPDiscreteSlider7
#pragma mark properties
- (void)setTickStyle:(ComponentStyle)tickStyle {
_tickStyle = tickStyle;
[self layoutTrack];
}
- (void)setTickSize:(CGSize)tickSize {
_tickSize.width = MAX(0, tickSize.width);
_tickSize.height = MAX(0, tickSize.height);
[self layoutTrack];
}
- (void)setTickCount:(int)tickCount {
_tickCount = MAX(2, tickCount);
[self layoutTrack];
}
- (CGFloat)ticksDistance {
NSAssert1(self.tickCount > 1, @"2 ticks minimum %d", self.tickCount);
const unsigned int segments = MAX(1, self.tickCount - 1);
return (self.trackRectangle.size.width / segments);
}
- (void)setTickImage:(NSString *)tickImage {
_tickImage = tickImage;
[self layoutTrack];
}
- (void)setTrackStyle:(ComponentStyle)trackStyle {
_trackStyle = trackStyle;
[self layoutTrack];
}
- (void)setTrackThickness:(CGFloat)trackThickness {
_trackThickness = MAX(0, trackThickness);
[self layoutTrack];
}
- (void)setTrackImage:(NSString *)trackImage {
_trackImage = trackImage;
[self layoutTrack];
}
- (void)setThumbStyle:(ComponentStyle)thumbStyle {
_thumbStyle = thumbStyle;
[self layoutTrack];
}
- (void)setThumbSize:(CGSize)thumbSize {
_thumbSize.width = MAX(1, thumbSize.width);
_thumbSize.height = MAX(1, thumbSize.height);
[self layoutTrack];
}
- (void)setThumbImage:(NSString *)thumbImage {
_thumbImage = thumbImage;
// Associate image to layer
NSString * imageName = self.thumbImage;
if(imageName.length > 0) {
UIImage * image = [UIImage imageNamed:imageName]; //[NSBundle bundleForClass:[self class]]
self.thumbLayer.contents = (id)image.CGImage;
}
[self layoutTrack];
}
- (void)setThumbShadowRadius:(CGFloat)thumbShadowRadius {
_thumbShadowRadius = thumbShadowRadius;
[self layoutTrack];
}
- (void)setTicksListener:(NSObject<TGPControlsTicksProtocol> *)ticksListener {
_ticksListener = ticksListener;
[self.ticksListener tgpTicksDistanceChanged:self.ticksDistance sender:self];
}
- (void)setIncrementValue:(int)incrementValue {
_incrementValue = incrementValue;
if(0 == incrementValue) {
_incrementValue = 1; // nonZeroIncrement
}
[self layoutTrack];
}
// AKA: UISlider value (as CGFloat for compatibility with UISlider API, but expected to contain integers)
- (void)setMinimumValue:(CGFloat)minimumValue {
_intMinimumValue = minimumValue;
[self layoutTrack];
}
- (CGFloat)minimumValue {
return _intMinimumValue; // calculated property, with a float-to-int adapter
}
- (void)setValue:(CGFloat)value {
const unsigned int nonZeroIncrement = ((0 == _incrementValue) ? 1 : _incrementValue);
const int rootValue = ((value - self.minimumValue) / nonZeroIncrement);
_intValue = self.minimumValue + (int)(rootValue * nonZeroIncrement);
[self layoutTrack];
}
- (CGFloat)value {
return _intValue; // calculated property, with a float-to-int adapter
}
// When bounds change, recalculate layout
- (void)setBounds:(CGRect)bounds
{
[super setBounds:bounds];
[self layoutTrack];
[self setNeedsDisplay];
}
#pragma mark UIControl
- (id)initWithCoder:(NSCoder *)aDecoder {
self = [super initWithCoder:aDecoder];
if(self != nil) {
[self initProperties];
}
return self;
}
- (instancetype)initWithFrame:(CGRect)frame {
self = [super initWithFrame:frame];
if(self != nil) {
[self initProperties];
}
return self;
}
- (void)drawRect:(CGRect)rect {
[self drawTrack];
[self drawThumb];
}
- (void)sendActionsForControlEvents {
// Automatic UIControlEventValueChanged notification
if([self.ticksListener respondsToSelector:@selector(tgpValueChanged:)]) {
[self.ticksListener tgpValueChanged:self.value];
}
}
#pragma mark TGPDiscreteSlider7
- (void)initProperties {
_tickStyle = ComponentStyleRectangular;
_tickSize = (CGSize) {1.0, 4.0};
_tickCount = 11;
_trackStyle = ComponentStyleIOS;
_trackThickness = 2.0;
_thumbStyle = ComponentStyleIOS;
_thumbSize = (CGSize) {10.0, 10.0};
_thumbColor = [UIColor lightGrayColor];
_thumbShadowRadius = 0.0;
_thumbShadowOffset = CGSizeZero;
_intMinimumValue = -5;
_incrementValue = 1;
_intValue = 0;
_ticksAbscisses = [NSMutableArray array];
_thumbAbscisse = 0.0;
_trackRectangle = CGRectZero;
// In case we need a colored track, initialize it now
// There may be a more elegant way to do this than with a CALayer,
// but then again CALayer brings free animation and will animate along the thumb
_colorTrackLayer = [CALayer layer];
_colorTrackLayer.backgroundColor = [[UIColor colorWithHue:211.0/360.0 saturation:1 brightness:1 alpha:1] CGColor];
_colorTrackLayer.cornerRadius = 2.0;
[self.layer addSublayer:self.colorTrackLayer];
// The thumb is its own CALayer, which brings in free animation
_thumbLayer = [CALayer layer];
[self.layer addSublayer:self.thumbLayer];
self.multipleTouchEnabled = NO;
[self layoutTrack];
}
- (void)drawTrack {
const CGContextRef ctx = UIGraphicsGetCurrentContext();
// Track
switch(self.trackStyle) {
case ComponentStyleRectangular:
CGContextAddRect(ctx, self.trackRectangle);
break;
case ComponentStyleInvisible:
// Nothing to draw
break;
case ComponentStyleImage: {
// Draw image if exists
NSString * imageName = self.trackImage;
if(imageName.length > 0) {
UIImage * image = [UIImage imageNamed:imageName]; //[NSBundle bundleForClass:[self class]]
if(image) {
CGRect centered = CGRectMake((self.frame.size.width/2) - (image.size.width/2),
(self.frame.size.height/2) - (image.size.height/2),
image.size.width,
image.size.height);
CGContextDrawImage(ctx, centered, image.CGImage);
}
}
break;
}
case ComponentStyleRounded:
case ComponentStyleIOS:
default: {
UIBezierPath * path = [UIBezierPath bezierPathWithRoundedRect:self.trackRectangle
cornerRadius:self.trackRectangle.size.height/2];
CGContextAddPath(ctx, [path CGPath]) ;
break;
}
}
// Ticks
if(ComponentStyleIOS != self.tickStyle) {
NSAssert(nil != self.ticksAbscisses, @"ticksAbscisses");
if(nil != self.ticksAbscisses) {
for(NSValue * originValue in self.ticksAbscisses) {
CGPoint originPoint = [originValue CGPointValue];
CGRect rectangle = CGRectMake(originPoint.x-(self.tickSize.width/2),
originPoint.y-(self.tickSize.height/2),
self.tickSize.width, self.tickSize.height);
switch(self.tickStyle) {
case ComponentStyleRounded: {
UIBezierPath * path = [UIBezierPath bezierPathWithRoundedRect:rectangle
cornerRadius:rectangle.size.height/2];
CGContextAddPath(ctx, [path CGPath]) ;
break;
}
case ComponentStyleRectangular:
CGContextAddRect(ctx, rectangle);
break;
case ComponentStyleImage: {
// Draw image if exists
NSString * imageName = self.tickImage;
if(imageName.length > 0) {
UIImage * image = [UIImage imageNamed:imageName]; //[NSBundle bundleForClass:[self class]]
if(image) {
CGRect centered = CGRectMake(rectangle.origin.x + (rectangle.size.width/2) - (image.size.width/2),
rectangle.origin.y + (rectangle.size.height/2) - (image.size.height/2),
image.size.width,
image.size.height);
CGContextDrawImage(ctx, centered, image.CGImage);
}
}
break;
}
case ComponentStyleInvisible:
case ComponentStyleIOS:
default:
// Nothing to draw
break;
}
}
}
} // iOS UISlider aka ComponentStyleIOS does not have ticks
CGContextSetFillColorWithColor(ctx, [self.tintColor CGColor]);
CGContextFillPath(ctx);
// For colored track, we overlay a CALayer, which will animate along with the cursor
if(ComponentStyleIOS == self.trackStyle) {
self.colorTrackLayer.frame = ({
CGRect frame = self.trackRectangle;
frame.size.width = self.thumbAbscisse - CGRectGetMinX(self.trackRectangle);
frame;
});
} else {
self.colorTrackLayer.frame = CGRectZero;
}
}
- (void)drawThumb {
if( self.value >= self.minimumValue) { // Feature: hide the thumb when below range
const CGSize thumbSizeForStyle = [self thumbSizeIncludingShadow];
const CGFloat thumbWidth = thumbSizeForStyle.width;
const CGFloat thumbHeight = thumbSizeForStyle.height;
const CGRect rectangle = CGRectMake(self.thumbAbscisse - (thumbWidth / 2),
(self.frame.size.height - thumbHeight)/2,
thumbWidth,
thumbHeight);
const CGFloat shadowRadius = ((self.thumbStyle == ComponentStyleIOS)
? iOSThumbShadowRadius
: self.thumbShadowRadius);
const CGSize shadowOffset = ((self.thumbStyle == ComponentStyleIOS)
? iosThumbShadowOffset
: self.thumbShadowOffset);
self.thumbLayer.frame = ((shadowRadius != 0.0) // Ignore offset if there is no shadow
? CGRectInset(rectangle,
shadowRadius + shadowOffset.width,
shadowRadius + shadowOffset.height)
: CGRectInset(rectangle, shadowRadius, shadowRadius));
switch(self.thumbStyle) {
case ComponentStyleRounded: // A rounded thumb is circular
self.thumbLayer.backgroundColor = [self.thumbColor CGColor];
self.thumbLayer.borderColor = [[UIColor clearColor] CGColor];
self.thumbLayer.borderWidth = 0.0;
self.thumbLayer.cornerRadius = self.thumbLayer.frame.size.width/2;
self.thumbLayer.allowsEdgeAntialiasing = YES;
break;
case ComponentStyleImage: {
// image is set using layer.contents
self.thumbLayer.backgroundColor = [[UIColor clearColor] CGColor];
self.thumbLayer.borderColor = [[UIColor clearColor] CGColor];
self.thumbLayer.borderWidth = 0.0;
self.thumbLayer.cornerRadius = 0.0;
self.thumbLayer.allowsEdgeAntialiasing = NO;
break;
}
case ComponentStyleRectangular:
self.thumbLayer.backgroundColor = [self.thumbColor CGColor];
self.thumbLayer.borderColor = [[UIColor clearColor] CGColor];
self.thumbLayer.borderWidth = 0.0;
self.thumbLayer.cornerRadius = 0.0;
self.thumbLayer.allowsEdgeAntialiasing = NO;
break;
case ComponentStyleInvisible:
self.thumbLayer.backgroundColor = [[UIColor clearColor] CGColor];
self.thumbLayer.cornerRadius = 0.0;
break;
case ComponentStyleIOS:
default:
self.thumbLayer.backgroundColor = [[UIColor whiteColor] CGColor];
self.thumbLayer.borderColor = [[UIColor colorWithHue:0 saturation: 0 brightness: 0.8 alpha: 1]
CGColor];
self.thumbLayer.borderWidth = 0.5;
self.thumbLayer.cornerRadius = self.thumbLayer.frame.size.width/2;
self.thumbLayer.allowsEdgeAntialiasing = YES;
break;
}
// Shadow
if(shadowRadius != 0.0) {
#if TARGET_INTERFACE_BUILDER
self.thumbLayer.shadowOffset = CGSizeMake(shadowOffset.width, -shadowOffset.height);
#else // !TARGET_INTERFACE_BUILDER
self.thumbLayer.shadowOffset = shadowOffset;
#endif // TARGET_INTERFACE_BUILDER
self.thumbLayer.shadowRadius = shadowRadius;
self.thumbLayer.shadowColor = [[UIColor blackColor] CGColor];
self.thumbLayer.shadowOpacity = 0.15;
} else {
self.thumbLayer.shadowRadius = 0.0;
self.thumbLayer.shadowOffset = CGSizeZero;
self.thumbLayer.shadowColor = [[UIColor clearColor] CGColor];
self.thumbLayer.shadowOpacity = 0.0;
}
}
}
- (void)layoutTrack {
NSAssert1(self.tickCount > 1, @"2 ticks minimum %d", self.tickCount);
const unsigned int segments = MAX(1, self.tickCount - 1);
const CGFloat thumbWidth = [self thumbSizeIncludingShadow].width;
// Calculate the track ticks positions
const CGFloat trackHeight = ((ComponentStyleIOS == self.trackStyle)
? 2.0
: self.trackThickness);
CGSize trackSize = CGSizeMake(self.frame.size.width - thumbWidth, trackHeight);
if(ComponentStyleImage == self.trackStyle) {
NSString * imageName = self.trackImage;
if(imageName.length > 0) {
UIImage * image = [UIImage imageNamed:imageName]; //[NSBundle bundleForClass:[self class]]
if(image) {
trackSize.width = image.size.width - thumbWidth;
}
}
}
self.trackRectangle = CGRectMake((self.frame.size.width - trackSize.width)/2,
(self.frame.size.height - trackSize.height)/2,
trackSize.width,
trackSize.height);
const CGFloat trackY = self.frame.size.height / 2;
[self.ticksAbscisses removeAllObjects];
for( int iterate = 0; iterate <= segments; iterate++) {
const double ratio = (double)iterate / (double)segments;
const CGFloat originX = self.trackRectangle.origin.x + (CGFloat)(trackSize.width * ratio);
[self.ticksAbscisses addObject: [NSValue valueWithCGPoint:CGPointMake(originX, trackY)]];
}
[self layoutThumb];
// If we have a TGPDiscreteSliderTicksListener (such as TGPCamelLabels), broadcast new spacing
[self.ticksListener tgpTicksDistanceChanged:self.ticksDistance sender:self];
}
- (void)layoutThumb {
NSAssert1(self.tickCount > 1, @"2 ticks minimum %d", self.tickCount);
const unsigned int segments = MAX(1, self.tickCount - 1);
// Calculate the thumb position
const unsigned int nonZeroIncrement = ((0 == self.incrementValue) ? 1 : self.incrementValue);
double thumbRatio = (double)(self.value - self.minimumValue) / (double)(segments * nonZeroIncrement);
thumbRatio = MAX(0.0, MIN(thumbRatio, 1.0)); // Normalized
self.thumbAbscisse = self.trackRectangle.origin.x + (self.trackRectangle.size.width * thumbRatio);
}
- (CGSize)thumbSizeIncludingShadow {
switch (self.thumbStyle) {
case ComponentStyleInvisible:
case ComponentStyleRectangular:
case ComponentStyleRounded:
return ((self.thumbShadowRadius != 0.0)
? CGSizeMake(self.thumbSize.width
+ (self.thumbShadowRadius * 2)
+ (self.thumbShadowOffset.width * 2),
self.thumbSize.height
+ (self.thumbShadowRadius * 2)
+ (self.thumbShadowOffset.height * 2))
: self.thumbSize);
case ComponentStyleIOS:
return CGSizeMake(33.0
+ (iOSThumbShadowRadius * 2)
+ (iosThumbShadowOffset.width * 2),
33.0
+ (iOSThumbShadowRadius * 2)
+ (iosThumbShadowOffset.height * 2));
case ComponentStyleImage: {
NSString * imageName = self.thumbImage;
if (imageName.length > 0) {
return [UIImage imageNamed:imageName].size;
}
// Fall through
}
default:
return (CGSize){33.0, 33.0};
}
}
#pragma mark UIResponder
- (void)touchesBegan:(NSSet *)touches withEvent:(UIEvent *)event {
self.touchedInside = YES;
[self touchDown:touches animationDuration:0.1];
[self sendActionForControlEvent:UIControlEventValueChanged forEvent:event];
[self sendActionForControlEvent:UIControlEventTouchDown forEvent:event];
const UITouch * touch = [touches anyObject];
if(nil != touch) {
if(touch.tapCount > 1) {
[self sendActionForControlEvent:UIControlEventTouchDownRepeat forEvent:event];
}
}
}
- (void)touchesMoved:(NSSet *)touches withEvent:(UIEvent *)event {
[self touchDown:touches animationDuration:0.0];
BOOL inside = [self touchesAreInside:touches];
[self sendActionForControlEvent:UIControlEventValueChanged forEvent:event];
// Crossing boundary
if(inside != self.touchedInside) {
[self sendActionForControlEvent:((inside)
? UIControlEventTouchDragEnter
: UIControlEventTouchDragExit)
forEvent:event];
self.touchedInside = inside;
}
// Drag
[self sendActionForControlEvent:((inside)
? UIControlEventTouchDragInside
: UIControlEventTouchDragOutside)
forEvent:event];
}
- (void)touchesEnded:(NSSet *)touches withEvent:(UIEvent *)event {
[self touchUp:touches];
[self sendActionForControlEvent:UIControlEventValueChanged forEvent:event];
[self sendActionForControlEvent:(([self touchesAreInside:touches])
? UIControlEventTouchUpInside
: UIControlEventTouchUpOutside)
forEvent:event];
}
- (void)touchesCancelled:(NSSet *)touches withEvent:(UIEvent *)event {
[self touchUp:touches];
[self sendActionForControlEvent:UIControlEventValueChanged forEvent:event];
[self sendActionForControlEvent:UIControlEventTouchCancel forEvent:event];
}
#pragma mark Touches
- (void)touchDown:(NSSet *)touches animationDuration:(NSTimeInterval)duration {
const UITouch * touch = [touches anyObject];
if(nil != touch) {
const CGPoint location = [touch locationInView:touch.view];
[self moveThumbTo:location.x animationDuration:duration];
}
}
- (void)touchUp:(NSSet *)touches {
const UITouch * touch = [touches anyObject];
if(nil != touch) {
const CGPoint location = [touch locationInView:touch.view];
const unsigned int tick = [self pickTickFromSliderPosition:location.x];
[self moveThumbToTick:tick];
}
}
- (BOOL)touchesAreInside:(NSSet *)touches {
BOOL inside = NO;
const UITouch * touch = [touches anyObject];
if(nil != touch) {
const CGPoint location = [touch locationInView:touch.view];
inside = CGRectContainsPoint(touch.view.bounds, location);
}
return inside;
}
#pragma mark Notifications
- (void)moveThumbToTick:(unsigned int)tick {
const unsigned int nonZeroIncrement = ((0 == self.incrementValue) ? 1 : self.incrementValue);
int intValue = self.minimumValue + (tick * nonZeroIncrement);
if( intValue != _intValue) {
_intValue = intValue;
[self sendActionsForControlEvents];
}
[self layoutThumb];
[self setNeedsDisplay];
}
- (void)moveThumbTo:(CGFloat)abscisse animationDuration:(CFTimeInterval)duration {
const CGFloat leftMost = CGRectGetMinX(self.trackRectangle);
const CGFloat rightMost = CGRectGetMaxX(self.trackRectangle);
self.thumbAbscisse = MAX(leftMost, MIN(abscisse, rightMost));
[CATransaction setAnimationDuration:duration];
const unsigned int tick = [self pickTickFromSliderPosition:self.thumbAbscisse];
const unsigned int nonZeroIncrement = ((0 == self.incrementValue) ? 1 : self.incrementValue);
int intValue = self.minimumValue + (tick * nonZeroIncrement);
if( intValue != _intValue) {
_intValue = intValue;
[self sendActionsForControlEvents];
}
[self setNeedsDisplay];
}
- (unsigned int)pickTickFromSliderPosition:(CGFloat)abscisse {
const CGFloat leftMost = CGRectGetMinX(self.trackRectangle);
const CGFloat rightMost = CGRectGetMaxX(self.trackRectangle);
const CGFloat clampedAbscisse = MAX(leftMost, MIN(abscisse, rightMost));
const double ratio = (double)(clampedAbscisse - leftMost) / (double)(rightMost - leftMost);
const unsigned int segments = MAX(1, self.tickCount - 1);
return (unsigned int) round( (double)segments * ratio);
}
- (void)sendActionForControlEvent:(UIControlEvents)controlEvent forEvent:(UIEvent *)event {
// Interface builder hides the IBInspectable for UIControl
#if !TARGET_INTERFACE_BUILDER
for (id target in self.allTargets) {
NSArray *actions = [self actionsForTarget:target forControlEvent:controlEvent];
for (NSString *action in actions) {
[self sendAction:NSSelectorFromString(action)
to:target
forEvent:event];
}
}
#endif // !TARGET_INTERFACE_BUILDER
}
#pragma mark - Interface Builder
#if TARGET_INTERFACE_BUILDER
// Interface builder hides the IBInspectable for UIControl
- (void)addTarget:(id)target action:(SEL)action forControlEvents:(UIControlEvents)controlEvents {}
#endif // TARGET_INTERFACE_BUILDER
@end