Hi,
I have a SwiftUI "work time" screen with a rotating ring (60 tick marks, Canvas-based).
While the app stays in foreground, rotation is fine.
After the app is in background for a while and comes back to foreground, I consistently see one visual glitch:
the ring makes one very short step in the opposite direction once
then continues rotating clockwise normally
So this is not a crash, only a visual reverse tick on resume.
What I expect:
no direction change after foreground resume
continuous clockwise motion
What I already tried:
withAnimation(.linear(...).repeatForever(...)) + restart on scenePhase
TimelineView (.animation and .periodic) with time-based angle
angle with and without modulo wrapping
wall-clock and monotonic time sources
rotation via rotationEffect and also via Canvas geometry
warmup delays after resume
restoring original ring visuals (long/short tick marks)
The effect is still reproducible.
Question: What is the correct SwiftUI approach to implement a continuously rotating ring that stays direction-stable across background/foreground transitions, with no one-frame reverse step on resume?
Any pattern that is robust on current iOS versions and avoids visual artifacts on scene phase changes would be appreciated.
Minimal repro:
import SwiftUI
struct ReproClockView: View {
@Environment(\.scenePhase) private var scenePhase
private let tickCount = 60
private let rotationDuration: Double = 120
@State private var rotationDegrees: Double = 0
@State private var hasAppeared = false
var body: some View {
ZStack {
Canvas { context, size in
let center = CGPoint(x: size.width / 2, y: size.height / 2)
let radius = min(size.width, size.height) / 2 - 8
for index in 0..<tickCount {
let angle = Double(index) * (360.0 / Double(tickCount)) - 90
let radians = angle * .pi / 180
let isLongTick = index % 5 == 0
let length: CGFloat = isLongTick ? 22 : 14
let outerRadius = radius
let innerRadius = radius - length
let startPoint = CGPoint(
x: center.x + cos(radians) * outerRadius,
y: center.y + sin(radians) * outerRadius
)
let endPoint = CGPoint(
x: center.x + cos(radians) * innerRadius,
y: center.y + sin(radians) * innerRadius
)
var path = Path()
path.move(to: startPoint)
path.addLine(to: endPoint)
context.stroke(
path,
with: .color(.red),
style: StrokeStyle(lineWidth: 2.5, lineCap: .round)
)
}
}
.rotationEffect(.degrees(rotationDegrees))
.drawingGroup()
}
.frame(width: 340, height: 340)
.onAppear {
guard !hasAppeared else { return }
hasAppeared = true
startRotation()
}
.onChange(of: scenePhase) { oldPhase, newPhase in
if oldPhase == .background && newPhase == .active {
withAnimation(.linear(duration: 0)) {
rotationDegrees = 0
}
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05) {
startRotation()
}
}
}
}
private func startRotation() {
rotationDegrees = 0
withAnimation(.linear(duration: rotationDuration).repeatForever(autoreverses: false)) {
rotationDegrees = 360
}
}
}
0
0
46