488 lines
16 KiB
Swift
488 lines
16 KiB
Swift
//
|
|
// Shape.swift
|
|
// lottie-swift
|
|
//
|
|
// Created by Brandon Withrow on 1/8/19.
|
|
//
|
|
|
|
import CoreGraphics
|
|
import Foundation
|
|
|
|
// MARK: - BezierPath
|
|
|
|
/// A container that holds instructions for creating a single, unbroken Bezier Path.
|
|
struct BezierPath {
|
|
|
|
// MARK: Lifecycle
|
|
|
|
/// Initializes a new Bezier Path.
|
|
init(startPoint: CurveVertex) {
|
|
elements = [PathElement(vertex: startPoint)]
|
|
length = 0
|
|
closed = false
|
|
}
|
|
|
|
init() {
|
|
elements = []
|
|
length = 0
|
|
closed = false
|
|
}
|
|
|
|
// MARK: Internal
|
|
|
|
/// The elements of the path
|
|
private(set) var elements: [PathElement]
|
|
|
|
/// If the path is closed or not.
|
|
private(set) var closed: Bool
|
|
|
|
/// The total length of the path.
|
|
private(set) var length: CGFloat
|
|
|
|
mutating func moveToStartPoint(_ vertex: CurveVertex) {
|
|
elements = [PathElement(vertex: vertex)]
|
|
length = 0
|
|
}
|
|
|
|
mutating func addVertex(_ vertex: CurveVertex) {
|
|
guard let previous = elements.last else {
|
|
addElement(PathElement(vertex: vertex))
|
|
return
|
|
}
|
|
addElement(previous.pathElementTo(vertex))
|
|
}
|
|
|
|
mutating func addCurve(toPoint: CGPoint, outTangent: CGPoint, inTangent: CGPoint) {
|
|
guard let previous = elements.last else { return }
|
|
let newVertex = CurveVertex(inTangent, toPoint, toPoint)
|
|
updateVertex(
|
|
CurveVertex(previous.vertex.inTangent, previous.vertex.point, outTangent),
|
|
atIndex: elements.endIndex - 1,
|
|
remeasure: false)
|
|
addVertex(newVertex)
|
|
}
|
|
|
|
mutating func addLine(toPoint: CGPoint) {
|
|
guard let previous = elements.last else { return }
|
|
let newVertex = CurveVertex(point: toPoint, inTangentRelative: .zero, outTangentRelative: .zero)
|
|
updateVertex(
|
|
CurveVertex(previous.vertex.inTangent, previous.vertex.point, previous.vertex.point),
|
|
atIndex: elements.endIndex - 1,
|
|
remeasure: false)
|
|
addVertex(newVertex)
|
|
}
|
|
|
|
mutating func close() {
|
|
closed = true
|
|
}
|
|
|
|
mutating func addElement(_ pathElement: PathElement) {
|
|
elements.append(pathElement)
|
|
length = length + pathElement.length
|
|
}
|
|
|
|
mutating func updateVertex(_ vertex: CurveVertex, atIndex: Int, remeasure: Bool) {
|
|
if remeasure {
|
|
var newElement: PathElement
|
|
if atIndex > 0 {
|
|
let previousElement = elements[atIndex - 1]
|
|
newElement = previousElement.pathElementTo(vertex)
|
|
} else {
|
|
newElement = PathElement(vertex: vertex)
|
|
}
|
|
elements[atIndex] = newElement
|
|
|
|
if atIndex + 1 < elements.count {
|
|
let nextElement = elements[atIndex + 1]
|
|
elements[atIndex + 1] = newElement.pathElementTo(nextElement.vertex)
|
|
}
|
|
|
|
} else {
|
|
let oldElement = elements[atIndex]
|
|
elements[atIndex] = oldElement.updateVertex(newVertex: vertex)
|
|
}
|
|
}
|
|
|
|
/// Trims a path fromLength toLength with an offset.
|
|
///
|
|
/// Length and offset are defined in the length coordinate space.
|
|
/// If any argument is outside the range of this path, then it will be looped over the path from finish to start.
|
|
///
|
|
/// Cutting the curve when fromLength is less than toLength
|
|
/// x x x x
|
|
/// ~~~~~~~~~~~~~~~ooooooooooooooooooooooooooooooooooooooooooooooooo-------------------
|
|
/// |Offset |fromLength toLength| |
|
|
///
|
|
/// Cutting the curve when from Length is greater than toLength
|
|
/// x x x x x
|
|
/// oooooooooooooooooo--------------------~~~~~~~~~~~~~~~~ooooooooooooooooooooooooooooo
|
|
/// | toLength| |Offset |fromLength |
|
|
///
|
|
func trim(fromLength: CGFloat, toLength: CGFloat, offsetLength: CGFloat) -> [BezierPath] {
|
|
guard elements.count > 1 else {
|
|
return []
|
|
}
|
|
|
|
if fromLength == toLength {
|
|
return []
|
|
}
|
|
|
|
/// Normalize lengths to the curve length.
|
|
var start = (fromLength + offsetLength).truncatingRemainder(dividingBy: length)
|
|
var end = (toLength + offsetLength).truncatingRemainder(dividingBy: length)
|
|
|
|
if start < 0 {
|
|
start = length + start
|
|
}
|
|
|
|
if end < 0 {
|
|
end = length + end
|
|
}
|
|
|
|
if start == length {
|
|
start = 0
|
|
}
|
|
if end == 0 {
|
|
end = length
|
|
}
|
|
|
|
if
|
|
start == 0 && end == length ||
|
|
start == end ||
|
|
start == length && end == 0
|
|
{
|
|
/// The trim encompasses the entire path. Return.
|
|
return [self]
|
|
}
|
|
|
|
if start > end {
|
|
// Start is greater than end. Two paths are returned.
|
|
return trimPathAtLengths(positions: [(start: 0, end: end), (start: start, end: length)])
|
|
}
|
|
|
|
return trimPathAtLengths(positions: [(start: start, end: end)])
|
|
}
|
|
|
|
// MARK: Private
|
|
|
|
private func trimPathAtLengths(positions: [(start: CGFloat, end: CGFloat)]) -> [BezierPath] {
|
|
guard positions.count > 0 else {
|
|
return []
|
|
}
|
|
var remainingPositions = positions
|
|
|
|
var trim = remainingPositions.remove(at: 0)
|
|
|
|
var paths = [BezierPath]()
|
|
|
|
var runningLength: CGFloat = 0
|
|
var finishedTrimming = false
|
|
var pathElements = elements
|
|
|
|
var currentPath = BezierPath()
|
|
var i = 0
|
|
|
|
while !finishedTrimming {
|
|
if pathElements.count <= i {
|
|
/// Do this for rounding errors
|
|
paths.append(currentPath)
|
|
finishedTrimming = true
|
|
continue
|
|
}
|
|
/// Loop through and add elements within start->end range.
|
|
/// Get current element
|
|
let element = pathElements[i]
|
|
|
|
/// Calculate new running length.
|
|
let newLength = runningLength + element.length
|
|
|
|
if newLength < trim.start {
|
|
/// Element is not included in the trim, continue.
|
|
runningLength = newLength
|
|
i = i + 1
|
|
/// Increment index, we are done with this element.
|
|
continue
|
|
}
|
|
|
|
if newLength == trim.start {
|
|
/// Current element IS the start element.
|
|
/// For start we want to add a zero length element.
|
|
currentPath.moveToStartPoint(element.vertex)
|
|
runningLength = newLength
|
|
i = i + 1
|
|
/// Increment index, we are done with this element.
|
|
continue
|
|
}
|
|
|
|
if runningLength < trim.start, trim.start < newLength, currentPath.elements.count == 0 {
|
|
/// The start of the trim is between this element and the previous, trim.
|
|
/// Get previous element.
|
|
let previousElement = pathElements[i - 1]
|
|
/// Trim it
|
|
let trimLength = trim.start - runningLength
|
|
let trimResults = element.splitElementAtPosition(fromElement: previousElement, atLength: trimLength)
|
|
/// Add the right span start.
|
|
currentPath.moveToStartPoint(trimResults.rightSpan.start.vertex)
|
|
|
|
pathElements[i] = trimResults.rightSpan.end
|
|
pathElements[i - 1] = trimResults.rightSpan.start
|
|
runningLength = runningLength + trimResults.leftSpan.end.length
|
|
/// Dont increment index or the current length, the end of this path can be within this span.
|
|
continue
|
|
}
|
|
|
|
if trim.start < newLength, newLength < trim.end {
|
|
/// Element lies within the trim span.
|
|
currentPath.addElement(element)
|
|
runningLength = newLength
|
|
i = i + 1
|
|
continue
|
|
}
|
|
|
|
if newLength == trim.end {
|
|
/// Element is the end element.
|
|
/// The element could have a new length if it's added right after the start node.
|
|
currentPath.addElement(element)
|
|
/// We are done with this span.
|
|
runningLength = newLength
|
|
i = i + 1
|
|
/// Allow the path to be finalized.
|
|
/// Fall through to finalize path and move to next position
|
|
}
|
|
|
|
if runningLength < trim.end, trim.end < newLength {
|
|
/// New element must be cut for end.
|
|
/// Get previous element.
|
|
let previousElement = pathElements[i - 1]
|
|
/// Trim it
|
|
let trimLength = trim.end - runningLength
|
|
let trimResults = element.splitElementAtPosition(fromElement: previousElement, atLength: trimLength)
|
|
/// Add the left span end.
|
|
|
|
currentPath.updateVertex(trimResults.leftSpan.start.vertex, atIndex: currentPath.elements.count - 1, remeasure: false)
|
|
currentPath.addElement(trimResults.leftSpan.end)
|
|
|
|
pathElements[i] = trimResults.rightSpan.end
|
|
pathElements[i - 1] = trimResults.rightSpan.start
|
|
runningLength = runningLength + trimResults.leftSpan.end.length
|
|
/// Dont increment index or the current length, the start of the next path can be within this span.
|
|
/// We are done with this span.
|
|
/// Allow the path to be finalized.
|
|
/// Fall through to finalize path and move to next position
|
|
}
|
|
|
|
paths.append(currentPath)
|
|
currentPath = BezierPath()
|
|
if remainingPositions.count > 0 {
|
|
trim = remainingPositions.remove(at: 0)
|
|
} else {
|
|
finishedTrimming = true
|
|
}
|
|
}
|
|
return paths
|
|
}
|
|
|
|
}
|
|
|
|
// MARK: Codable
|
|
|
|
extension BezierPath: Codable {
|
|
|
|
// MARK: Lifecycle
|
|
|
|
init(from decoder: Decoder) throws {
|
|
let container: KeyedDecodingContainer<BezierPath.CodingKeys>
|
|
|
|
if let keyedContainer = try? decoder.container(keyedBy: BezierPath.CodingKeys.self) {
|
|
container = keyedContainer
|
|
} else {
|
|
var unkeyedContainer = try decoder.unkeyedContainer()
|
|
container = try unkeyedContainer.nestedContainer(keyedBy: BezierPath.CodingKeys.self)
|
|
}
|
|
|
|
closed = try container.decodeIfPresent(Bool.self, forKey: .closed) ?? true
|
|
|
|
var vertexContainer = try container.nestedUnkeyedContainer(forKey: .vertices)
|
|
var inPointsContainer = try container.nestedUnkeyedContainer(forKey: .inPoints)
|
|
var outPointsContainer = try container.nestedUnkeyedContainer(forKey: .outPoints)
|
|
|
|
guard vertexContainer.count == inPointsContainer.count, inPointsContainer.count == outPointsContainer.count else {
|
|
/// Will throw an error if vertex, inpoints, and outpoints are not the same length.
|
|
/// This error is to be expected.
|
|
throw DecodingError.dataCorruptedError(
|
|
forKey: CodingKeys.vertices,
|
|
in: container,
|
|
debugDescription: "Vertex data does not match In Tangents and Out Tangents")
|
|
}
|
|
|
|
guard let count = vertexContainer.count, count > 0 else {
|
|
length = 0
|
|
elements = []
|
|
return
|
|
}
|
|
|
|
var decodedElements = [PathElement]()
|
|
|
|
/// Create first point
|
|
let firstVertex = CurveVertex(
|
|
point: try vertexContainer.decode(CGPoint.self),
|
|
inTangentRelative: try inPointsContainer.decode(CGPoint.self),
|
|
outTangentRelative: try outPointsContainer.decode(CGPoint.self))
|
|
var previousElement = PathElement(vertex: firstVertex)
|
|
decodedElements.append(previousElement)
|
|
|
|
var totalLength: CGFloat = 0
|
|
while !vertexContainer.isAtEnd {
|
|
/// Get the next vertex data.
|
|
let vertex = CurveVertex(
|
|
point: try vertexContainer.decode(CGPoint.self),
|
|
inTangentRelative: try inPointsContainer.decode(CGPoint.self),
|
|
outTangentRelative: try outPointsContainer.decode(CGPoint.self))
|
|
let pathElement = previousElement.pathElementTo(vertex)
|
|
decodedElements.append(pathElement)
|
|
previousElement = pathElement
|
|
totalLength = totalLength + pathElement.length
|
|
}
|
|
if closed {
|
|
let closeElement = previousElement.pathElementTo(firstVertex)
|
|
decodedElements.append(closeElement)
|
|
totalLength = totalLength + closeElement.length
|
|
}
|
|
length = totalLength
|
|
elements = decodedElements
|
|
}
|
|
|
|
// MARK: Internal
|
|
|
|
/// The BezierPath container is encoded and decoded from the JSON format
|
|
/// that defines points for a lottie animation.
|
|
///
|
|
/// {
|
|
/// "c" = Bool
|
|
/// "i" = [[Double]],
|
|
/// "o" = [[Double]],
|
|
/// "v" = [[Double]]
|
|
/// }
|
|
///
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case closed = "c"
|
|
case inPoints = "i"
|
|
case outPoints = "o"
|
|
case vertices = "v"
|
|
}
|
|
|
|
func encode(to encoder: Encoder) throws {
|
|
var container = encoder.container(keyedBy: BezierPath.CodingKeys.self)
|
|
try container.encode(closed, forKey: .closed)
|
|
|
|
var vertexContainer = container.nestedUnkeyedContainer(forKey: .vertices)
|
|
var inPointsContainer = container.nestedUnkeyedContainer(forKey: .inPoints)
|
|
var outPointsContainer = container.nestedUnkeyedContainer(forKey: .outPoints)
|
|
|
|
/// If closed path, ignore the final element.
|
|
let finalIndex = closed ? elements.endIndex - 1 : elements.endIndex
|
|
for i in 0..<finalIndex {
|
|
let element = elements[i]
|
|
try vertexContainer.encode(element.vertex.point)
|
|
try inPointsContainer.encode(element.vertex.inTangentRelative)
|
|
try outPointsContainer.encode(element.vertex.outTangentRelative)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: AnyInitializable
|
|
|
|
extension BezierPath: AnyInitializable {
|
|
|
|
init(value: Any) throws {
|
|
var pathDictionary: [String: Any]
|
|
if
|
|
let array = value as? [[String: Any]],
|
|
let firstDictionary = array.first
|
|
{
|
|
pathDictionary = firstDictionary
|
|
} else if let dictionary = value as? [String: Any] {
|
|
pathDictionary = dictionary
|
|
} else {
|
|
throw InitializableError.invalidInput
|
|
}
|
|
closed = (try? pathDictionary.value(for: CodingKeys.closed)) ?? true
|
|
var vertexDictionaries: [Any] = try pathDictionary.value(for: CodingKeys.vertices)
|
|
var inPointsDictionaries: [Any] = try pathDictionary.value(for: CodingKeys.inPoints)
|
|
var outPointsDictionaries: [Any] = try pathDictionary.value(for: CodingKeys.outPoints)
|
|
guard
|
|
vertexDictionaries.count == inPointsDictionaries.count,
|
|
inPointsDictionaries.count == outPointsDictionaries.count else
|
|
{
|
|
throw InitializableError.invalidInput
|
|
}
|
|
guard vertexDictionaries.count > 0 else {
|
|
length = 0
|
|
elements = []
|
|
return
|
|
}
|
|
|
|
var decodedElements = [PathElement]()
|
|
let firstVertexDictionary = vertexDictionaries.removeFirst()
|
|
let firstInPointsDictionary = inPointsDictionaries.removeFirst()
|
|
let firstOutPointsDictionary = outPointsDictionaries.removeFirst()
|
|
let firstVertex = CurveVertex(
|
|
point: try CGPoint(value: firstVertexDictionary),
|
|
inTangentRelative: try CGPoint(value: firstInPointsDictionary),
|
|
outTangentRelative: try CGPoint(value: firstOutPointsDictionary))
|
|
var previousElement = PathElement(vertex: firstVertex)
|
|
decodedElements.append(previousElement)
|
|
|
|
var totalLength: CGFloat = 0
|
|
while vertexDictionaries.count > 0 {
|
|
let vertexDictionary = vertexDictionaries.removeFirst()
|
|
let inPointsDictionary = inPointsDictionaries.removeFirst()
|
|
let outPointsDictionary = outPointsDictionaries.removeFirst()
|
|
let vertex = CurveVertex(
|
|
point: try CGPoint(value: vertexDictionary),
|
|
inTangentRelative: try CGPoint(value: inPointsDictionary),
|
|
outTangentRelative: try CGPoint(value: outPointsDictionary))
|
|
let pathElement = previousElement.pathElementTo(vertex)
|
|
decodedElements.append(pathElement)
|
|
previousElement = pathElement
|
|
totalLength = totalLength + pathElement.length
|
|
}
|
|
if closed {
|
|
let closeElement = previousElement.pathElementTo(firstVertex)
|
|
decodedElements.append(closeElement)
|
|
totalLength = totalLength + closeElement.length
|
|
}
|
|
|
|
length = totalLength
|
|
elements = decodedElements
|
|
}
|
|
|
|
}
|
|
|
|
extension BezierPath {
|
|
|
|
func cgPath() -> CGPath {
|
|
let cgPath = CGMutablePath()
|
|
|
|
var previousElement: PathElement?
|
|
for element in elements {
|
|
if let previous = previousElement {
|
|
if previous.vertex.outTangentRelative.isZero, element.vertex.inTangentRelative.isZero {
|
|
cgPath.addLine(to: element.vertex.point)
|
|
} else {
|
|
cgPath.addCurve(to: element.vertex.point, control1: previous.vertex.outTangent, control2: element.vertex.inTangent)
|
|
}
|
|
} else {
|
|
cgPath.move(to: element.vertex.point)
|
|
}
|
|
previousElement = element
|
|
}
|
|
if closed {
|
|
cgPath.closeSubpath()
|
|
}
|
|
return cgPath
|
|
}
|
|
|
|
}
|