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"
15 const CGFloat kDegrees90 = (M_PI / 2);
16 const CGFloat kDegrees180 = (M_PI);
17 const CGFloat kDegrees270 = (3 * M_PI / 2);
18 const CGFloat kDegrees360 = (2 * M_PI);
19 const CGFloat kDesignWidth = 28.0;
20 const CGFloat kArcRadius = 12.5;
21 const CGFloat kArcDiameter = kArcRadius * 2.0;
22 const CGFloat kArcLength = 58.9;
23 const CGFloat kArcStrokeWidth = 3.0;
24 const CGFloat kArcAnimationTime = 1.333;
25 const CGFloat kArcStartAngle = kDegrees180;
26 const CGFloat kArcEndAngle = (kArcStartAngle + kDegrees270);
27 const CGFloat kRotationTime = 1.56863;
28 const SkColor kBlue = SkColorSetRGB(0x42, 0x85, 0xf4);
29 NSString* const kSpinnerAnimationName = @"SpinnerAnimationName";
30 NSString* const kRotationAnimationName = @"RotationAnimationName";
33 @interface SpinnerView () {
34 base::scoped_nsobject<CAAnimationGroup> spinnerAnimation_;
35 base::scoped_nsobject<CABasicAnimation> rotationAnimation_;
36 CAShapeLayer* shapeLayer_; // Weak.
37 CALayer* rotationLayer_; // Weak.
42 @implementation SpinnerView
44 - (instancetype)initWithFrame:(NSRect)frame {
45 if (self = [super initWithFrame:frame]) {
46 [self setWantsLayer:YES];
52 [[NSNotificationCenter defaultCenter] removeObserver:self];
56 // Register/unregister for window miniaturization event notifications so that
57 // the spinner can stop animating if the window is minaturized
58 // (i.e. not visible).
59 - (void)viewWillMoveToWindow:(NSWindow*)newWindow {
61 [[NSNotificationCenter defaultCenter]
63 name:NSWindowWillMiniaturizeNotification
64 object:[self window]];
65 [[NSNotificationCenter defaultCenter]
67 name:NSWindowDidDeminiaturizeNotification
68 object:[self window]];
72 [[NSNotificationCenter defaultCenter]
74 selector:@selector(updateAnimation:)
75 name:NSWindowWillMiniaturizeNotification
77 [[NSNotificationCenter defaultCenter]
79 selector:@selector(updateAnimation:)
80 name:NSWindowDidDeminiaturizeNotification
85 // Start or stop the animation whenever the view is added to or removed from a
87 - (void)viewDidMoveToWindow {
88 [self updateAnimation:nil];
92 return [shapeLayer_ animationForKey:kSpinnerAnimationName] != nil;
95 // Overridden to return a custom CALayer for the view (called from
97 - (CALayer*)makeBackingLayer {
98 CGRect bounds = [self bounds];
99 // The spinner was designed to be |kDesignWidth| points wide. Compute the
100 // scale factor needed to scale design parameters like |RADIUS| so that the
101 // spinner scales to fit the view's bounds.
102 CGFloat scaleFactor = bounds.size.width / kDesignWidth;
104 shapeLayer_ = [CAShapeLayer layer];
105 [shapeLayer_ setDelegate:self];
106 [shapeLayer_ setBounds:bounds];
107 // Per the design, the line width does not scale linearly.
108 CGFloat scaledDiameter = kArcDiameter * scaleFactor;
110 if (scaledDiameter < kArcDiameter) {
111 lineWidth = kArcStrokeWidth - (kArcDiameter - scaledDiameter) / 16.0;
113 lineWidth = kArcStrokeWidth + (scaledDiameter - kArcDiameter) / 11.0;
115 [shapeLayer_ setLineWidth:lineWidth];
116 [shapeLayer_ setLineCap:kCALineCapRound];
117 [shapeLayer_ setLineDashPattern:@[ @(kArcLength * scaleFactor) ]];
118 [shapeLayer_ setFillColor:NULL];
119 CGColorRef blueColor = gfx::CGColorCreateFromSkColor(kBlue);
120 [shapeLayer_ setStrokeColor:blueColor];
121 CGColorRelease(blueColor);
123 // Create the arc that, when stroked, creates the spinner.
124 base::ScopedCFTypeRef<CGMutablePathRef> shapePath(CGPathCreateMutable());
125 CGPathAddArc(shapePath, NULL, bounds.size.width / 2.0,
126 bounds.size.height / 2.0, kArcRadius * scaleFactor,
127 kArcStartAngle, kArcEndAngle, 0);
128 [shapeLayer_ setPath:shapePath];
130 // Place |shapeLayer_| in a layer so that it's easy to rotate the entire
131 // spinner animation.
132 rotationLayer_ = [CALayer layer];
133 [rotationLayer_ setBounds:bounds];
134 [rotationLayer_ addSublayer:shapeLayer_];
135 [shapeLayer_ setPosition:CGPointMake(NSMidX(bounds), NSMidY(bounds))];
137 // Place |rotationLayer_| in a parent layer so that it's easy to rotate
138 // |rotationLayer_| around the center of the view.
139 CALayer* parentLayer = [CALayer layer];
140 [parentLayer setBounds:bounds];
141 [parentLayer addSublayer:rotationLayer_];
142 [rotationLayer_ setPosition:CGPointMake(bounds.size.width / 2.0,
143 bounds.size.height / 2.0)];
147 // Overridden to start or stop the animation whenever the view is unhidden or
149 - (void)setHidden:(BOOL)flag {
150 [super setHidden:flag];
151 [self updateAnimation:nil];
154 // Make sure the layer's backing store matches the window as the window moves
156 - (BOOL)layer:(CALayer*)layer
157 shouldInheritContentsScale:(CGFloat)newScale
158 fromWindow:(NSWindow*)window {
162 // The spinner animation consists of four cycles that it continuously repeats.
163 // Each cycle consists of one complete rotation of the spinner's arc plus a
164 // rotation adjustment at the end of each cycle (see rotation animation comment
165 // below for the reason for the adjustment). The arc's length also grows and
166 // shrinks over the course of each cycle, which the spinner achieves by drawing
167 // the arc using a (solid) dashed line pattern and animating the "lineDashPhase"
169 - (void)initializeAnimation {
170 CGRect bounds = [self bounds];
171 CGFloat scaleFactor = bounds.size.width / kDesignWidth;
173 // Make sure |shapeLayer_|'s content scale factor matches the window's
174 // backing depth (e.g. it's 2.0 on Retina Macs). Don't worry about adjusting
175 // any other layers because |shapeLayer_| is the only one displaying content.
176 if (base::mac::IsOSLionOrLater()) {
177 CGFloat backingScaleFactor = [[self window] backingScaleFactor];
178 [shapeLayer_ setContentsScale:backingScaleFactor];
181 // Create the first half of the arc animation, where it grows from a short
182 // block to its full length.
183 base::scoped_nsobject<CAMediaTimingFunction> timingFunction(
184 [[CAMediaTimingFunction alloc] initWithControlPoints:0.4 :0.0 :0.2 :1]);
185 base::scoped_nsobject<CAKeyframeAnimation> firstHalfAnimation(
186 [[CAKeyframeAnimation alloc] init]);
187 [firstHalfAnimation setTimingFunction:timingFunction];
188 [firstHalfAnimation setKeyPath:@"lineDashPhase"];
189 // Begin the lineDashPhase animation just short of the full arc length,
190 // otherwise the arc will be zero length at start.
191 NSArray* animationValues = @[ @(-(kArcLength - 0.4) * scaleFactor), @(0.0) ];
192 [firstHalfAnimation setValues:animationValues];
193 NSArray* keyTimes = @[ @(0.0), @(1.0) ];
194 [firstHalfAnimation setKeyTimes:keyTimes];
195 [firstHalfAnimation setDuration:kArcAnimationTime / 2.0];
196 [firstHalfAnimation setRemovedOnCompletion:NO];
197 [firstHalfAnimation setFillMode:kCAFillModeForwards];
199 // Create the second half of the arc animation, where it shrinks from full
200 // length back to a short block.
201 base::scoped_nsobject<CAKeyframeAnimation> secondHalfAnimation(
202 [[CAKeyframeAnimation alloc] init]);
203 [secondHalfAnimation setTimingFunction:timingFunction];
204 [secondHalfAnimation setKeyPath:@"lineDashPhase"];
205 // Stop the lineDashPhase animation just before it reaches the full arc
206 // length, otherwise the arc will be zero length at the end.
207 animationValues = @[ @(0.0), @((kArcLength - 0.3) * scaleFactor) ];
208 [secondHalfAnimation setValues:animationValues];
209 [secondHalfAnimation setKeyTimes:keyTimes];
210 [secondHalfAnimation setDuration:kArcAnimationTime / 2.0];
211 [secondHalfAnimation setRemovedOnCompletion:NO];
212 [secondHalfAnimation setFillMode:kCAFillModeForwards];
214 // Make four copies of the arc animations, to cover the four complete cycles
215 // of the full animation.
216 NSMutableArray* animations = [NSMutableArray array];
217 CGFloat beginTime = 0;
218 for (NSUInteger i = 0; i < 4; i++, beginTime += kArcAnimationTime) {
219 [firstHalfAnimation setBeginTime:beginTime];
220 [secondHalfAnimation setBeginTime:beginTime + kArcAnimationTime / 2.0];
221 [animations addObject:firstHalfAnimation];
222 [animations addObject:secondHalfAnimation];
223 firstHalfAnimation.reset([firstHalfAnimation copy]);
224 secondHalfAnimation.reset([secondHalfAnimation copy]);
227 // Create a step rotation animation, which rotates the arc 90 degrees on each
228 // cycle. Each arc starts as a short block at degree 0 and ends as a short
229 // block at degree -270. Without a 90 degree rotation at the end of each
230 // cycle, the short block would appear to suddenly jump from -270 degrees to
231 // -360 degrees. The full animation has to contain four of these 90 degree
232 // adjustments in order for the arc to return to its starting point, at which
233 // point the full animation can smoothly repeat.
234 CAKeyframeAnimation* stepRotationAnimation = [CAKeyframeAnimation animation];
235 [stepRotationAnimation setTimingFunction:
236 [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionLinear]];
237 [stepRotationAnimation setKeyPath:@"transform.rotation"];
238 animationValues = @[ @(0.0), @(0.0),
245 [stepRotationAnimation setValues:animationValues];
246 keyTimes = @[ @(0.0), @(0.25), @(0.25), @(0.5), @(0.5), @(0.75), @(0.75),
248 [stepRotationAnimation setKeyTimes:keyTimes];
249 [stepRotationAnimation setDuration:kArcAnimationTime * 4.0];
250 [stepRotationAnimation setRemovedOnCompletion:NO];
251 [stepRotationAnimation setFillMode:kCAFillModeForwards];
252 [stepRotationAnimation setRepeatCount:HUGE_VALF];
253 [animations addObject:stepRotationAnimation];
255 // Use an animation group so that the animations are easier to manage, and to
256 // give them the best chance of firing synchronously.
257 CAAnimationGroup* group = [CAAnimationGroup animation];
258 [group setDuration:kArcAnimationTime * 4];
259 [group setRepeatCount:HUGE_VALF];
260 [group setFillMode:kCAFillModeForwards];
261 [group setRemovedOnCompletion:NO];
262 [group setAnimations:animations];
264 spinnerAnimation_.reset([group retain]);
266 // Finally, create an animation that rotates the entire spinner layer.
267 CABasicAnimation* rotationAnimation = [CABasicAnimation animation];
268 rotationAnimation.keyPath = @"transform.rotation";
269 [rotationAnimation setFromValue:@0];
270 [rotationAnimation setToValue:@(-kDegrees360)];
271 [rotationAnimation setDuration:kRotationTime];
272 [rotationAnimation setRemovedOnCompletion:NO];
273 [rotationAnimation setFillMode:kCAFillModeForwards];
274 [rotationAnimation setRepeatCount:HUGE_VALF];
276 rotationAnimation_.reset([rotationAnimation retain]);
279 - (void)updateAnimation:(NSNotification*)notification {
280 // Only animate the spinner if it's within a window, and that window is not
281 // currently minimized or being minimized.
282 if ([self window] && ![[self window] isMiniaturized] && ![self isHidden] &&
283 ![[notification name] isEqualToString:
284 NSWindowWillMiniaturizeNotification]) {
285 if (spinnerAnimation_.get() == nil) {
286 [self initializeAnimation];
288 if (![self isAnimating]) {
289 [shapeLayer_ addAnimation:spinnerAnimation_.get()
290 forKey:kSpinnerAnimationName];
291 [rotationLayer_ addAnimation:rotationAnimation_.get()
292 forKey:kRotationAnimationName];
295 [shapeLayer_ removeAllAnimations];
296 [rotationLayer_ removeAllAnimations];