swiftswiftui

How can I solve dash phase animation imperfections in SwiftUI?


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?

enter image description here

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()
        
    }
}

enter image description here


Solution

  • 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)