summaryrefslogtreecommitdiffstats
path: root/chrome/browser/ui/cocoa/spinner_view.mm
blob: 0134050621d78595d5040852fc6797f2968d8d1e (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
// 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 <QuartzCore/QuartzCore.h>

#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"

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<CAAnimationGroup> spinnerAnimation_;
  base::scoped_nsobject<CABasicAnimation> 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::NativeTheme::instance();
  SkColor throbberBlueColor = nativeTheme->GetSystemColor(
      ui::NativeTheme::kColorId_ThrobberSpinningColor);
  CGColorRef blueColor = gfx::CGColorCreateFromSkColor(throbberBlueColor);
  [shapeLayer_ setStrokeColor:blueColor];
  CGColorRelease(blueColor);

  // Create the arc that, when stroked, creates the spinner.
  base::ScopedCFTypeRef<CGMutablePathRef> 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<CAMediaTimingFunction> timingFunction(
      [[CAMediaTimingFunction alloc] initWithControlPoints:0.4 :0.0 :0.2 :1]);
  base::scoped_nsobject<CAKeyframeAnimation> 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<CAKeyframeAnimation> 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