blob: 52d0cace7c0023a22267d91791fde732dd658675 [file] [log] [blame] [edit]
// Copyright 2015-present the Material Components for iOS authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
#import "MDCThumbTrack.h"
#import "private/MDCThumbTrack+Private.h"
#import "MDCNumericValueLabel.h"
#import "MDCThumbView.h"
#import "MaterialAvailability.h"
#import "MaterialInk.h"
#import "MaterialMath.h"
#import "MaterialRipple.h"
#import "MaterialTypography.h"
#pragma mark - ThumbTrack constants
static const CGFloat kAnimationDuration = (CGFloat)0.25;
static const CGFloat kThumbChangeAnimationDuration = (CGFloat)0.12;
static const CGFloat kDefaultThumbBorderWidth = 2;
static const CGFloat kDefaultThumbRadius = 6;
static const CGFloat kDefaultTrackHeight = 2;
static const CGFloat kDefaultFilledTrackAnchorValue = -CGFLOAT_MAX;
static const CGFloat kTrackOnAlpha = (CGFloat)0.5;
static const CGFloat kMinTouchSize = 48;
static const CGFloat kThumbSlopFactor = (CGFloat)3.5;
static const CGFloat kValueLabelMinHeight = 48.0f;
static const CGFloat kValueLabelAspectRatio = 0.81f;
static const CGFloat kValueLabelThumbPadding = 4.0f;
static UIColor *ValueLabelTextColorDefault(void) { return UIColor.whiteColor; }
static UIColor *ValueLabelBackgroundColorDefault(void) { return UIColor.blueColor; }
static UIColor *TrackOnColorDefault(void) { return UIColor.blueColor; }
static UIColor *ThumbEnabledColorDefault(void) { return UIColor.blueColor; }
static UIColor *InkColorDefault(void) {
return [UIColor.blueColor colorWithAlphaComponent:kTrackOnAlpha];
}
static UIFont *ValueLabelFontDefault(void) {
return [[MDCTypography fontLoader] regularFontOfSize:12];
}
// TODO(iangordon): Properly handle broken tgmath
/**
Returns the distance between two points.
@param point1 a CGPoint to measure from.
@param point2 a CGPoint to meature to.
@return Absolute straight line distance.
*/
static inline CGFloat DistanceFromPointToPoint(CGPoint point1, CGPoint point2) {
return hypot(point1.x - point2.x, point1.y - point2.y);
}
#if MDC_AVAILABLE_SDK_IOS(10_0)
@interface MDCThumbTrack () <CAAnimationDelegate>
@end
#endif // MDC_AVAILABLE_SDK_IOS(10_0)
@interface MDCThumbTrack () <MDCInkTouchControllerDelegate>
@property(nonatomic, strong, nullable) UIColor *primaryColor;
@property(nonatomic, strong, nullable) MDCRippleView *rippleView;
@property(nonatomic, nonnull, readonly) UIPanGestureRecognizer *dummyPanRecognizer;
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wdeprecated-declarations"
@property(nonatomic, strong, nullable) MDCInkTouchController *touchController;
#pragma clang diagnostic pop
@end
@implementation MDCThumbTrack {
CGFloat _lastDispatchedValue;
UIColor *_clearColor;
UIView *_trackView;
CAShapeLayer *_trackMaskLayer;
CALayer *_trackOnLayer;
MDCDiscreteDotView *_discreteDots;
BOOL _shouldDisplayInk;
BOOL _shouldDisplayRipple;
MDCNumericValueLabel *_valueLabel;
// Attributes to handle interaction. To associate touches to previous touches, we keep a reference
// to the current touch, since the system reuses the same memory address when sending subsequent
// touches for the same gesture. If _currentTouch == nil, then there's no interaction going on.
UITouch *_currentTouch;
BOOL _isDraggingThumb;
BOOL _didChangeValueDuringPan;
CGFloat _panThumbGrabPosition;
}
@synthesize primaryColor = _primaryColor;
@synthesize thumbEnabledColor = _thumbEnabledColor;
@synthesize trackOnColor = _trackOnColor;
@synthesize touchController = _touchController;
// TODO(iangordon): ThumbView is not respecting the bounds of ThumbTrack
- (instancetype)initWithFrame:(CGRect)frame {
return [self initWithFrame:frame onTintColor:nil];
}
- (instancetype)initWithFrame:(CGRect)frame onTintColor:(UIColor *)onTintColor {
self = [super initWithFrame:frame];
if (self) {
self.userInteractionEnabled = YES;
[super setMultipleTouchEnabled:NO]; // We only want one touch event at a time
_allowAnimatedValueChanges = YES;
_continuousUpdateEvents = YES;
_lastDispatchedValue = _value;
_maximumValue = 1;
_trackHeight = kDefaultTrackHeight;
_thumbRadius = kDefaultThumbRadius;
_filledTrackAnchorValue = kDefaultFilledTrackAnchorValue;
_shouldDisplayInk = YES;
_shouldDisplayRipple = YES;
_discreteDotVisibility = MDCThumbDiscreteDotVisibilityNever;
_discrete = YES;
// Default thumb view.
CGFloat sideLength = MAX(_thumbRadius * 2, kMinTouchSize);
CGRect thumbFrame = CGRectMake(0, 0, sideLength * 2, sideLength * 2);
_thumbView = [[MDCThumbView alloc] initWithFrame:thumbFrame];
_thumbView.borderWidth = kDefaultThumbBorderWidth;
_thumbView.cornerRadius = self.thumbRadius;
_thumbView.layer.zPosition = 1;
_thumbView.centerVisibleArea = YES;
[self addSubview:_thumbView];
_trackView = [[UIView alloc] init];
_trackView.userInteractionEnabled = NO;
_trackMaskLayer = [CAShapeLayer layer];
_trackMaskLayer.fillRule = kCAFillRuleEvenOdd;
_trackView.layer.mask = _trackMaskLayer;
_trackOnLayer = [CALayer layer];
[_trackView.layer addSublayer:_trackOnLayer];
_trackView.layer.masksToBounds = YES;
[self addSubview:_trackView];
// Set up ink layer.
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wdeprecated-declarations"
_touchController = [[MDCInkTouchController alloc] initWithView:_thumbView];
#pragma clang diagnostic pop
_touchController.delegate = self;
[_touchController addInkView];
_touchController.defaultInkView.inkStyle = MDCInkStyleUnbounded;
// Set up ripple layer.
_rippleView = [[MDCRippleView alloc] init];
_rippleView.rippleStyle = MDCRippleStyleUnbounded;
_primaryColor = onTintColor ?: TrackOnColorDefault();
_thumbEnabledColor = onTintColor ?: ThumbEnabledColorDefault();
_trackOnColor = onTintColor ?: TrackOnColorDefault();
_valueLabelBackgroundColor = onTintColor ?: ValueLabelBackgroundColorDefault();
UIColor *rippleColor =
onTintColor ? [onTintColor colorWithAlphaComponent:kTrackOnAlpha] : InkColorDefault();
_touchController.defaultInkView.inkColor = rippleColor;
_rippleView.rippleColor = rippleColor;
_clearColor = UIColor.clearColor;
_valueLabelTextColor = ValueLabelTextColorDefault();
_trackOnTickColor = UIColor.blackColor;
_trackOffTickColor = UIColor.blackColor;
_discreteValueLabelFont = ValueLabelFontDefault();
[self setNeedsLayout];
// We add this UIPanGestureRecognizer to our view so that any superviews of the thumb track know
// when we are dragging the thumb track, and can treat it accordingly. Specifically, without
// this if a ThumbTrack is contained within a UIScrollView, the scroll view will cancel any
// touch events sent to the thumb track whenever the view is scrolling, regardless of whether or
// not we're in the middle of dragging the thumb. Adding a dummy gesture recognizer lets the
// scroll view know that we are in the middle of dragging, so those touch events shouldn't be
// cancelled.
//
// Note that an alternative to this would be to set canCancelContentTouches = NO on the
// UIScrollView, but because we can't guarantee that the thumb track will always be contained in
// scroll views configured like that, we have to handle it within the thumb track.
_dummyPanRecognizer = [[UIPanGestureRecognizer alloc] initWithTarget:self action:nil];
_dummyPanRecognizer.cancelsTouchesInView = NO;
[self updateDummyPanRecognizerTarget];
[self setValue:_minimumValue animated:NO];
}
return self;
}
- (void)layoutSubviews {
[super layoutSubviews];
[self updateTrackMask];
[self updateThumbTrackAnimated:NO animateThumbAfterMove:NO previousValue:_value completion:nil];
}
- (BOOL)pointInside:(CGPoint)point withEvent:(__unused UIEvent *)event {
CGFloat dx = MIN(0, kDefaultThumbRadius - kMinTouchSize / 2);
CGFloat dy = MIN(0, (self.bounds.size.height - kMinTouchSize) / 2);
CGRect rect = CGRectInset(self.bounds, dx, dy);
return CGRectContainsPoint(rect, point);
}
#pragma mark - Properties
- (void)setPrimaryColor:(UIColor *)primaryColor {
_primaryColor = primaryColor ?: TrackOnColorDefault();
_thumbEnabledColor = self.primaryColor;
_trackOnColor = self.primaryColor;
UIColor *rippleColor = [self.primaryColor colorWithAlphaComponent:kTrackOnAlpha];
_touchController.defaultInkView.inkColor = rippleColor;
_rippleView.rippleColor = rippleColor;
_valueLabelBackgroundColor = self.primaryColor;
[self setNeedsLayout];
}
- (void)setInkColor:(UIColor *)inkColor {
_touchController.defaultInkView.inkColor = inkColor;
[self setNeedsLayout];
}
- (UIColor *)inkColor {
return _touchController.defaultInkView.inkColor;
}
- (void)setShouldDisplayInk:(BOOL)shouldDisplayInk {
_shouldDisplayInk = shouldDisplayInk;
}
- (BOOL)shouldDisplayInk {
return _shouldDisplayInk;
}
- (void)setRippleColor:(UIColor *)rippleColor {
_rippleView.rippleColor = rippleColor;
[self setNeedsLayout];
}
- (UIColor *)rippleColor {
return _rippleView.rippleColor;
}
- (void)setThumbEnabledColor:(UIColor *)thumbEnabledColor {
_thumbEnabledColor = thumbEnabledColor ?: ThumbEnabledColorDefault();
[self setNeedsLayout];
}
- (void)setTrackOnColor:(UIColor *)trackOnColor {
_trackOnColor = trackOnColor ?: TrackOnColorDefault();
[self setNeedsLayout];
}
- (void)setThumbDisabledColor:(UIColor *)thumbDisabledColor {
_thumbDisabledColor = thumbDisabledColor;
[self setNeedsLayout];
}
- (void)setTrackHeight:(CGFloat)trackHeight {
if (MDCCGFloatEqual(trackHeight, _trackHeight)) {
return;
}
_trackHeight = trackHeight;
[self updateTrackEnds];
[self setNeedsLayout];
}
- (void)setTrackOffColor:(UIColor *)trackOffColor {
_trackOffColor = trackOffColor;
[self setNeedsLayout];
}
- (void)setTrackDisabledColor:(UIColor *)trackDisabledColor {
_trackDisabledColor = trackDisabledColor;
[self setNeedsLayout];
}
- (void)setValueLabelTextColor:(UIColor *)valueLabelTextColor {
_valueLabelTextColor = valueLabelTextColor ?: ValueLabelTextColorDefault();
[self setNeedsLayout];
}
- (void)setValueLabelBackgroundColor:(UIColor *)valueLabelBackgroundColor {
_valueLabelBackgroundColor = valueLabelBackgroundColor ?: ValueLabelBackgroundColorDefault();
[self setNeedsLayout];
}
- (void)setTrackOnTickColor:(UIColor *)trackOnTickColor {
_trackOnTickColor = trackOnTickColor;
if (_discreteDots) {
_discreteDots.activeDotColor = trackOnTickColor;
[self setNeedsLayout];
}
}
- (void)setTrackOffTickColor:(UIColor *)trackOffTickColor {
_trackOffTickColor = trackOffTickColor;
if (_discreteDots) {
_discreteDots.inactiveDotColor = trackOffTickColor;
[self setNeedsLayout];
}
}
- (void)setThumbElevation:(MDCShadowElevation)thumbElevation {
_thumbView.elevation = thumbElevation;
}
- (MDCShadowElevation)thumbElevation {
return _thumbView.elevation;
}
- (void)setThumbShadowColor:(UIColor *)thumbShadowColor {
_thumbView.shadowColor = thumbShadowColor;
}
- (UIColor *)thumbShadowColor {
return _thumbView.shadowColor;
}
- (void)setDiscreteDotVisibility:(MDCThumbDiscreteDotVisibility)discreteDotVisibility {
if (_discreteDotVisibility != discreteDotVisibility) {
if (discreteDotVisibility == MDCThumbDiscreteDotVisibilityNever) {
[_discreteDots removeFromSuperview];
_discreteDots = nil;
} else if (!_discreteDots) {
_discreteDots = [[MDCDiscreteDotView alloc] init];
_discreteDots.alpha = 0.0;
_discreteDots.activeDotColor = self.trackOnTickColor;
_discreteDots.inactiveDotColor = self.trackOffTickColor;
_discreteDots.numDiscreteDots = _numDiscreteValues;
[_trackView addSubview:_discreteDots];
}
_discreteDotVisibility = discreteDotVisibility;
}
}
- (void)setShouldDisplayDiscreteValueLabel:(BOOL)shouldDisplayDiscreteValueLabel {
if (_shouldDisplayDiscreteValueLabel == shouldDisplayDiscreteValueLabel) {
return;
}
_shouldDisplayDiscreteValueLabel = shouldDisplayDiscreteValueLabel;
if (shouldDisplayDiscreteValueLabel) {
_valueLabel = [[MDCNumericValueLabel alloc]
initWithFrame:CGRectMake(0, 0, kValueLabelMinHeight * kValueLabelAspectRatio,
kValueLabelMinHeight)];
// Effectively 0, but setting it to 0 results in animation not happening
_valueLabel.transform = CGAffineTransformMakeScale((CGFloat)0.001, (CGFloat)0.001);
[self addSubview:_valueLabel];
} else {
[_valueLabel removeFromSuperview];
_valueLabel = nil;
}
}
- (BOOL)shouldDisplayFilledTrack {
return [_trackOnLayer superlayer] != nil;
}
- (void)setShouldDisplayFilledTrack:(BOOL)shouldDisplayFilledTrack {
if (shouldDisplayFilledTrack) {
if ([_trackOnLayer superlayer] == nil) {
[_trackView.layer addSublayer:_trackOnLayer];
}
} else {
[_trackOnLayer removeFromSuperlayer];
}
}
- (void)setMinimumValue:(CGFloat)minimumValue {
_minimumValue = minimumValue;
CGFloat previousValue = _value;
if (_value < _minimumValue) {
_value = _minimumValue;
}
if (_maximumValue < _minimumValue) {
_maximumValue = _minimumValue;
}
[self updateThumbTrackAnimated:NO
animateThumbAfterMove:NO
previousValue:previousValue
completion:NULL];
}
- (void)setMaximumValue:(CGFloat)maximumValue {
_maximumValue = maximumValue;
CGFloat previousValue = _value;
if (_value > _maximumValue) {
_value = _maximumValue;
}
if (_minimumValue > _maximumValue) {
_minimumValue = _maximumValue;
}
[self updateThumbTrackAnimated:NO
animateThumbAfterMove:NO
previousValue:previousValue
completion:NULL];
}
- (void)setTrackEndsAreRounded:(BOOL)trackEndsAreRounded {
_trackEndsAreRounded = trackEndsAreRounded;
[self updateTrackEnds];
}
- (void)updateTrackEnds {
if (_trackEndsAreRounded) {
_trackView.layer.cornerRadius = _trackHeight / 2;
} else {
_trackView.layer.cornerRadius = 0;
}
}
- (void)setPanningAllowedOnEntireControl:(BOOL)panningAllowedOnEntireControl {
if (_panningAllowedOnEntireControl != panningAllowedOnEntireControl) {
_panningAllowedOnEntireControl = panningAllowedOnEntireControl;
[self updateDummyPanRecognizerTarget];
}
}
- (void)setFilledTrackAnchorValue:(CGFloat)filledTrackAnchorValue {
_filledTrackAnchorValue = MAX(_minimumValue, MIN(filledTrackAnchorValue, _maximumValue));
[self setNeedsLayout];
}
- (void)setValue:(CGFloat)value {
[self setValue:value animated:NO];
}
- (void)setValue:(CGFloat)value animated:(BOOL)animated {
[self setValue:value
animated:animated
animateThumbAfterMove:animated
userGenerated:NO
completion:NULL];
}
- (void)setValue:(CGFloat)value
animated:(BOOL)animated
animateThumbAfterMove:(BOOL)animateThumbAfterMove
userGenerated:(BOOL)userGenerated
completion:(void (^)(void))completion {
CGFloat previousValue = _value;
CGFloat newValue = MAX(_minimumValue, MIN(value, _maximumValue));
newValue = [self closestValueToTargetValue:newValue];
if (newValue != previousValue && [_delegate respondsToSelector:@selector(thumbTrack:
willJumpToValue:)]) {
[self.delegate thumbTrack:self willJumpToValue:newValue];
}
_value = newValue;
if (!userGenerated) {
_lastDispatchedValue = _value;
}
if (_value != previousValue) {
[self interruptAnimation];
[self updateThumbTrackAnimated:animated && self.allowAnimatedValueChanges
animateThumbAfterMove:animateThumbAfterMove
previousValue:previousValue
completion:completion];
}
}
- (void)setNumDiscreteValues:(NSUInteger)numDiscreteValues {
_numDiscreteValues = numDiscreteValues;
_discreteDots.numDiscreteDots = numDiscreteValues;
[self setValue:_value];
}
- (void)setDiscrete:(BOOL)discrete {
_discrete = discrete;
[self setValue:_value];
}
- (void)setThumbRadius:(CGFloat)thumbRadius {
_thumbRadius = thumbRadius;
[self setDisplayThumbRadius:_thumbRadius];
}
- (void)setDisplayThumbRadius:(CGFloat)thumbRadius {
_thumbView.cornerRadius = thumbRadius;
CGPoint thumbCenter = _thumbView.center;
CGFloat halfSideLength = MAX(thumbRadius, kMinTouchSize / 2);
_thumbView.frame = CGRectMake(thumbCenter.x - halfSideLength, thumbCenter.y - halfSideLength,
2 * halfSideLength, 2 * halfSideLength);
}
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wdeprecated-implementations"
- (CGFloat)thumbMaxRippleRadius {
return _touchController.defaultInkView.maxRippleRadius;
}
#pragma clang diagnostic pop
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wdeprecated-implementations"
- (void)setThumbMaxRippleRadius:(CGFloat)thumbMaxRippleRadius {
_touchController.defaultInkView.maxRippleRadius = thumbMaxRippleRadius;
}
#pragma clang diagnostic pop
- (CGFloat)thumbRippleMaximumRadius {
return _rippleView.maximumRadius;
}
- (void)setThumbRippleMaximumRadius:(CGFloat)thumbRippleMaximumRadius {
_rippleView.maximumRadius = thumbRippleMaximumRadius;
}
- (void)setIcon:(nullable UIImage *)icon {
[_thumbView setIcon:icon];
}
- (void)setEnabled:(BOOL)enabled {
[super setEnabled:enabled];
if (enabled) {
[self setPrimaryColor:_primaryColor];
}
[self setNeedsLayout];
}
- (void)setEnableRippleBehavior:(BOOL)enableRippleBehavior {
if (_enableRippleBehavior == enableRippleBehavior) {
return;
}
_enableRippleBehavior = enableRippleBehavior;
if (self.enableRippleBehavior) {
[self.touchController.defaultInkView removeFromSuperview];
_rippleView.frame = CGRectMake(CGRectGetMidX(self.thumbView.bounds) - self.thumbRadius,
CGRectGetMidY(self.thumbView.bounds) - self.thumbRadius,
2 * self.thumbRadius, 2 * self.thumbRadius);
[self.thumbView addSubview:_rippleView];
} else {
[_rippleView removeFromSuperview];
[self.touchController addInkView];
}
}
- (void)setDiscreteValueLabelFont:(UIFont *)discreteValueLabelFont {
_discreteValueLabelFont = discreteValueLabelFont ?: ValueLabelFontDefault();
_valueLabel.font = _discreteValueLabelFont;
[self setNeedsLayout];
}
- (void)setAdjustsFontForContentSizeCategory:(BOOL)adjustsFontForContentSizeCategory {
_valueLabel.adjustsFontForContentSizeCategory = adjustsFontForContentSizeCategory;
}
- (BOOL)adjustsFontForContentSizeCategory {
return _valueLabel.adjustsFontForContentSizeCategory;
}
#pragma mark - MDCInkTouchControllerDelegate
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wdeprecated-declarations"
- (BOOL)inkTouchController:(nonnull __unused MDCInkTouchController *)inkTouchController
shouldProcessInkTouchesAtTouchLocation:(__unused CGPoint)location {
return _shouldDisplayInk;
}
#pragma clang diagnostic pop
#pragma mark - Animation helpers
- (CAMediaTimingFunction *)timingFunctionFromUIViewAnimationOptions:
(UIViewAnimationOptions)options {
NSString *name;
// It's important to check these in this order, due to their actual values specified in UIView.h:
// UIViewAnimationOptionCurveEaseInOut = 0 << 16, // default
// UIViewAnimationOptionCurveEaseIn = 1 << 16,
// UIViewAnimationOptionCurveEaseOut = 2 << 16,
// UIViewAnimationOptionCurveLinear = 3 << 16,
if ((options & UIViewAnimationOptionCurveLinear) == UIViewAnimationOptionCurveLinear) {
name = kCAMediaTimingFunctionEaseIn;
} else if ((options & UIViewAnimationOptionCurveEaseIn) == UIViewAnimationOptionCurveEaseIn) {
name = kCAMediaTimingFunctionEaseIn;
} else if ((options & UIViewAnimationOptionCurveEaseOut) == UIViewAnimationOptionCurveEaseOut) {
name = kCAMediaTimingFunctionEaseOut;
} else {
name = kCAMediaTimingFunctionEaseInEaseOut;
}
return [CAMediaTimingFunction functionWithName:name];
}
- (void)interruptAnimation {
if (_thumbView.layer.presentationLayer) {
_thumbView.layer.position = [(CALayer *)_thumbView.layer.presentationLayer position];
_valueLabel.layer.position = [(CALayer *)_valueLabel.layer.presentationLayer position];
}
[_thumbView.layer removeAllAnimations];
[_trackView.layer removeAllAnimations];
[_valueLabel.layer removeAllAnimations];
[_trackOnLayer removeAllAnimations];
}
#pragma mark - Layout and animation
/**
Updates the state of the thumb track. First updates the views with properties that should change
before the animation. Then performs the main update block, which is animated or not as specified by
the `animated` parameter. After this completes, the secondary animation kicks in, again
animated or not as specified by `animateThumbAfterMove`. After this completes, the `completion`
handler is run.
*/
- (void)updateThumbTrackAnimated:(BOOL)animated
animateThumbAfterMove:(BOOL)animateThumbAfterMove
previousValue:(CGFloat)previousValue
completion:(void (^)(void))completion {
[self updateViewsNoAnimation];
BOOL activeSegmentShrinking = fabs(self.value - self.filledTrackAnchorValue) <
fabs(previousValue - self.filledTrackAnchorValue);
UIViewAnimationOptions baseAnimationOptions =
UIViewAnimationOptionBeginFromCurrentState | UIViewAnimationOptionAllowUserInteraction;
// Note that UIViewAnimationOptionCurveEaseInOut == 0, so by not specifying it, these options
// default to animating with Ease in / Ease out
if (animated) {
BOOL hasInheritedAnimationDuration = UIView.inheritedAnimationDuration > 0;
NSTimeInterval animationDuration =
hasInheritedAnimationDuration ? UIView.inheritedAnimationDuration : kAnimationDuration;
// UIView animateWithDuration:delay:options:animations: takes a different block signature.
void (^animationCompletion)(BOOL) = ^void(BOOL finished) {
if (!finished) {
// If we were interrupted, we shoudldn't complete the second animation.
return;
}
// If the active segment is shrinking, we will update the dot colors immediately. If it's
// growing, update the colors here in the completion block.
if (!activeSegmentShrinking) {
[self updateDotsViewActiveSegment];
}
// Do secondary animation and return.
[self updateThumbAfterMoveAnimated:animateThumbAfterMove
options:baseAnimationOptions
completion:completion];
};
BOOL crossesAnchor =
(previousValue < _filledTrackAnchorValue && _filledTrackAnchorValue < _value) ||
(_value < _filledTrackAnchorValue && _filledTrackAnchorValue < previousValue);
if (crossesAnchor) {
CGFloat currentValue = _value;
CGFloat animationDurationToAnchor =
(fabs(previousValue - _filledTrackAnchorValue) / fabs(previousValue - currentValue)) *
animationDuration;
CGFloat animationDurationAfterAnchor = animationDuration - animationDurationToAnchor;
if (hasInheritedAnimationDuration) {
// If the animation duration is being set by a UIView animation block outside of this class,
// the first animation to the anchor will have the inherited duration. So to maintain a
// consistent animation speed throughout, the animation duration after the anchor should be
// proportionate to the relative distance traveled after the anchor.
animationDurationToAnchor = animationDuration;
animationDurationAfterAnchor = animationDuration *
fabs(currentValue - _filledTrackAnchorValue) /
fabs(previousValue - _filledTrackAnchorValue);
}
void (^afterCrossingAnchorAnimation)(BOOL) = ^void(__unused BOOL finished) {
UIViewAnimationOptions options = baseAnimationOptions | UIViewAnimationOptionCurveEaseOut;
[UIView animateWithDuration:animationDurationAfterAnchor
delay:0
options:options
animations:^{
[self updateViewsMainIsAnimated:animated
withDuration:animationDurationAfterAnchor
animationOptions:options];
}
completion:animationCompletion];
};
UIViewAnimationOptions options = baseAnimationOptions;
if (!hasInheritedAnimationDuration) {
// If this class is controlling the animation, it can specify the animation curve; if not,
// default to UIViewAnimationOptionCurveEaseInOut by not specifying a curve.
options = options | UIViewAnimationOptionCurveEaseIn;
}
[UIView animateWithDuration:animationDurationToAnchor
delay:0
options:options
animations:^{
if (activeSegmentShrinking) {
[self updateDotsViewActiveSegment];
}
// Set _value ivar instead of property to avoid conflicts with logic that
// sends UIControlEventValueChanged.
// Setting self.value programmatically here causes _lastDispatchedValue to
// be updated to the new value before sendDiscreteChangeAction executes.
// sendDiscreteChangeAction uses _lastDispatchedValue to ensure that
// UIControlEventValueChanged actions aren't sent as a result of
// programmatic changes to the value property.
self->_value = self.filledTrackAnchorValue;
[self updateViewsMainIsAnimated:animated
withDuration:animationDurationToAnchor
animationOptions:options];
self->_value = currentValue;
}
completion:afterCrossingAnchorAnimation];
} else {
[UIView animateWithDuration:animationDuration
delay:0
options:baseAnimationOptions
animations:^{
if (activeSegmentShrinking) {
[self updateDotsViewActiveSegment];
}
[self updateViewsMainIsAnimated:animated
withDuration:animationDuration
animationOptions:baseAnimationOptions];
}
completion:animationCompletion];
}
} else {
[self updateViewsMainIsAnimated:animated withDuration:0 animationOptions:baseAnimationOptions];
[self updateDotsViewActiveSegment];
[self updateThumbAfterMoveAnimated:animateThumbAfterMove
options:baseAnimationOptions
completion:completion];
}
}
- (void)updateThumbAfterMoveAnimated:(BOOL)animated
options:(UIViewAnimationOptions)animationOptions
completion:(void (^)(void))completion {
if (animated) {
[UIView animateWithDuration:kThumbChangeAnimationDuration
delay:0
options:animationOptions
animations:^{
[self updateViewsForThumbAfterMoveIsAnimated:animated
withDuration:kThumbChangeAnimationDuration];
}
completion:^void(__unused BOOL _) {
if (completion) {
completion();
}
}];
} else {
[self updateViewsForThumbAfterMoveIsAnimated:animated withDuration:0];
if (completion) {
completion();
}
}
}
/**
Updates the display of the ThumbTrack with properties we want to appear instantly, before the
animated properties are animated.
*/
- (void)updateViewsNoAnimation {
// If not enabled, adjust thumbView accordingly
if (self.enabled) {
// Set thumb color if needed. Note that setting color to hollow start state happes in secondary
// animation block (-updateViewsSecondaryAnimated:withDuration:).
if (!_thumbIsHollowAtStart || ![self isValueAtMinimum]) {
[self updateTrackMask];
_thumbView.backgroundColor = _thumbEnabledColor;
_thumbView.borderColor = _thumbEnabledColor;
}
} else {
_thumbView.backgroundColor = _thumbDisabledColor;
_thumbView.borderColor = _clearColor;
if (_thumbIsSmallerWhenDisabled) {
[self setDisplayThumbRadius:_thumbRadius - _trackHeight];
}
}
}
- (void)updateDotsViewActiveSegment {
if (!MDCCGFloatEqual(self.maximumValue, self.minimumValue)) {
CGFloat relativeAnchorPoint =
(self.filledTrackAnchorValue - self.minimumValue) / (self.maximumValue - self.minimumValue);
CGFloat relativeValuePoint =
(self.value - self.minimumValue) / (self.maximumValue - self.minimumValue);
CGFloat activeSegmentWidth = fabs(relativeAnchorPoint - relativeValuePoint);
CGFloat activeSegmentOriginX = MIN(relativeAnchorPoint, relativeValuePoint);
_discreteDots.activeDotsSegment = CGRectMake(activeSegmentOriginX, 0, activeSegmentWidth, 0);
}
}
/**
Updates the properties of the ThumbTrack that are animated in the main animation body. May be
called from within a UIView animation block.
*/
- (void)updateViewsMainIsAnimated:(__unused BOOL)animated
withDuration:(NSTimeInterval)duration
animationOptions:(UIViewAnimationOptions)animationOptions {
// Move thumb position.
CGPoint point = [self thumbPositionForValue:_value];
_thumbView.center = point;
// Re-draw track position
if (_trackEndsAreInset) {
_trackView.frame = CGRectMake(_thumbRadius, CGRectGetMidY(self.bounds) - (_trackHeight / 2),
CGRectGetWidth(self.bounds) - (_thumbRadius * 2), _trackHeight);
} else {
_trackView.frame = CGRectMake(0, CGRectGetMidY(self.bounds) - (_trackHeight / 2),
CGRectGetWidth(self.bounds), _trackHeight);
}
// Make sure discrete dots match up
_discreteDots.frame = [_trackView bounds];
// Make sure Numeric Value Label matches up
if (_shouldDisplayDiscreteValueLabel && _discrete && _numDiscreteValues > 1) {
_valueLabel.backgroundColor = self.valueLabelBackgroundColor;
_valueLabel.textColor = self.valueLabelTextColor;
if ([_delegate respondsToSelector:@selector(thumbTrack:stringForValue:)]) {
_valueLabel.text = [_delegate thumbTrack:self stringForValue:_value];
CGFloat labelHeight = CGRectGetHeight(_valueLabel.frame);
CGFloat labelWidth = CGRectGetWidth(_valueLabel.frame);
if (CGAffineTransformIsIdentity(_valueLabel.transform)) {
labelHeight = MAX(kValueLabelMinHeight, ceil(_valueLabel.font.lineHeight));
labelWidth = MAX(kValueLabelAspectRatio * labelHeight,
[_valueLabel sizeThatFits:CGSizeMake(CGFLOAT_MAX, labelHeight)].height);
}
CGRect labelFrame =
[self determineVisibleValueLabelFrameWithSize:CGSizeMake(labelWidth, labelHeight)];
_valueLabel.frame = labelFrame;
_valueLabel.center =
[self determineValueLabelCenterWithVisibleFrame:labelFrame
anchorPoint:_valueLabel.layer.anchorPoint];
}
}
// Update colors, etc.
if (self.enabled) {
_trackView.backgroundColor = _trackOffColor;
_trackOnLayer.backgroundColor = _trackOnColor.CGColor;
CGFloat anchorXValue = [self trackPositionForValue:_filledTrackAnchorValue].x;
CGFloat currentXValue = [self trackPositionForValue:_value].x;
CGFloat trackOnXValue = MIN(currentXValue, anchorXValue);
if (_trackEndsAreInset) {
// Account for the fact that the layer's coords are relative to the frame of the track.
trackOnXValue -= _thumbRadius;
}
// We have to use a CATransaction here because CALayer.frame is only animatable using this
// method, not the UIVIew block-based animation that the rest of this method uses. We use
// the timing function and duration passed in in order to match with the other animations.
[CATransaction begin];
[CATransaction
setAnimationTimingFunction:[self
timingFunctionFromUIViewAnimationOptions:animationOptions]];
[CATransaction setAnimationDuration:duration];
_trackOnLayer.frame =
CGRectMake(trackOnXValue, 0, fabs(currentXValue - anchorXValue), _trackHeight);
[CATransaction commit];
} else {
// Set background colors for disabled state.
_trackView.backgroundColor = _trackDisabledColor;
_trackOnLayer.backgroundColor = _clearColor.CGColor;
// Update mask again, since thumb may have moved
[self updateTrackMask];
}
}
/**
Updates the properties of the ThumbTrack that animate after the thumb move has finished, i.e. after
the main animation block completes. May be called from within a UIView animation block.
*/
- (void)updateViewsForThumbAfterMoveIsAnimated:(BOOL)animated
withDuration:(NSTimeInterval)duration {
switch (self.discreteDotVisibility) {
case MDCThumbDiscreteDotVisibilityNever:
_discreteDots.alpha = 0;
break;
case MDCThumbDiscreteDotVisibilityAlways:
_discreteDots.alpha = 1;
break;
case MDCThumbDiscreteDotVisibilityWhenDragging:
_discreteDots.alpha = (self.enabled && _isDraggingThumb) ? 1 : 0;
break;
}
if (_shouldDisplayDiscreteValueLabel && _discrete && _numDiscreteValues > 1) {
if (self.enabled && _isDraggingThumb) {
_valueLabel.transform = CGAffineTransformIdentity;
} else {
_valueLabel.transform = CGAffineTransformMakeScale((CGFloat)0.001, (CGFloat)0.001);
}
}
if (!self.enabled) {
// The following changes only matter if the track is enabled.
return;
}
if ([self isValueAtMinimum] && _thumbIsHollowAtStart) {
[self updateTrackMask];
_thumbView.backgroundColor = _clearColor;
_thumbView.borderColor = _trackOffColor;
}
if (_thumbRadius == _thumbView.cornerRadius) {
// No need to change anything
return;
}
if (animated) {
CABasicAnimation *anim = [CABasicAnimation animationWithKeyPath:@"cornerRadius"];
anim.timingFunction =
[CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseInEaseOut];
anim.fromValue = [NSNumber numberWithDouble:_thumbView.cornerRadius];
anim.toValue = [NSNumber numberWithDouble:_thumbRadius];
anim.duration = duration;
anim.delegate = self;
anim.removedOnCompletion = NO; // We'll remove it ourselves as the delegate
[_thumbView.layer addAnimation:anim forKey:anim.keyPath];
}
[self setDisplayThumbRadius:_thumbRadius]; // Updates frame and corner radius
[self updateTrackMask];
}
// Used to make sure we update the mask after animating the thumb growing or shrinking. Specifically
// in the case where the thumb is at the start and hollow, forgetting to update could leave the mask
// in a strange visual state.
- (void)animationDidStop:(CAAnimation *)anim finished:(__unused BOOL)flag {
if (anim == [_thumbView.layer animationForKey:@"cornerRadius"]) {
[_thumbView.layer removeAllAnimations];
[self updateTrackMask];
}
}
- (void)updateTrackMask {
// Adding 1pt to the top and bottom is necessary to account for the behavior of CAShapeLayer,
// which according Apple's documentation "may favor speed over accuracy" when rasterizing.
// https://developer.apple.com/library/ios/documentation/GraphicsImaging/Reference/CAShapeLayer_class
// This means that its rasterization sometimes doesn't line up with the UIView that it's masking,
// particularly when that view's edges fall on a subpixel. Adding the extra pt on the top and
// bottom accounts for this case here, and ensures that none of the _trackView appears where it
// isn't supposed to.
// This fixes https://github.com/material-components/material-components-ios/issues/566 for all
// orientations.
CGRect maskFrame = CGRectMake(0, -1, CGRectGetWidth(self.bounds), _trackHeight + 2);
CGMutablePathRef path = CGPathCreateMutable();
CGPathAddRect(path, NULL, maskFrame);
CGFloat radius = _thumbView.cornerRadius;
if (_thumbView.layer.presentationLayer != NULL) {
// If we're animating (growing or shrinking) lean on the side of the smaller radius, to prevent
// a gap from appearing between the thumb and the track in the intermediate frames.
radius = MIN(((CALayer *)_thumbView.layer.presentationLayer).cornerRadius, radius);
}
radius = MAX(radius, _thumbRadius);
BOOL isDisabledWithThumbGaps = !self.enabled && _disabledTrackHasThumbGaps;
BOOL isNotDiscreteWithValueLabelWhileDraggingThumb = !(
_shouldDisplayDiscreteValueLabel && _discrete && _numDiscreteValues > 1 && _isDraggingThumb);
BOOL isMinValueWithHollowThumbAndNotDiscreteWhileDraggingThumb =
([self isValueAtMinimum] && _thumbIsHollowAtStart &&
isNotDiscreteWithValueLabelWhileDraggingThumb);
if (isDisabledWithThumbGaps || isMinValueWithHollowThumbAndNotDiscreteWhileDraggingThumb) {
// The reason we calculate this explicitly instead of just using _thumbView.frame is because
// the thumb view might not be have the exact radius of _thumbRadius, depending on if the track
// is disabled or if a user is dragging the thumb.
CGRect gapMaskFrame = CGRectMake(_thumbView.center.x - radius, _thumbView.center.y - radius,
radius * 2, radius * 2);
gapMaskFrame = [self convertRect:gapMaskFrame toView:_trackView];
CGPathAddRect(path, NULL, gapMaskFrame);
}
_trackMaskLayer.path = path;
CGPathRelease(path);
}
#pragma mark - Interaction Helpers
- (CGPoint)thumbPosition {
return _thumbView.center;
}
- (CGPoint)thumbPositionForValue:(CGFloat)value {
CGFloat relValue = [self relativeValueForValue:value];
return CGPointMake(_thumbRadius + self.thumbPanRange * relValue, self.frame.size.height / 2);
}
- (CGRect)determineVisibleValueLabelFrameWithSize:(CGSize)size {
CGFloat relValue = [self relativeValueForValue:_value];
// To account for the discrete dots on the left and right sides
CGFloat range = self.thumbPanRange - _trackHeight;
CGFloat centerX = _thumbRadius + (_trackHeight / 2) + range * relValue;
CGFloat minX = centerX - (0.5f * size.width);
CGFloat maxY = CGRectGetMidY(self.frame) - _thumbRadius - kValueLabelThumbPadding;
CGFloat minY = maxY - size.height;
return CGRectMake(minX, minY, size.width, size.height);
}
- (CGPoint)determineValueLabelCenterWithVisibleFrame:(CGRect)visibleFrame
anchorPoint:(CGPoint)anchorPoint {
CGFloat centerX = CGRectGetMinX(visibleFrame) + (anchorPoint.x * CGRectGetWidth(visibleFrame));
CGFloat centerY = CGRectGetMinY(visibleFrame) + (anchorPoint.y * CGRectGetHeight(visibleFrame));
return CGPointMake(centerX, centerY);
}
- (CGFloat)valueForThumbPosition:(CGPoint)position {
CGFloat relValue = (position.x - _thumbRadius) / self.thumbPanRange;
relValue = MAX(0, MIN(relValue, 1));
// For RTL we invert the value
if (self.effectiveUserInterfaceLayoutDirection == UIUserInterfaceLayoutDirectionRightToLeft) {
relValue = 1 - relValue;
}
return (1 - relValue) * _minimumValue + relValue * _maximumValue;
}
// Describes where on the track the specified value would fall. Differs from
// -thumbPositionForValue: because it varies by whether or not the track ends are inset. Note that
// if the edges are inset, the two values are equivalent, but if not, this point's x value can
// differ from the thumb's x value by at most _thumbRadius.
- (CGPoint)trackPositionForValue:(CGFloat)value {
if (_trackEndsAreInset) {
return [self thumbPositionForValue:value];
}
CGFloat xValue = [self relativeValueForValue:value] * self.bounds.size.width;
return CGPointMake(xValue, self.frame.size.height / 2);
}
- (BOOL)isPointOnThumb:(CGPoint)point {
// Note that we let the thumb's draggable area extend beyond its actual view to account for
// the imprecise nature of hit targets on device.
return DistanceFromPointToPoint(point, _thumbView.center) <= (_thumbRadius * kThumbSlopFactor);
}
- (BOOL)isValueAtMinimum {
return _value == _minimumValue;
}
- (CGFloat)thumbPanOffset {
return _thumbView.frame.origin.x / self.thumbPanRange;
}
- (CGFloat)thumbPanRange {
return self.bounds.size.width - (self.thumbRadius * 2);
}
- (CGFloat)relativeValueForValue:(CGFloat)value {
value = MAX(_minimumValue, MIN(value, _maximumValue));
if (MDCCGFloatEqual(_minimumValue, _maximumValue)) {
return _minimumValue;
}
CGFloat relValue = (value - _minimumValue) / fabs(_minimumValue - _maximumValue);
// For RTL we invert the value
if (self.effectiveUserInterfaceLayoutDirection == UIUserInterfaceLayoutDirectionRightToLeft) {
relValue = 1 - relValue;
}
return relValue;
}
- (CGFloat)closestValueToTargetValue:(CGFloat)targetValue {
if (!_discrete || _numDiscreteValues < 2) {
return targetValue;
}
if (MDCCGFloatEqual(_minimumValue, _maximumValue)) {
return _minimumValue;
}
CGFloat scaledTargetValue = (targetValue - _minimumValue) / (_maximumValue - _minimumValue);
CGFloat snappedValue =
round((_numDiscreteValues - 1) * scaledTargetValue) / (_numDiscreteValues - 1);
return (1 - snappedValue) * _minimumValue + snappedValue * _maximumValue;
}
- (void)updateDummyPanRecognizerTarget {
[_dummyPanRecognizer.view removeGestureRecognizer:_dummyPanRecognizer];
UIView *panTarget = _panningAllowedOnEntireControl ? self : _thumbView;
[panTarget addGestureRecognizer:_dummyPanRecognizer];
}
#pragma mark - UIResponder Events
/**
We implement our own touch handling here instead of using gesture recognizers. This allows more
fine grained control over how the thumb track behaves, including more specific logic over what
counts as a tap vs. a drag.
Note that we must use -touchesBegan:, -touchesMoves:, etc here, rather than the UIControl methods
-beginDraggingWithTouch:withEvent:, -continueDraggingWithTouch:withEvent:, etc. This is because
with those events, we are forced to disable user interaction on our subviews else the events could
be swallowed up by their event handlers and not ours. We can't do this because the we have an ink
controller attached to the thumb view, and that needs to receive touch events in order to know when
to display ink.
Using -touchesBegan:, etc. solves this problem because we can handle touches ourselves as well as
continue to have them pass through to the contained thumb view. So we get our custom event handling
without disabling the ink display, hurray!
Because we set `multipleTouchEnabled = NO`, the sets of touches in these methods will always be of
size 1. For this reason, we can simply call `-anyObject` on the set instead of iterating through
every touch.
*/
- (void)touchesBegan:(NSSet<UITouch *> *)touches withEvent:(__unused UIEvent *)event {
if (!self.enabled || _currentTouch != nil) {
return;
}
UITouch *touch = [touches anyObject];
CGPoint touchLoc = [[touches anyObject] locationInView:self];
_currentTouch = touch;
_didChangeValueDuringPan = NO;
_isDraggingThumb = _panningAllowedOnEntireControl || [self isPointOnThumb:touchLoc];
if (_isDraggingThumb) {
// Start panning
_panThumbGrabPosition = touchLoc.x - self.thumbPosition.x;
// Grow the thumb
[self updateThumbTrackAnimated:NO
animateThumbAfterMove:YES
previousValue:_value
completion:nil];
}
// This is to make sure that we only show a ripple when there is a thumb view visible.
if (self.shouldDisplayRipple && CGRectGetWidth(_thumbView.frame) > 0 &&
CGRectGetHeight(_thumbView.frame) > 0) {
[_rippleView beginRippleTouchDownAtPoint:_rippleView.center animated:YES completion:nil];
}
[self sendActionsForControlEvents:UIControlEventTouchDown];
}
- (void)touchesMoved:(NSSet<UITouch *> *)touches withEvent:(__unused UIEvent *)event {
UITouch *touch = [touches anyObject];
if (!self.enabled || touch != _currentTouch) {
return;
}
if (!_isDraggingThumb) {
// The rest is dragging logic
return;
}
CGPoint touchLoc = [touch locationInView:self];
CGFloat thumbPosition = touchLoc.x - _panThumbGrabPosition;
CGFloat previousValue = _value;
CGFloat value = [self valueForThumbPosition:CGPointMake(thumbPosition, 0)];
BOOL shouldAnimate = _discrete && _numDiscreteValues > 1;
[self setValue:value
animated:shouldAnimate
animateThumbAfterMove:YES
userGenerated:YES
completion:NULL];
[self sendContinuousChangeAction];
if (_value != previousValue) {
// We made a move, now this action can't later count as a tap
_didChangeValueDuringPan = YES;
}
if ([self pointInside:touchLoc withEvent:nil]) {
[self sendActionsForControlEvents:UIControlEventTouchDragInside];
} else {
[self sendActionsForControlEvents:UIControlEventTouchDragOutside];
}
}
- (void)touchesCancelled:(NSSet<UITouch *> *)touches withEvent:(__unused UIEvent *)event {
UITouch *touch = [touches anyObject];
if (touch == _currentTouch) {
BOOL wasDragging = _isDraggingThumb;
_isDraggingThumb = NO;
_currentTouch = nil;
if (wasDragging) {
// Shrink the thumb
[self updateThumbTrackAnimated:NO
animateThumbAfterMove:YES
previousValue:_value
completion:nil];
}
[_rippleView cancelAllRipplesAnimated:YES completion:nil];
[self sendActionsForControlEvents:UIControlEventTouchCancel];
if (!_continuousUpdateEvents && wasDragging) {
[self sendDiscreteChangeAction];
}
}
}
- (void)touchesEnded:(NSSet<UITouch *> *)touches withEvent:(__unused UIEvent *)event {
UITouch *touch = [touches anyObject];
if (!self.enabled || touch != _currentTouch) {
return;
}
BOOL wasDragging = _isDraggingThumb;
_isDraggingThumb = NO;
_currentTouch = nil;
if (wasDragging) {
// Shrink the thumb
[self updateThumbTrackAnimated:NO
animateThumbAfterMove:YES
previousValue:_value
completion:nil];
}
[_rippleView beginRippleTouchUpAnimated:YES completion:nil];
CGPoint touchLoc = [touch locationInView:self];
if ([self pointInside:touchLoc withEvent:nil]) {
if (!_didChangeValueDuringPan && (_tapsAllowedOnThumb || ![self isPointOnThumb:touchLoc])) {
// Treat it like a tap
if (![_delegate respondsToSelector:@selector(thumbTrack:shouldJumpToValue:)] ||
[self.delegate thumbTrack:self shouldJumpToValue:[self valueForThumbPosition:touchLoc]]) {
[self setValueFromThumbPosition:touchLoc isTap:YES];
}
}
[self sendActionsForControlEvents:UIControlEventTouchUpInside];
} else {
[self sendActionsForControlEvents:UIControlEventTouchUpOutside];
}
if (!_continuousUpdateEvents && wasDragging) {
[self sendDiscreteChangeAction];
}
}
- (void)setValueFromThumbPosition:(CGPoint)position isTap:(BOOL)isTap {
// Having two discrete values is a special case (e.g. the switch) in which any tap just flips the
// value between the two discrete values, irrespective of the tap location.
CGFloat value;
if (isTap && _discrete && _numDiscreteValues == 2) {
// If we are at the maximum then make it the minimum:
// For switch like thumb tracks where there is only 2 values we ignore the position of the tap
// and toggle between the minimum and maximum values.
value = _value < MDCCGFloatEqual(_value, _minimumValue) ? _maximumValue : _minimumValue;
} else {
value = [self valueForThumbPosition:position];
}
__weak MDCThumbTrack *weakSelf = self;
if ([_delegate respondsToSelector:@selector(thumbTrack:willAnimateToValue:)]) {
[_delegate thumbTrack:self willAnimateToValue:value];
}
if (isTap && _numDiscreteValues > 1 &&
((_discreteDotVisibility == MDCThumbDiscreteDotVisibilityAlways) ||
(_discreteDotVisibility == MDCThumbDiscreteDotVisibilityWhenDragging))) {
_discreteDots.alpha = 1.0;
}
[self setValue:value
animated:YES
animateThumbAfterMove:YES
userGenerated:YES
completion:^{
MDCThumbTrack *strongSelf = weakSelf;
[strongSelf sendDiscreteChangeAction];
if (strongSelf && [strongSelf->_delegate respondsToSelector:@selector
(thumbTrack:didAnimateToValue:)]) {
[strongSelf->_delegate thumbTrack:weakSelf didAnimateToValue:value];
}
}];
}
#pragma mark - Events
- (void)sendContinuousChangeAction {
if (_continuousUpdateEvents && _value != _lastDispatchedValue) {
[self sendActionsForControlEvents:UIControlEventValueChanged];
_lastDispatchedValue = _value;
}
}
- (void)sendDiscreteChangeAction {
if (_value != _lastDispatchedValue) {
[self sendActionsForControlEvents:UIControlEventValueChanged];
_lastDispatchedValue = _value;
}
}
#pragma mark - UIControl methods
- (BOOL)isTracking {
return _isDraggingThumb;
}
@end
@implementation MDCThumbTrack (Private)
- (MDCNumericValueLabel *)numericValueLabel {
return _valueLabel;
}
- (MDCDiscreteDotView *)discreteDotView {
return _discreteDots;
}
@end