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)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy