// // KBSlider.m // KBSlider // // Created by Kevin Bradley on 12/25/20. // Copyright © 2020 nito. All rights reserved. // #import "KBSlider.h" #import @interface KBSlider() { CGFloat _minimumValue; CGFloat _maximumValue; UIColor *_maximumTrackTintColor; UIColor *_minimumTrackTintColor; UIColor *_thumbTintColor; CGFloat _focusScaleFactor; BOOL _continuous; BOOL _isEnabled; BOOL _isSelected; BOOL _isHighlighted; } @property CGFloat trackViewHeight; @property CGFloat thumbSize; @property NSTimeInterval animationDuration; @property CGFloat defaultValue; @property CGFloat defaultMinimumValue; @property CGFloat defaultMaximumValue; @property BOOL defaultIsContinuous; @property UIColor *defaultThumbTintColor; @property UIColor *defaultTrackColor; @property UIColor *defaultMininumTrackTintColor; @property CGFloat defaultFocusScaleFactor; @property CGFloat defaultStepValue; @property CGFloat decelerationRate; @property CGFloat decelerationMaxVelocity; @property CGFloat fineTunningVelocityThreshold; @property NSMutableDictionary *thumbViewImages; //[UInt: UIImage] - not an allowed dict type in obj-c @property UIImageView *thumbView; @property NSMutableDictionary *trackViewImages; //[UInt: UIImage] - not an allowed dict type in obj-c @property UIImageView *trackView; @property NSMutableDictionary *minimumTrackViewImages; //[UInt: UIImage] - not an allowed dict type in obj-c @property UIImageView *minimumTrackView; @property NSMutableDictionary *maximumTrackViewImages; //[UInt: UIImage] - not an allowed dict type in obj-c @property UIImageView *maximumTrackView; @property UIPanGestureRecognizer *panGestureRecognizer; @property UITapGestureRecognizer *leftTapGestureRecognizer; @property UITapGestureRecognizer *rightTapGestureRecognizer; @property NSLayoutConstraint *thumbViewCenterXConstraint; @property DPadState dPadState; //.select @property NSTimer *deceleratingTimer; @property CGFloat deceleratingVelocity; @property CGFloat thumbViewCenterXConstraintConstant; @end @implementation KBSlider - (void)initializeDefaults { _trackViewHeight = 5; _thumbSize = 30; _animationDuration = 0.3; _defaultValue = 0; _defaultMinimumValue = 0; _defaultMaximumValue = 1; _defaultIsContinuous = true; _defaultThumbTintColor = [UIColor whiteColor]; _defaultTrackColor = [UIColor grayColor]; _defaultMininumTrackTintColor = [UIColor blueColor]; _defaultFocusScaleFactor = 1.05; _defaultStepValue = 0.1; _decelerationRate = 0.92; _decelerationMaxVelocity = 1000; _fineTunningVelocityThreshold = 600; _storedValue = _defaultValue; _dPadState = DPadStateSelect; _continuous = _defaultIsContinuous; _minimumTrackViewImages = [NSMutableDictionary new]; _maximumTrackViewImages = [NSMutableDictionary new]; _trackViewImages = [NSMutableDictionary new]; _thumbViewImages = [NSMutableDictionary new]; _thumbTintColor = _defaultThumbTintColor; _minimumTrackTintColor = _defaultMininumTrackTintColor; _focusScaleFactor = _defaultFocusScaleFactor; _minimumValue = _defaultMinimumValue; _maximumValue = _defaultMaximumValue; _stepValue = _defaultStepValue; [self setEnabled:true]; } - (BOOL)isContinuous { return _continuous; } - (void)setContinuous:(BOOL)continuous { _continuous = continuous; } - (void)setSelected:(BOOL)selected { _isSelected = selected; [self updateStateDependantViews]; } - (BOOL)isSelected { return _isSelected; } - (void)setHighlighted:(BOOL)highlighted { _isHighlighted = highlighted; [self updateStateDependantViews]; } - (BOOL)isHighlighted { return _isHighlighted; } - (void)setEnabled:(BOOL)enabled { _isEnabled = enabled; _panGestureRecognizer.enabled = enabled; [self updateStateDependantViews]; } - (BOOL)isEnabled { return _isEnabled; } - (CGFloat)value { return _storedValue; } - (void)setValue:(CGFloat)value afterDelay:(NSInteger)delay { dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(delay * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{ [self setValue:value]; }); } - (void)setValue:(CGFloat)newValue { _storedValue = MIN(_maximumValue, newValue); _storedValue = MAX(_minimumValue, _storedValue); if (_trackView.bounds.size.width == 0){ [self setValue:newValue afterDelay:0.1]; } CGFloat offset = _trackView.bounds.size.width * (_storedValue - _minimumValue) / (_maximumValue - _minimumValue); offset = MIN(_trackView.bounds.size.width, offset); if(isnan(offset)){ return; } _thumbViewCenterXConstraint.constant = offset; } - (CGFloat)maximumValue { return _maximumValue; } - (void)setMaximumValue:(CGFloat)maximumValue { _maximumValue = maximumValue; [self setValue:MIN(self.value, maximumValue)]; } - (CGFloat)minimumValue { return _minimumValue; } - (void)setMinimumValue:(CGFloat)minimumValue { _minimumValue = minimumValue; [self setValue:MAX(self.value, minimumValue)]; } - (UIColor *)maximumTrackTintColor { return _maximumTrackTintColor; } - (void)setMaximumTrackTintColor:(UIColor *)maximumTrackTintColor { _maximumTrackTintColor = maximumTrackTintColor; _maximumTrackView.backgroundColor = maximumTrackTintColor; } - (void)setMinimumTrackTintColor:(UIColor *)minimumTrackTintColor { _minimumTrackTintColor = minimumTrackTintColor; _minimumTrackView.backgroundColor = minimumTrackTintColor; } - (UIColor *)thumbTintColor { return _thumbTintColor; } - (void)setThumbTintColor:(UIColor *)thumbTintColor { _thumbTintColor = thumbTintColor; _thumbView.backgroundColor = thumbTintColor; } - (UIColor *)minimumTrackTintColor { return _minimumTrackTintColor; } - (CGFloat)focusScaleFactor { return _focusScaleFactor; } - (void)setFocusScaleFactor:(CGFloat)focusScaleFactor { _focusScaleFactor = focusScaleFactor; [self updateStateDependantViews]; } - (void)setupView { [self initializeDefaults]; [self setUpTrackView]; [self setUpMinimumTrackView]; [self setUpMaximumTrackView]; [self setUpThumbView]; [self setUpTrackViewConstraints]; [self setUpMinimumTrackViewConstraints]; [self setUpMaximumTrackViewConstraints]; [self setUpThumbViewConstraints]; [self setUpGestures]; [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(controllerConnected:) name:GCControllerDidConnectNotification object:nil]; [self updateStateDependantViews]; } - (void)setValue:(CGFloat)value animated:(BOOL)animated { [self setValue:value]; [self stopDeceleratingTimer]; if (animated){ [UIView animateWithDuration:self.animationDuration animations:^{ [self setNeedsLayout]; [self layoutIfNeeded]; }]; } } - (void)setMinimumTrackImage:(UIImage *)image forState:(UIControlState)state { _minimumTrackViewImages[[NSNumber numberWithUnsignedInteger:state]] = image; [self updateStateDependantViews]; } - (void)setMaximumTrackImage:(UIImage *)image forState:(UIControlState)state { _maximumTrackViewImages[[NSNumber numberWithUnsignedInteger:state]] = image; [self updateStateDependantViews]; } - (void)setThumbImage:(UIImage *)image forState:(UIControlState)state { _thumbViewImages[[NSNumber numberWithUnsignedInteger:state]] = image; [self updateStateDependantViews]; } - (UIImage *)currentThumbImage { return _thumbView.image; } - (UIImage *)minimumTrackImageForState:(UIControlState)state { NSNumber *key = [NSNumber numberWithUnsignedInteger:state]; return _minimumTrackViewImages[key]; } - (UIImage *)maximumTrackImageForState:(UIControlState)state { NSNumber *key = [NSNumber numberWithUnsignedInteger:state]; return _maximumTrackViewImages[key]; } - (UIImage *)thumbImageForState:(UIControlState)state { NSNumber *key = [NSNumber numberWithUnsignedInteger:state]; return _thumbViewImages[key]; } - (void)setUpThumbView { _thumbView = [UIImageView new]; _thumbView.layer.cornerRadius = _thumbSize/2; _thumbView.backgroundColor = _thumbTintColor; [self addSubview:_thumbView]; } - (void)setUpTrackView { _trackView = [UIImageView new]; _trackView.layer.cornerRadius = _trackViewHeight/2; _trackView.backgroundColor = _defaultTrackColor; [self addSubview:_trackView]; } - (void)setUpMinimumTrackView { _minimumTrackView = [UIImageView new]; _minimumTrackView.layer.cornerRadius = _trackViewHeight/2; _minimumTrackView.backgroundColor = _minimumTrackTintColor; [self addSubview:_minimumTrackView]; } - (void)setUpMaximumTrackView { _maximumTrackView = [UIImageView new]; _maximumTrackView.layer.cornerRadius = _trackViewHeight/2; _maximumTrackView.backgroundColor = _maximumTrackTintColor; [self addSubview:_maximumTrackView]; } - (void)setUpTrackViewConstraints { _trackView.translatesAutoresizingMaskIntoConstraints = false; [_trackView.leadingAnchor constraintEqualToAnchor:self.leadingAnchor].active = true; [_trackView.trailingAnchor constraintEqualToAnchor:self.trailingAnchor].active = true; [_trackView.centerYAnchor constraintEqualToAnchor:self.centerYAnchor].active = true; [_trackView.heightAnchor constraintEqualToConstant:_trackViewHeight].active = true; } - (void)setUpMinimumTrackViewConstraints { _minimumTrackView.translatesAutoresizingMaskIntoConstraints = false; [_minimumTrackView.leadingAnchor constraintEqualToAnchor:_trackView.leadingAnchor].active = true; [_minimumTrackView.trailingAnchor constraintEqualToAnchor:_thumbView.centerXAnchor].active = true; [_minimumTrackView.centerYAnchor constraintEqualToAnchor:_trackView.centerYAnchor].active = true; [_minimumTrackView.heightAnchor constraintEqualToConstant:_trackViewHeight].active = true; } - (void)setUpMaximumTrackViewConstraints { _maximumTrackView.translatesAutoresizingMaskIntoConstraints = false; [_maximumTrackView.leadingAnchor constraintEqualToAnchor:_thumbView.centerXAnchor].active = true; [_maximumTrackView.trailingAnchor constraintEqualToAnchor:_trackView.trailingAnchor].active = true; [_maximumTrackView.centerYAnchor constraintEqualToAnchor:_trackView.centerYAnchor].active = true; [_maximumTrackView.heightAnchor constraintEqualToConstant:_trackViewHeight].active = true; } - (void)setUpThumbViewConstraints { _thumbView.translatesAutoresizingMaskIntoConstraints = false; [_thumbView.centerYAnchor constraintEqualToAnchor:self.centerYAnchor].active = true; [_thumbView.heightAnchor constraintEqualToConstant:_thumbSize].active = true; [_thumbView.widthAnchor constraintEqualToConstant:_thumbSize].active = true; _thumbViewCenterXConstraint = [_thumbView.centerXAnchor constraintEqualToAnchor:_trackView.leadingAnchor constant:self.value]; _thumbViewCenterXConstraint.active = true; } - (void)setUpGestures { _panGestureRecognizer = [[UIPanGestureRecognizer alloc] initWithTarget:self action:@selector(panGestureWasTriggered:)]; [self addGestureRecognizer:_panGestureRecognizer]; _leftTapGestureRecognizer = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(leftTapWasTriggered)]; _leftTapGestureRecognizer.allowedPressTypes = @[@(UIPressTypeLeftArrow)]; _leftTapGestureRecognizer.allowedTouchTypes = @[@(UITouchTypeIndirect)]; [self addGestureRecognizer:_leftTapGestureRecognizer]; _rightTapGestureRecognizer = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(rightTapWasTriggered)]; _rightTapGestureRecognizer.allowedPressTypes = @[@(UIPressTypeRightArrow)]; _rightTapGestureRecognizer.allowedTouchTypes = @[@(UITouchTypeIndirect)]; [self addGestureRecognizer:_rightTapGestureRecognizer]; } - (void)updateStateDependantViews { UIImage *currentMinImage = _minimumTrackViewImages[[NSNumber numberWithUnsignedInteger:self.state]]; if (currentMinImage){ _minimumTrackView.image = currentMinImage; } else { _minimumTrackView.image = _minimumTrackViewImages[[NSNumber numberWithUnsignedInteger:UIControlStateNormal]]; } UIImage *currentMaxImage = _maximumTrackViewImages[[NSNumber numberWithUnsignedInteger:self.state]]; if (currentMaxImage){ _maximumTrackView.image = currentMaxImage; } else { _maximumTrackView.image = _maximumTrackViewImages[[NSNumber numberWithUnsignedInteger:UIControlStateNormal]]; } UIImage *currentThumbImage = _thumbViewImages[[NSNumber numberWithUnsignedInteger:self.state]]; if (currentThumbImage){ _thumbView.image = currentThumbImage; } else { _thumbView.image = _thumbViewImages[[NSNumber numberWithUnsignedInteger:UIControlStateNormal]]; } if ([self isFocused]){ self.transform = CGAffineTransformMakeScale(_focusScaleFactor, _focusScaleFactor); } else { self.transform = CGAffineTransformIdentity; } } - (void)controllerConnected:(NSNotification *)n { GCController *controller = [n object]; GCMicroGamepad *micro = [controller microGamepad]; if (!micro)return; CGFloat threshold = 0.7; micro.reportsAbsoluteDpadValues = true; micro.dpad.valueChangedHandler = ^(GCControllerDirectionPad * _Nonnull dpad, float xValue, float yValue) { if (xValue < -threshold){ self.dPadState = DPadStateLeft; } else if (xValue > threshold){ self.dPadState = DPadStateRight; } else { self.dPadState = DPadStateSelect; } }; } - (void)handleDeceleratingTimer:(NSTimer *)timer { CGFloat centerX = _thumbViewCenterXConstraintConstant + _deceleratingVelocity * 0.01; CGFloat percent = centerX / (_trackView.frame.size.width); CGFloat newValue = _minimumValue + ((_maximumValue - _minimumValue) * percent); [self setValue:newValue]; if ([self isContinuous]){ [self sendActionsForControlEvents:UIControlEventValueChanged]; } _thumbViewCenterXConstraintConstant = _thumbViewCenterXConstraint.constant; _deceleratingVelocity *= _decelerationRate; if (![self isFocused] || fabs(_deceleratingVelocity) < 1){ [self stopDeceleratingTimer]; } } - (void)stopDeceleratingTimer { [_deceleratingTimer invalidate]; _deceleratingTimer = nil; _deceleratingVelocity = 0; [self sendActionsForControlEvents:UIControlEventValueChanged]; } - (BOOL)isVerticalGesture:(UIPanGestureRecognizer *)recognizer { CGPoint translation = [recognizer translationInView:self]; if (fabs(translation.y) > fabs(translation.x)) { return true; } return false; } #pragma mark - Actions - (void)panGestureWasTriggered:(UIPanGestureRecognizer *)panGestureRecognizer { if ([self isVerticalGesture:panGestureRecognizer]){ return; } CGFloat translation = [panGestureRecognizer translationInView:self].x; CGFloat velocity = [panGestureRecognizer velocityInView:self].x; switch(panGestureRecognizer.state){ case UIGestureRecognizerStateBegan: [self stopDeceleratingTimer]; _thumbViewCenterXConstraintConstant = _thumbViewCenterXConstraint.constant; break; case UIGestureRecognizerStateChanged:{ CGFloat centerX = _thumbViewCenterXConstraintConstant + translation / 5; CGFloat percent = centerX / _trackView.frame.size.width; CGFloat newValue = _minimumValue + ((_maximumValue - _minimumValue) * percent); [self setValue:newValue]; if ([self isContinuous]){ [self sendActionsForControlEvents:UIControlEventValueChanged]; } } break; case UIGestureRecognizerStateEnded: case UIGestureRecognizerStateCancelled: _thumbViewCenterXConstraintConstant = _thumbViewCenterXConstraint.constant; if (fabs(velocity) > _fineTunningVelocityThreshold){ CGFloat direction = velocity > 0 ? 1 : -1; _deceleratingVelocity = fabs(velocity) > _decelerationMaxVelocity ? _decelerationMaxVelocity * direction : velocity; _deceleratingTimer = [NSTimer scheduledTimerWithTimeInterval:0.01 target:self selector:@selector(handleDeceleratingTimer:) userInfo:nil repeats:true]; } else { [self stopDeceleratingTimer]; } break; default: break; } } - (void)leftTapWasTriggered { CGFloat newValue = [self value]-_stepValue; [self setValue:newValue]; } - (void)rightTapWasTriggered { CGFloat newValue = [self value]+_stepValue; [self setValue:newValue]; } - (void)pressesBegan:(NSSet *)presses withEvent:(UIPressesEvent *)event { for (UIPress *press in presses){ switch (press.type) { case UIPressTypeSelect: if(_dPadState == DPadStateLeft){ _panGestureRecognizer.enabled = false; [self leftTapWasTriggered]; } else if (_dPadState == DPadStateRight){ _panGestureRecognizer.enabled = false; [self rightTapWasTriggered]; } else { _panGestureRecognizer.enabled = false; } break; default: break; } } _panGestureRecognizer.enabled = true; [super pressesBegan:presses withEvent:event]; } - (void)didUpdateFocusInContext:(UIFocusUpdateContext *)context withAnimationCoordinator:(UIFocusAnimationCoordinator *)coordinator { [coordinator addCoordinatedAnimations:^{ [self updateStateDependantViews]; } completion:nil]; } #pragma mark - Initializers - (id)initWithCoder:(NSCoder *)coder { self = [super initWithCoder:coder]; [self setupView]; return self; } - (id)initWithFrame:(CGRect)frame { self = [super initWithFrame:frame]; [self setupView]; return self; } - (id)init { self = [super init]; [self setupView]; return self; } - (void)dealloc { [[NSNotificationCenter defaultCenter] removeObserver:self]; } @end