'SwiftUI Animation Circle with Colors

My problem is simple I think but I can't figure how solve it. I've this :

struct ArcSelectionView: View {
    @Binding var isShowing: Bool
    @Binding var curColor: Color
    @Binding var colorToPress: Color
    @Binding var score: Int
    
    @State var colors = [Color.blue, Color.red, Color.green, Color.yellow]
    
    var body: some View {
        ZStack {
            ForEach(1 ..< 5, id: \.self) { item in
                Circle()
                    .trim(from: self.isShowing ? CGFloat((Double(item) * 0.25) - 0.25) : CGFloat(Double(item) * 0.25),
                          to: CGFloat(Double(item) * 0.25))
                    .stroke(self.colors[item - 1], lineWidth: 50)
                    .frame(width: 300, height: 300)
                    .onTapGesture {
                        if colors[item - 1] == colorToPress {
                            score += 1
                        }
                        isShowing.toggle()
                        colorToPress = colors.randomElement() ?? Color.offWhite
                        colors.shuffle()
                        DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) {
                            self.isShowing.toggle()
                        }
                }
            }
        }
        .opacity(self.isShowing ? 1 : 0)
        .rotationEffect(.degrees(self.isShowing ? 0 : 180))
        .animation(.linear(duration: 0.35))
    }
}

If I didn't shuffle colors in the .onTapGesture, everything is ok. But If I do, I've a strange plain Circle that appears in the middle and disappear after. It's ugly. Ugly Circle

Thank you for your help !



Solution 1:[1]

The issue is with the animation of the Circles. The better solution is to use arc shapes. Here is a working solution:

struct ArcSelectionView: View {
    @Binding var curColor: Color
    @Binding var colorToPress: Color
    @Binding var score: Int
    
    @State private var colors = [Color.blue, Color.red, Color.green, Color.yellow]
    @State private var pct: CGFloat = 0.25
    @State private var originalPCT: CGFloat = 0.25
    
    let duration: Double = 0.35
    
    var body: some View {
        ZStack {
            CircleView(wedge: originalPCT)
            // I am not sure why, but at there is a difference of 10 in the sizes of the
            // circle and the modifier. This corrects for it so the touch is accurate.
                .frame(width: 310, height: 310)
            
            PercentageArc(Color.clear, colors: colors, pct: pct) {
                // With this solution you must have the callback sent to
                // the main thread. This was unnecessary with AnimatbleModifier.
                DispatchQueue.main.async {
                    pct = originalPCT
                }
            }
            .animation(.linear(duration: duration), value: pct)
            .frame(width: 300, height: 300)
            // This forces the view to ignore taps.
            .allowsHitTesting(false)
        }
        .onAppear {
            pct = 1.0 / CGFloat(colors.count)
            originalPCT = pct
        }
    }
    
    func CircleView(wedge: CGFloat) -> some View {
        
        ZStack {
            // Array(zip()) is a cleaner and safe way of using indices AND you
            // have the original object to use as well.
            ForEach(Array(zip(colors, colors.indices)), id: \.0) { color, index in
                Circle()
                    .trim(from: CGFloat((Double(index) * wedge)),
                          to: CGFloat(Double(index + 1) * wedge))
                // The color of the stroke should match your background color.
                // Clear won't work.
                    .stroke(.white, lineWidth: 50)
                    .onTapGesture {
                        if color == colorToPress {
                            score += 1
                            print("score!")
                        }
                        pct = 0
                        DispatchQueue.main.asyncAfter(deadline: .now() + duration) {
                            colorToPress = colors.randomElement() ?? .white
                            colors.shuffle()
                        }
                    }
            }
        }
    }
    
}

struct PercentageArc<Content>: View, Animatable where Content: View {
    private var content: Content
    private var colors: [Color]
    private var pct: CGFloat
    
    private var target: CGFloat
    private var onEnded: () -> ()
    
    init(_ content: Content, colors: [Color], pct: CGFloat, onEnded: @escaping () -> () = {}) {
        self.content = content
        self.colors = colors
        self.pct = pct
        self.target = pct
        self.onEnded = onEnded
    }
    
    var animatableData: CGFloat {
        get { pct }
        set { pct = newValue
            // newValue here is interpolating by engine, so changing
            // from previous to initially set, so when they got equal
            // animation ended
            if newValue == target {
                onEnded()
            }
        }
    }
    
    var body: some View {
        content
            .overlay(
                ForEach(Array(zip(colors, colors.indices)), id: \.0) { color, index in
                    ArcPortionShape(pct: pct, startAngle: .degrees(1.0 / CGFloat(colors.count) * CGFloat(index) * 360.0))
                        .foregroundColor(color)
                }
            )
    }
    
    struct ArcPortionShape: InsettableShape {
        let pct: CGFloat
        let startAngle: Angle
        var insetAmount = 0.0
        
        init(pct: CGFloat, startAngle: Angle) {
            self.pct = pct
            self.startAngle = startAngle
            
        }
        var portion: CGFloat {
            pct * 360.0
        }
        
        var endAngle: Angle {
            .degrees(startAngle.degrees + portion)
        }
        
        func path(in rect: CGRect) -> Path {
            
            var p = Path()
            
            p.addArc(center: CGPoint(x: rect.width / 2.0, y:rect.height / 2.0),
                     radius: rect.height / 2.0 + 5.0,
                     startAngle: startAngle,
                     endAngle: endAngle,
                     clockwise: false)
            
            return p.strokedPath(.init(lineWidth: 50))
        }
        
        func inset(by amount: CGFloat) -> some InsettableShape {
            var arc = self
            arc.insetAmount += amount
            return arc
        }
        
    }
}

Originally, I made this with an AnimatableModifier, but it is deprecated, and the solution using it fails if it is placed in ANY stack or NavigationView. I can see why AnimatableModifier is deprecated.

This solution draws inspiration from this answer from Asperi, for the callback idea, though the solution will not work in iOS 15.2.

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1 Yrb