Search code examples
swiftswiftuidrawing

Fill a Shape after drawing its Path(s) in SwiftUI


I have the following Shape in which I've attempted to draw the swift bird.

struct SwiftBird: Shape {
    func path(in rect: CGRect) -> Path {
        let size = min(rect.width, rect.height)

        let path = Path { path in
            path.move(to: CGPoint(x: 0.565 * size, y: 0.145 * size))
            path.addQuadCurve(to: CGPoint(x: 0.79 * size, y: 0.62 * size), control: CGPointMake(0.85 * size, 0.34 * size))
            
            path.move(to: CGPoint(x: 0.79 * size, y: 0.62 * size))
            path.addQuadCurve(to: CGPoint(x: 0.845 * size, y: 0.825 * size), control: CGPointMake(0.88 * size, 0.75 * size))
            
            path.move(to: CGPoint(x: 0.1 * size, y: 0.58 * size))
            path.addQuadCurve(to: CGPoint(x: 0.5 * size, y: 0.82 * size), control: CGPointMake(0.25 * size, 0.8 * size))
            path.addQuadCurve(to: CGPoint(x: 0.67 * size, y: 0.775 * size), control: CGPointMake(0.6 * size, 0.82 * size))
            path.addQuadCurve(to: CGPoint(x: 0.845 * size, y: 0.825 * size), control: CGPointMake(0.78 * size, 0.715 * size))
            
            path.move(to: CGPoint(x: 0.1 * size, y: 0.58 * size))
            path.addQuadCurve(to: CGPoint(x: 0.525 * size, y: 0.63 * size), control: CGPointMake(0.325 * size, 0.735 * size))
            
            path.move(to: CGPoint(x: 0.175 * size, y: 0.25 * size))
            path.addQuadCurve(to: CGPoint(x: 0.525 * size, y: 0.63 * size), control: CGPointMake(0.305 * size, 0.445 * size))
            
            path.move(to: CGPoint(x: 0.175 * size, y: 0.25 * size))
            path.addQuadCurve(to: CGPoint(x: 0.475 * size, y: 0.475 * size), control: CGPointMake(0.36 * size, 0.405 * size))
            
            path.move(to: CGPoint(x: 0.26 * size, y: 0.205 * size))
            path.addQuadCurve(to: CGPoint(x: 0.475 * size, y: 0.475 * size), control: CGPointMake(0.4 * size, 0.405 * size))
            
            path.move(to: CGPoint(x: 0.26 * size, y: 0.205 * size))
            path.addQuadCurve(to: CGPoint(x: 0.63 * size, y: 0.505 * size), control: CGPointMake(0.465 * size, 0.405 * size))
            
            path.move(to: CGPoint(x: 0.565 * size, y: 0.145 * size))
            path.addQuadCurve(to: CGPoint(x: 0.63 * size, y: 0.505 * size), control: CGPointMake(0.7 * size, 0.355 * size))
        }

        return path
    }
}

I add and animate the drawing of the bird in another view like so:

struct TestView: View {
    @State private var percentage: CGFloat = 0
    
    var body: some View {
        ZStack {
            Color(.black)
            SwiftBird()
                .trim(from: 0, to: percentage)
                .stroke(Color.white, style: StrokeStyle(lineWidth: 1, lineCap: .round, lineJoin: .round))
                .frame(width: 100, height: 100)
                .animation(.easeInOut(duration: 5.0), value: percentage)
                .onAppear {
                    self.percentage = 1.0
                }
        }
    }
}

I'd like to fill the shape with color once the drawing of the paths is complete. However if I apply .fill(Color.orange) modifier to the shape it doesn't fill the shape inside correctly. I am able to see black areas inside the shape and also the color overruns the paths. How can I achieve the color fill with animation? Any help is appreciated.


Solution

  • Your shape looks like this when filled:

    Swift logo, incorrectly filled

    The problem is that your shape isn't a single closed subpath. It's a bunch of disconnected subpaths. Every move(to:) starts a new subpath. When you fill the shape, each subpath gets closed and filled individually. Most of the logo's interior isn't enclosed by any of the subpaths, and some of the exterior is enclosed by the individually-closed subpaths.

    Here's a version that creates a single closed subpath:

    struct SwiftBird: Shape {
        func path(in rect: CGRect) -> Path {
            let size = min(rect.width, rect.height)
            var path = Path()
    
            path.move(to: CGPoint(x: 0.565, y: 0.145))
            for (ex, ey, cx, cy): (CGFloat, CGFloat, CGFloat, CGFloat) in [
                (0.79, 0.62, 0.85, 0.34), // Upper wing outside
                (0.845, 0.825, 0.88, 0.75), // Head top
                (0.67, 0.775, 0.78, 0.715), // Head bottom
                (0.5, 0.82, 0.6, 0.82), // Lower shoulder
                (0.1, 0.58, 0.25, 0.8), // Lower wing outside
                (0.525, 0.63, 0.325, 0.735), // Lower wing inside
                (0.175, 0.25, 0.305, 0.445), // Lower tail outside
                (0.475, 0.475, 0.36, 0.405), // Lower tail inside
                (0.26, 0.205, 0.4, 0.405), // Upper tail inside
                (0.63, 0.505, 0.465, 0.405), // Upper tail outside
                (0.565, 0.145, 0.7, 0.355), // Upper wing inside
            ] {
                path.addQuadCurve(
                    to: CGPoint(x: ex, y: ey),
                    control: CGPoint(x: cx, y: cy)
                )
            }
    
            path.closeSubpath()
            path = path.applying(.init(scaleX: size, y: size))
            return path
        }
    }
    

    It looks the same as yours when stroked:

    Swift logo, stroked

    But fills correctly:

    Swift logo, filled

    One way to make SwiftUI fill the logo precisely at the end of the animation is to wrap it in a custom Animatable View:

    struct AnimatedSwiftLogo: View, Animatable {
        var animatableData: Double
    
        var body: some View {
            ZStack {
                SwiftBird()
                    .fill(animatableData == 1 ? .orange : .clear)
                SwiftBird()
                    .trim(from: 0, to: animatableData)
                    .stroke(Color.white, style: StrokeStyle(lineWidth: 1, lineCap: .round, lineJoin: .round))
            }
        }
    }
    
    struct TestView: View {
        @State private var percentage: CGFloat = 0
        
        var body: some View {
            ZStack {
                Color(.black)
                AnimatedSwiftLogo(animatableData: percentage)
                    .frame(width: 100, height: 100)
                    .animation(.easeInOut(duration: 5.0), value: percentage)
                    .onAppear {
                        self.percentage = 1.0
                    }
            }
        }
    }
    

    Result:

    animation of the Swift logo being stroked over five seconds, then filled with orange instantly when the stroke is complete