From aa29efaa426e1e9b2eefea70c24a4dfbef6eb63b Mon Sep 17 00:00:00 2001 From: Robert Moore Date: Thu, 12 Apr 2018 15:44:39 -0400 Subject: [PATCH] [ThumbTrack] Add support for colored tick marks (#3329) The Slider needs a way to color the tick marks within the "filled" region differently from the tick marks in the "unfilled" region of the track. The ThumbTrack should support an API that allows customization of the colors and definition of the "filled" or "active" track area. **Resulting Effect (with non-default colors)** ![slider-colored-dots](https://user-images.githubusercontent.com/1753199/38683860-63175184-3e3c-11e8-9014-7c55a528a942.gif) **Before** ![slider-ticks-before](https://user-images.githubusercontent.com/1753199/38697970-796153de-3e61-11e8-9c7e-6dc29755e381.png) **After** ![slider-ticks-after](https://user-images.githubusercontent.com/1753199/38697976-7cbf6084-3e61-11e8-9cf5-e873abffdef4.png) Partially implements #3137 Pivotal story: https://www.pivotaltracker.com/story/show/155525171 --- .../private/ThumbTrack/src/MDCThumbTrack.h | 6 ++ .../private/ThumbTrack/src/MDCThumbTrack.m | 101 ++++++++++++++++-- .../src/private/MDCThumbTrack+Private.h | 23 ++++ .../ThumbTrack/tests/unit/ThumbTrackTests.m | 74 +++++++++++++ 4 files changed, 194 insertions(+), 10 deletions(-) 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 {