1 // Copyright 2015 The Chromium Authors. All rights reserved.
2 // Use of this source code is governed by a BSD-style license that can be
3 // found in the LICENSE file.
5 #import "chrome/browser/ui/cocoa/spinner_view.h"
7 #import <QuartzCore/QuartzCore.h>
9 #include "base/mac/mac_util.h"
10 #include "base/mac/sdk_forward_declarations.h"
11 #include "base/mac/scoped_cftyperef.h"
12 #include "skia/ext/skia_utils_mac.h"
13 #include "ui/base/theme_provider.h"
14 #include "ui/native_theme/native_theme.h"
17 const CGFloat kDegrees90 = (M_PI / 2);
18 const CGFloat kDegrees180 = (M_PI);
19 const CGFloat kDegrees270 = (3 * M_PI / 2);
20 const CGFloat kDegrees360 = (2 * M_PI);
21 const CGFloat kDesignWidth = 28.0;
22 const CGFloat kArcRadius = 12.5;
23 const CGFloat kArcDiameter = kArcRadius * 2.0;
24 const CGFloat kArcLength = 58.9;
25 const CGFloat kArcStrokeWidth = 3.0;
26 const CGFloat kArcAnimationTime = 1.333;
27 const CGFloat kArcStartAngle = kDegrees180;
28 const CGFloat kArcEndAngle = (kArcStartAngle + kDegrees270);
29 const CGFloat kRotationTime = 1.56863;
30 NSString* const kSpinnerAnimationName = @"SpinnerAnimationName";
31 NSString* const kRotationAnimationName = @"RotationAnimationName";
34 @interface SpinnerView () {
35 base::scoped_nsobject<CAAnimationGroup> spinnerAnimation_;
36 base::scoped_nsobject<CABasicAnimation> rotationAnimation_;
37 CAShapeLayer* shapeLayer_; // Weak.
38 CALayer* rotationLayer_; // Weak.
43 @implementation SpinnerView
45 - (instancetype)initWithFrame:(NSRect)frame {
46 if (self = [super initWithFrame:frame]) {
47 [self setWantsLayer:YES];
53 [[NSNotificationCenter defaultCenter] removeObserver:self];
57 // Register/unregister for window miniaturization event notifications so that
58 // the spinner can stop animating if the window is minaturized
59 // (i.e. not visible).
60 - (void)viewWillMoveToWindow:(NSWindow*)newWindow {
62 [[NSNotificationCenter defaultCenter]
64 name:NSWindowWillMiniaturizeNotification
65 object:[self window]];
66 [[NSNotificationCenter defaultCenter]
68 name:NSWindowDidDeminiaturizeNotification
69 object:[self window]];
73 [[NSNotificationCenter defaultCenter]
75 selector:@selector(updateAnimation:)
76 name:NSWindowWillMiniaturizeNotification
78 [[NSNotificationCenter defaultCenter]
80 selector:@selector(updateAnimation:)
81 name:NSWindowDidDeminiaturizeNotification
86 // Start or stop the animation whenever the view is added to or removed from a
88 - (void)viewDidMoveToWindow {
89 [self updateAnimation:nil];
93 return [shapeLayer_ animationForKey:kSpinnerAnimationName] != nil;
96 // Overridden to return a custom CALayer for the view (called from
98 - (CALayer*)makeBackingLayer {
99 CGRect bounds = [self bounds];
100 // The spinner was designed to be |kDesignWidth| points wide. Compute the
101 // scale factor needed to scale design parameters like |RADIUS| so that the
102 // spinner scales to fit the view's bounds.
103 CGFloat scaleFactor = bounds.size.width / kDesignWidth;
105 shapeLayer_ = [CAShapeLayer layer];
106 [shapeLayer_ setDelegate:self];
107 [shapeLayer_ setBounds:bounds];
108 // Per the design, the line width does not scale linearly.
109 CGFloat scaledDiameter = kArcDiameter * scaleFactor;
111 if (scaledDiameter < kArcDiameter) {
112 lineWidth = kArcStrokeWidth - (kArcDiameter - scaledDiameter) / 16.0;
114 lineWidth = kArcStrokeWidth + (scaledDiameter - kArcDiameter) / 11.0;
116 [shapeLayer_ setLineWidth:lineWidth];
117 [shapeLayer_ setLineCap:kCALineCapRound];
118 [shapeLayer_ setLineDashPattern:@[ @(kArcLength * scaleFactor) ]];
119 [shapeLayer_ setFillColor:NULL];
120 ui::NativeTheme* nativeTheme = ui::NativeTheme::instance();
121 SkColor throbberBlueColor = nativeTheme->GetSystemColor(
122 ui::NativeTheme::kColorId_ThrobberSpinningColor);
123 CGColorRef blueColor = gfx::CGColorCreateFromSkColor(throbberBlueColor);
124 [shapeLayer_ setStrokeColor:blueColor];
125 CGColorRelease(blueColor);
127 // Create the arc that, when stroked, creates the spinner.
128 base::ScopedCFTypeRef<CGMutablePathRef> shapePath(CGPathCreateMutable());
129 CGPathAddArc(shapePath, NULL, bounds.size.width / 2.0,
130 bounds.size.height / 2.0, kArcRadius * scaleFactor,
131 kArcStartAngle, kArcEndAngle, 0);
132 [shapeLayer_ setPath:shapePath];
134 // Place |shapeLayer_| in a layer so that it's easy to rotate the entire
135 // spinner animation.
136 rotationLayer_ = [CALayer layer];
137 [rotationLayer_ setBounds:bounds];
138 [rotationLayer_ addSublayer:shapeLayer_];
139 [shapeLayer_ setPosition:CGPointMake(NSMidX(bounds), NSMidY(bounds))];
141 // Place |rotationLayer_| in a parent layer so that it's easy to rotate
142 // |rotationLayer_| around the center of the view.
143 CALayer* parentLayer = [CALayer layer];
144 [parentLayer setBounds:bounds];
145 [parentLayer addSublayer:rotationLayer_];
146 [rotationLayer_ setPosition:CGPointMake(bounds.size.width / 2.0,
147 bounds.size.height / 2.0)];
151 // Overridden to start or stop the animation whenever the view is unhidden or
153 - (void)setHidden:(BOOL)flag {
154 [super setHidden:flag];
155 [self updateAnimation:nil];
158 // Make sure the layer's backing store matches the window as the window moves
160 - (BOOL)layer:(CALayer*)layer
161 shouldInheritContentsScale:(CGFloat)newScale
162 fromWindow:(NSWindow*)window {
166 // The spinner animation consists of four cycles that it continuously repeats.
167 // Each cycle consists of one complete rotation of the spinner's arc plus a
168 // rotation adjustment at the end of each cycle (see rotation animation comment
169 // below for the reason for the adjustment). The arc's length also grows and
170 // shrinks over the course of each cycle, which the spinner achieves by drawing
171 // the arc using a (solid) dashed line pattern and animating the "lineDashPhase"
173 - (void)initializeAnimation {
174 CGRect bounds = [self bounds];
175 CGFloat scaleFactor = bounds.size.width / kDesignWidth;
177 // Make sure |shapeLayer_|'s content scale factor matches the window's
178 // backing depth (e.g. it's 2.0 on Retina Macs). Don't worry about adjusting
179 // any other layers because |shapeLayer_| is the only one displaying content.
180 if (base::mac::IsOSLionOrLater()) {
181 CGFloat backingScaleFactor = [[self window] backingScaleFactor];
182 [shapeLayer_ setContentsScale:backingScaleFactor];
185 // Create the first half of the arc animation, where it grows from a short
186 // block to its full length.
187 base::scoped_nsobject<CAMediaTimingFunction> timingFunction(
188 [[CAMediaTimingFunction alloc] initWithControlPoints:0.4 :0.0 :0.2 :1]);
189 base::scoped_nsobject<CAKeyframeAnimation> firstHalfAnimation(
190 [[CAKeyframeAnimation alloc] init]);
191 [firstHalfAnimation setTimingFunction:timingFunction];
192 [firstHalfAnimation setKeyPath:@"lineDashPhase"];
193 // Begin the lineDashPhase animation just short of the full arc length,
194 // otherwise the arc will be zero length at start.
195 NSArray* animationValues = @[ @(-(kArcLength - 0.4) * scaleFactor), @(0.0) ];
196 [firstHalfAnimation setValues:animationValues];
197 NSArray* keyTimes = @[ @(0.0), @(1.0) ];
198 [firstHalfAnimation setKeyTimes:keyTimes];
199 [firstHalfAnimation setDuration:kArcAnimationTime / 2.0];
200 [firstHalfAnimation setRemovedOnCompletion:NO];
201 [firstHalfAnimation setFillMode:kCAFillModeForwards];
203 // Create the second half of the arc animation, where it shrinks from full
204 // length back to a short block.
205 base::scoped_nsobject<CAKeyframeAnimation> secondHalfAnimation(
206 [[CAKeyframeAnimation alloc] init]);
207 [secondHalfAnimation setTimingFunction:timingFunction];
208 [secondHalfAnimation setKeyPath:@"lineDashPhase"];
209 // Stop the lineDashPhase animation just before it reaches the full arc
210 // length, otherwise the arc will be zero length at the end.
211 animationValues = @[ @(0.0), @((kArcLength - 0.3) * scaleFactor) ];
212 [secondHalfAnimation setValues:animationValues];
213 [secondHalfAnimation setKeyTimes:keyTimes];
214 [secondHalfAnimation setDuration:kArcAnimationTime / 2.0];
215 [secondHalfAnimation setRemovedOnCompletion:NO];
216 [secondHalfAnimation setFillMode:kCAFillModeForwards];
218 // Make four copies of the arc animations, to cover the four complete cycles
219 // of the full animation.
220 NSMutableArray* animations = [NSMutableArray array];
221 CGFloat beginTime = 0;
222 for (NSUInteger i = 0; i < 4; i++, beginTime += kArcAnimationTime) {
223 [firstHalfAnimation setBeginTime:beginTime];
224 [secondHalfAnimation setBeginTime:beginTime + kArcAnimationTime / 2.0];
225 [animations addObject:firstHalfAnimation];
226 [animations addObject:secondHalfAnimation];
227 firstHalfAnimation.reset([firstHalfAnimation copy]);
228 secondHalfAnimation.reset([secondHalfAnimation copy]);
231 // Create a step rotation animation, which rotates the arc 90 degrees on each
232 // cycle. Each arc starts as a short block at degree 0 and ends as a short
233 // block at degree -270. Without a 90 degree rotation at the end of each
234 // cycle, the short block would appear to suddenly jump from -270 degrees to
235 // -360 degrees. The full animation has to contain four of these 90 degree
236 // adjustments in order for the arc to return to its starting point, at which
237 // point the full animation can smoothly repeat.
238 CAKeyframeAnimation* stepRotationAnimation = [CAKeyframeAnimation animation];
239 [stepRotationAnimation setTimingFunction:
240 [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionLinear]];
241 [stepRotationAnimation setKeyPath:@"transform.rotation"];
242 animationValues = @[ @(0.0), @(0.0),
249 [stepRotationAnimation setValues:animationValues];
250 keyTimes = @[ @(0.0), @(0.25), @(0.25), @(0.5), @(0.5), @(0.75), @(0.75),
252 [stepRotationAnimation setKeyTimes:keyTimes];
253 [stepRotationAnimation setDuration:kArcAnimationTime * 4.0];
254 [stepRotationAnimation setRemovedOnCompletion:NO];
255 [stepRotationAnimation setFillMode:kCAFillModeForwards];
256 [stepRotationAnimation setRepeatCount:HUGE_VALF];
257 [animations addObject:stepRotationAnimation];
259 // Use an animation group so that the animations are easier to manage, and to
260 // give them the best chance of firing synchronously.
261 CAAnimationGroup* group = [CAAnimationGroup animation];
262 [group setDuration:kArcAnimationTime * 4];
263 [group setRepeatCount:HUGE_VALF];
264 [group setFillMode:kCAFillModeForwards];
265 [group setRemovedOnCompletion:NO];
266 [group setAnimations:animations];
268 spinnerAnimation_.reset([group retain]);
270 // Finally, create an animation that rotates the entire spinner layer.
271 CABasicAnimation* rotationAnimation = [CABasicAnimation animation];
272 rotationAnimation.keyPath = @"transform.rotation";
273 [rotationAnimation setFromValue:@0];
274 [rotationAnimation setToValue:@(-kDegrees360)];
275 [rotationAnimation setDuration:kRotationTime];
276 [rotationAnimation setRemovedOnCompletion:NO];
277 [rotationAnimation setFillMode:kCAFillModeForwards];
278 [rotationAnimation setRepeatCount:HUGE_VALF];
280 rotationAnimation_.reset([rotationAnimation retain]);
283 - (void)updateAnimation:(NSNotification*)notification {
284 // Only animate the spinner if it's within a window, and that window is not
285 // currently minimized or being minimized.
286 if ([self window] && ![[self window] isMiniaturized] && ![self isHidden] &&
287 ![[notification name] isEqualToString:
288 NSWindowWillMiniaturizeNotification]) {
289 if (spinnerAnimation_.get() == nil) {
290 [self initializeAnimation];
292 if (![self isAnimating]) {
293 [shapeLayer_ addAnimation:spinnerAnimation_.get()
294 forKey:kSpinnerAnimationName];
295 [rotationLayer_ addAnimation:rotationAnimation_.get()
296 forKey:kRotationAnimationName];
299 [shapeLayer_ removeAllAnimations];
300 [rotationLayer_ removeAllAnimations];