// Copyright 2015 The Chromium Authors. All rights reserved. // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. #import "chrome/browser/ui/cocoa/spinner_view.h" #import #include "base/mac/mac_util.h" #include "base/mac/sdk_forward_declarations.h" #include "base/mac/scoped_cftyperef.h" #include "skia/ext/skia_utils_mac.h" #include "ui/base/theme_provider.h" #include "ui/native_theme/native_theme.h" #include "ui/native_theme/native_theme_mac.h" namespace { const CGFloat kDegrees90 = (M_PI / 2); const CGFloat kDegrees180 = (M_PI); const CGFloat kDegrees270 = (3 * M_PI / 2); const CGFloat kDegrees360 = (2 * M_PI); const CGFloat kDesignWidth = 28.0; const CGFloat kArcRadius = 12.5; const CGFloat kArcDiameter = kArcRadius * 2.0; const CGFloat kArcLength = 58.9; const CGFloat kArcStrokeWidth = 3.0; const CGFloat kArcAnimationTime = 1.333; const CGFloat kArcStartAngle = kDegrees180; const CGFloat kArcEndAngle = (kArcStartAngle + kDegrees270); const CGFloat kRotationTime = 1.56863; NSString* const kSpinnerAnimationName = @"SpinnerAnimationName"; NSString* const kRotationAnimationName = @"RotationAnimationName"; } @interface SpinnerView () { base::scoped_nsobject spinnerAnimation_; base::scoped_nsobject rotationAnimation_; CAShapeLayer* shapeLayer_; // Weak. CALayer* rotationLayer_; // Weak. } @end @implementation SpinnerView - (instancetype)initWithFrame:(NSRect)frame { if (self = [super initWithFrame:frame]) { [self setWantsLayer:YES]; } return self; } - (void)dealloc { [[NSNotificationCenter defaultCenter] removeObserver:self]; [super dealloc]; } // Register/unregister for window miniaturization event notifications so that // the spinner can stop animating if the window is minaturized // (i.e. not visible). - (void)viewWillMoveToWindow:(NSWindow*)newWindow { if ([self window]) { [[NSNotificationCenter defaultCenter] removeObserver:self name:NSWindowWillMiniaturizeNotification object:[self window]]; [[NSNotificationCenter defaultCenter] removeObserver:self name:NSWindowDidDeminiaturizeNotification object:[self window]]; } if (newWindow) { [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(updateAnimation:) name:NSWindowWillMiniaturizeNotification object:newWindow]; [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(updateAnimation:) name:NSWindowDidDeminiaturizeNotification object:newWindow]; } } // Start or stop the animation whenever the view is added to or removed from a // window. - (void)viewDidMoveToWindow { [self updateAnimation:nil]; } - (BOOL)isAnimating { return [shapeLayer_ animationForKey:kSpinnerAnimationName] != nil; } // Overridden to return a custom CALayer for the view (called from // setWantsLayer:). - (CALayer*)makeBackingLayer { CGRect bounds = [self bounds]; // The spinner was designed to be |kDesignWidth| points wide. Compute the // scale factor needed to scale design parameters like |RADIUS| so that the // spinner scales to fit the view's bounds. CGFloat scaleFactor = bounds.size.width / kDesignWidth; shapeLayer_ = [CAShapeLayer layer]; [shapeLayer_ setDelegate:self]; [shapeLayer_ setBounds:bounds]; // Per the design, the line width does not scale linearly. CGFloat scaledDiameter = kArcDiameter * scaleFactor; CGFloat lineWidth; if (scaledDiameter < kArcDiameter) { lineWidth = kArcStrokeWidth - (kArcDiameter - scaledDiameter) / 16.0; } else { lineWidth = kArcStrokeWidth + (scaledDiameter - kArcDiameter) / 11.0; } [shapeLayer_ setLineWidth:lineWidth]; [shapeLayer_ setLineCap:kCALineCapRound]; [shapeLayer_ setLineDashPattern:@[ @(kArcLength * scaleFactor) ]]; [shapeLayer_ setFillColor:NULL]; ui::NativeTheme* nativeTheme = ui::NativeThemeMac::instance(); SkColor throbberBlueColor = nativeTheme->GetSystemColor( ui::NativeTheme::kColorId_ThrobberSpinningColor); CGColorRef blueColor = skia::CGColorCreateFromSkColor(throbberBlueColor); [shapeLayer_ setStrokeColor:blueColor]; CGColorRelease(blueColor); // Create the arc that, when stroked, creates the spinner. base::ScopedCFTypeRef shapePath(CGPathCreateMutable()); CGPathAddArc(shapePath, NULL, bounds.size.width / 2.0, bounds.size.height / 2.0, kArcRadius * scaleFactor, kArcStartAngle, kArcEndAngle, 0); [shapeLayer_ setPath:shapePath]; // Place |shapeLayer_| in a layer so that it's easy to rotate the entire // spinner animation. rotationLayer_ = [CALayer layer]; [rotationLayer_ setBounds:bounds]; [rotationLayer_ addSublayer:shapeLayer_]; [shapeLayer_ setPosition:CGPointMake(NSMidX(bounds), NSMidY(bounds))]; // Place |rotationLayer_| in a parent layer so that it's easy to rotate // |rotationLayer_| around the center of the view. CALayer* parentLayer = [CALayer layer]; [parentLayer setBounds:bounds]; [parentLayer addSublayer:rotationLayer_]; [rotationLayer_ setPosition:CGPointMake(bounds.size.width / 2.0, bounds.size.height / 2.0)]; return parentLayer; } // Overridden to start or stop the animation whenever the view is unhidden or // hidden. - (void)setHidden:(BOOL)flag { [super setHidden:flag]; [self updateAnimation:nil]; } // Make sure the layer's backing store matches the window as the window moves // between screens. - (BOOL)layer:(CALayer*)layer shouldInheritContentsScale:(CGFloat)newScale fromWindow:(NSWindow*)window { return YES; } // The spinner animation consists of four cycles that it continuously repeats. // Each cycle consists of one complete rotation of the spinner's arc plus a // rotation adjustment at the end of each cycle (see rotation animation comment // below for the reason for the adjustment). The arc's length also grows and // shrinks over the course of each cycle, which the spinner achieves by drawing // the arc using a (solid) dashed line pattern and animating the "lineDashPhase" // property. - (void)initializeAnimation { CGRect bounds = [self bounds]; CGFloat scaleFactor = bounds.size.width / kDesignWidth; // Make sure |shapeLayer_|'s content scale factor matches the window's // backing depth (e.g. it's 2.0 on Retina Macs). Don't worry about adjusting // any other layers because |shapeLayer_| is the only one displaying content. if (base::mac::IsOSLionOrLater()) { CGFloat backingScaleFactor = [[self window] backingScaleFactor]; [shapeLayer_ setContentsScale:backingScaleFactor]; } // Create the first half of the arc animation, where it grows from a short // block to its full length. base::scoped_nsobject timingFunction( [[CAMediaTimingFunction alloc] initWithControlPoints:0.4 :0.0 :0.2 :1]); base::scoped_nsobject firstHalfAnimation( [[CAKeyframeAnimation alloc] init]); [firstHalfAnimation setTimingFunction:timingFunction]; [firstHalfAnimation setKeyPath:@"lineDashPhase"]; // Begin the lineDashPhase animation just short of the full arc length, // otherwise the arc will be zero length at start. NSArray* animationValues = @[ @(-(kArcLength - 0.4) * scaleFactor), @(0.0) ]; [firstHalfAnimation setValues:animationValues]; NSArray* keyTimes = @[ @(0.0), @(1.0) ]; [firstHalfAnimation setKeyTimes:keyTimes]; [firstHalfAnimation setDuration:kArcAnimationTime / 2.0]; [firstHalfAnimation setRemovedOnCompletion:NO]; [firstHalfAnimation setFillMode:kCAFillModeForwards]; // Create the second half of the arc animation, where it shrinks from full // length back to a short block. base::scoped_nsobject secondHalfAnimation( [[CAKeyframeAnimation alloc] init]); [secondHalfAnimation setTimingFunction:timingFunction]; [secondHalfAnimation setKeyPath:@"lineDashPhase"]; // Stop the lineDashPhase animation just before it reaches the full arc // length, otherwise the arc will be zero length at the end. animationValues = @[ @(0.0), @((kArcLength - 0.3) * scaleFactor) ]; [secondHalfAnimation setValues:animationValues]; [secondHalfAnimation setKeyTimes:keyTimes]; [secondHalfAnimation setDuration:kArcAnimationTime / 2.0]; [secondHalfAnimation setRemovedOnCompletion:NO]; [secondHalfAnimation setFillMode:kCAFillModeForwards]; // Make four copies of the arc animations, to cover the four complete cycles // of the full animation. NSMutableArray* animations = [NSMutableArray array]; CGFloat beginTime = 0; for (NSUInteger i = 0; i < 4; i++, beginTime += kArcAnimationTime) { [firstHalfAnimation setBeginTime:beginTime]; [secondHalfAnimation setBeginTime:beginTime + kArcAnimationTime / 2.0]; [animations addObject:firstHalfAnimation]; [animations addObject:secondHalfAnimation]; firstHalfAnimation.reset([firstHalfAnimation copy]); secondHalfAnimation.reset([secondHalfAnimation copy]); } // Create a step rotation animation, which rotates the arc 90 degrees on each // cycle. Each arc starts as a short block at degree 0 and ends as a short // block at degree -270. Without a 90 degree rotation at the end of each // cycle, the short block would appear to suddenly jump from -270 degrees to // -360 degrees. The full animation has to contain four of these 90 degree // adjustments in order for the arc to return to its starting point, at which // point the full animation can smoothly repeat. CAKeyframeAnimation* stepRotationAnimation = [CAKeyframeAnimation animation]; [stepRotationAnimation setTimingFunction: [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionLinear]]; [stepRotationAnimation setKeyPath:@"transform.rotation"]; animationValues = @[ @(0.0), @(0.0), @(kDegrees90), @(kDegrees90), @(kDegrees180), @(kDegrees180), @(kDegrees270), @(kDegrees270)]; [stepRotationAnimation setValues:animationValues]; keyTimes = @[ @(0.0), @(0.25), @(0.25), @(0.5), @(0.5), @(0.75), @(0.75), @(1.0) ]; [stepRotationAnimation setKeyTimes:keyTimes]; [stepRotationAnimation setDuration:kArcAnimationTime * 4.0]; [stepRotationAnimation setRemovedOnCompletion:NO]; [stepRotationAnimation setFillMode:kCAFillModeForwards]; [stepRotationAnimation setRepeatCount:HUGE_VALF]; [animations addObject:stepRotationAnimation]; // Use an animation group so that the animations are easier to manage, and to // give them the best chance of firing synchronously. CAAnimationGroup* group = [CAAnimationGroup animation]; [group setDuration:kArcAnimationTime * 4]; [group setRepeatCount:HUGE_VALF]; [group setFillMode:kCAFillModeForwards]; [group setRemovedOnCompletion:NO]; [group setAnimations:animations]; spinnerAnimation_.reset([group retain]); // Finally, create an animation that rotates the entire spinner layer. CABasicAnimation* rotationAnimation = [CABasicAnimation animation]; rotationAnimation.keyPath = @"transform.rotation"; [rotationAnimation setFromValue:@0]; [rotationAnimation setToValue:@(-kDegrees360)]; [rotationAnimation setDuration:kRotationTime]; [rotationAnimation setRemovedOnCompletion:NO]; [rotationAnimation setFillMode:kCAFillModeForwards]; [rotationAnimation setRepeatCount:HUGE_VALF]; rotationAnimation_.reset([rotationAnimation retain]); } - (void)updateAnimation:(NSNotification*)notification { // Only animate the spinner if it's within a window, and that window is not // currently minimized or being minimized. if ([self window] && ![[self window] isMiniaturized] && ![self isHidden] && ![[notification name] isEqualToString: NSWindowWillMiniaturizeNotification]) { if (spinnerAnimation_.get() == nil) { [self initializeAnimation]; } if (![self isAnimating]) { [shapeLayer_ addAnimation:spinnerAnimation_.get() forKey:kSpinnerAnimationName]; [rotationLayer_ addAnimation:rotationAnimation_.get() forKey:kRotationAnimationName]; } } else { [shapeLayer_ removeAllAnimations]; [rotationLayer_ removeAllAnimations]; } } @end