I want to animate the dash phase, but I'm encountering animation imperfections. How can I resolve these imperfections to achieve a smooth and continuous animation for given lineWidth?
I updated the code without changing the original issue; I just added a button to start and stop the animation.
import SwiftUI
struct ContentView: View {
let lineWidth: CGFloat = 90.0
var body: some View {
DashedCircleView(lineWidth: lineWidth)
}
}
struct DashedCircleView: View {
let lineWidth: CGFloat
@State private var phaseAnimation: Bool = Bool()
@State private var animation: Animation? = nil
var body: some View {
VStack {
Spacer()
Circle()
.strokeBorder(Color.red, style: StrokeStyle(
lineWidth: lineWidth,
dash: [2*lineWidth],
dashPhase: phaseAnimation ? 4*lineWidth : .zero))
.frame(width: 400, height: 400)
.animation(animation, value: phaseAnimation)
Spacer()
Button("run Animation") {
if (phaseAnimation) {
animation = Animation.interactiveSpring
phaseAnimation.toggle()
}
else {
animation = Animation.linear(duration: 1).repeatForever(autoreverses: false)
phaseAnimation.toggle()
}
}
}
.padding()
}
}
Update: Working code:
import SwiftUI
struct ContentView: View {
var body: some View {
DashedCircleView(lineWidth: 30.0, dashCount: 10, size: 400.0)
}
}
struct DashedCircleView: View {
let lineWidth: CGFloat
let dashCount: Int
let size: CGFloat
@State private var phaseAnimation: Bool = Bool()
var body: some View {
VStack {
Spacer()
Circle()
.strokeBorder(Color.red, style: StrokeStyle(lineWidth: lineWidth,
dash: [(size - lineWidth) * CGFloat.pi / (CGFloat(dashCount)*2.0)],
dashPhase: phaseAnimation ? (size - lineWidth) * CGFloat.pi / CGFloat(dashCount) : .zero))
.frame(width: size, height: size)
.animation(phaseAnimation ? Animation.linear(duration: 1).repeatForever(autoreverses: false) : Animation.interactiveSpring, value: phaseAnimation)
Spacer()
Button(phaseAnimation ? "Pause Animation" : "Run Animation") {
phaseAnimation.toggle()
}
}
.padding()
}
}
This imperfection is because the dashes do not fully fill the whole length of the path. If you use a dash size that is an exact factor of the path length, it looks good. For example,
Circle()
.strokeBorder(
style: StrokeStyle(
lineWidth: lineWidth,
dash: [(400 - lineWidth) * .pi / 6],
dashPhase: phaseAnimation ? (400 - lineWidth) * .pi / 3 : .zero
))
.frame(width: 400, height: 400)
400 - lineWidth
is the diameter of the circle. Remember that strokeBorder
insets the shape by lineWidth / 2
, so the diameter of the circle is lineWidth
less. By dividing the perimeter of the circle by an even number, I ensure that the dashes completely fill up the whole path.
The length of the path can be tricky to calculate, and you will have to make do with an uneven dash size. You can still get the dashes to move correctly by animating where the path starts, because that's where the dashes start.
The built-in Circle
always starts at the right side of the circle, which is why the weird visual effect always happens there.
Here I have created a MyCircle
whose starting point can be controlled,
struct MyCircle: InsettableShape, Animatable {
var startAngle: Angle
let inset: CGFloat
var animatableData: Double {
get { startAngle.radians }
set { startAngle = .radians(newValue) }
}
nonisolated func path(in rect: CGRect) -> Path {
Path { p in
p.addArc(center: .init(x: rect.midX, y: rect.midY), radius: rect.width / 2 - inset, startAngle: startAngle, endAngle: startAngle + .degrees(360), clockwise: false)
}
}
nonisolated func inset(by amount: CGFloat) -> MyCircle {
MyCircle(startAngle: startAngle, inset: inset + amount)
}
}
It can be animated like this:
MyCircle(startAngle: phaseAnimation ? .zero : .degrees(360), inset: 0)
.strokeBorder(
style: StrokeStyle(
lineWidth: lineWidth,
// here I use a rather random dash size
// - the dash will not be evenly spread out, but at least they rotate as you'd expect
dash: [2 * lineWidth]
// I am not animating the dash phase here
))
.frame(width: 400, height: 400)