diff --git a/components/private/ThumbTrack/src/MDCThumbTrack.h b/components/private/ThumbTrack/src/MDCThumbTrack.h index 7d1bc15e2..b6f181339 100644 --- a/components/private/ThumbTrack/src/MDCThumbTrack.h +++ b/components/private/ThumbTrack/src/MDCThumbTrack.h @@ -49,6 +49,12 @@ /** The color of the track when disabled. */ @property(nullable, nonatomic, strong) UIColor *trackDisabledColor; +/** The color of the discrete "ticks" in the "on" portion of the track. */ +@property(nullable, nonatomic, strong) UIColor *trackOnTickColor; + +/** The color of the discrete "ticks" in the "off" portion of the track. */ +@property(nullable, nonatomic, strong) UIColor *trackOffTickColor; + /** The color of the Ink ripple. */ @property(nullable, nonatomic, strong) UIColor *inkColor; diff --git a/components/private/ThumbTrack/src/MDCThumbTrack.m b/components/private/ThumbTrack/src/MDCThumbTrack.m index b674bad13..209a5e1a3 100644 --- a/components/private/ThumbTrack/src/MDCThumbTrack.m +++ b/components/private/ThumbTrack/src/MDCThumbTrack.m @@ -24,6 +24,8 @@ #import "MaterialInk.h" #import "MaterialMath.h" +#pragma mark - ThumbTrack constants + static const CGFloat kAnimationDuration = 0.25f; static const CGFloat kThumbChangeAnimationDuration = 0.12f; static const CGFloat kDefaultThumbBorderWidth = 2.0f; @@ -57,19 +59,15 @@ static UIColor *InkColorDefault() { return [UIColor.blueColor colorWithAlphaComponent:kTrackOnAlpha]; } -// Credit to the Beacon Tools iOS team for the idea for this implementations -@interface MDCDiscreteDotView : UIView - -@property(nonatomic, assign) NSUInteger numDiscreteDots; - -@end - @implementation MDCDiscreteDotView - (instancetype)init { self = [super init]; if (self) { self.backgroundColor = [UIColor clearColor]; + _inactiveDotColor = UIColor.blackColor; + _activeDotColor = UIColor.blackColor; + _activeDotsSegment = CGRectMake(CGFLOAT_MIN, 0, 0, 0); } return self; } @@ -79,18 +77,50 @@ static UIColor *InkColorDefault() { [self setNeedsDisplay]; } +- (void)setActiveDotColor:(UIColor *)activeDotColor { + _activeDotColor = activeDotColor; + [self setNeedsDisplay]; +} + +- (void)setInactiveDotColor:(UIColor *)inactiveDotColor { + _inactiveDotColor = inactiveDotColor; + [self setNeedsDisplay]; +} + +- (void)setActiveDotsSegment:(CGRect)activeDotsSegment { + CGFloat newMinX = MAX(0, MIN(1, CGRectGetMinX(activeDotsSegment))); + CGFloat newMaxX = MIN(1, MAX(0, CGRectGetMaxX(activeDotsSegment))); + + _activeDotsSegment = CGRectMake(newMinX, 0, + (newMaxX - newMinX), 0); + [self setNeedsDisplay]; +} + - (void)drawRect:(CGRect)rect { [super drawRect:rect]; if (_numDiscreteDots >= 2) { CGContextRef contextRef = UIGraphicsGetCurrentContext(); - CGContextSetFillColorWithColor(contextRef, [UIColor blackColor].CGColor); CGRect circleRect = CGRectMake(0, 0, self.bounds.size.height, self.bounds.size.height); - CGFloat increment = (self.bounds.size.width - self.bounds.size.height) / (_numDiscreteDots - 1); + // Increment within the bounds + CGFloat absoluteIncrement = + (self.bounds.size.width - self.bounds.size.height) / (_numDiscreteDots - 1); + // Increment within 0..1 + CGFloat relativeIncrement = (CGFloat)1.0 / (_numDiscreteDots - 1); + // Allow an extra 10% of the increment to guard against rounding errors excluding dots that + // should genuinely be within the active segment. + CGFloat minActiveX = CGRectGetMinX(self.activeDotsSegment) - relativeIncrement * 0.1f; + CGFloat maxActiveX = CGRectGetMaxX(self.activeDotsSegment) + relativeIncrement * 0.1f; for (NSUInteger i = 0; i < _numDiscreteDots; i++) { - circleRect.origin.x = (i * increment); + CGFloat relativePosition = i * relativeIncrement; + if (minActiveX <= relativePosition && maxActiveX >= relativePosition) { + [self.activeDotColor setFill]; + } else { + [self.inactiveDotColor setFill]; + } + circleRect.origin.x = (i * absoluteIncrement); CGContextFillEllipseInRect(contextRef, circleRect); } } @@ -202,6 +232,8 @@ static inline CGFloat DistanceFromPointToPoint(CGPoint point1, CGPoint point2) { [onTintColor colorWithAlphaComponent:kTrackOnAlpha] : InkColorDefault(); _clearColor = UIColor.clearColor; _valueLabelTextColor = ValueLabelTextColorDefault(); + _trackOnTickColor = UIColor.blackColor; + _trackOffTickColor = UIColor.blackColor; [self setNeedsLayout]; // We add this UIPanGestureRecognizer to our view so that any superviews of the thumb track know @@ -296,6 +328,22 @@ static inline CGFloat DistanceFromPointToPoint(CGPoint point1, CGPoint point2) { [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; } @@ -309,6 +357,8 @@ static inline CGFloat DistanceFromPointToPoint(CGPoint point1, CGPoint point2) { if (shouldDisplayDiscreteDots) { _discreteDots = [[MDCDiscreteDotView alloc] init]; _discreteDots.alpha = 0.0; + _discreteDots.activeDotColor = self.trackOnTickColor; + _discreteDots.inactiveDotColor = self.trackOffTickColor; [_trackView addSubview:_discreteDots]; } else { [_discreteDots removeFromSuperview]; @@ -538,6 +588,9 @@ static inline CGFloat DistanceFromPointToPoint(CGPoint point1, CGPoint point2) { completion:(void (^)(void))completion { [self updateViewsNoAnimation]; + BOOL activeSegmentShrinking = MDCFabs(self.value - self.filledTrackAnchorValue) < + MDCFabs(previousValue - self.filledTrackAnchorValue); + UIViewAnimationOptions baseAnimationOptions = UIViewAnimationOptionBeginFromCurrentState | UIViewAnimationOptionAllowUserInteraction; // Note that UIViewAnimationOptionCurveEaseInOut == 0, so by not specifying it, these options @@ -551,6 +604,11 @@ static inline CGFloat DistanceFromPointToPoint(CGPoint point1, CGPoint point2) { 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 @@ -595,6 +653,9 @@ static inline CGFloat DistanceFromPointToPoint(CGPoint point1, CGPoint point2) { delay:0.0f options:baseAnimationOptions animations:^{ + if (activeSegmentShrinking) { + [self updateDotsViewActiveSegment]; + } [self updateViewsMainIsAnimated:animated withDuration:kAnimationDuration animationOptions:baseAnimationOptions]; @@ -605,6 +666,7 @@ static inline CGFloat DistanceFromPointToPoint(CGPoint point1, CGPoint point2) { [self updateViewsMainIsAnimated:animated withDuration:0.0f animationOptions:baseAnimationOptions]; + [self updateDotsViewActiveSegment]; [self updateThumbAfterMoveAnimated:animateThumbAfterMove options:baseAnimationOptions completion:completion]; @@ -661,6 +723,17 @@ static inline CGFloat DistanceFromPointToPoint(CGPoint point1, CGPoint point2) { } } +- (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 = MDCFabs(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. @@ -697,6 +770,10 @@ static inline CGFloat DistanceFromPointToPoint(CGPoint point1, CGPoint point2) { // Reset the size prior to pixel alignement since previous alignement likely increased it CGRect valueLabelFrame = CGRectMake(_valueLabel.frame.origin.x, _valueLabel.frame.origin.y, kValueLabelWidth, kValueLabelHeight); + // TODO(https://github.com/material-components/material-components-ios/issues/3326 ): + // Don't assign the frame AND the center (above). Do it only once to avoid extra layout + // passes. This is the cause of the visual glitch seen when coloring the "active" tick + // marks in the _discreteDots view. _valueLabel.frame = MDCRectAlignToScale(valueLabelFrame, [UIScreen mainScreen].scale); } } @@ -1170,4 +1247,8 @@ static inline CGFloat DistanceFromPointToPoint(CGPoint point1, CGPoint point2) { return _touchController; } +- (MDCDiscreteDotView *)discreteDotView { + return _discreteDots; +} + @end diff --git a/components/private/ThumbTrack/src/private/MDCThumbTrack+Private.h b/components/private/ThumbTrack/src/private/MDCThumbTrack+Private.h index b7e074d99..de532e70f 100644 --- a/components/private/ThumbTrack/src/private/MDCThumbTrack+Private.h +++ b/components/private/ThumbTrack/src/private/MDCThumbTrack+Private.h @@ -18,9 +18,32 @@ #import "MDCNumericValueLabel.h" #import "MaterialInk.h" +// Credit to the Beacon Tools iOS team for the idea for this implementations +@interface MDCDiscreteDotView : UIView + +@property(nonatomic, assign) NSUInteger numDiscreteDots; + +/** The color of dots within the @c activeDotsSegment bounds. Defaults to black. */ +@property(nonatomic, strong, nonnull) UIColor *activeDotColor; + +/** The color of dots outside the @c activeDotsSegment bounds. Defaults to black. */ +@property(nonatomic, strong, nonnull) UIColor *inactiveDotColor; + +/** + The segment of the track that uses @c activeDotColor. The horizontal dimension should be bound + to [0..1]. The vertical dimension is ignored. + + @note Only the @c origin.x and @c size.width are used to determine whether a dot is in the active + segment. + */ +@property(nonatomic, assign) CGRect activeDotsSegment; + +@end + @interface MDCThumbTrack (Private) @property(nonatomic, nonnull, readonly) MDCNumericValueLabel *numericValueLabel; @property(nonatomic, nonnull, readonly) MDCInkTouchController *touchController; +@property(nonatomic, nonnull, readonly) MDCDiscreteDotView *discreteDotView; @end diff --git a/components/private/ThumbTrack/tests/unit/ThumbTrackTests.m b/components/private/ThumbTrack/tests/unit/ThumbTrackTests.m index b53e988af..12011d27f 100644 --- a/components/private/ThumbTrack/tests/unit/ThumbTrackTests.m +++ b/components/private/ThumbTrack/tests/unit/ThumbTrackTests.m @@ -159,6 +159,80 @@ XCTAssertEqualObjects(thumbTrack.trackOffColor, UIColor.yellowColor); } +#pragma mark - trackOnTickColor + +- (void)testTrackOnTickColorDefaults { + // Given + MDCThumbTrack *thumbTrack = [[MDCThumbTrack alloc] init]; + + // Then + XCTAssertEqualObjects(thumbTrack.trackOnTickColor, UIColor.blackColor); +} + +- (void)testSetTrackOnTickColor { + // Given + MDCThumbTrack *thumbTrack = [[MDCThumbTrack alloc] init]; + + // When + thumbTrack.shouldDisplayDiscreteDots = YES; + thumbTrack.trackOnTickColor = UIColor.cyanColor; + + // Then + XCTAssertEqualObjects(thumbTrack.trackOnTickColor, UIColor.cyanColor); + XCTAssertEqualObjects(thumbTrack.discreteDotView.activeDotColor, thumbTrack.trackOnTickColor); +} + +- (void)testTrackOnTickColorWorksBeforeEnablingDiscreteDots { + // Given + MDCThumbTrack *thumbTrack = [[MDCThumbTrack alloc] init]; + + // When + thumbTrack.shouldDisplayDiscreteDots = NO; + thumbTrack.trackOnTickColor = UIColor.cyanColor; + thumbTrack.shouldDisplayDiscreteDots = YES; + + // Then + XCTAssertEqualObjects(thumbTrack.trackOnTickColor, UIColor.cyanColor); + XCTAssertEqualObjects(thumbTrack.discreteDotView.activeDotColor, thumbTrack.trackOnTickColor); +} + +#pragma mark - trackOffTickColor + +- (void)testTrackOffTickColorDefaults { + // Given + MDCThumbTrack *thumbTrack = [[MDCThumbTrack alloc] init]; + + // Then + XCTAssertEqualObjects(thumbTrack.trackOffTickColor, UIColor.blackColor); +} + +- (void)testSetTrackOffTickColor { + // Given + MDCThumbTrack *thumbTrack = [[MDCThumbTrack alloc] init]; + + // When + thumbTrack.shouldDisplayDiscreteDots = YES; + thumbTrack.trackOffTickColor = UIColor.cyanColor; + + // Then + XCTAssertEqualObjects(thumbTrack.trackOffTickColor, UIColor.cyanColor); + XCTAssertEqualObjects(thumbTrack.discreteDotView.inactiveDotColor, thumbTrack.trackOffTickColor); +} + +- (void)testTrackOffTickColorWorksBeforeEnablingDiscreteDots { + // Given + MDCThumbTrack *thumbTrack = [[MDCThumbTrack alloc] init]; + + // When + thumbTrack.shouldDisplayDiscreteDots = NO; + thumbTrack.trackOffTickColor = UIColor.cyanColor; + thumbTrack.shouldDisplayDiscreteDots = YES; + + // Then + XCTAssertEqualObjects(thumbTrack.trackOffTickColor, UIColor.cyanColor); + XCTAssertEqualObjects(thumbTrack.discreteDotView.inactiveDotColor, thumbTrack.trackOffTickColor); +} + #pragma mark - inkColor - (void)testInkColorDefault {