Search code examples
swiftanimationswiftui

Issues with checkmark animation


I am using following code to display success with circle and checkmark

struct CheckMarkView: View {
    @State var borderInit: Bool = false
    @State var spinArrow: Bool = false
    @State var dismissArrow: Bool = false
    @State var displayCheckmark: Bool = false
    
    var body: some View {
        ZStack {
            Circle()
                .strokeBorder(style: StrokeStyle(lineWidth: borderInit ? 10 : 64))
                .frame(width: 128, height: 128)
                .foregroundColor(borderInit ? .white : .black)
                .animation(.easeOut(duration: 3).speed(1.5))
                .onAppear() {
                    borderInit.toggle()
                }
            
            // Arrow Icon
            Image(systemName: "arrow.2.circlepath")
                .frame(width: 80, height: 80)
                .font(.largeTitle)
                .foregroundColor(.white)
                .rotationEffect(.degrees(spinArrow ? 360 : -360))
                .opacity(dismissArrow ? 0 : 1)
                .animation(.easeOut(duration: 2))
                .onAppear() {
                    spinArrow.toggle()
                    withAnimation(Animation.easeInOut(duration: 1).delay(1)) {
                        self.dismissArrow.toggle()
                    }
                }
            
            // Checkmark
            Path { path in
                path.move(to: CGPoint(x: 20, y: -40))
                path.addLine(to: CGPoint(x: 40, y: -20))
                path.addLine(to: CGPoint(x: 80, y: -60))
            }
            .trim(from: 0, to: displayCheckmark ? 1 : 0)
            .stroke(style: StrokeStyle(lineWidth: 10, lineCap: .round, lineJoin: .round))
            .foregroundColor(displayCheckmark ? .white : .black)
            .offset(x: 150, y: 420)
            //.animation(.spring(.bouncy, blendDuration: 2).delay(2))
            .animation(Animation.interpolatingSpring(stiffness: 150, damping: 10, initialVelocity: 0).delay(2))
            //.animation(Animation.interpolatingSpring(mass: 1, stiffness: 100, damping: 10, initialVelocity: 0).delay(2))
            .onAppear() {
                displayCheckmark.toggle()
            }
            
        }
        .background(.black)
    }
}

When I try to add frame to CheckMarkView, image and circle displayed at different place. Checkmark should be displayed within the circle. Also checkmark animation is not smooth at end. How to resolve this issue? Any help would be appreciated.


Solution

  • You should make the checkmark into a Shape. This way, SwiftUI handles the layout for you, and you don't need to get it in the right place using offset.

    Here is an example of a Shape implementation, based on the points in your Path

    struct CheckmarkShape: Shape {
        func path(in rect: CGRect) -> Path {
            var path = Path()
            let fifthWidth = rect.width / 5
            let quarterHeight = rect.height / 4
            path.move(to: .init(x: rect.minX + fifthWidth, y: rect.minY + 2 * quarterHeight))
            path.addLine(to: .init(x: rect.minX + 2 * fifthWidth, y: rect.minY + 3 * quarterHeight))
            path.addLine(to: .init(x: rect.minX + 4 * fifthWidth, y: rect.minY + quarterHeight))
            return path
        }
    }
    

    The CheckmarkView can look something like this:

    @State var borderInit: Bool = false
    @State var spinArrow: Bool = false
    @State var dismissArrow: Bool = false
    @State var displayCheckmark: Bool = false
    
    var body: some View {
        ZStack {
            Color.black.ignoresSafeArea()
            Circle()
                .strokeBorder(style: StrokeStyle(lineWidth: borderInit ? 10 : 64))
                .frame(width: 128, height: 128)
                .foregroundColor(borderInit ? .white : .black)
                .animation(.easeOut(duration: 3).speed(1.5), value: borderInit)
                .onAppear() {
                    borderInit.toggle()
                }
            
            // Arrow Icon
            Image(systemName: "arrow.2.circlepath")
                .frame(width: 80, height: 80)
                .font(.largeTitle)
                .foregroundColor(.white)
                .rotationEffect(.degrees(spinArrow ? 360 : -360))
                .opacity(dismissArrow ? 0 : 1)
                .animation(.easeOut(duration: 2), value: dismissArrow)
                .animation(.easeOut(duration: 2), value: spinArrow)
                .onAppear() {
                    spinArrow.toggle()
                    withAnimation(Animation.easeInOut(duration: 1).delay(1)) {
                        self.dismissArrow.toggle()
                    }
                }
            CheckmarkShape()
                .trim(from: 0, to: displayCheckmark ? 1 : 0)
                .stroke(style: StrokeStyle(lineWidth: 10, lineCap: .round, lineJoin: .round))
                .frame(width: 100, height: 100)
                .foregroundColor(displayCheckmark ? .white : .black)
                .animation(.bouncy.delay(2), value: displayCheckmark)
                .onAppear() {
                    displayCheckmark.toggle()
                }
        }
    }
    

    You should pass a value parameter to all the animation modifiers. The version without value is deprecated.

    Since you used a spring animation, I assume you want the checkmark's stroke to go a bit beyond where it's supposed to end, and then bounce back. You can achieve this by extending the arrow's path in the Shape implementation, and animating the trim to something less than 1.

    struct CheckmarkShape: Shape {
        func path(in rect: CGRect) -> Path {
            var path = Path()
            let fifthWidth = rect.width / 5
            let quarterHeight = rect.height / 4
            path.move(to: .init(x: rect.minX + fifthWidth, y: rect.minY + 2 * quarterHeight))
            path.addLine(to: .init(x: rect.minX + 2 * fifthWidth, y: rect.minY + 3 * quarterHeight))
            path.addLine(to: .init(x: rect.minX + 5 * fifthWidth, y: rect.minY))
            return path
        }
    }
    
    .trim(from: 0, to: displayCheckmark ? 0.75 : 0)