kirkyoyx
kirkyoyx

Reputation: 373

How to animate/fill an UIBezierPath in a given order?

From a file I have a bunch of SVG paths that I convert to UIBezierPath. In order to make my example simple I create the path manually:

struct myShape: Shape {
    func path(in rect: CGRect) -> Path {
        let p = UIBezierPath()

        p.move(to: CGPoint(x: 147, y: 32))
        p.addQuadCurve(to: CGPoint(x: 203, y: 102), controlPoint: CGPoint(x: 181, y: 74))
        p.addQuadCurve(to: CGPoint(x: 271, y: 189), controlPoint: CGPoint(x: 242, y: 166))
        p.addQuadCurve(to: CGPoint(x: 274, y: 217), controlPoint: CGPoint(x: 287, y: 204))
        p.addQuadCurve(to: CGPoint(x: 229, y: 235), controlPoint: CGPoint(x: 258, y: 229))
        p.addQuadCurve(to: CGPoint(x: 193, y: 235), controlPoint: CGPoint(x: 204, y: 241))
        p.addQuadCurve(to: CGPoint(x: 190, y: 219), controlPoint: CGPoint(x: 183, y: 231))
        p.addQuadCurve(to: CGPoint(x: 143, y: 71), controlPoint: CGPoint(x: 199, y: 195))
        p.addQuadCurve(to: CGPoint(x: 125, y: 33), controlPoint: CGPoint(x: 134, y: 55))
        p.addCurve(to: CGPoint(x: 147, y: 32), controlPoint1: CGPoint(x: 113, y: 5), controlPoint2: CGPoint(x: 128, y: 9))
        p.close()

        return Path(p.cgPath)
    }
}

The result is the following figure:

enter image description here

For this figure I have a separated path/shape that represents the "median" of the figure and the order in which this figure should be filled. The path is just a concatenation of lines.

 struct myMedian: Shape {
    func path(in rect: CGRect) -> Path {
        let p = UIBezierPath()

        p.move(to: CGPoint(x: 196, y: 226))
        p.addLine(to: CGPoint(x: 209, y: 220))
        p.addLine(to: CGPoint(x: 226, y: 195))
        p.addLine(to: CGPoint(x: 170, y: 86))
        p.addLine(to: CGPoint(x: 142, y: 43))
        p.addLine(to: CGPoint(x: 131, y: 39))

        return Path(p.cgPath)
    }
}

To visualize the order of the lines I've added the red arrows:

enter image description here

Now I need to fill the "big figure" in the same order as the "median stroke". I know how to fill the whole figure in one step, but not splitwise and especially I don't know how to manage the direction of the animation.

The final result should look like this:

enter image description here

Since I'm using SwiftUI it should be compatible with it.

The main view is:

struct DrawCharacter: View {
    var body: some View {
        ZStack(alignment: .topLeading){
            myShape()
            myMedian()
            }
        }
    }

Upvotes: 3

Views: 404

Answers (1)

Ryan
Ryan

Reputation: 1392

You can define an animatableData property in your Shape to enable SwiftUI to interpolate between states (e.g., filled and unfilled). Where you start drawing each stroke will matter for direction. You could also use .trim on the path to truncate it if you prefer and tie that value into animatableData.

For a whole character/kanji, you may need to compose a meta Shape or View of multiple sub-Shapes with defined positions, but that's actually less work for you in the long run because you can create a library of strokes that are easy to recombine, no?

In the example below, I move a moon from full to crescent by changing the percentFullMoon property from other views. That property is used by the path drawing function to setup some arcs. SwiftUI reads the animatableData property to figure out how to draw however many frames it chooses to interpolate at the time of animation.

It's rather simple, even if this API is obscurely named.

Does that make sense? Note: the code below uses some helper functions like clamp, north/south, and center/radius, but are irrelevant to the concept.

import SwiftUI

/// Percent full moon is from 0 to 1
struct WaningMoon: Shape {
    /// From 0 to 1
    var percentFullMoon: Double

    var animatableData: Double {
        get { percentFullMoon }
        set { self.percentFullMoon = newValue }
    }

    func path(in rect: CGRect) -> Path {
        var path = Path()
        addExteriorArc(&path, rect)

        let cycle = percentFullMoon * 180
        switch cycle {
            case 90:
                return path

            case ..<90:
                let crescent = Angle(degrees: 90 + .clamp(0.nextUp, 90, 90 - cycle))
                addInteriorArc(&path, rect, angle: crescent)
                return path

            case 90.nextUp...:
                let gibbous = Angle(degrees: .clamp(0, 90.nextDown, 180 - cycle))
                addInteriorArc(&path, rect, angle: gibbous)
                return path

            default: return path

        }
    }

    private func addInteriorArc(_ path: inout Path, _ rect: CGRect, angle: Angle) {
        let xOffset = rect.radius * angle.tan()
        let offsetCenter = CGPoint(x: rect.midX - xOffset, y: rect.midY)

        return path.addArc(
            center: offsetCenter,
            radius: rect.radius / angle.cos(),
            startAngle: .south() - angle,
            endAngle: .north() + angle,
            clockwise: angle.degrees < 90) // False == Crescent, True == Gibbous
    }

    private func addExteriorArc(_ path: inout Path, _ rect: CGRect) {
        path.addArc(center: rect.center,
                    radius: rect.radius,
                    startAngle: .north(),
                    endAngle: .south(),
                    clockwise: true)
    }
}

Helpers


extension Comparable {
    static func clamp<N:Comparable>(_ min: N, _ max: N, _ variable: N) -> N {
        Swift.max(min, Swift.min(variable, max))
    }

    func clamp<N:Comparable>(_ min: N, _ max: N, _ variable: N) -> N {
        Swift.max(min, Swift.min(variable, max))
    }
}

extension Angle {
    func sin() -> CGFloat {
        CoreGraphics.sin(CGFloat(self.radians))
    }

    func cos() -> CGFloat {
        CoreGraphics.cos(CGFloat(self.radians))
    }

    func tan() -> CGFloat {
        CoreGraphics.tan(CGFloat(self.radians))
    }

    static func north() -> Angle {
        Angle(degrees: -90)
    }

    static func south() -> Angle {
        Angle(degrees: 90)
    }
}



extension CGRect {
    var center: CGPoint {
        CGPoint(x: midX, y: midY)
    }

    var radius: CGFloat {
        min(width, height) / 2
    }

    var diameter: CGFloat {
        min(width, height)
    }

    var N: CGPoint { CGPoint(x: midX, y: minY) }
    var E: CGPoint { CGPoint(x: minX, y: midY) }
    var W: CGPoint { CGPoint(x: maxX, y: midY) }
    var S: CGPoint { CGPoint(x: midX, y: maxY) }

    var NE: CGPoint { CGPoint(x: maxX, y: minY) }
    var NW: CGPoint { CGPoint(x: minX, y: minY) }
    var SE: CGPoint { CGPoint(x: maxX, y: maxY) }
    var SW: CGPoint { CGPoint(x: minX, y: maxY) }


    func insetN(_ denominator: CGFloat) -> CGPoint {
        CGPoint(x: midX,                               y: minY + height / denominator)
    }

    func insetE(_ denominator: CGFloat) -> CGPoint {
        CGPoint(x: minX - width / denominator,         y: midY)
    }

    func insetW(_ denominator: CGFloat) -> CGPoint {
        CGPoint(x: maxX + width / denominator,         y: midY)
    }

    func insetS(_ denominator: CGFloat) -> CGPoint {
        CGPoint(x: midX,                               y: maxY - height / denominator)
    }

    func insetNE(_ denominator: CGFloat) -> CGPoint {
        CGPoint(x: maxX + width / denominator,         y: minY + height / denominator)
    }

    func insetNW(_ denominator: CGFloat) -> CGPoint {
        CGPoint(x: minX - width / denominator,         y: minY + height / denominator)
    }

    func insetSE(_ denominator: CGFloat) -> CGPoint {
        CGPoint(x: maxX + width / denominator,         y: maxY - height / denominator)
    }

    func insetSW(_ denominator: CGFloat) -> CGPoint {
        CGPoint(x: minX - width / denominator,         y: maxY - height / denominator)
    }
}

Upvotes: 3

Related Questions