Skip to content

Commit 9a7b776

Browse files
chore: improve performance of indeterminate spinner (#184)
A user reported a constant 10% CPU usage whilst the Cursor svg failed to load. It turns out unnecessarily tying a looping animation to some state in SwiftUI is a bad idea. If you want to render a looping animation that's not tied to some state, you should use the CoreAnimation framework. In this case, we use a `CABasicAnimation`. We leave the determinate spinner unmodified, as it by definition must be tied to some SwiftUI state. Before: ![before](https://github.com/user-attachments/assets/aadd00bd-d779-456d-9a2a-d72e24b085b1) After: ![after](https://github.com/user-attachments/assets/ca788653-fbb2-469b-8bc8-2c0e5361945f)
1 parent f8a5ca5 commit 9a7b776

File tree

1 file changed

+67
-25
lines changed

1 file changed

+67
-25
lines changed

Coder-Desktop/Coder-Desktop/Views/CircularProgressView.swift

Lines changed: 67 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -8,45 +8,35 @@ struct CircularProgressView: View {
88
var primaryColor: Color = .secondary
99
var backgroundColor: Color = .secondary.opacity(0.3)
1010

11-
@State private var rotation = 0.0
12-
@State private var trimAmount: CGFloat = 0.15
13-
1411
var autoCompleteThreshold: Float?
1512
var autoCompleteDuration: TimeInterval?
1613

1714
var body: some View {
1815
ZStack {
19-
// Background circle
20-
Circle()
21-
.stroke(backgroundColor, style: StrokeStyle(lineWidth: strokeWidth, lineCap: .round))
22-
.frame(width: diameter, height: diameter)
23-
Group {
24-
if let value {
25-
// Determinate gauge
16+
if let value {
17+
ZStack {
18+
Circle()
19+
.stroke(backgroundColor, style: StrokeStyle(lineWidth: strokeWidth, lineCap: .round))
20+
2621
Circle()
2722
.trim(from: 0, to: CGFloat(displayValue(for: value)))
2823
.stroke(primaryColor, style: StrokeStyle(lineWidth: strokeWidth, lineCap: .round))
29-
.frame(width: diameter, height: diameter)
3024
.rotationEffect(.degrees(-90))
3125
.animation(autoCompleteAnimation(for: value), value: value)
32-
} else {
33-
// Indeterminate gauge
34-
Circle()
35-
.trim(from: 0, to: trimAmount)
36-
.stroke(primaryColor, style: StrokeStyle(lineWidth: strokeWidth, lineCap: .round))
37-
.frame(width: diameter, height: diameter)
38-
.rotationEffect(.degrees(rotation))
3926
}
27+
.frame(width: diameter, height: diameter)
28+
29+
} else {
30+
IndeterminateSpinnerView(
31+
diameter: diameter,
32+
strokeWidth: strokeWidth,
33+
primaryColor: NSColor(primaryColor),
34+
backgroundColor: NSColor(backgroundColor)
35+
)
36+
.frame(width: diameter, height: diameter)
4037
}
4138
}
4239
.frame(width: diameter + strokeWidth * 2, height: diameter + strokeWidth * 2)
43-
.onAppear {
44-
if value == nil {
45-
withAnimation(.linear(duration: 0.8).repeatForever(autoreverses: false)) {
46-
rotation = 360
47-
}
48-
}
49-
}
5040
}
5141

5242
private func displayValue(for value: Float) -> Float {
@@ -78,3 +68,55 @@ extension CircularProgressView {
7868
return view
7969
}
8070
}
71+
72+
// We note a constant >10% CPU usage when using a SwiftUI rotation animation that
73+
// repeats forever, while this implementation, using Core Animation, uses <1% CPU.
74+
struct IndeterminateSpinnerView: NSViewRepresentable {
75+
var diameter: CGFloat
76+
var strokeWidth: CGFloat
77+
var primaryColor: NSColor
78+
var backgroundColor: NSColor
79+
80+
func makeNSView(context _: Context) -> NSView {
81+
let view = NSView(frame: NSRect(x: 0, y: 0, width: diameter, height: diameter))
82+
view.wantsLayer = true
83+
84+
guard let viewLayer = view.layer else { return view }
85+
86+
let fullPath = NSBezierPath(
87+
ovalIn: NSRect(x: 0, y: 0, width: diameter, height: diameter)
88+
).cgPath
89+
90+
let backgroundLayer = CAShapeLayer()
91+
backgroundLayer.path = fullPath
92+
backgroundLayer.strokeColor = backgroundColor.cgColor
93+
backgroundLayer.fillColor = NSColor.clear.cgColor
94+
backgroundLayer.lineWidth = strokeWidth
95+
viewLayer.addSublayer(backgroundLayer)
96+
97+
let foregroundLayer = CAShapeLayer()
98+
99+
foregroundLayer.frame = viewLayer.bounds
100+
foregroundLayer.path = fullPath
101+
foregroundLayer.strokeColor = primaryColor.cgColor
102+
foregroundLayer.fillColor = NSColor.clear.cgColor
103+
foregroundLayer.lineWidth = strokeWidth
104+
foregroundLayer.lineCap = .round
105+
foregroundLayer.strokeStart = 0
106+
foregroundLayer.strokeEnd = 0.15
107+
viewLayer.addSublayer(foregroundLayer)
108+
109+
let rotationAnimation = CABasicAnimation(keyPath: "transform.rotation")
110+
rotationAnimation.fromValue = 0
111+
rotationAnimation.toValue = 2 * Double.pi
112+
rotationAnimation.duration = 1.0
113+
rotationAnimation.repeatCount = .infinity
114+
rotationAnimation.isRemovedOnCompletion = false
115+
116+
foregroundLayer.add(rotationAnimation, forKey: "rotationAnimation")
117+
118+
return view
119+
}
120+
121+
func updateNSView(_: NSView, context _: Context) {}
122+
}

0 commit comments

Comments
 (0)