Add an api to MDCActivityIndicator to allow animations before/after animating
ADD
components/ActivityIndicator/examples/ActivityIndicatorTransitionExample.m
+/*
+ Copyright 2016-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 <UIKit/UIKit.h>
+
+#import "MaterialActivityIndicator.h"
+#import "MaterialButtons.h"
+
+static const CGFloat kActivityIndicatorExampleArrowHeadSize = 5.0f;
+static const CGFloat kActivityIndicatorExampleStrokeWidth = 2.0f;
+
+static const NSTimeInterval kActivityIndicatorExampleAnimationDuration = 2.0 / 3.0;
+
+
+@interface ActivityIndicatorTransitionExample : UIViewController <MDCActivityIndicatorDelegate>
+@end
+
+@implementation ActivityIndicatorTransitionExample {
+ MDCActivityIndicator *_activityIndicator;
+ MDCButton *_button;
+
+ CALayer *_rotationContainer;
+ CALayer *_refreshArrowContainer;
+ CAShapeLayer *_refreshArrowPoint;
+ CAShapeLayer *_refreshStrokeLayer;
+}
+
+- (id)init {
+ self = [super init];
+ if (self) {
+ self.title = @"Activity Indicator Transition";
+ self.view.backgroundColor = [UIColor whiteColor];
+ }
+ return self;
+}
+
+- (void)viewDidLoad {
+ [super viewDidLoad];
+
+ _activityIndicator = [[MDCActivityIndicator alloc] initWithFrame:CGRectZero];
+ [_activityIndicator sizeToFit];
+ _activityIndicator.center = CGPointMake(self.view.bounds.size.width / 2, 100);
+ _activityIndicator.delegate = self;
+ [self.view addSubview:_activityIndicator];
+
+ _button = [[MDCButton alloc] init];
+ [_button addTarget:self
+ action:@selector(startRefreshing)
+ forControlEvents:UIControlEventTouchUpInside];
+ [_button setTitle:@"Refresh" forState:UIControlStateNormal];
+ [_button sizeToFit];
+ _button.center = CGPointMake(self.view.bounds.size.width / 2, 200);
+ [self.view addSubview:_button];
+
+ // Layers used in the custom transition animation.
+
+ _rotationContainer = [CALayer layer];
+ [_activityIndicator.layer addSublayer:_rotationContainer];
+
+ _refreshArrowContainer = [CALayer layer];
+ [_rotationContainer addSublayer:_refreshArrowContainer];
+
+ CGMutablePathRef refreshArrowPath = CGPathCreateMutable();
+ CGPathMoveToPoint(refreshArrowPath, NULL, 0, -kActivityIndicatorExampleArrowHeadSize);
+ CGPathAddLineToPoint(refreshArrowPath, NULL, kActivityIndicatorExampleArrowHeadSize, 0);
+ CGPathAddLineToPoint(refreshArrowPath, NULL, 0, kActivityIndicatorExampleArrowHeadSize);
+ CGPathCloseSubpath(refreshArrowPath);
+
+ _refreshArrowPoint = [CAShapeLayer layer];
+ _refreshArrowPoint.anchorPoint = CGPointMake(.5, 1);
+ _refreshArrowPoint.path = refreshArrowPath;
+ [_refreshArrowContainer addSublayer:_refreshArrowPoint];
+
+ CGPathRelease(refreshArrowPath);
+
+ _refreshStrokeLayer = [CAShapeLayer layer];
+ _refreshStrokeLayer.lineWidth = kActivityIndicatorExampleStrokeWidth;
+ _refreshStrokeLayer.fillColor = [UIColor clearColor].CGColor;
+ _refreshStrokeLayer.strokeColor = [UIColor blackColor].CGColor;
+ _refreshStrokeLayer.strokeStart = 0;
+ _refreshStrokeLayer.strokeEnd = .8f;
+ [_rotationContainer addSublayer:_refreshStrokeLayer];
+
+ _rotationContainer.transform = CATransform3DMakeRotation((CGFloat)M_PI * .65f, 0, 0, 1);
+ _refreshArrowContainer.transform = CATransform3DMakeRotation(1.6f * (float)M_PI, 0, 0, 1);
+
+ [CATransaction commit];
+}
+
+- (void)viewWillLayoutSubviews {
+ [super viewWillLayoutSubviews];
+
+ [CATransaction begin];
+ [CATransaction setDisableActions:YES];
+
+ CGRect bounds = _activityIndicator.bounds;
+ _rotationContainer.bounds = bounds;
+ _rotationContainer.position = CGPointMake(bounds.size.width / 2, bounds.size.height / 2);
+
+ _refreshStrokeLayer.bounds = _rotationContainer.bounds;
+ _refreshStrokeLayer.position = _rotationContainer.position;
+
+ _refreshArrowContainer.bounds = _rotationContainer.bounds;
+ _refreshArrowContainer.position = _rotationContainer.position;
+
+ _refreshArrowPoint.position =
+ CGPointMake(bounds.size.width / 2, kActivityIndicatorExampleStrokeWidth / 2.0f);
+
+ CGFloat offsetRadius = _activityIndicator.radius - kActivityIndicatorExampleStrokeWidth / 2.0f;
+ UIBezierPath *strokePath = [UIBezierPath bezierPathWithArcCenter:_refreshStrokeLayer.position
+ radius:offsetRadius
+ startAngle:-1.0f * (CGFloat)M_PI_2
+ endAngle:3.0f * (CGFloat)M_PI_2
+ clockwise:YES];
+ _refreshStrokeLayer.path = strokePath.CGPath;
+
+ [CATransaction commit];
+}
+
+- (void)startRefreshing {
+ _button.enabled = NO;
+
+ MDCActivityIndicatorTransition *transition =
+ [[MDCActivityIndicatorTransition alloc] initWithAnimation:^(CGFloat start, CGFloat end) {
+ [self addFromRefreshIconAnimationsToActivityIndicatorWithStrokeStart:start strokeEnd:end];
+ }];
+ transition.duration = kActivityIndicatorExampleAnimationDuration;
+ transition.completion = ^{
+ [CATransaction begin];
+ [CATransaction setDisableActions:YES];
+ _rotationContainer.hidden = YES;
+ [_refreshArrowContainer removeAllAnimations];
+ [_refreshArrowPoint removeAllAnimations];
+ [CATransaction commit];
+
+ dispatch_time_t stopTime =
+ dispatch_time(DISPATCH_TIME_NOW, (int64_t)(3 * NSEC_PER_SEC));
+ dispatch_after(stopTime, dispatch_get_main_queue(), ^{
+ [self stopRefreshing];
+ });
+ };
+
+ [_activityIndicator startAnimatingWithTransition:transition
+ cycleStartIndex:1];
+}
+
+- (void)stopRefreshing {
+ [_refreshStrokeLayer removeAllAnimations];
+ [_rotationContainer removeAllAnimations];
+
+ MDCActivityIndicatorTransition *transition =
+ [[MDCActivityIndicatorTransition alloc] initWithAnimation:^(CGFloat start, CGFloat end) {
+ [self addToRefreshIconAnimationsFromActivityIndicatorWithStrokeStart:start strokeEnd:end];
+ }];
+ transition.duration = kActivityIndicatorExampleAnimationDuration;
+ transition.completion = ^{
+ _button.enabled = YES;
+ };
+
+ [_activityIndicator stopAnimatingWithTransition:transition];
+}
+
+#pragma mark - Private
+
+- (void)addFromRefreshIconAnimationsToActivityIndicatorWithStrokeStart:(CGFloat)strokeStart
+ strokeEnd:(CGFloat)strokeEnd {
+ // Outer rotation
+ CABasicAnimation *outerRotationAnimation =
+ [CABasicAnimation animationWithKeyPath:@"transform.rotation.z"];
+ outerRotationAnimation.fromValue = @((CGFloat)M_PI * .65f);
+ outerRotationAnimation.toValue = @(strokeEnd * 2 * M_PI);
+ outerRotationAnimation.fillMode = kCAFillModeForwards;
+ outerRotationAnimation.removedOnCompletion = NO;
+ [_rotationContainer addAnimation:outerRotationAnimation forKey:@"transform.rotation.z"];
+
+ CGFloat difference = strokeEnd - strokeStart;
+
+ // Stroke start
+ CABasicAnimation *strokeStartAnimation = [CABasicAnimation animationWithKeyPath:@"strokeStart"];
+ strokeStartAnimation.fromValue = @(0);
+ // Ensure the stroke never disappears by never hitting stroke end's toValue.
+ strokeStartAnimation.toValue = @(1.0f - difference);
+ strokeStartAnimation.fillMode = kCAFillModeBoth;
+ strokeStartAnimation.removedOnCompletion = NO;
+ [_refreshStrokeLayer addAnimation:strokeStartAnimation forKey:@"strokeStart"];
+
+ // Stroke end
+ CABasicAnimation *strokeEndAnimation = [CABasicAnimation animationWithKeyPath:@"strokeEnd"];
+ strokeEndAnimation.fromValue = @(_refreshStrokeLayer.strokeEnd);
+ strokeEndAnimation.toValue = @(1);
+ strokeEndAnimation.fillMode = kCAFillModeBoth;
+ strokeEndAnimation.removedOnCompletion = NO;
+ [_refreshStrokeLayer addAnimation:strokeEndAnimation forKey:@"strokeEnd"];
+
+ // Refresh arrow rotation and scale
+ CABasicAnimation *refreshArrowRotation =
+ [CABasicAnimation animationWithKeyPath:@"transform.rotation.z"];
+ refreshArrowRotation.fromValue = @(M_PI * 1.6f);
+ refreshArrowRotation.toValue = @(M_PI * 2);
+ refreshArrowRotation.fillMode = kCAFillModeForwards;
+ refreshArrowRotation.removedOnCompletion = NO;
+ [_refreshArrowContainer addAnimation:refreshArrowRotation forKey:@"transform.rotation.z"];
+
+ CABasicAnimation *arrowPointScaleAnimation =
+ [CABasicAnimation animationWithKeyPath:@"transform.scale"];
+ arrowPointScaleAnimation.fromValue = @(1);
+ arrowPointScaleAnimation.toValue = @(0);
+ arrowPointScaleAnimation.fillMode = kCAFillModeForwards;
+ arrowPointScaleAnimation.removedOnCompletion = NO;
+ [_refreshArrowPoint addAnimation:arrowPointScaleAnimation forKey:@"transform.scale"];
+}
+
+- (void)addToRefreshIconAnimationsFromActivityIndicatorWithStrokeStart:(CGFloat)strokeStart
+ strokeEnd:(CGFloat)strokeEnd {
+ // Adjust stroke position to offset outer rotation angle and ensure stroke position is in range
+ // [0,1] for smooth animation
+ strokeStart -= .325f;
+ strokeStart = strokeStart < 0 ? strokeStart + 1 : strokeStart;
+ strokeEnd -= .325f;
+ strokeEnd = strokeEnd < 0 ? strokeEnd + 1 : strokeEnd;
+
+ _rotationContainer.hidden = NO;
+
+ // Stroke start
+ CABasicAnimation *strokeStartAnimation = [CABasicAnimation animationWithKeyPath:@"strokeStart"];
+ strokeStartAnimation.fromValue = @(strokeStart);
+ strokeStartAnimation.toValue = @(0);
+ strokeStartAnimation.fillMode = kCAFillModeBoth;
+ strokeStartAnimation.removedOnCompletion = NO;
+ [_refreshStrokeLayer addAnimation:strokeStartAnimation forKey:@"strokeStart"];
+
+ // Stroke end
+ CABasicAnimation *strokeEndAnimation = [CABasicAnimation animationWithKeyPath:@"strokeEnd"];
+ strokeEndAnimation.fromValue = @(strokeEnd);
+ strokeEndAnimation.toValue = @(.8f);
+ strokeEndAnimation.fillMode = kCAFillModeBoth;
+ strokeEndAnimation.removedOnCompletion = NO;
+ [_refreshStrokeLayer addAnimation:strokeEndAnimation forKey:@"strokeEnd"];
+
+ // Refresh arrow rotation and scale
+ CABasicAnimation *refreshArrowRotation =
+ [CABasicAnimation animationWithKeyPath:@"transform.rotation.z"];
+ refreshArrowRotation.fromValue = @(strokeStart * 2 * M_PI);
+ refreshArrowRotation.toValue = @(1.6f * M_PI);
+ refreshArrowRotation.fillMode = kCAFillModeForwards;
+ refreshArrowRotation.removedOnCompletion = NO;
+ [_refreshArrowContainer addAnimation:refreshArrowRotation forKey:@"transform.rotation.z"];
+
+ CABasicAnimation *arrowPointScaleAnimation =
+ [CABasicAnimation animationWithKeyPath:@"transform.scale"];
+ arrowPointScaleAnimation.fromValue = @(0);
+ arrowPointScaleAnimation.toValue = @(1);
+ arrowPointScaleAnimation.fillMode = kCAFillModeForwards;
+ arrowPointScaleAnimation.removedOnCompletion = NO;
+ [_refreshArrowPoint addAnimation:arrowPointScaleAnimation forKey:@"transform.scale"];
+}
+
+#pragma mark - Catalog by Convention
+
++ (NSArray *)catalogBreadcrumbs {
+ return @[ @"Activity Indicator", @"Activity Indicator Transition" ];
+}
+
++ (BOOL)catalogIsPrimaryDemo {
+ return NO;
+}
+
++ (BOOL)catalogIsPresentable {
+ return YES;
+}
+
+@end

MODIFY
components/ActivityIndicator/src/MDCActivityIndicator.h
components/ActivityIndicator/src/MDCActivityIndicator.h
#import <UIKit/UIKit.h>
+@class MDCActivityIndicatorTransition;
@protocol MDCActivityIndicatorDelegate;
/**
@@ -109,12 +110,30 @@ IB_DESIGNABLE
*/
- (void)startAnimating;
+/**
+ Starts the animated activity indicator after performing the provided transition. The animation
+ cycle will begin on the cycleStartIndex provided. The startTransition will be applied with the
+ starting and ending positions of the indicator stroke at the moment when the animation will begin
+ taking into account the provided cycleStartIndex in the range [0,1]. The indicatorMode must be
+ MDCActivityIndicatorModeIndeterminate before calling.
+ */
+- (void)startAnimatingWithTransition:(nonnull MDCActivityIndicatorTransition *)startTransition
+ cycleStartIndex:(NSInteger)cycleStartIndex;
+
/**
Stops the animated activity indicator with a short opacity and stroke width animation. Does nothing
if the spinner is not animating.
*/
- (void)stopAnimating;
+/**
+ Stops the animated activity indicator and then performs the provided transition. The provided
+ stopTransition will be called with the starting and ending positions of the indicator stroke at the
+ moment when the animation will begin in the range [0,1]. The indicatorMode must be
+ MDCActivityIndicatorModeIndeterminate before calling.
+ */
+- (void)stopAnimatingWithTransition:(nonnull MDCActivityIndicatorTransition *)stopTransition;
+
@end
/**
@@ -141,3 +160,38 @@ IB_DESIGNABLE
- (void)activityIndicatorModeTransitionDidFinish:(nonnull MDCActivityIndicator *)activityIndicator;
@end
+
+typedef void (^MDCActivityIndicatorAnimation)(CGFloat strokeStart, CGFloat strokeEnd);
+
+/**
+ Describes an animation that can be provided to an MDCActivityIndicator instance to perform before
+ or after its standard cycle animation.
+ */
+@interface MDCActivityIndicatorTransition : NSObject
+
+/**
+ The animations to be performed by MDCActivityIndicator. In this block add CAAnimations to be
+ animated before or after MDCActivityIndicator's cycle animation. MDCActivityIndicator will trigger
+ these animations and call completion after they complete.
+ */
+@property(nonatomic, copy, nonnull) MDCActivityIndicatorAnimation animation;
+
+/**
+ The completion block to call after animation's completion. This should be used to clean up any
+ layers placed and animating on the MDCActivityIndicator.
+ */
+@property(nonatomic, copy, nullable) void (^completion)(void);
+
+/**
+ The duration of the animation.
+ */
+@property(nonatomic, assign) NSTimeInterval duration;
+
+- (nonnull instancetype)init NS_UNAVAILABLE;
+
+- (nonnull instancetype)initWithCoder:(nonnull NSCoder *)aDecoder NS_UNAVAILABLE;
+
+- (nonnull instancetype)initWithAnimation:
+ (_Nonnull MDCActivityIndicatorAnimation)animation NS_DESIGNATED_INITIALIZER;
+
+@end

MODIFY
components/ActivityIndicator/src/MDCActivityIndicator.m
components/ActivityIndicator/src/MDCActivityIndicator.m
@@ -95,6 +95,12 @@ static const CGFloat kSingleCycleRotation =
*/
@property(nonatomic, strong, readonly, nullable) CAShapeLayer *trackLayer;
+/**
+ The currently queued stop transition, which will be run as soon as the current animation cycle
+ completes. At all other times should be nil.
+ */
+@property(nonatomic, strong, nullable) MDCActivityIndicatorTransition *stopTransition;
+
@end
@implementation MDCActivityIndicator {
@@ -236,9 +242,73 @@ static const CGFloat kSingleCycleRotation =
_animating = YES;
- if (self.window && !_backgrounded) {
+ if (!self.window || _backgrounded) {
+ return;
+ }
+
+ [self actuallyStartAnimating];
+}
+
+- (void)startAnimatingWithTransition:(nonnull MDCActivityIndicatorTransition *)startTransition
+ cycleStartIndex:(NSInteger)cycleStartIndex {
+ if (_animating) {
+ return;
+ }
+
+ BOOL indeterminate = self.indicatorMode == MDCActivityIndicatorModeIndeterminate;
+ NSAssert(indeterminate, @"startAnimatingWithStartAnimation requires an indeterminate mode.");
+ if (!indeterminate) {
+ return;
+ }
+
+ if (_animatingOut) {
+ if ([_delegate respondsToSelector:@selector(activityIndicatorAnimationDidFinish:)]) {
+ [_delegate activityIndicatorAnimationDidFinish:self];
+ }
+ [self removeAnimations];
+ }
+
+ _animating = YES;
+
+ if (!self.window || _backgrounded) {
+ return;
+ }
+
+ _cycleStartIndex = cycleStartIndex;
+ _cycleCount = _cycleStartIndex;
+
+ _animationInProgress = YES;
+
+ [CATransaction begin];
+ {
+ [CATransaction setCompletionBlock:^{
+ _animationInProgress = NO;
+
[self actuallyStartAnimating];
+
+ _cycleColorsIndex = self.cycleColors.count > 0 ? cycleStartIndex % self.cycleColors.count : 0;
+ [self applyPropertiesWithoutAnimation:^{
+ [self updateStrokeColor];
+ }];
+
+ if (startTransition.completion) {
+ startTransition.completion();
+ }
+ }];
+ [CATransaction setAnimationDuration:startTransition.duration];
+ [CATransaction setDisableActions:YES];
+
+ CGFloat outerRotation = kOuterRotationIncrement * _cycleStartIndex;
+ CGFloat innerRotation = _cycleStartIndex * (CGFloat)M_PI;
+ CGFloat strokeStart =
+ (CGFloat)fmod(innerRotation + outerRotation, 2 * M_PI) / (CGFloat)(2 * M_PI);
+
+ CGFloat strokeEnd = _minStrokeDifference + strokeStart;
+ strokeEnd = strokeEnd > 1 ? strokeEnd - 1 : strokeEnd;
+
+ startTransition.animation(strokeStart, strokeEnd);
}
+ [CATransaction commit];
}
- (void)stopAnimating {
@@ -251,6 +321,23 @@ static const CGFloat kSingleCycleRotation =
[self animateOut];
}
+- (void)stopAnimatingWithTransition:(MDCActivityIndicatorTransition *)stopTransition {
+ if (!_animating) {
+ return;
+ }
+
+ BOOL indeterminate = self.indicatorMode == MDCActivityIndicatorModeIndeterminate;
+ NSAssert(indeterminate, @"stopAnimationWithTransition requires an indeterminate mode.");
+ if (!indeterminate) {
+ return;
+ }
+
+ _animating = NO;
+ _animatingOut = YES;
+
+ self.stopTransition = stopTransition;
+}
+
- (void)stopAnimatingImmediately {
if (!_animating) {
return;
@@ -290,7 +377,7 @@ static const CGFloat kSingleCycleRotation =
[self addTransitionToIndeterminateCycle];
break;
}
- } else {
+ } else if (!_animating) {
if ([_delegate respondsToSelector:@selector(activityIndicatorModeTransitionDidFinish:)]) {
[_delegate activityIndicatorModeTransitionDidFinish:self];
}
@@ -428,6 +515,47 @@ static const CGFloat kSingleCycleRotation =
}
}
+- (void)addStopAnimation {
+ MDCActivityIndicatorTransition *stopTransition = self.stopTransition;
+
+ CGFloat innerRotation =
+ [[_strokeLayer valueForKeyPath:MDMKeyPathRotation] floatValue];
+ CGFloat outerRotation =
+ [[_outerRotationLayer valueForKeyPath:MDMKeyPathRotation] floatValue];
+ CGFloat totalRotation =
+ (CGFloat)fmod(innerRotation + outerRotation, 2 * M_PI) / (CGFloat)(2 * M_PI);
+
+ CGFloat strokeStart = _strokeLayer.strokeStart + totalRotation;
+ strokeStart = strokeStart > 1 ? strokeStart - 1 : strokeStart;
+
+ CGFloat strokeEnd = _strokeLayer.strokeEnd + totalRotation;
+ strokeEnd = strokeEnd > 1 ? strokeEnd - 1 : strokeEnd;
+
+ [self applyPropertiesWithoutAnimation:^{
+ _strokeLayer.strokeStart = 0.0f;
+ _strokeLayer.strokeEnd = 0.0f;
+ }];
+
+ [CATransaction begin];
+ {
+ [CATransaction setCompletionBlock:^{
+ if (stopTransition.completion) {
+ stopTransition.completion();
+ }
+ if (stopTransition == self.stopTransition) {
+ if ([_delegate respondsToSelector:@selector(activityIndicatorAnimationDidFinish:)]) {
+ [_delegate activityIndicatorAnimationDidFinish:self];
+ }
+ [self removeAnimations];
+ }
+ }];
+ [CATransaction setAnimationDuration:stopTransition.duration];
+ [CATransaction setDisableActions:YES];
+ stopTransition.animation(strokeStart, strokeEnd);
+ }
+ [CATransaction commit];
+}
+
- (void)updateStrokePath {
CGFloat offsetRadius = _radius - _strokeLayer.lineWidth / 2.0f;
UIBezierPath *strokePath = [UIBezierPath bezierPathWithArcCenter:_strokeLayer.position
@@ -683,6 +811,12 @@ static const CGFloat kSingleCycleRotation =
if (!_animationsAdded) {
return;
}
+
+ if (self.stopTransition) {
+ [self addStopAnimation];
+ return;
+ }
+
if (state == MDCActivityIndicatorStateIndeterminate) {
if (self.cycleColors.count > 0) {
self.cycleColorsIndex = (self.cycleColorsIndex + 1) % self.cycleColors.count;
@@ -760,6 +894,7 @@ static const CGFloat kSingleCycleRotation =
- (void)removeAnimations {
_animationsAdded = NO;
_animatingOut = NO;
+ self.stopTransition = nil;
[_strokeLayer removeAllAnimations];
[_outerRotationLayer removeAllAnimations];
@@ -871,3 +1006,13 @@ static const CGFloat kSingleCycleRotation =
}
@end
+
+@implementation MDCActivityIndicatorTransition
+- (nonnull instancetype)initWithAnimation:(_Nonnull MDCActivityIndicatorAnimation)animation {
+ self = [super init];
+ if (self) {
+ self.animation = animation;
+ }
+ return self;
+}
+@end

Add an api to MDCActivityIndicator to allow animations before/after animating (#2766)