Reputation: 373
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:
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:
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:
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
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