blob: a70371f5b4c54b1073c993d66c89e1081e0603ff [file] [log] [blame]
// 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 "MDCActivityIndicator.h"
#import <QuartzCore/QuartzCore.h>
#import <MDFInternationalization/MDFInternationalization.h>
#import <MotionAnimator/MotionAnimator.h>
#import "MaterialApplication.h"
#import "MaterialPalettes.h"
#import "private/MDCActivityIndicatorMotionSpec.h"
#import "private/MDCActivityIndicator+Private.h"
#import "private/MaterialActivityIndicatorStrings.h"
#import "private/MaterialActivityIndicatorStrings_table.h"
static const NSInteger kTotalDetentCount = 5;
static const NSTimeInterval kAnimateOutDuration = 0.1;
static const CGFloat kCycleRotation = (CGFloat)(3.0 / 2);
static const CGFloat kOuterRotationIncrement = (CGFloat)(1.0 / kTotalDetentCount) * (CGFloat)M_PI;
static const CGFloat kSpinnerRadius = 12;
static const CGFloat kStrokeLength = (CGFloat)0.75;
#ifndef CGFLOAT_EPSILON
#if CGFLOAT_IS_DOUBLE
#define CGFLOAT_EPSILON DBL_EPSILON
#else
#define CGFLOAT_EPSILON FLT_EPSILON
#endif
#endif
// The Bundle for string resources.
static NSString *const kBundle = @"MaterialActivityIndicator.bundle";
/**
Total rotation (outer rotation + stroke rotation) per _cycleCount. One turn is 2.
*/
static const CGFloat kSingleCycleRotation =
2 * kStrokeLength + kCycleRotation + (CGFloat)(1.0 / kTotalDetentCount);
@interface MDCActivityIndicator ()
/**
The minimum stroke difference to use when collapsing the stroke to a dot. Based on current
radius and stroke width.
*/
@property(nonatomic, assign, readonly) CGFloat minStrokeDifference;
/**
The index of the current stroke color in the @c cycleColors array.
@note Subclasses can change this value to start the spinner at a different color.
*/
@property(nonatomic, assign) NSUInteger cycleColorsIndex;
/**
The current cycle count.
*/
@property(nonatomic, assign, readonly) NSInteger cycleCount;
/**
The cycle index at which to start the activity spinner animation. Default is 0, which corresponds
to the top of the spinner (12 o'clock position). Spinner cycle indices are based on a 5-point
star.
*/
@property(nonatomic, assign) NSInteger cycleStartIndex;
/**
The outer layer that handles cycle rotations and houses the stroke layer.
*/
@property(nonatomic, strong, readonly, nullable) CALayer *outerRotationLayer;
/**
The shape layer that handles the animating stroke.
*/
@property(nonatomic, strong, readonly, nullable) CAShapeLayer *strokeLayer;
/**
The shape layer that shows a faint, circular track along the path of the stroke layer. Enabled
via the trackEnabled property.
*/
@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 {
BOOL _animatingOut;
BOOL _animationsAdded;
BOOL _animationInProgress;
BOOL _backgrounded;
BOOL _cycleInProgress;
CGFloat _currentProgress;
CGFloat _lastProgress;
MDMMotionAnimator *_animator;
}
#pragma mark - Init
- (instancetype)initWithFrame:(CGRect)frame {
self = [super initWithFrame:frame];
if (self) {
[self commonMDCActivityIndicatorInit];
}
return self;
}
- (instancetype)initWithCoder:(NSCoder *)coder {
self = [super initWithCoder:coder];
if (self) {
[self commonMDCActivityIndicatorInit];
}
return self;
}
- (void)dealloc {
[[NSNotificationCenter defaultCenter] removeObserver:self];
}
- (void)layoutSubviews {
[super layoutSubviews];
[self applyPropertiesWithoutAnimation:^{
// Resize and recenter rotation layer.
self.outerRotationLayer.bounds = self.bounds;
self.outerRotationLayer.position =
CGPointMake(self.frame.size.width / 2, self.frame.size.height / 2);
self.strokeLayer.bounds = self.outerRotationLayer.bounds;
self.strokeLayer.position = self.outerRotationLayer.position;
[self updateStrokePath];
}];
}
- (void)commonMDCActivityIndicatorInit {
// Register notifications for foreground and background if needed.
[self registerForegroundAndBackgroundNotificationObserversIfNeeded];
// UISemanticContentAttribute was added in iOS SDK 9.0 but is available on devices running earlier
// version of iOS. We ignore the partial-availability warning that gets thrown on our use of this
// symbol.
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wpartial-availability"
// The activity indicator reflects the passage of time (a spatial semantic context) and so
// will not be mirrored in RTL languages.
self.mdf_semanticContentAttribute = UISemanticContentAttributeSpatial;
#pragma clang diagnostic pop
_animator = [[MDMMotionAnimator alloc] init];
_animator.additive = NO;
_cycleStartIndex = 0;
_indicatorMode = MDCActivityIndicatorModeIndeterminate;
// Property defaults.
_radius = kSpinnerRadius;
_strokeWidth = 2;
// Colors.
_cycleColorsIndex = 0;
_cycleColors = [MDCActivityIndicator defaultCycleColors];
self.accessibilityLabel = [self defaultAccessibilityLabel];
// Track layer.
_trackLayer = [CAShapeLayer layer];
_trackLayer.lineWidth = _strokeWidth;
_trackLayer.fillColor = [UIColor clearColor].CGColor;
[self.layer addSublayer:_trackLayer];
_trackLayer.hidden = YES;
// Rotation layer.
_outerRotationLayer = [CALayer layer];
[self.layer addSublayer:_outerRotationLayer];
// Stroke layer.
_strokeLayer = [CAShapeLayer layer];
_strokeLayer.lineWidth = _strokeWidth;
_strokeLayer.fillColor = [UIColor clearColor].CGColor;
_strokeLayer.strokeStart = 0;
_strokeLayer.strokeEnd = 0;
[_outerRotationLayer addSublayer:_strokeLayer];
}
#pragma mark - UIView
- (void)willMoveToWindow:(UIWindow *)newWindow {
// If the activity indicator is removed from the window, we should
// immediately stop animating, otherwise it will start chewing up CPU.
if (!newWindow) {
[self actuallyStopAnimating];
} else if (_animating && !_backgrounded) {
[self actuallyStartAnimating];
}
}
- (CGSize)intrinsicContentSize {
CGFloat edge = 2 * _radius + _strokeWidth;
return CGSizeMake(edge, edge);
}
- (CGSize)sizeThatFits:(__unused CGSize)size {
CGFloat edge = 2 * _radius + _strokeWidth;
return CGSizeMake(edge, edge);
}
#pragma mark - Public methods
- (void)startAnimating {
if (_animatingOut) {
if ([_delegate respondsToSelector:@selector(activityIndicatorAnimationDidFinish:)]) {
[_delegate activityIndicatorAnimationDidFinish:self];
}
[self removeAnimations];
}
if (_animating) {
return;
}
_animating = YES;
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:^{
self->_animationInProgress = NO;
[self actuallyStartAnimating];
self.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 {
if (!_animating) {
return;
}
_animating = NO;
[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;
}
_animating = NO;
[self actuallyStopAnimating];
if ([_delegate respondsToSelector:@selector(activityIndicatorAnimationDidFinish:)]) {
[_delegate activityIndicatorAnimationDidFinish:self];
}
}
- (void)resetStrokeColor {
_cycleColorsIndex = 0;
[self updateStrokeColor];
}
- (void)setStrokeColor:(UIColor *)strokeColor {
_strokeLayer.strokeColor = strokeColor.CGColor;
_trackLayer.strokeColor = [strokeColor colorWithAlphaComponent:(CGFloat)0.3].CGColor;
}
- (void)setIndicatorMode:(MDCActivityIndicatorMode)indicatorMode {
if (_indicatorMode == indicatorMode) {
return;
}
_indicatorMode = indicatorMode;
if (_animating && !_animationInProgress) {
switch (indicatorMode) {
case MDCActivityIndicatorModeDeterminate:
[self addTransitionToDeterminateCycle];
break;
case MDCActivityIndicatorModeIndeterminate:
[self addTransitionToIndeterminateCycle];
break;
}
} else if (!_animating) {
if ([_delegate respondsToSelector:@selector(activityIndicatorModeTransitionDidFinish:)]) {
[_delegate activityIndicatorModeTransitionDidFinish:self];
}
}
}
- (void)setIndicatorMode:(MDCActivityIndicatorMode)mode animated:(__unused BOOL)animated {
[self setIndicatorMode:mode];
}
- (void)setProgress:(float)progress {
[self setProgress:progress animated:YES];
}
- (void)setProgress:(float)progress animated:(BOOL)animated {
_progress = MAX(0, MIN(progress, 1));
if (_indicatorMode == MDCActivityIndicatorModeIndeterminate || _progress == _currentProgress) {
return;
}
if (animated) {
if (_animating && !_animationInProgress) {
// Currently animating the determinate mode but no animation queued.
[self addProgressAnimation];
}
} else {
[self setDeterminateProgressWithoutAnimation];
}
}
#pragma mark - Properties
- (void)setStrokeWidth:(CGFloat)strokeWidth {
_strokeWidth = strokeWidth;
_strokeLayer.lineWidth = _strokeWidth;
_trackLayer.lineWidth = _strokeWidth;
[self updateStrokePath];
}
- (void)setRadius:(CGFloat)radius {
_radius = MIN(MAX(radius, 5), 72);
[self updateStrokePath];
}
- (void)setTrackEnabled:(BOOL)trackEnabled {
_trackEnabled = trackEnabled;
_trackLayer.hidden = !_trackEnabled;
}
#pragma mark - Private methods
/**
If this class is not being run in an extension, register for foreground changes and initialize
the app background state in case UI is created when the app is backgrounded. (Extensions always
return UIApplicationStateBackground for |[UIApplication sharedApplication].applicationState|.)
*/
- (void)registerForegroundAndBackgroundNotificationObserversIfNeeded {
if ([UIApplication mdc_isAppExtension]) {
return;
}
_backgrounded =
[UIApplication mdc_safeSharedApplication].applicationState == UIApplicationStateBackground;
NSNotificationCenter *notificationCenter = [NSNotificationCenter defaultCenter];
[notificationCenter addObserver:self
selector:@selector(controlAnimatingOnForegroundChange:)
name:UIApplicationWillEnterForegroundNotification
object:nil];
[notificationCenter addObserver:self
selector:@selector(controlAnimatingOnForegroundChange:)
name:UIApplicationDidEnterBackgroundNotification
object:nil];
}
- (void)controlAnimatingOnForegroundChange:(NSNotification *)notification {
// Stop or restart animating if the app has a foreground change.
_backgrounded = [notification.name isEqualToString:UIApplicationDidEnterBackgroundNotification];
if (_animating) {
if (_backgrounded) {
[self actuallyStopAnimating];
} else if (self.window) {
[self actuallyStartAnimating];
}
}
}
- (void)actuallyStartAnimating {
if (_animationsAdded) {
return;
}
_animationsAdded = YES;
_cycleCount = _cycleStartIndex;
[self applyPropertiesWithoutAnimation:^{
self.strokeLayer.strokeStart = 0;
self.strokeLayer.strokeEnd = (CGFloat)0.001;
self.strokeLayer.lineWidth = self.strokeWidth;
self.trackLayer.lineWidth = self.strokeWidth;
[self resetStrokeColor];
[self updateStrokePath];
}];
switch (_indicatorMode) {
case MDCActivityIndicatorModeIndeterminate:
[self addStrokeRotationCycle];
break;
case MDCActivityIndicatorModeDeterminate:
[self addProgressAnimation];
break;
}
}
- (void)actuallyStopAnimating {
if (!_animationsAdded) {
return;
}
[self removeAnimations];
[self applyPropertiesWithoutAnimation:^{
self.strokeLayer.strokeStart = 0;
self.strokeLayer.strokeEnd = 0;
}];
}
- (void)setCycleColors:(NSArray<UIColor *> *)cycleColors {
if (cycleColors.count) {
_cycleColors = [cycleColors copy];
} else {
_cycleColors = [MDCActivityIndicator defaultCycleColors];
}
if (self.cycleColors.count) {
[self setStrokeColor:self.cycleColors[0]];
}
}
- (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:^{
self.strokeLayer.strokeStart = 0;
self.strokeLayer.strokeEnd = 0;
}];
[CATransaction begin];
{
[CATransaction setCompletionBlock:^{
if (stopTransition.completion) {
stopTransition.completion();
}
if (stopTransition == self.stopTransition) {
if ([self.delegate respondsToSelector:@selector(activityIndicatorAnimationDidFinish:)]) {
[self.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;
UIBezierPath *strokePath = [UIBezierPath bezierPathWithArcCenter:_strokeLayer.position
radius:offsetRadius
startAngle:-1 * (CGFloat)M_PI_2
endAngle:3 * (CGFloat)M_PI_2
clockwise:YES];
_strokeLayer.path = strokePath.CGPath;
_trackLayer.path = strokePath.CGPath;
_minStrokeDifference = _strokeLayer.lineWidth / ((CGFloat)M_PI * 2 * _radius);
}
- (void)updateStrokeColor {
if (self.cycleColors.count > 0 && self.cycleColors.count > self.cycleColorsIndex) {
[self setStrokeColor:self.cycleColors[self.cycleColorsIndex]];
} else {
NSAssert(NO, @"cycleColorsIndex is outside the bounds of cycleColors.");
[self setStrokeColor:[[MDCActivityIndicator defaultCycleColors] firstObject]];
}
}
- (void)addStrokeRotationCycle {
if (_animationInProgress) {
return;
}
[CATransaction begin];
[CATransaction setCompletionBlock:^{
[self strokeRotationCycleFinishedFromState:MDCActivityIndicatorStateIndeterminate];
}];
MDCActivityIndicatorMotionSpecIndeterminate timing =
MDCActivityIndicatorMotionSpec.loopIndeterminate;
// These values may be equal if we've never received a progress. In this case we don't want our
// duration to become zero.
if (fabs(_lastProgress - _currentProgress) > CGFLOAT_EPSILON) {
timing.strokeEnd.duration *= ABS(_lastProgress - _currentProgress);
}
[_animator animateWithTiming:timing.outerRotation
toLayer:_outerRotationLayer
withValues:@[@(kOuterRotationIncrement * _cycleCount),
@(kOuterRotationIncrement * (_cycleCount + 1))]
keyPath:MDMKeyPathRotation];
CGFloat startRotation = _cycleCount * (CGFloat)M_PI;
CGFloat endRotation = startRotation + kCycleRotation * (CGFloat)M_PI;
[_animator animateWithTiming:timing.innerRotation
toLayer:_strokeLayer
withValues:@[@(startRotation), @(endRotation)]
keyPath:MDMKeyPathRotation];
[_animator animateWithTiming:timing.strokeStart
toLayer:_strokeLayer
withValues:@[@0, @(kStrokeLength)]
keyPath:MDMKeyPathStrokeStart];
// Ensure the stroke never completely disappears on start by animating from non-zero start and
// to a value slightly larger than the strokeStart's final value.
[_animator animateWithTiming:timing.strokeEnd
toLayer:_strokeLayer
withValues:@[@(_minStrokeDifference), @(kStrokeLength + _minStrokeDifference)]
keyPath:MDMKeyPathStrokeEnd];
[CATransaction commit];
_animationInProgress = YES;
}
- (void)addTransitionToIndeterminateCycle {
if (_animationInProgress) {
return;
}
// Find the nearest cycle to transition through.
NSInteger nearestCycle = 0;
CGFloat nearestDistance = CGFLOAT_MAX;
const CGFloat normalizedProgress = MAX(_lastProgress - _minStrokeDifference, 0);
for (NSInteger cycle = 0; cycle < kTotalDetentCount; cycle++) {
const CGFloat currentRotation = [self normalizedRotationForCycle:cycle];
if (currentRotation >= normalizedProgress) {
if (nearestDistance >= (currentRotation - normalizedProgress)) {
nearestDistance = currentRotation - normalizedProgress;
nearestCycle = cycle;
}
}
}
if (nearestCycle == 0 && _lastProgress <= _minStrokeDifference) {
// Special case for 0% progress.
_cycleCount = nearestCycle;
[self strokeRotationCycleFinishedFromState:MDCActivityIndicatorStateTransitionToIndeterminate];
if ([_delegate respondsToSelector:@selector(activityIndicatorModeTransitionDidFinish:)]) {
[_delegate activityIndicatorModeTransitionDidFinish:self];
}
return;
}
_cycleCount = nearestCycle;
CGFloat targetRotation = [self normalizedRotationForCycle:nearestCycle];
if (targetRotation <= (CGFloat)0.001) {
targetRotation = 1;
}
CGFloat pointCycleDuration = (CGFloat)MDCActivityIndicatorMotionSpec.pointCycleDuration;
CGFloat pointCycleMinimumVariableDuration =
(CGFloat)MDCActivityIndicatorMotionSpec.pointCycleMinimumVariableDuration;
CGFloat normalizedDuration = 2 * (targetRotation + _currentProgress) / kSingleCycleRotation *
pointCycleDuration;
CGFloat strokeEndTravelDistance = targetRotation - _currentProgress + _minStrokeDifference;
CGFloat totalDistance = targetRotation + strokeEndTravelDistance;
CGFloat strokeStartDuration =
MAX(normalizedDuration * targetRotation / totalDistance, pointCycleMinimumVariableDuration);
CGFloat strokeEndDuration = MAX(normalizedDuration * strokeEndTravelDistance / totalDistance,
pointCycleMinimumVariableDuration);
[CATransaction begin];
{
[CATransaction setCompletionBlock:^{
[self
strokeRotationCycleFinishedFromState:MDCActivityIndicatorStateTransitionToIndeterminate];
if ([self.delegate respondsToSelector:@selector(activityIndicatorModeTransitionDidFinish:)]) {
[self.delegate activityIndicatorModeTransitionDidFinish:self];
}
}];
[CATransaction setDisableActions:YES];
MDCActivityIndicatorMotionSpecTransitionToIndeterminate timing =
MDCActivityIndicatorMotionSpec.willChangeToIndeterminate;
_outerRotationLayer.transform = CATransform3DIdentity;
_strokeLayer.transform = CATransform3DIdentity;
timing.strokeStart.duration = strokeStartDuration;
timing.strokeStart.delay = strokeEndDuration;
[_animator animateWithTiming:timing.strokeStart
toLayer:_strokeLayer
withValues:@[@0, @(targetRotation)]
keyPath:MDMKeyPathStrokeStart];
timing.strokeEnd.duration = strokeEndDuration;
timing.strokeEnd.delay = 0;
[_animator animateWithTiming:timing.strokeEnd
toLayer:_strokeLayer
withValues:@[@(_currentProgress), @(targetRotation + _minStrokeDifference)]
keyPath:MDMKeyPathStrokeEnd];
}
[CATransaction commit];
_animationInProgress = YES;
}
- (void)addTransitionToDeterminateCycle {
if (_animationInProgress) {
return;
}
if (!_cycleCount) {
// The animation period is complete: no need for transition.
[_strokeLayer removeAllAnimations];
[_outerRotationLayer removeAllAnimations];
// Necessary for transition from indeterminate to determinate when cycle == 0.
_currentProgress = 0;
_lastProgress = _currentProgress;
[self strokeRotationCycleFinishedFromState:MDCActivityIndicatorStateTransitionToDeterminate];
if ([_delegate respondsToSelector:@selector(activityIndicatorModeTransitionDidFinish:)]) {
[_delegate activityIndicatorModeTransitionDidFinish:self];
}
} else {
_currentProgress = MAX(_progress, _minStrokeDifference);
CGFloat rotationDelta = 1 - [self normalizedRotationForCycle:_cycleCount];
// Change the duration relative to the distance in order to keep same relative speed.
CGFloat pointCycleDuration = (CGFloat)MDCActivityIndicatorMotionSpec.pointCycleDuration;
CGFloat pointCycleMinimumVariableDuration =
(CGFloat)MDCActivityIndicatorMotionSpec.pointCycleMinimumVariableDuration;
CGFloat duration =
2 * (rotationDelta + _currentProgress) / kSingleCycleRotation * pointCycleDuration;
duration = MAX(duration, pointCycleMinimumVariableDuration);
[CATransaction begin];
{
[CATransaction setCompletionBlock:^{
[self
strokeRotationCycleFinishedFromState:MDCActivityIndicatorStateTransitionToDeterminate];
if ([self.delegate respondsToSelector:@selector(activityIndicatorModeTransitionDidFinish:)]) {
[self.delegate activityIndicatorModeTransitionDidFinish:self];
}
}];
[CATransaction setDisableActions:YES];
[CATransaction mdm_setTimeScaleFactor:@(duration)];
MDCActivityIndicatorMotionSpecTransitionToDeterminate spec =
MDCActivityIndicatorMotionSpec.willChangeToDeterminate;
_outerRotationLayer.transform =
CATransform3DMakeRotation(kOuterRotationIncrement * _cycleCount, 0, 0, 1);
CGFloat startRotation = _cycleCount * (CGFloat)M_PI;
CGFloat endRotation = startRotation + rotationDelta * 2 * (CGFloat)M_PI;
[_animator animateWithTiming:spec.innerRotation
toLayer:_strokeLayer
withValues:@[@(startRotation), @(endRotation)]
keyPath:MDMKeyPathRotation];
_strokeLayer.strokeStart = 0;
[_animator animateWithTiming:spec.strokeEnd
toLayer:_strokeLayer
withValues:@[@(_minStrokeDifference), @(_currentProgress)]
keyPath:MDMKeyPathStrokeEnd];
}
[CATransaction commit];
_animationInProgress = YES;
_lastProgress = _currentProgress;
}
}
- (void)addProgressAnimation {
if (_animationInProgress) {
return;
}
_currentProgress = MAX(_progress, _minStrokeDifference);
[CATransaction begin];
{
[CATransaction setCompletionBlock:^{
[self strokeRotationCycleFinishedFromState:MDCActivityIndicatorStateDeterminate];
}];
[CATransaction setDisableActions:YES];
_outerRotationLayer.transform = CATransform3DIdentity;
_strokeLayer.transform = CATransform3DIdentity;
_strokeLayer.strokeStart = 0;
[_animator animateWithTiming:MDCActivityIndicatorMotionSpec.willChangeProgress.strokeEnd
toLayer:_strokeLayer
withValues:@[@(_lastProgress), @(_currentProgress)]
keyPath:MDMKeyPathStrokeEnd];
}
[CATransaction commit];
_lastProgress = _currentProgress;
_animationInProgress = YES;
}
- (void)setDeterminateProgressWithoutAnimation {
[self removeAnimations];
_animationInProgress = NO;
_currentProgress = MAX(_progress, _minStrokeDifference);
_strokeLayer.strokeStart = 0.0;
_strokeLayer.strokeEnd = _currentProgress;
_lastProgress = _currentProgress;
}
- (void)strokeRotationCycleFinishedFromState:(MDCActivityIndicatorState)state {
_animationInProgress = NO;
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;
[self updateStrokeColor];
}
_cycleCount = (_cycleCount + 1) % kTotalDetentCount;
}
switch (_indicatorMode) {
case MDCActivityIndicatorModeDeterminate:
switch (state) {
case MDCActivityIndicatorStateDeterminate:
case MDCActivityIndicatorStateTransitionToDeterminate:
[self addProgressAnimationIfRequired];
break;
case MDCActivityIndicatorStateIndeterminate:
case MDCActivityIndicatorStateTransitionToIndeterminate:
[self addTransitionToDeterminateCycle];
break;
}
break;
case MDCActivityIndicatorModeIndeterminate:
switch (state) {
case MDCActivityIndicatorStateDeterminate:
case MDCActivityIndicatorStateTransitionToDeterminate:
[self addTransitionToIndeterminateCycle];
break;
case MDCActivityIndicatorStateIndeterminate:
case MDCActivityIndicatorStateTransitionToIndeterminate:
[self addStrokeRotationCycle];
break;
}
break;
}
}
- (void)addProgressAnimationIfRequired {
if (_indicatorMode == MDCActivityIndicatorModeDeterminate) {
if (MAX(_progress, _minStrokeDifference) != _currentProgress) {
// The values were changes in the while animating or animation is starting.
[self addProgressAnimation];
}
}
}
/**
Rotation that a given cycle has. Represented between 0 (cycle has no rotation) and 1.
*/
- (CGFloat)normalizedRotationForCycle:(NSInteger)cycle {
CGFloat cycleRotation = cycle * kSingleCycleRotation / 2;
return cycleRotation - ((NSInteger)cycleRotation);
}
- (void)animateOut {
_animatingOut = YES;
[CATransaction begin];
[CATransaction setCompletionBlock:^{
if (self->_animatingOut) {
[self removeAnimations];
if ([self.delegate respondsToSelector:@selector(activityIndicatorAnimationDidFinish:)]) {
[self.delegate activityIndicatorAnimationDidFinish:self];
}
}
}];
[CATransaction setAnimationDuration:kAnimateOutDuration];
_strokeLayer.lineWidth = 0;
_trackLayer.lineWidth = 0;
[CATransaction commit];
}
- (void)removeAnimations {
_animationsAdded = NO;
_animatingOut = NO;
self.stopTransition = nil;
[_strokeLayer removeAllAnimations];
[_outerRotationLayer removeAllAnimations];
// Reset current and latest progress, to ensure addProgressAnimationIfRequired adds a progress animation
// when returning from hidden.
_currentProgress = 0;
_lastProgress = 0;
// Reset cycle count to 0 rather than cycleStart to reflect default starting position (top).
_cycleCount = 0;
// However _animationInProgress represents the CATransaction that hasn't finished, so we leave it
// alone here.
}
+ (CGFloat)defaultHeight {
return kSpinnerRadius * 2;
}
+ (NSArray<UIColor *> *)defaultCycleColors {
static NSArray<UIColor *> *s_defaultCycleColors;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
s_defaultCycleColors =
@[ MDCPalette.bluePalette.tint500,
MDCPalette.redPalette.tint500,
MDCPalette.yellowPalette.tint500,
MDCPalette.greenPalette.tint500 ];
});
return s_defaultCycleColors;
}
- (void)applyPropertiesWithoutAnimation:(void (^)(void))setPropBlock {
[CATransaction begin];
// Disable implicit CALayer animations
[CATransaction setDisableActions:YES];
setPropBlock();
[CATransaction commit];
}
#pragma mark - Resource Bundle
+ (NSBundle *)bundle {
static NSBundle *bundle = nil;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
bundle = [NSBundle bundleWithPath:[self bundlePathWithName:kBundle]];
});
return bundle;
}
+ (NSString *)bundlePathWithName:(NSString *)bundleName {
// In iOS 8+, we could be included by way of a dynamic framework, and our resource bundles may
// not be in the main .app bundle, but rather in a nested framework, so figure out where we live
// and use that as the search location.
NSBundle *bundle = [NSBundle bundleForClass:[MDCActivityIndicator class]];
NSString *resourcePath = [(nil == bundle ? [NSBundle mainBundle] : bundle)resourcePath];
return [resourcePath stringByAppendingPathComponent:bundleName];
}
#pragma mark - Accessibility
- (BOOL)isAccessibilityElement {
return self.isAnimating;
}
- (NSString *)defaultAccessibilityLabel {
MaterialActivityIndicatorStringId keyIndex = kStr_MaterialActivityIndicatorAccessibilityLabel;
NSString *key = kMaterialActivityIndicatorStringTable[keyIndex];
return NSLocalizedStringFromTableInBundle(key,
kMaterialActivityIndicatorStringsTableName,
[[self class] bundle],
@"Activity Indicator");
}
- (NSString *)accessibilityValue {
if (self.isAnimating) {
if (self.indicatorMode == MDCActivityIndicatorModeIndeterminate) {
MaterialActivityIndicatorStringId keyIndex =
kStr_MaterialActivityIndicatorInProgressAccessibilityValue;
NSString *key = kMaterialActivityIndicatorStringTable[keyIndex];
return NSLocalizedStringFromTableInBundle(key,
kMaterialActivityIndicatorStringsTableName,
[[self class] bundle],
@"In Progress");
} else {
NSUInteger percentage = (int)(self.progress * 100);
MaterialActivityIndicatorStringId keyIndex =
kStr_MaterialActivityIndicatorProgressCompletedAccessibilityValue;
NSString *key = kMaterialActivityIndicatorStringTable[keyIndex];
NSString *localizedString = NSLocalizedStringFromTableInBundle(key,
kMaterialActivityIndicatorStringsTableName,
[[self class] bundle],
@"{percentage} Percent Complete");
return [NSString localizedStringWithFormat:localizedString, percentage];
}
} else {
MaterialActivityIndicatorStringId keyIndex =
kStr_MaterialActivityIndicatorProgressHaltedAccessibilityValue;
NSString *key = kMaterialActivityIndicatorStringTable[keyIndex];
return NSLocalizedStringFromTableInBundle(key,
kMaterialActivityIndicatorStringsTableName,
[[self class] bundle],
@"Progress Halted");
}
}
- (UIAccessibilityTraits)accessibilityTraits {
return UIAccessibilityTraitUpdatesFrequently;
}
@end
@implementation MDCActivityIndicatorTransition
- (nonnull instancetype)initWithAnimation:(_Nonnull MDCActivityIndicatorAnimation)animation {
self = [super init];
if (self) {
self.animation = animation;
}
return self;
}
@end