Merge commit '7621e2f8dec938cf48181c8b10afc9b01f444e68' into beta

This commit is contained in:
Ilya Laktyushin
2025-12-06 02:17:48 +04:00
commit 8344b97e03
28070 changed files with 7995182 additions and 0 deletions
+18
View File
@@ -0,0 +1,18 @@
load("@build_bazel_rules_swift//swift:swift.bzl", "swift_library")
swift_library(
name = "Lottie",
module_name = "Lottie",
srcs = glob([
"Sources/**/*.swift",
]),
copts = [
"-warnings-as-errors",
],
deps = [
"//submodules/Display"
],
visibility = [
"//visibility:public",
],
)
+201
View File
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "{}"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright 2018 Airbnb, Inc.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
@@ -0,0 +1,76 @@
// Created by Cal Stephens on 1/6/22.
// Copyright © 2022 Airbnb Inc. All rights reserved.
import QuartzCore
extension CAAnimation {
/// Creates a `CAAnimation` that wraps this animation,
/// applying timing-related configuration from the given `LayerAnimationContext`
@nonobjc
func timed(with context: LayerAnimationContext, for layer: CALayer) -> CAAnimation {
// The base animation always has the duration of the full animation,
// since that's the time space where keyframing and interpolating happens.
// So we start with a simple animation timeline from 0% to 100%:
//
//
// baseAnimation
//
// 0% 100%
//
let baseAnimation = self
baseAnimation.duration = context.animation.duration
baseAnimation.speed = (context.endFrame < context.startFrame) ? -1 : 1
// To select the subrange of the `baseAnimation` that should be played,
// we create a parent animation with the duration of that subrange
// to clip the `baseAnimation`. This parent animation can then loop
// and/or autoreverse over the clipped subrange.
//
//
//  clippingParent ...
//
// 25% 75%
//
// baseAnimation
//
// 0% 100%
//
let clippingParent = CAAnimationGroup()
clippingParent.animations = [baseAnimation]
clippingParent.duration = Double(abs(context.endFrame - context.startFrame)) / context.animation.framerate
baseAnimation.timeOffset = context.animation.time(forFrame: context.startFrame)
clippingParent.autoreverses = context.timingConfiguration.autoreverses
clippingParent.repeatCount = context.timingConfiguration.repeatCount
clippingParent.timeOffset = context.timingConfiguration.timeOffset
// Once the animation ends, it should pause on the final frame
clippingParent.fillMode = .both
clippingParent.isRemovedOnCompletion = false
// We can pause the animation on a specific frame by setting the root layer's
// `speed` to 0, and then setting the `timeOffset` for the given frame.
// - For that setup to work properly, we have to set the `beginTime`
// of this animation to a time slightly before the current time.
// - It's not really clear why this is necessary, but `timeOffset`
// is not applied correctly without this configuration.
// - We can't do this when playing the animation in real time,
// because it can cause keyframe timings to be incorrect.
if context.timingConfiguration.speed == 0 {
let currentTime = layer.convertTime(CACurrentMediaTime(), from: nil)
clippingParent.beginTime = currentTime - .leastNonzeroMagnitude
}
return clippingParent
}
}
extension CALayer {
/// Adds the given animation to this layer, timed with the given timing configuration
@nonobjc
func add(_ animation: CAPropertyAnimation, timedWith context: LayerAnimationContext) {
add(animation.timed(with: context, for: self), forKey: animation.keyPath)
}
}
@@ -0,0 +1,316 @@
// Created by Cal Stephens on 12/14/21.
// Copyright © 2021 Airbnb Inc. All rights reserved.
import QuartzCore
import UIKit
extension CALayer {
// MARK: Internal
/// Constructs a `CAKeyframeAnimation` that reflects the given keyframes,
/// and adds it to this `CALayer`.
@nonobjc
func addAnimation<KeyframeValue, ValueRepresentation: Equatable>(
for property: LayerProperty<ValueRepresentation>,
keyframes: ContiguousArray<Keyframe<KeyframeValue>>,
value keyframeValueMapping: (KeyframeValue) throws -> ValueRepresentation,
context: LayerAnimationContext)
throws
{
if let customAnimation = try customizedAnimation(for: property, context: context) {
add(customAnimation, timedWith: context)
}
else if
let defaultAnimation = try defaultAnimation(
for: property,
keyframes: keyframes,
value: keyframeValueMapping,
context: context)
{
add(defaultAnimation, timedWith: context)
}
}
// MARK: Private
/// Constructs a `CAAnimation` that reflects the given keyframes
/// - If the value can be applied directly to the CALayer using KVC,
/// then no `CAAnimation` will be created and the value will be applied directly.
@nonobjc
private func defaultAnimation<KeyframeValue, ValueRepresentation>(
for property: LayerProperty<ValueRepresentation>,
keyframes: ContiguousArray<Keyframe<KeyframeValue>>,
value keyframeValueMapping: (KeyframeValue) throws -> ValueRepresentation,
context: LayerAnimationContext)
throws
-> CAPropertyAnimation?
{
guard !keyframes.isEmpty else { return nil }
// If there is exactly one keyframe value, we can improve performance
// by applying that value directly to the layer instead of creating
// a relatively expensive `CAKeyframeAnimation`.
if keyframes.count == 1 {
let keyframeValue = try keyframeValueMapping(keyframes[0].value)
// If the keyframe value is the same as the layer's default value for this property,
// then we can just ignore this set of keyframes.
if keyframeValue == property.defaultValue {
return nil
}
// If the property on the CALayer being animated hasn't been modified from the default yet,
// then we can apply the keyframe value directly to the layer using KVC instead
// of creating a `CAAnimation`.
if
let defaultValue = property.defaultValue,
defaultValue == value(forKey: property.caLayerKeypath) as? ValueRepresentation
{
setValue(keyframeValue, forKeyPath: property.caLayerKeypath)
return nil
}
// Otherwise, we still need to create a `CAAnimation`, but we can
// create a simple `CABasicAnimation` that is still less expensive
// than computing a `CAKeyframeAnimation`.
let animation = CABasicAnimation(keyPath: property.caLayerKeypath)
animation.fromValue = keyframeValue
animation.toValue = keyframeValue
return animation
}
return try keyframeAnimation(
for: property,
keyframes: keyframes,
value: keyframeValueMapping,
context: context)
}
/// A `CAAnimation` that applies the custom value from the `AnyValueProvider`
/// registered for this specific property's `AnimationKeypath`,
/// if one has been registered using `AnimationView.setValueProvider(_:keypath:)`.
@nonobjc
private func customizedAnimation<ValueRepresentation>(
for property: LayerProperty<ValueRepresentation>,
context: LayerAnimationContext)
throws
-> CAPropertyAnimation?
{
guard
let customizableProperty = property.customizableProperty,
let customKeyframes = try context.valueProviderStore.customKeyframes(
of: customizableProperty,
for: AnimationKeypath(keys: context.currentKeypath.keys + customizableProperty.name.map { $0.rawValue }),
context: context)
else { return nil }
// Since custom animations are overriding an existing animation,
// we always have to create a CAKeyframeAnimation for these instead of
// letting `defaultAnimation(...)` try to apply the value using KVC.
return try keyframeAnimation(
for: property,
keyframes: customKeyframes.keyframes,
value: { $0 },
context: context)
}
/// Creates a `CAKeyframeAnimation` for the given keyframes
private func keyframeAnimation<KeyframeValue, ValueRepresentation>(
for property: LayerProperty<ValueRepresentation>,
keyframes: ContiguousArray<Keyframe<KeyframeValue>>,
value keyframeValueMapping: (KeyframeValue) throws -> ValueRepresentation,
context: LayerAnimationContext)
throws
-> CAKeyframeAnimation
{
// Convert the list of `Keyframe<T>` into
// the representation used by `CAKeyframeAnimation`
var keyTimes = keyframes.map { keyframeModel -> NSNumber in
NSNumber(value: Float(context.progressTime(for: keyframeModel.time)))
}
var timingFunctions = self.timingFunctions(for: keyframes)
let calculationMode = try self.calculationMode(for: keyframes, context: context)
let animation = CAKeyframeAnimation(keyPath: property.caLayerKeypath)
// Position animations define a `CGPath` curve that should be followed,
// instead of animating directly between keyframe point values.
if property.caLayerKeypath == LayerProperty<CGPoint>.position.caLayerKeypath {
animation.path = try path(keyframes: keyframes, value: { value in
guard let point = try keyframeValueMapping(value) as? CGPoint else {
LottieLogger.shared.assertionFailure("Cannot create point from keyframe with value \(value)")
return .zero
}
return point
})
}
// All other types of keyframes provide individual values that are interpolated by Core Animation
else {
var values = try keyframes.map { keyframeModel in
try keyframeValueMapping(keyframeModel.value)
}
validate(values: &values, keyTimes: &keyTimes, timingFunctions: &timingFunctions, for: calculationMode)
animation.values = values
}
animation.calculationMode = calculationMode
animation.keyTimes = keyTimes
animation.timingFunctions = timingFunctions
return animation
}
/// The `CAAnimationCalculationMode` that should be used for a `CAKeyframeAnimation`
/// animating the given keyframes
private func calculationMode<KeyframeValue>(
for keyframes: ContiguousArray<Keyframe<KeyframeValue>>,
context: LayerAnimationContext)
throws
-> CAAnimationCalculationMode
{
// Animations using `isHold` should use `CAAnimationCalculationMode.discrete`
//
// - Since we currently only create a single `CAKeyframeAnimation`,
// we can currently only correctly support animations where
// `isHold` is either always `true` or always `false`
// (this requirement doesn't apply to the first/last keyframes).
//
// - We should be able to support this in the future by creating multiple
// `CAKeyframeAnimation`s with different `calculationMode`s and
// playing them sequentially.
//
let intermediateKeyframes = keyframes.dropFirst().dropLast()
if intermediateKeyframes.contains(where: \.isHold) {
if intermediateKeyframes.allSatisfy(\.isHold) {
return .discrete
} else {
try context.logCompatibilityIssue("Mixed `isHold` / `!isHold` keyframes are currently unsupported")
}
}
return .linear
}
/// `timingFunctions` to apply to a `CAKeyframeAnimation` animating the given keyframes
private func timingFunctions<KeyframeValue>(
for keyframes: ContiguousArray<Keyframe<KeyframeValue>>)
-> [CAMediaTimingFunction]
{
// Compute the timing function between each keyframe and the subsequent keyframe
var timingFunctions: [CAMediaTimingFunction] = []
for (index, keyframe) in keyframes.enumerated()
where index != keyframes.indices.last
{
let nextKeyframe = keyframes[index + 1]
let controlPoint1 = keyframe.outTangent?.pointValue ?? .zero
let controlPoint2 = nextKeyframe.inTangent?.pointValue ?? CGPoint(x: 1, y: 1)
timingFunctions.append(CAMediaTimingFunction(
controlPoints:
Float(controlPoint1.x),
Float(controlPoint1.y),
Float(controlPoint2.x),
Float(controlPoint2.y)))
}
return timingFunctions
}
/// Creates a `CGPath` for the given `position` keyframes,
/// which accounts for `spatialInTangent`s and `spatialOutTangents`
private func path<KeyframeValue>(
keyframes positionKeyframes: ContiguousArray<Keyframe<KeyframeValue>>,
value keyframeValueMapping: (KeyframeValue) throws -> CGPoint) rethrows
-> CGPath {
let path = CGMutablePath()
for (index, keyframe) in positionKeyframes.enumerated() {
if index == positionKeyframes.indices.first {
path.move(to: try keyframeValueMapping(keyframe.value))
}
if index != positionKeyframes.indices.last {
let nextKeyframe = positionKeyframes[index + 1]
if
let controlPoint1 = keyframe.spatialOutTangent?.pointValue,
let controlPoint2 = nextKeyframe.spatialInTangent?.pointValue,
controlPoint1 != .zero,
controlPoint2 != .zero
{
path.addCurve(
to: try keyframeValueMapping(nextKeyframe.value),
control1: try keyframeValueMapping(keyframe.value) + controlPoint1,
control2: try keyframeValueMapping(nextKeyframe.value) + controlPoint2)
}
else {
path.addLine(to: try keyframeValueMapping(nextKeyframe.value))
}
}
}
path.closeSubpath()
return path
}
/// Validates that the requirements of the `CAKeyframeAnimation` API are met correctly
private func validate<ValueRepresentation>(
values: inout [ValueRepresentation],
keyTimes: inout [NSNumber],
timingFunctions: inout [CAMediaTimingFunction],
for calculationMode: CAAnimationCalculationMode)
{
// Validate that we have correct start (0.0) and end (1.0) keyframes.
// From the documentation of `CAKeyframeAnimation.keyTimes`:
// - The first value in the `keyTimes` array must be 0.0 and the last value must be 1.0.
if keyTimes.first != 0.0 {
keyTimes.insert(0.0, at: 0)
values.insert(values[0], at: 0)
timingFunctions.insert(CAMediaTimingFunction(name: .linear), at: 0)
}
if keyTimes.last != 1.0 {
keyTimes.append(1.0)
values.append(values.last!)
timingFunctions.append(CAMediaTimingFunction(name: .linear))
}
switch calculationMode {
case .linear, .cubic:
// From the documentation of `CAKeyframeAnimation.keyTimes`:
// - The number of elements in the keyTimes array
// should match the number of elements in the values property
LottieLogger.shared.assert(
values.count == keyTimes.count,
"`values.count` must exactly equal `keyTimes.count`")
LottieLogger.shared.assert(
timingFunctions.count == (values.count - 1),
"`timingFunctions.count` must exactly equal `values.count - 1`")
case .discrete:
// From the documentation of `CAKeyframeAnimation.keyTimes`:
// - If the calculationMode is set to discrete... the keyTimes array
// should have one more entry than appears in the values array.
values.removeLast()
LottieLogger.shared.assert(
keyTimes.count == values.count + 1,
"`keyTimes.count` must exactly equal `values.count + 1`")
default:
LottieLogger.shared.assertionFailure("""
Unexpected keyframe calculation mode \(calculationMode)
""")
}
}
}
@@ -0,0 +1,52 @@
// Created by Cal Stephens on 1/28/22.
// Copyright © 2022 Airbnb Inc. All rights reserved.
import QuartzCore
extension CAShapeLayer {
/// Adds animations for the given `CombinedShapeItem` to this `CALayer`
@nonobjc
func addAnimations(
for combinedShapes: CombinedShapeItem,
context: LayerAnimationContext)
throws
{
try addAnimation(
for: .path,
keyframes: combinedShapes.shapes.keyframes,
value: { paths in
let combinedPath = CGMutablePath()
for path in paths {
combinedPath.addPath(path.cgPath())
}
return combinedPath
},
context: context)
}
}
// MARK: - CombinedShapeItem
/// A custom `ShapeItem` subclass that combines multiple `Shape`s into a single `KeyframeGroup`
final class CombinedShapeItem: ShapeItem {
// MARK: Lifecycle
init(shapes: KeyframeGroup<[BezierPath]>, name: String) {
self.shapes = shapes
super.init(name: name, type: .shape, hidden: false)
}
required init(from _: Decoder) throws {
fatalError("init(from:) has not been implemented")
}
required init(dictionary _: [String: Any]) throws {
fatalError("init(dictionary:) has not been implemented")
}
// MARK: Internal
let shapes: KeyframeGroup<[BezierPath]>
}
@@ -0,0 +1,22 @@
// Created by Cal Stephens on 12/21/21.
// Copyright © 2021 Airbnb Inc. All rights reserved.
import QuartzCore
extension CAShapeLayer {
/// Adds animations for the given `BezierPath` keyframes to this `CALayer`
@nonobjc
func addAnimations(
for customPath: KeyframeGroup<BezierPath>,
context: LayerAnimationContext)
throws
{
try addAnimation(
for: .path,
keyframes: customPath.keyframes,
value: { pathKeyframe in
pathKeyframe.cgPath()
},
context: context)
}
}
@@ -0,0 +1,26 @@
// Created by Cal Stephens on 12/21/21.
// Copyright © 2021 Airbnb Inc. All rights reserved.
import QuartzCore
extension CAShapeLayer {
/// Adds animations for the given `Ellipse` to this `CALayer`
@nonobjc
func addAnimations(
for ellipse: Ellipse,
context: LayerAnimationContext)
throws
{
try addAnimation(
for: .path,
keyframes: ellipse.size.keyframes,
value: { sizeKeyframe in
BezierPath.ellipse(
size: sizeKeyframe.sizeValue,
center: try ellipse.position.exactlyOneKeyframe(context: context, description: "ellipse position").value.pointValue,
direction: ellipse.direction)
.cgPath()
},
context: context)
}
}
@@ -0,0 +1,146 @@
// Created by Cal Stephens on 1/7/22.
// Copyright © 2022 Airbnb Inc. All rights reserved.
import QuartzCore
// MARK: - GradientShapeItem
/// A `ShapeItem` that represents a gradient
protocol GradientShapeItem: OpacityAnimationModel {
var startPoint: KeyframeGroup<Vector3D> { get }
var endPoint: KeyframeGroup<Vector3D> { get }
var gradientType: GradientType { get }
var numberOfColors: Int { get }
var colors: KeyframeGroup<[Double]> { get }
}
// MARK: - GradientFill + GradientShapeItem
extension GradientFill: GradientShapeItem { }
// MARK: - GradientStroke + GradientShapeItem
extension GradientStroke: GradientShapeItem { }
// MARK: - GradientRenderLayer + GradientShapeItem
extension GradientRenderLayer {
// MARK: Internal
/// Adds gradient-related animations to this layer, from the given `GradientFill`
func addGradientAnimations(for gradient: GradientShapeItem, context: LayerAnimationContext) throws {
// We have to set `colors` to a non-nil value with some valid number of colors
// for the color animation below to have any effect
colors = .init(
repeating: CGColor.rgb(0, 0, 0),
count: gradient.numberOfColors)
try addAnimation(
for: .colors,
keyframes: gradient.colors.keyframes,
value: { colorComponents in
gradient.colorConfiguration(from: colorComponents).map { $0.color }
},
context: context)
try addAnimation(
for: .locations,
keyframes: gradient.colors.keyframes,
value: { colorComponents in
gradient.colorConfiguration(from: colorComponents).map { $0.location }
},
context: context)
try addOpacityAnimation(for: gradient, context: context)
switch gradient.gradientType {
case .linear:
try addLinearGradientAnimations(for: gradient, context: context)
case .radial:
try addRadialGradientAnimations(for: gradient, context: context)
case .none:
break
}
}
// MARK: Private
private func addLinearGradientAnimations(
for gradient: GradientShapeItem,
context: LayerAnimationContext)
throws
{
type = .axial
try addAnimation(
for: .startPoint,
keyframes: gradient.startPoint.keyframes,
value: { absoluteStartPoint in
percentBasedPointInBounds(from: absoluteStartPoint.pointValue)
},
context: context)
try addAnimation(
for: .endPoint,
keyframes: gradient.endPoint.keyframes,
value: { absoluteEndPoint in
percentBasedPointInBounds(from: absoluteEndPoint.pointValue)
},
context: context)
}
private func addRadialGradientAnimations(for gradient: GradientShapeItem, context: LayerAnimationContext) throws {
type = .radial
// To draw the correct gradients, we have to derive a custom `endPoint`
// relative to the `startPoint` value. Since calculating the `endPoint`
// at any given time requires knowing the current `startPoint`,
// we can't allow them to animate separately.
let absoluteStartPoint = try gradient.startPoint
.exactlyOneKeyframe(context: context, description: "gradient startPoint").value.pointValue
let absoluteEndPoint = try gradient.endPoint
.exactlyOneKeyframe(context: context, description: "gradient endPoint").value.pointValue
startPoint = percentBasedPointInBounds(from: absoluteStartPoint)
let radius = absoluteStartPoint.distanceTo(absoluteEndPoint)
endPoint = percentBasedPointInBounds(
from: CGPoint(
x: absoluteStartPoint.x + radius,
y: absoluteStartPoint.y + radius))
}
}
extension GradientShapeItem {
/// Converts the compact `[Double]` color components representation
/// into an array of `CGColor`s and the location of those colors within the gradient
fileprivate func colorConfiguration(
from colorComponents: [Double])
-> [(color: CGColor, location: CGFloat)]
{
precondition(
colorComponents.count >= numberOfColors * 4,
"Each color must have RGB components and a location component")
var cgColors = [(color: CGColor, location: CGFloat)]()
// Each group of four `Double` values represents a single `CGColor`,
// and its relative location within the gradient.
for colorIndex in 0..<numberOfColors {
let colorStartIndex = colorIndex * 4
let location = CGFloat(colorComponents[colorStartIndex])
let color = CGColor.rgb(
CGFloat(colorComponents[colorStartIndex + 1]),
CGFloat(colorComponents[colorStartIndex + 2]),
CGFloat(colorComponents[colorStartIndex + 3]))
cgColors.append((color, location))
}
return cgColors
}
}
@@ -0,0 +1,229 @@
// Created by Cal Stephens on 1/11/22.
// Copyright © 2022 Airbnb Inc. All rights reserved.
import QuartzCore
// MARK: - LayerProperty
/// A strongly typed value that can be used as the `keyPath` of a `CAAnimation`
///
/// Supported key paths and their expected value types are described
/// at https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/CoreAnimation_guide/AnimatableProperties/AnimatableProperties.html#//apple_ref/doc/uid/TP40004514-CH11-SW1
/// and https://developer.apple.com/library/archive/documentation/Cocoa/Conceptual/CoreAnimation_guide/Key-ValueCodingExtensions/Key-ValueCodingExtensions.html
struct LayerProperty<ValueRepresentation: Equatable> {
/// The `CALayer` KVC key path that this value should be assigned to
let caLayerKeypath: String
/// The default value of this property on a `CALayer`
/// - If the keyframe values are just equal to the default value,
/// then we can improve performance a bit by just not creating
/// a CAAnimation (since it would be redundant).
let defaultValue: ValueRepresentation?
/// A description of how this property can be customized dynamically
/// at runtime using `AnimationView.setValueProvider(_:keypath:)`
let customizableProperty: CustomizableProperty<ValueRepresentation>?
}
// MARK: - CustomizableProperty
/// A description of how a `CALayer` property can be customized dynamically
/// at runtime using `AnimationView.setValueProvider(_:keypath:)`
struct CustomizableProperty<ValueRepresentation> {
/// The name that `AnimationKeypath`s can use to refer to this property
/// - When building an animation for this property that will be applied
/// to a specific layer, this `name` is appended to the end of that
/// layer's `AnimationKeypath`. The combined keypath is used to query
/// the `ValueProviderStore`.
let name: [PropertyName]
/// A closure that coverts the type-erased value of an `AnyValueProvider`
/// to the strongly-typed representation used by this property, if possible.
let conversion: (Any) -> ValueRepresentation?
}
// MARK: - PropertyName
/// The name of a customizable property that can be used in an `AnimationKeypath`
/// - These values should be shared between the two rendering engines,
/// since they form the public API of the `AnimationKeypath` system.
enum PropertyName: String {
case color = "Color"
}
// MARK: CALayer properties
extension LayerProperty {
static var position: LayerProperty<CGPoint> {
.init(
caLayerKeypath: "transform.translation",
defaultValue: CGPoint(x: 0, y: 0),
customizableProperty: nil /* currently unsupported */)
}
static var positionX: LayerProperty<CGFloat> {
.init(
caLayerKeypath: "transform.translation.x",
defaultValue: 0,
customizableProperty: nil /* currently unsupported */)
}
static var positionY: LayerProperty<CGFloat> {
.init(
caLayerKeypath: "transform.translation.y",
defaultValue: 0,
customizableProperty: nil /* currently unsupported */)
}
static var scale: LayerProperty<CGFloat> {
.init(
caLayerKeypath: "transform.scale",
defaultValue: 1,
customizableProperty: nil /* currently unsupported */)
}
static var scaleX: LayerProperty<CGFloat> {
.init(
caLayerKeypath: "transform.scale.x",
defaultValue: 1,
customizableProperty: nil /* currently unsupported */)
}
static var scaleY: LayerProperty<CGFloat> {
.init(
caLayerKeypath: "transform.scale.y",
defaultValue: 1,
customizableProperty: nil /* currently unsupported */)
}
static var rotation: LayerProperty<CGFloat> {
.init(
caLayerKeypath: "transform.rotation",
defaultValue: 0,
customizableProperty: nil /* currently unsupported */)
}
static var rotationY: LayerProperty<CGFloat> {
.init(
caLayerKeypath: "transform.rotation.y",
defaultValue: 0,
customizableProperty: nil /* currently unsupported */)
}
static var anchorPoint: LayerProperty<CGPoint> {
.init(
caLayerKeypath: #keyPath(CALayer.anchorPoint),
// This is intentionally not `GGPoint(x: 0.5, y: 0.5)` (the actual default)
// to opt `anchorPoint` out of the KVC `setValue` flow, which causes issues.
defaultValue: nil,
customizableProperty: nil /* currently unsupported */)
}
static var opacity: LayerProperty<CGFloat> {
.init(
caLayerKeypath: #keyPath(CALayer.opacity),
defaultValue: 1,
customizableProperty: nil /* currently unsupported */)
}
}
// MARK: CAShapeLayer properties
extension LayerProperty {
static var path: LayerProperty<CGPath> {
.init(
caLayerKeypath: #keyPath(CAShapeLayer.path),
defaultValue: nil,
customizableProperty: nil /* currently unsupported */)
}
static var fillColor: LayerProperty<CGColor> {
.init(
caLayerKeypath: #keyPath(CAShapeLayer.fillColor),
defaultValue: nil,
customizableProperty: .color)
}
static var lineWidth: LayerProperty<CGFloat> {
.init(
caLayerKeypath: #keyPath(CAShapeLayer.lineWidth),
defaultValue: 1,
customizableProperty: nil /* currently unsupported */)
}
static var lineDashPhase: LayerProperty<CGFloat> {
.init(
caLayerKeypath: #keyPath(CAShapeLayer.lineDashPhase),
defaultValue: 0,
customizableProperty: nil /* currently unsupported */)
}
static var strokeColor: LayerProperty<CGColor> {
.init(
caLayerKeypath: #keyPath(CAShapeLayer.strokeColor),
defaultValue: nil,
customizableProperty: .color)
}
static var strokeStart: LayerProperty<CGFloat> {
.init(
caLayerKeypath: #keyPath(CAShapeLayer.strokeStart),
defaultValue: 0,
customizableProperty: nil /* currently unsupported */)
}
static var strokeEnd: LayerProperty<CGFloat> {
.init(
caLayerKeypath: #keyPath(CAShapeLayer.strokeEnd),
defaultValue: 1,
customizableProperty: nil /* currently unsupported */)
}
}
// MARK: CAGradientLayer properties
extension LayerProperty {
static var colors: LayerProperty<[CGColor]> {
.init(
caLayerKeypath: #keyPath(CAGradientLayer.colors),
defaultValue: nil,
customizableProperty: nil /* currently unsupported */)
}
static var locations: LayerProperty<[CGFloat]> {
.init(
caLayerKeypath: #keyPath(CAGradientLayer.locations),
defaultValue: nil,
customizableProperty: nil /* currently unsupported */)
}
static var startPoint: LayerProperty<CGPoint> {
.init(
caLayerKeypath: #keyPath(CAGradientLayer.startPoint),
defaultValue: nil,
customizableProperty: nil /* currently unsupported */)
}
static var endPoint: LayerProperty<CGPoint> {
.init(
caLayerKeypath: #keyPath(CAGradientLayer.endPoint),
defaultValue: nil,
customizableProperty: nil /* currently unsupported */)
}
}
// MARK: - CustomizableProperty types
extension CustomizableProperty {
static var color: CustomizableProperty<CGColor> {
.init(
name: [.color],
conversion: { typeErasedValue in
guard let color = typeErasedValue as? Color else {
return nil
}
return .rgba(CGFloat(color.r), CGFloat(color.g), CGFloat(color.b), CGFloat(color.a))
})
}
}
@@ -0,0 +1,52 @@
// Created by Cal Stephens on 5/17/22.
// Copyright © 2022 Airbnb Inc. All rights reserved.
import QuartzCore
// MARK: - OpacityAnimationModel
protocol OpacityAnimationModel {
/// The opacity animation to apply to a `CALayer`
var opacity: KeyframeGroup<Vector1D> { get }
}
// MARK: - Transform + OpacityAnimationModel
extension Transform: OpacityAnimationModel { }
// MARK: - ShapeTransform + OpacityAnimationModel
extension ShapeTransform: OpacityAnimationModel { }
// MARK: - Fill + OpacityAnimationModel
extension Fill: OpacityAnimationModel { }
// MARK: - GradientFill + OpacityAnimationModel
extension GradientFill: OpacityAnimationModel { }
// MARK: - Stroke + OpacityAnimationModel
extension Stroke: OpacityAnimationModel { }
// MARK: - GradientStroke + OpacityAnimationModel
extension GradientStroke: OpacityAnimationModel { }
extension CALayer {
/// Adds the opacity animation from the given `OpacityAnimationModel` to this layer
@nonobjc
func addOpacityAnimation(for opacity: OpacityAnimationModel, context: LayerAnimationContext) throws {
try addAnimation(
for: .opacity,
keyframes: opacity.opacity.keyframes,
value: {
// Lottie animation files express opacity as a numerical percentage value
// (e.g. 0%, 50%, 100%) so we divide by 100 to get the decimal values
// expected by Core Animation (e.g. 0.0, 0.5, 1.0).
$0.cgFloatValue / 100
},
context: context)
}
}
@@ -0,0 +1,29 @@
// Created by Cal Stephens on 12/21/21.
// Copyright © 2021 Airbnb Inc. All rights reserved.
import QuartzCore
extension CAShapeLayer {
/// Adds animations for the given `Rectangle` to this `CALayer`
@nonobjc
func addAnimations(
for rectangle: Rectangle,
context: LayerAnimationContext)
throws
{
try addAnimation(
for: .path,
keyframes: rectangle.size.keyframes,
value: { sizeKeyframe in
BezierPath.rectangle(
position: try rectangle.position
.exactlyOneKeyframe(context: context, description: "rectangle position").value.pointValue,
size: sizeKeyframe.sizeValue,
cornerRadius: try rectangle.cornerRadius
.exactlyOneKeyframe(context: context, description: "rectangle cornerRadius").value.cgFloatValue,
direction: rectangle.direction)
.cgPath()
},
context: context)
}
}
@@ -0,0 +1,123 @@
// Created by Cal Stephens on 1/7/22.
// Copyright © 2022 Airbnb Inc. All rights reserved.
import QuartzCore
extension CAShapeLayer {
/// Adds a `path` animation for the given `ShapeItem`
@nonobjc
func addAnimations(for shape: ShapeItem, context: LayerAnimationContext) throws {
switch shape {
case let customShape as Shape:
try addAnimations(for: customShape.path, context: context)
case let combinedShape as CombinedShapeItem:
try addAnimations(for: combinedShape, context: context)
case let ellipse as Ellipse:
try addAnimations(for: ellipse, context: context)
case let rectangle as Rectangle:
try addAnimations(for: rectangle, context: context)
case let star as Star:
try addAnimations(for: star, context: context)
default:
// None of the other `ShapeItem` subclasses draw a `path`
try context.logCompatibilityIssue("Unexpected shape type \(type(of: shape))")
return
}
}
/// Adds a `fillColor` animation for the given `Fill` object
@nonobjc
func addAnimations(for fill: Fill, context: LayerAnimationContext) throws {
fillRule = fill.fillRule.caFillRule
try addAnimation(
for: .fillColor,
keyframes: fill.color.keyframes,
value: \.cgColorValue,
context: context)
try addOpacityAnimation(for: fill, context: context)
}
/// Adds animations for `strokeStart` and `strokeEnd` from the given `Trim` object
@nonobjc
func addAnimations(for trim: Trim, context: LayerAnimationContext) throws {
let (strokeStartKeyframes, strokeEndKeyframes) = trim.caShapeLayerKeyframes()
if trim.offset.keyframes.contains(where: { $0.value.cgFloatValue != 0 }) {
try context.logCompatibilityIssue("""
The CoreAnimation rendering engine doesn't support Trim offsets
""")
}
try addAnimation(
for: .strokeStart,
keyframes: strokeStartKeyframes.keyframes,
value: { strokeStart in
// Lottie animation files express stoke trims as a numerical percentage value
// (e.g. 25%, 50%, 100%) so we divide by 100 to get the decimal values
// expected by Core Animation (e.g. 0.25, 0.5, 1.0).
CGFloat(strokeStart.cgFloatValue) / 100
}, context: context)
try addAnimation(
for: .strokeEnd,
keyframes: strokeEndKeyframes.keyframes,
value: { strokeEnd in
// Lottie animation files express stoke trims as a numerical percentage value
// (e.g. 25%, 50%, 100%) so we divide by 100 to get the decimal values
// expected by Core Animation (e.g. 0.25, 0.5, 1.0).
CGFloat(strokeEnd.cgFloatValue) / 100
}, context: context)
}
}
extension Trim {
// MARK: Fileprivate
/// The `strokeStart` and `strokeEnd` keyframes to apply to a `CAShapeLayer`
/// - `CAShapeLayer` requires that `strokeStart` be less than `strokeEnd`.
/// - Since this isn't a requirement in the Lottie schema, there are
/// some animations that have `strokeStart` and `strokeEnd` flipped.
/// - If we detect that this is the case for this specific `Trim`, then
/// we swap the start/end keyframes to match what `CAShapeLayer` expects.
fileprivate func caShapeLayerKeyframes()
-> (strokeStart: KeyframeGroup<Vector1D>, strokeEnd: KeyframeGroup<Vector1D>)
{
if startValueIsAlwaysGreaterThanEndValue() {
return (strokeStart: end, strokeEnd: start)
} else {
return (strokeStart: start, strokeEnd: end)
}
}
// MARK: Private
/// Checks whether or not the value for `trim.start` is greater
/// than the value for every `trim.end` at every keyframe.
private func startValueIsAlwaysGreaterThanEndValue() -> Bool {
let keyframeTimes = Set(start.keyframes.map { $0.time } + end.keyframes.map { $0.time })
let startInterpolator = KeyframeInterpolator(keyframes: start.keyframes)
let endInterpolator = KeyframeInterpolator(keyframes: end.keyframes)
for keyframeTime in keyframeTimes {
guard
let startAtTime = startInterpolator.value(frame: keyframeTime) as? Vector1D,
let endAtTime = endInterpolator.value(frame: keyframeTime) as? Vector1D
else { continue }
if startAtTime.cgFloatValue < endAtTime.cgFloatValue {
return false
}
}
return true
}
}
@@ -0,0 +1,90 @@
// Created by Cal Stephens on 1/10/22.
// Copyright © 2022 Airbnb Inc. All rights reserved.
import QuartzCore
extension CAShapeLayer {
// MARK: Internal
/// Adds animations for the given `Rectangle` to this `CALayer`
@nonobjc
func addAnimations(
for star: Star,
context: LayerAnimationContext)
throws
{
switch star.starType {
case .star:
try addStarAnimation(for: star, context: context)
case .polygon:
try addPolygonAnimation(for: star, context: context)
case .none:
break
}
}
// MARK: Private
@nonobjc
private func addStarAnimation(
for star: Star,
context: LayerAnimationContext)
throws
{
try addAnimation(
for: .path,
keyframes: star.position.keyframes,
value: { position in
// We can only use one set of keyframes to animate a given CALayer keypath,
// so we currently animate `position` and ignore any other keyframes.
// TODO: Is there a way to support this properly?
BezierPath.star(
position: position.pointValue,
outerRadius: try star.outerRadius
.exactlyOneKeyframe(context: context, description: "outerRadius").value.cgFloatValue,
innerRadius: try star.innerRadius?
.exactlyOneKeyframe(context: context, description: "innerRadius").value.cgFloatValue ?? 0,
outerRoundedness: try star.outerRoundness
.exactlyOneKeyframe(context: context, description: "outerRoundness").value.cgFloatValue,
innerRoundedness: try star.innerRoundness?
.exactlyOneKeyframe(context: context, description: "innerRoundness").value.cgFloatValue ?? 0,
numberOfPoints: try star.points
.exactlyOneKeyframe(context: context, description: "points").value.cgFloatValue,
rotation: try star.rotation
.exactlyOneKeyframe(context: context, description: "rotation").value.cgFloatValue,
direction: star.direction)
.cgPath()
},
context: context)
}
@nonobjc
private func addPolygonAnimation(
for star: Star,
context: LayerAnimationContext)
throws
{
try addAnimation(
for: .path,
keyframes: star.position.keyframes,
value: { position in
// We can only use one set of keyframes to animate a given CALayer keypath,
// so we currently animate `position` and ignore any other keyframes.
// TODO: Is there a way to support this properly?
BezierPath.polygon(
position: position.pointValue,
numberOfPoints: try star.points
.exactlyOneKeyframe(context: context, description: "numberOfPoints").value.cgFloatValue,
outerRadius: try star.outerRadius
.exactlyOneKeyframe(context: context, description: "outerRadius").value.cgFloatValue,
outerRoundedness: try star.outerRoundness
.exactlyOneKeyframe(context: context, description: "outerRoundedness").value.cgFloatValue,
rotation: try star.rotation
.exactlyOneKeyframe(context: context, description: "rotation").value.cgFloatValue,
direction: star.direction)
.cgPath()
},
context: context)
}
}
@@ -0,0 +1,70 @@
// Created by Cal Stephens on 2/10/22.
// Copyright © 2022 Airbnb Inc. All rights reserved.
import Foundation
import QuartzCore
// MARK: - StrokeShapeItem
/// A `ShapeItem` that represents a stroke
protocol StrokeShapeItem: OpacityAnimationModel {
var strokeColor: KeyframeGroup<Color>? { get }
var width: KeyframeGroup<Vector1D> { get }
var lineCap: LineCap { get }
var lineJoin: LineJoin { get }
var miterLimit: Double { get }
var dashPattern: [DashElement]? { get }
}
// MARK: - Stroke + StrokeShapeItem
extension Stroke: StrokeShapeItem {
var strokeColor: KeyframeGroup<Color>? { color }
}
// MARK: - GradientStroke + StrokeShapeItem
extension GradientStroke: StrokeShapeItem {
var strokeColor: KeyframeGroup<Color>? { nil }
}
// MARK: - CAShapeLayer + StrokeShapeItem
extension CAShapeLayer {
/// Adds animations for properties related to the given `Stroke` object (`strokeColor`, `lineWidth`, etc)
@nonobjc
func addStrokeAnimations(for stroke: StrokeShapeItem, context: LayerAnimationContext) throws {
lineJoin = stroke.lineJoin.caLineJoin
lineCap = stroke.lineCap.caLineCap
miterLimit = CGFloat(stroke.miterLimit)
if let strokeColor = stroke.strokeColor {
try addAnimation(
for: .strokeColor,
keyframes: strokeColor.keyframes,
value: \.cgColorValue,
context: context)
}
try addAnimation(
for: .lineWidth,
keyframes: stroke.width.keyframes,
value: \.cgFloatValue,
context: context)
try addOpacityAnimation(for: stroke, context: context)
if let (dashPattern, dashPhase) = stroke.dashPattern?.shapeLayerConfiguration {
lineDashPattern = try dashPattern.map {
try KeyframeGroup(keyframes: $0)
.exactlyOneKeyframe(context: context, description: "stroke dashPattern").value.cgFloatValue as NSNumber
}
try addAnimation(
for: .lineDashPhase,
keyframes: dashPhase,
value: \.cgFloatValue,
context: context)
}
}
}
@@ -0,0 +1,205 @@
// Created by Cal Stephens on 12/17/21.
// Copyright © 2021 Airbnb Inc. All rights reserved.
import QuartzCore
// MARK: - TransformModel
/// This protocol mirrors the interface of `Transform`,
/// but it also implemented by `ShapeTransform` to allow
/// both transform types to share the same animation implementation.
protocol TransformModel {
/// The anchor point of the transform.
var anchorPoint: KeyframeGroup<Vector3D> { get }
/// The position of the transform. This is nil if the position data was split.
var _position: KeyframeGroup<Vector3D>? { get }
/// The positionX of the transform. This is nil if the position property is set.
var _positionX: KeyframeGroup<Vector1D>? { get }
/// The positionY of the transform. This is nil if the position property is set.
var _positionY: KeyframeGroup<Vector1D>? { get }
/// The scale of the transform
var scale: KeyframeGroup<Vector3D> { get }
/// The rotation of the transform. Note: This is single dimensional rotation.
var rotation: KeyframeGroup<Vector1D> { get }
}
// MARK: - Transform + TransformModel
extension Transform: TransformModel {
var _position: KeyframeGroup<Vector3D>? { position }
var _positionX: KeyframeGroup<Vector1D>? { positionX }
var _positionY: KeyframeGroup<Vector1D>? { positionY }
}
// MARK: - ShapeTransform + TransformModel
extension ShapeTransform: TransformModel {
var anchorPoint: KeyframeGroup<Vector3D> { anchor }
var _position: KeyframeGroup<Vector3D>? { position }
var _positionX: KeyframeGroup<Vector1D>? { nil }
var _positionY: KeyframeGroup<Vector1D>? { nil }
}
// MARK: - CALayer + TransformModel
extension CALayer {
// MARK: Internal
/// Adds transform-related animations from the given `TransformModel` to this layer
/// - This _doesn't_ apply `transform.opacity`, which has to be handled separately
/// since child layers don't inherit the `opacity` of their parent.
@nonobjc
func addTransformAnimations(for transformModel: TransformModel, context: LayerAnimationContext) throws {
try addPositionAnimations(from: transformModel, context: context)
try addAnchorPointAnimation(from: transformModel, context: context)
try addScaleAnimations(from: transformModel, context: context)
try addRotationAnimation(from: transformModel, context: context)
}
// MARK: Private
@nonobjc
private func addPositionAnimations(
from transformModel: TransformModel,
context: LayerAnimationContext)
throws
{
if let positionKeyframes = transformModel._position?.keyframes {
try addAnimation(
for: .position,
keyframes: positionKeyframes,
value: \.pointValue,
context: context)
} else if
let xKeyframes = transformModel._positionX?.keyframes,
let yKeyframes = transformModel._positionY?.keyframes
{
try addAnimation(
for: .positionX,
keyframes: xKeyframes,
value: \.cgFloatValue,
context: context)
try addAnimation(
for: .positionY,
keyframes: yKeyframes,
value: \.cgFloatValue,
context: context)
} else {
try context.logCompatibilityIssue("""
`Transform` values must provide either `position` or `positionX` / `positionY` keyframes
""")
}
}
@nonobjc
private func addAnchorPointAnimation(
from transformModel: TransformModel,
context: LayerAnimationContext)
throws
{
try addAnimation(
for: .anchorPoint,
keyframes: transformModel.anchorPoint.keyframes,
value: { absoluteAnchorPoint in
guard bounds.width > 0, bounds.height > 0 else {
LottieLogger.shared.assertionFailure("Size must be non-zero before an animation can be played")
return .zero
}
// Lottie animation files express anchorPoint as an absolute point value,
// so we have to divide by the width/height of this layer to get the
// relative decimal values expected by Core Animation.
return CGPoint(
x: CGFloat(absoluteAnchorPoint.x) / bounds.width,
y: CGFloat(absoluteAnchorPoint.y) / bounds.height)
},
context: context)
}
@nonobjc
private func addScaleAnimations(
from transformModel: TransformModel,
context: LayerAnimationContext)
throws
{
try addAnimation(
for: .scaleX,
keyframes: transformModel.scale.keyframes,
value: { scale in
// Lottie animation files express scale as a numerical percentage value
// (e.g. 50%, 100%, 200%) so we divide by 100 to get the decimal values
// expected by Core Animation (e.g. 0.5, 1.0, 2.0).
// - Negative `scale.x` values aren't applied correctly by Core Animation.
// This appears to be because we animate `transform.scale.x` and `transform.scale.y`
// as separate `CAKeyframeAnimation`s instead of using a single animation of `transform` itself.
// https://openradar.appspot.com/FB9862872
// - To work around this, we set up a `rotationY` animation below
// to flip the view horizontally, which gives us the desired effect.
abs(CGFloat(scale.x) / 100)
},
context: context)
// When `scale.x` is negative, we have to rotate the view
// half way around the y axis to flip it horizontally.
// - We don't do this in snapshot tests because it breaks the tests
// in surprising ways that don't happen at runtime. Definitely not ideal.
if TestHelpers.snapshotTestsAreRunning {
if transformModel.scale.keyframes.contains(where: { $0.value.x < 0 }) {
LottieLogger.shared.warn("""
Negative `scale.x` values are not displayed correctly in snapshot tests
""")
}
} else {
try addAnimation(
for: .rotationY,
keyframes: transformModel.scale.keyframes,
value: { scale in
if scale.x < 0 {
return .pi
} else {
return 0
}
},
context: context)
}
try addAnimation(
for: .scaleY,
keyframes: transformModel.scale.keyframes,
value: { scale in
// Lottie animation files express scale as a numerical percentage value
// (e.g. 50%, 100%, 200%) so we divide by 100 to get the decimal values
// expected by Core Animation (e.g. 0.5, 1.0, 2.0).
// - Negative `scaleY` values are correctly applied (they flip the view
// vertically), so we don't have to apply an additional rotation animation
// like we do for `scaleX`.
CGFloat(scale.y) / 100
},
context: context)
}
private func addRotationAnimation(
from transformModel: TransformModel,
context: LayerAnimationContext)
throws
{
try addAnimation(
for: .rotation,
keyframes: transformModel.rotation.keyframes,
value: { rotationDegrees in
// Lottie animation files express rotation in degrees
// (e.g. 90º, 180º, 360º) so we covert to radians to get the
// values expected by Core Animation (e.g. π/2, π, 2π)
rotationDegrees.cgFloatValue * .pi / 180
},
context: context)
}
}
@@ -0,0 +1,37 @@
// Created by Cal Stephens on 12/21/21.
// Copyright © 2021 Airbnb Inc. All rights reserved.
import QuartzCore
extension CALayer {
/// Adds an animation for the given `inTime` and `outTime` to this `CALayer`
@nonobjc
func addVisibilityAnimation(
inFrame: AnimationFrameTime,
outFrame: AnimationFrameTime,
context: LayerAnimationContext)
{
let animation = CAKeyframeAnimation(keyPath: #keyPath(isHidden))
animation.calculationMode = .discrete
animation.values = [
true, // hidden, before `inFrame`
false, // visible
true, // hidden, after `outFrame`
]
// From the documentation of `keyTimes`:
// - If the calculationMode is set to discrete, the first value in the array
// must be 0.0 and the last value must be 1.0. The array should have one more
// entry than appears in the values array. For example, if there are two values,
// there should be three key times.
animation.keyTimes = [
NSNumber(value: 0.0),
NSNumber(value: max(Double(context.progressTime(for: inFrame)), 0)),
NSNumber(value: min(Double(context.progressTime(for: outFrame)), 1)),
NSNumber(value: 1.0),
]
add(animation, timedWith: context)
}
}
@@ -0,0 +1,128 @@
// Created by Cal Stephens on 5/4/22.
// Copyright © 2022 Airbnb Inc. All rights reserved.
// MARK: - CompatibilityIssue
/// A compatibility issue that was encountered while setting up an animation with the Core Animation engine
struct CompatibilityIssue: CustomStringConvertible {
let message: String
let context: String
var description: String {
"[\(context)] \(message)"
}
}
// MARK: - CompatibilityTracker
/// A type that tracks whether or not an animation is compatible with the Core Animation engine
final class CompatibilityTracker {
// MARK: Lifecycle
init(mode: Mode) {
self.mode = mode
}
// MARK: Internal
/// How compatibility issues should be handled
enum Mode {
/// When a compatibility issue is encountered, an error will be thrown immediately,
/// aborting the animation setup process as soon as possible.
case abort
/// When a compatibility issue is encountered, it is stored in `CompatibilityTracker.issues`
case track
}
enum Error: Swift.Error {
case encounteredCompatibilityIssue(CompatibilityIssue)
}
/// Records a compatibility issue that will be reported according to `CompatibilityTracker.Mode`
func logIssue(message: String, context: String) throws {
LottieLogger.shared.assert(!context.isEmpty, "Compatibility issue context is unexpectedly empty")
let issue = CompatibilityIssue(
// Compatibility messages are usually written in source files using multi-line strings,
// but converting them to be a single line makes it easier to read the ultimate log output.
message: message.replacingOccurrences(of: "\n", with: " "),
context: context)
switch mode {
case .abort:
throw CompatibilityTracker.Error.encounteredCompatibilityIssue(issue)
case .track:
issues.append(issue)
}
}
/// Asserts that a condition is true, otherwise logs a compatibility issue that will be reported
/// according to `CompatibilityTracker.Mode`
func assert(
_ condition: Bool,
_ message: @autoclosure () -> String,
context: @autoclosure () -> String)
throws
{
if !condition {
try logIssue(message: message(), context: context())
}
}
/// Reports the compatibility issues that were recorded when setting up the animation,
/// and clears the set of tracked issues.
func reportCompatibilityIssues(_ handler: ([CompatibilityIssue]) -> Void) {
handler(issues)
issues = []
}
// MARK: Private
private let mode: Mode
/// Compatibility issues encountered while setting up the animation
private var issues = [CompatibilityIssue]()
}
// MARK: - CompatibilityTrackerProviding
protocol CompatibilityTrackerProviding {
var compatibilityTracker: CompatibilityTracker { get }
var compatibilityIssueContext: String { get }
}
extension CompatibilityTrackerProviding {
/// Records a compatibility issue that will be reported according to `CompatibilityTracker.Mode`
func logCompatibilityIssue(_ message: String) throws {
try compatibilityTracker.logIssue(message: message, context: compatibilityIssueContext)
}
/// Asserts that a condition is true, otherwise logs a compatibility issue that will be reported
/// according to `CompatibilityTracker.Mode`
func compatibilityAssert(
_ condition: Bool,
_ message: @autoclosure () -> String)
throws
{
try compatibilityTracker.assert(condition, message(), context: compatibilityIssueContext)
}
}
// MARK: - LayerContext + CompatibilityTrackerProviding
extension LayerContext: CompatibilityTrackerProviding {
var compatibilityIssueContext: String {
layerName
}
}
// MARK: - LayerAnimationContext + CompatibilityTrackerProviding
extension LayerAnimationContext: CompatibilityTrackerProviding {
var compatibilityIssueContext: String {
currentKeypath.fullPath
}
}
@@ -0,0 +1,465 @@
// Created by Cal Stephens on 12/13/21.
// Copyright © 2021 Airbnb Inc. All rights reserved.
import Foundation
import QuartzCore
// MARK: - CoreAnimationLayer
/// The root `CALayer` of the Core Animation rendering engine
final class CoreAnimationLayer: BaseAnimationLayer {
// MARK: Lifecycle
/// Initializes a `CALayer` that renders the given animation using `CAAnimation`s.
/// - This initializer is throwing, but will only throw when using
/// `CompatibilityTracker.Mode.abort`.
init(
animation: Animation,
imageProvider: AnimationImageProvider,
fontProvider: AnimationFontProvider,
compatibilityTrackerMode: CompatibilityTracker.Mode)
throws
{
self.animation = animation
self.imageProvider = imageProvider
self.fontProvider = fontProvider
compatibilityTracker = CompatibilityTracker(mode: compatibilityTrackerMode)
super.init()
setup()
try setupChildLayers()
}
/// Called by CoreAnimation to create a shadow copy of this layer
/// More details: https://developer.apple.com/documentation/quartzcore/calayer/1410842-init
override init(layer: Any) {
guard let typedLayer = layer as? Self else {
fatalError("init(layer:) incorrectly called with \(type(of: layer))")
}
animation = typedLayer.animation
currentAnimationConfiguration = typedLayer.currentAnimationConfiguration
imageProvider = typedLayer.imageProvider
fontProvider = typedLayer.fontProvider
didSetUpAnimation = typedLayer.didSetUpAnimation
compatibilityTracker = typedLayer.compatibilityTracker
super.init(layer: typedLayer)
}
required init?(coder _: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// MARK: Internal
/// Timing-related configuration to apply to this layer's child `CAAnimation`s
/// - This is effectively a configurable subset of `CAMediaTiming`
struct CAMediaTimingConfiguration: Equatable {
var autoreverses = false
var repeatCount: Float = 0
var speed: Float = 1
var timeOffset: TimeInterval = 0
}
enum PlaybackState: Equatable {
/// The animation is playing in real-time
case playing
/// The animation is statically displaying a specific frame
case paused(frame: AnimationFrameTime)
}
/// A closure that is called after this layer sets up its animation.
/// If the animation setup was unsuccessful and encountered compatibility issues,
/// those issues are included in this call.
var didSetUpAnimation: (([CompatibilityIssue]) -> Void)?
/// The `AnimationImageProvider` that `ImageLayer`s use to retrieve images,
/// referenced by name in the animation json.
var imageProvider: AnimationImageProvider {
didSet { reloadImages() }
}
/// The `FontProvider` that `TextLayer`s use to retrieve the `CTFont`
/// that they should use to render their text content
var fontProvider: AnimationFontProvider {
didSet { reloadFonts() }
}
/// Queues the animation with the given timing configuration
/// to begin playing at the next `display()` call.
/// - This batches together animations so that even if `playAnimation`
/// is called multiple times in the same run loop cycle, the animation
/// will only be set up a single time.
func playAnimation(
context: AnimationContext,
timingConfiguration: CAMediaTimingConfiguration,
playbackState: PlaybackState = .playing)
{
pendingAnimationConfiguration = (
animationConfiguration: .init(animationContext: context, timingConfiguration: timingConfiguration),
playbackState: playbackState)
setNeedsDisplay()
}
override func layoutSublayers() {
super.layoutSublayers()
// If no animation has been set up yet, display the first frame
// now that the layer hierarchy has been setup and laid out
if
pendingAnimationConfiguration == nil,
currentAnimationConfiguration == nil,
bounds.size != .zero
{
currentFrame = animation.frameTime(forProgress: animationProgress)
}
}
override func display() {
// We intentionally don't call `super.display()`, since this layer
// doesn't directly render any content.
// - This fixes an issue where certain animations would unexpectedly
// allocate a very large amount of memory (400mb+).
// - Alternatively this layer could subclass `CATransformLayer`,
// but this causes Core Animation to emit unnecessary logs.
if let pendingAnimationConfiguration = pendingAnimationConfiguration {
self.pendingAnimationConfiguration = nil
do {
try setupAnimation(for: pendingAnimationConfiguration.animationConfiguration)
} catch {
if case CompatibilityTracker.Error.encounteredCompatibilityIssue(let compatibilityIssue) = error {
// Even though the animation setup failed, we still update the layer's playback state
// so it can be read by the parent `AnimationView` when handling this error
currentPlaybackState = pendingAnimationConfiguration.playbackState
didSetUpAnimation?([compatibilityIssue])
return
}
}
currentPlaybackState = pendingAnimationConfiguration.playbackState
compatibilityTracker.reportCompatibilityIssues { compatibilityIssues in
didSetUpAnimation?(compatibilityIssues)
}
}
}
// MARK: Private
private struct AnimationConfiguration: Equatable {
let animationContext: AnimationContext
let timingConfiguration: CAMediaTimingConfiguration
}
/// The configuration for the most recent animation which has been
/// queued by calling `playAnimation` but not yet actually set up
private var pendingAnimationConfiguration: (
animationConfiguration: AnimationConfiguration,
playbackState: PlaybackState)?
/// Configuration for the animation that is currently setup in this layer
private var currentAnimationConfiguration: AnimationConfiguration?
/// The current progress of the placeholder `CAAnimation`,
/// which is also the realtime animation progress of this layer's animation
@objc private var animationProgress: CGFloat = 0
private let animation: Animation
private let valueProviderStore = ValueProviderStore()
private let compatibilityTracker: CompatibilityTracker
/// The current playback state of the animation that is displayed in this layer
private var currentPlaybackState: PlaybackState? {
didSet {
guard playbackState != oldValue else { return }
switch playbackState {
case .playing, nil:
timeOffset = 0
case .paused(let frame):
timeOffset = animation.time(forFrame: frame)
}
}
}
/// The current or pending playback state of the animation displayed in this layer
private var playbackState: PlaybackState? {
pendingAnimationConfiguration?.playbackState ?? currentPlaybackState
}
/// Context used when setting up and configuring sublayers
private var layerContext: LayerContext {
LayerContext(
animation: animation,
imageProvider: imageProvider,
fontProvider: fontProvider,
compatibilityTracker: compatibilityTracker,
layerName: "root layer")
}
private func setup() {
bounds = animation.bounds
}
private func setupChildLayers() throws {
try setupLayerHierarchy(
for: animation.layers,
context: layerContext)
}
/// Immediately builds and begins playing `CAAnimation`s for each sublayer
private func setupAnimation(for configuration: AnimationConfiguration) throws {
// Remove any existing animations from the layer hierarchy
removeAnimations()
currentAnimationConfiguration = configuration
let layerContext = LayerAnimationContext(
animation: animation,
timingConfiguration: configuration.timingConfiguration,
startFrame: configuration.animationContext.playFrom,
endFrame: configuration.animationContext.playTo,
valueProviderStore: valueProviderStore,
compatibilityTracker: compatibilityTracker,
currentKeypath: AnimationKeypath(keys: []))
// Perform a layout pass if necessary so all of the sublayers
// have the most up-to-date sizing information
layoutIfNeeded()
// Set the speed of this layer, which will be inherited
// by all sublayers and their animations.
// - This is required to support scrubbing with a speed of 0
speed = configuration.timingConfiguration.speed
// Setup a placeholder animation to let us track the realtime animation progress
setupPlaceholderAnimation(context: layerContext)
// Set up the new animations with the current `TimingConfiguration`
for animationLayer in sublayers ?? [] {
try (animationLayer as? AnimationLayer)?.setupAnimations(context: layerContext)
}
}
/// Sets up a placeholder `CABasicAnimation` that tracks the current
/// progress of this animation (between 0 and 1). This lets us provide
/// realtime animation progress via `self.currentFrame`.
private func setupPlaceholderAnimation(context: LayerAnimationContext) {
let animationProgressTracker = CABasicAnimation(keyPath: #keyPath(animationProgress))
animationProgressTracker.fromValue = 0
animationProgressTracker.toValue = 1
let timedProgressAnimation = animationProgressTracker.timed(with: context, for: self)
timedProgressAnimation.delegate = currentAnimationConfiguration?.animationContext.closure
add(timedProgressAnimation, forKey: #keyPath(animationProgress))
}
// Removes the current `CAAnimation`s, and rebuilds new animations
// using the same configuration as the previous animations.
private func rebuildCurrentAnimation() {
guard
let currentConfiguration = currentAnimationConfiguration,
let playbackState = playbackState,
// Don't replace any pending animations that are queued to begin
// on the next run loop cycle, since an existing pending animation
// will cause the animation to be rebuilt anyway.
pendingAnimationConfiguration == nil
else { return }
removeAnimations()
switch playbackState {
case .paused(let frame):
currentFrame = frame
case .playing:
playAnimation(
context: currentConfiguration.animationContext,
timingConfiguration: currentConfiguration.timingConfiguration)
}
}
}
// MARK: RootAnimationLayer
extension CoreAnimationLayer: RootAnimationLayer {
var primaryAnimationKey: AnimationKey {
.specific(#keyPath(animationProgress))
}
var isAnimationPlaying: Bool? {
switch playbackState {
case .playing:
return true
case nil, .paused:
return false
}
}
var currentFrame: AnimationFrameTime {
get {
switch playbackState {
case .playing, nil:
return animation.frameTime(forProgress: (presentation() ?? self).animationProgress)
case .paused(let frame):
return frame
}
}
set {
// We can display a specific frame of the animation by setting
// `timeOffset` of this layer. This requires setting up the layer hierarchy
// with a specific configuration (speed=0, etc) at least once. But if
// the layer hierarchy is already set up correctly, we can update the
// `timeOffset` very cheaply.
let requiredAnimationConfiguration = AnimationConfiguration(
animationContext: AnimationContext(
playFrom: animation.startFrame,
playTo: animation.endFrame,
closure: nil),
timingConfiguration: CAMediaTimingConfiguration(speed: 0))
if
pendingAnimationConfiguration == nil,
currentAnimationConfiguration == requiredAnimationConfiguration
{
currentPlaybackState = .paused(frame: newValue)
}
else {
playAnimation(
context: requiredAnimationConfiguration.animationContext,
timingConfiguration: requiredAnimationConfiguration.timingConfiguration,
playbackState: .paused(frame: newValue))
}
}
}
var renderScale: CGFloat {
get { contentsScale }
set {
contentsScale = newValue
for sublayer in allSublayers {
sublayer.contentsScale = newValue
}
}
}
var respectAnimationFrameRate: Bool {
get { false }
set { LottieLogger.shared.assertionFailure("`respectAnimationFrameRate` is currently unsupported") }
}
var _animationLayers: [CALayer] {
(sublayers ?? []).filter { $0 is AnimationLayer }
}
var textProvider: AnimationTextProvider {
get { DictionaryTextProvider([:]) }
set { LottieLogger.shared.assertionFailure("`textProvider` is currently unsupported") }
}
func reloadImages() {
// When the image provider changes, we have to update all `ImageLayer`s
// so they can query the most up-to-date image from the new image provider.
for sublayer in allSublayers {
if let imageLayer = sublayer as? ImageLayer {
imageLayer.setupImage(context: layerContext)
}
}
}
func reloadFonts() {
// When the text provider changes, we have to update all `TextLayer`s
// so they can query the most up-to-date font from the new font provider.
for sublayer in allSublayers {
if let textLayer = sublayer as? TextLayer {
try? textLayer.configureRenderLayer(with: layerContext)
}
}
}
func forceDisplayUpdate() {
// Unimplemented / unused
}
func logHierarchyKeypaths() {
// Unimplemented / unused
}
func allKeypaths(predicate: (AnimationKeypath) -> Bool) -> [String] {
return []
}
func setValueProvider(_ valueProvider: AnyValueProvider, keypath: AnimationKeypath) {
valueProviderStore.setValueProvider(valueProvider, keypath: keypath)
// We need to rebuild the current animation after registering a value provider,
// since any existing `CAAnimation`s could now be out of date.
rebuildCurrentAnimation()
}
func getValue(for _: AnimationKeypath, atFrame _: AnimationFrameTime?) -> Any? {
LottieLogger.shared.assertionFailure("""
The Core Animation rendering engine doesn't support querying values for individual frames
""")
return nil
}
func getOriginalValue(for _: AnimationKeypath, atFrame _: AnimationFrameTime?) -> Any? {
LottieLogger.shared.assertionFailure("""
The Core Animation rendering engine doesn't support querying values for individual frames
""")
return nil
}
func layer(for _: AnimationKeypath) -> CALayer? {
LottieLogger.shared.assertionFailure("`AnimationKeypath`s are currently unsupported")
return nil
}
func allLayers(for keypath: AnimationKeypath) -> [CALayer] {
LottieLogger.shared.assertionFailure("`AnimationKeypath`s are currently unsupported")
return []
}
func animatorNodes(for _: AnimationKeypath) -> [AnimatorNode]? {
LottieLogger.shared.assertionFailure("`AnimatorNode`s are not used in this rendering implementation")
return nil
}
func removeAnimations() {
currentAnimationConfiguration = nil
currentPlaybackState = nil
removeAllAnimations()
for sublayer in allSublayers {
sublayer.removeAllAnimations()
}
}
}
// MARK: - CALayer + allSublayers
extension CALayer {
/// All of the layers in the layer tree that are descendants from this later
@nonobjc
var allSublayers: [CALayer] {
var allSublayers: [CALayer] = []
for sublayer in sublayers ?? [] {
allSublayers.append(sublayer)
allSublayers.append(contentsOf: sublayer.allSublayers)
}
return allSublayers
}
}
@@ -0,0 +1,35 @@
// Created by Cal Stephens on 12/15/21.
// Copyright © 2021 Airbnb Inc. All rights reserved.
import QuartzCore
// MARK: - CALayer + fillBoundsOfSuperlayer
extension CALayer {
/// Updates the `bounds` of this layer to fill the bounds of its `superlayer`
/// without setting `frame` (which is not permitted if the layer can rotate)
@nonobjc
func fillBoundsOfSuperlayer() {
guard let superlayer = superlayer else { return }
if let customLayerLayer = self as? CustomLayoutLayer {
customLayerLayer.layout(superlayerBounds: superlayer.bounds)
}
else {
// By default the `anchorPoint` of a layer is `CGPoint(x: 0.5, y: 0.5)`.
// Setting it to `.zero` makes the layer have the same coordinate space
// as its superlayer, which lets use use `superlayer.bounds` directly.
anchorPoint = .zero
bounds = superlayer.bounds
}
}
}
// MARK: - CustomLayoutLayer
/// A `CALayer` that sets a custom `bounds` and `anchorPoint` relative to its superlayer
protocol CustomLayoutLayer: CALayer {
func layout(superlayerBounds: CGRect)
}
@@ -0,0 +1,37 @@
// Created by Cal Stephens on 1/11/22.
// Copyright © 2022 Airbnb Inc. All rights reserved.
// MARK: - KeyframeGroup + exactlyOneKeyframe
extension KeyframeGroup {
/// Retrieves the first `Keyframe` from this group,
/// and asserts that there are not any extra keyframes that would be ignored
///
/// - There are several places in Lottie animation definitions where multiple
/// sets of keyframe timings can be provided for properties that have to
/// be applied to a single `CALayer` property (for example, the definition for a
/// `Rectangle` technically lets you animate `size`, `position`, and `cornerRadius`
/// separately, but these all have to be combined into a single `CAKeyframeAnimation`
/// on the `CAShapeLayer.path` property.
///
/// - In those sorts of cases, we currently choose one one `KeyframeGroup` to provide the
/// timing information, and disallow simultaneous animations on the other properties.
///
func exactlyOneKeyframe(
context: CompatibilityTrackerProviding,
description: String,
fileID _: StaticString = #fileID,
line _: UInt = #line)
throws
-> Keyframe<T>
{
try context.compatibilityAssert(
keyframes.count == 1,
"""
The Core Animation rendering engine does not support animating multiple keyframes
for \(description) values (due to limitations of Core Animation `CAKeyframeAnimation`s).
""")
return keyframes[0]
}
}
@@ -0,0 +1,61 @@
// Created by Cal Stephens on 1/28/22.
// Copyright © 2022 Airbnb Inc. All rights reserved.
// MARK: - Keyframes
enum Keyframes {
/// Combines the given `[KeyframeGroup]` of `Keyframe<T>`s
/// into a single `KeyframeGroup` of `Keyframe<[T]>`s
/// if all of the `KeyframeGroup`s have the exact same animation timing
static func combinedIfPossible<T>(_ groups: [KeyframeGroup<T>]) -> KeyframeGroup<[T]>? {
guard
!groups.isEmpty,
groups.allSatisfy({ $0.hasSameTimingParameters(as: groups[0]) })
else { return nil }
var combinedKeyframes = ContiguousArray<Keyframe<[T]>>()
for index in groups[0].keyframes.indices {
let baseKeyframe = groups[0].keyframes[index]
let combinedValues = groups.map { $0.keyframes[index].value }
combinedKeyframes.append(baseKeyframe.withValue(combinedValues))
}
return KeyframeGroup(keyframes: combinedKeyframes)
}
/// Combines the given `[KeyframeGroup?]` of `Keyframe<T>`s
/// into a single `KeyframeGroup` of `Keyframe<[T]>`s
/// if all of the `KeyframeGroup`s have the exact same animation timing
static func combinedIfPossible<T>(_ groups: [KeyframeGroup<T>?]) -> KeyframeGroup<[T]>? {
let nonOptionalGroups = groups.compactMap { $0 }
guard nonOptionalGroups.count == groups.count else { return nil }
return combinedIfPossible(nonOptionalGroups)
}
}
extension KeyframeGroup {
/// Whether or not all of the keyframes in this `KeyframeGroup` have the same
/// timing parameters as the corresponding keyframe in the other given `KeyframeGroup`
func hasSameTimingParameters<A>(as other: KeyframeGroup<A>) -> Bool {
guard keyframes.count == other.keyframes.count else {
return false
}
return zip(keyframes, other.keyframes).allSatisfy {
$0.hasSameTimingParameters(as: $1)
}
}
}
extension Keyframe {
/// Whether or not this keyframe has the same timing parameters as the given keyframe
func hasSameTimingParameters<A>(as other: Keyframe<A>) -> Bool {
time == other.time
&& isHold == other.isHold
&& inTangent == other.inTangent
&& outTangent == other.outTangent
&& spatialInTangent == other.spatialInTangent
&& spatialOutTangent == other.spatialOutTangent
}
}
@@ -0,0 +1,70 @@
// Created by Cal Stephens on 12/14/21.
// Copyright © 2021 Airbnb Inc. All rights reserved.
import QuartzCore
// MARK: - AnimationLayer
/// A type of `CALayer` that can be used in a Lottie animation
/// - Layers backed by a `LayerModel` subclass should subclass `BaseCompositionLayer`
protocol AnimationLayer: CALayer {
/// Instructs this layer to setup its `CAAnimation`s
/// using the given `LayerAnimationContext`
func setupAnimations(context: LayerAnimationContext) throws
}
// MARK: - LayerAnimationContext
// Context describing the timing parameters of the current animation
struct LayerAnimationContext {
/// The animation being played
let animation: Animation
/// The timing configuration that should be applied to `CAAnimation`s
let timingConfiguration: CoreAnimationLayer.CAMediaTimingConfiguration
/// The absolute frame number that this animation begins at
let startFrame: AnimationFrameTime
/// The absolute frame number that this animation ends at
let endFrame: AnimationFrameTime
/// The set of custom Value Providers applied to this animation
let valueProviderStore: ValueProviderStore
/// Information about whether or not an animation is compatible with the Core Animation engine
let compatibilityTracker: CompatibilityTracker
/// The AnimationKeypath represented by the current layer
var currentKeypath: AnimationKeypath
/// A closure that remaps the given frame in the child layer's local time to a frame
/// in the animation's overall global time
private(set) var timeRemapping: ((AnimationFrameTime) -> AnimationFrameTime) = { $0 }
/// Adds the given component string to the `AnimationKeypath` stored
/// that describes the current path being configured by this context value
func addingKeypathComponent(_ component: String) -> LayerAnimationContext {
var context = self
context.currentKeypath.keys.append(component)
return context
}
/// The `AnimationProgressTime` for the given `AnimationFrameTime` within this layer,
/// accounting for the `timeRemapping` applied to this layer
func progressTime(for frame: AnimationFrameTime) -> AnimationProgressTime {
animation.progressTime(forFrame: timeRemapping(frame), clamped: false)
}
/// Chains an additional `timeRemapping` closure onto this layer context
func withTimeRemapping(
_ additionalTimeRemapping: @escaping (AnimationFrameTime) -> AnimationFrameTime)
-> LayerAnimationContext
{
var copy = self
copy.timeRemapping = { [existingTimeRemapping = timeRemapping] time in
existingTimeRemapping(additionalTimeRemapping(time))
}
return copy
}
}
@@ -0,0 +1,33 @@
// Created by Cal Stephens on 1/27/22.
// Copyright © 2022 Airbnb Inc. All rights reserved.
import QuartzCore
/// A base `CALayer` that manages the frame and animations
/// of its `sublayers` and `mask`
class BaseAnimationLayer: CALayer, AnimationLayer {
// MARK: Internal
override func layoutSublayers() {
super.layoutSublayers()
for sublayer in managedSublayers {
sublayer.fillBoundsOfSuperlayer()
}
}
func setupAnimations(context: LayerAnimationContext) throws {
for childAnimationLayer in managedSublayers {
try (childAnimationLayer as? AnimationLayer)?.setupAnimations(context: context)
}
}
// MARK: Private
/// All of the sublayers managed by this container
private var managedSublayers: [CALayer] {
(sublayers ?? []) + [mask].compactMap { $0 }
}
}
@@ -0,0 +1,87 @@
// Created by Cal Stephens on 12/20/21.
// Copyright © 2021 Airbnb Inc. All rights reserved.
import QuartzCore
// MARK: - BaseCompositionLayer
/// The base type of `AnimationLayer` that can contain other `AnimationLayer`s
class BaseCompositionLayer: BaseAnimationLayer {
// MARK: Lifecycle
init(layerModel: LayerModel) {
baseLayerModel = layerModel
super.init()
setupSublayers()
compositingFilter = layerModel.blendMode.filterName
name = layerModel.name
}
required init?(coder _: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
/// Called by CoreAnimation to create a shadow copy of this layer
/// More details: https://developer.apple.com/documentation/quartzcore/calayer/1410842-init
override init(layer: Any) {
guard let typedLayer = layer as? Self else {
fatalError("\(Self.self).init(layer:) incorrectly called with \(type(of: layer))")
}
baseLayerModel = typedLayer.baseLayerModel
super.init(layer: typedLayer)
}
// MARK: Internal
/// Whether or not this layer render should render any visible content
var renderLayerContents: Bool { true }
/// Sets up the base `LayerModel` animations for this layer,
/// and all child `AnimationLayer`s.
/// - Can be overridden by subclasses, which much call `super`.
override func setupAnimations(context: LayerAnimationContext) throws {
var context = context
if renderLayerContents {
context = context.addingKeypathComponent(baseLayerModel.name)
}
try setupLayerAnimations(context: context)
try setupChildAnimations(context: context)
}
func setupLayerAnimations(context: LayerAnimationContext) throws {
let context = context.addingKeypathComponent(baseLayerModel.name)
try addTransformAnimations(for: baseLayerModel.transform, context: context)
if renderLayerContents {
try addOpacityAnimation(for: baseLayerModel.transform, context: context)
addVisibilityAnimation(
inFrame: CGFloat(baseLayerModel.inFrame),
outFrame: CGFloat(baseLayerModel.outFrame),
context: context)
}
}
func setupChildAnimations(context: LayerAnimationContext) throws {
try super.setupAnimations(context: context)
}
// MARK: Private
private let baseLayerModel: LayerModel
private func setupSublayers() {
if
renderLayerContents,
let masks = baseLayerModel.masks
{
mask = MaskCompositionLayer(masks: masks)
}
}
}
@@ -0,0 +1,131 @@
// Created by Cal Stephens on 1/11/22.
// Copyright © 2022 Airbnb Inc. All rights reserved.
import QuartzCore
extension CALayer {
/// Sets up an `AnimationLayer` / `CALayer` hierarchy in this layer,
/// using the given list of layers.
@nonobjc
func setupLayerHierarchy(
for layers: [LayerModel],
context: LayerContext)
throws
{
// An `Animation`'s `LayerModel`s are listed from front to back,
// but `CALayer.sublayers` are listed from back to front.
// We reverse the layer ordering to match what Core Animation expects.
// The final view hierarchy must display the layers in this exact order.
let layersInZAxisOrder = layers.reversed()
let layersByIndex = Dictionary(grouping: layersInZAxisOrder, by: \.index)
.compactMapValues(\.first)
/// Layers specify a `parent` layer. Child layers inherit the `transform` of their parent.
/// - We can't add the child as a sublayer of the parent `CALayer`, since that would
/// break the ordering specified in `layersInZAxisOrder`.
/// - Instead, we create an invisible `TransformLayer` to handle the parent
/// transform animations, and add the child layer to that `TransformLayer`.
func makeParentTransformLayer(
childLayerModel: LayerModel,
childLayer: CALayer,
name: (LayerModel) -> String)
-> CALayer
{
guard
let parentIndex = childLayerModel.parent,
let parentLayerModel = layersByIndex[parentIndex]
else { return childLayer }
let parentLayer = TransformLayer(layerModel: parentLayerModel)
parentLayer.name = name(parentLayerModel)
parentLayer.addSublayer(childLayer)
return makeParentTransformLayer(
childLayerModel: parentLayerModel,
childLayer: parentLayer,
name: name)
}
// Create an `AnimationLayer` for each `LayerModel`
for (layerModel, maskLayerModel) in try layersInZAxisOrder.pairedLayersAndMasks(context: context) {
guard let layer = try layerModel.makeAnimationLayer(context: context) else {
continue
}
// If this layer has a `parent`, we create an invisible `TransformLayer`
// to handle displaying / animating the parent transform.
let parentTransformLayer = makeParentTransformLayer(
childLayerModel: layerModel,
childLayer: layer,
name: { parentLayerModel in
"\(layerModel.name) (parent, \(parentLayerModel.name))"
})
// Create the `mask` layer for this layer, if it has a `MatteType`
if
let maskLayerModel = maskLayerModel,
let maskLayer = try maskLayerModel.makeAnimationLayer(context: context)
{
let maskParentTransformLayer = makeParentTransformLayer(
childLayerModel: maskLayerModel,
childLayer: maskLayer,
name: { parentLayerModel in
"\(maskLayerModel.name) (mask of \(layerModel.name)) (parent, \(parentLayerModel.name))"
})
// Set up a parent container to host both the layer
// and its mask in the same coordinate space
let maskContainer = BaseAnimationLayer()
maskContainer.name = "\(layerModel.name) (parent, masked)"
maskContainer.addSublayer(parentTransformLayer)
// Core Animation will silently fail to apply a mask if a `mask` layer
// itself _also_ has a `mask`. As a workaround, we can wrap this layer's
// mask in an additional container layer which never has its own `mask`.
let additionalMaskParent = BaseAnimationLayer()
additionalMaskParent.addSublayer(maskParentTransformLayer)
maskContainer.mask = additionalMaskParent
addSublayer(maskContainer)
}
else {
addSublayer(parentTransformLayer)
}
}
}
}
extension Collection where Element == LayerModel {
/// Pairs each `LayerModel` within this array with
/// a `LayerModel` to use as its mask, if applicable
/// based on the layer's `MatteType` configuration.
/// - Assumes the layers are sorted in z-axis order.
fileprivate func pairedLayersAndMasks(context: LayerContext) throws -> [(layer: LayerModel, mask: LayerModel?)] {
var layersAndMasks = [(layer: LayerModel, mask: LayerModel?)]()
var unprocessedLayers = reversed()
while let layer = unprocessedLayers.popLast() {
/// If a layer has a `MatteType`, then the next layer will be used as its `mask`
if
let matteType = layer.matte,
matteType != .none,
let maskLayer = unprocessedLayers.popLast()
{
try context.compatibilityAssert(
matteType == .add,
"The Core Animation rendering engine currently only supports `MatteMode.add`.")
layersAndMasks.append((layer: layer, mask: maskLayer))
}
else {
layersAndMasks.append((layer: layer, mask: nil))
}
}
return layersAndMasks
}
}
@@ -0,0 +1,87 @@
// Created by Cal Stephens on 1/10/22.
// Copyright © 2022 Airbnb Inc. All rights reserved.
import QuartzCore
// MARK: - GradientRenderLayer
/// A `CAGradientLayer` subclass used to render a gradient _outside_ the normal layer bounds
///
/// - `GradientFill.startPoint` and `GradientFill.endPoint` are expressed
/// with respect to the `bounds` of the `ShapeItemLayer`.
///
/// - The gradient itself is supposed to be rendered infinitely in all directions
/// (e.g. including outside of `bounds`). This is because `ShapeItemLayer` paths
/// don't necessarily sit within the layer's `bounds`.
///
/// - To support this, `GradientRenderLayer` tracks a `gradientReferenceBounds`
/// that `startPoint` / `endPoint` are calculated relative to.
/// The _actual_ `bounds` of this layer is padded by a large amount so that
/// the gradient can be drawn outside of the `gradientReferenceBounds`.
///
final class GradientRenderLayer: CAGradientLayer {
// MARK: Internal
/// The reference bounds within this layer that the gradient's
/// `startPoint` and `endPoint` should be calculated relative to
var gradientReferenceBounds: CGRect = .zero {
didSet {
if oldValue != gradientReferenceBounds {
updateLayout()
}
}
}
/// Converts the given `CGPoint` within `gradientReferenceBounds`
/// to a percentage value relative to the full `bounds` of this layer
/// - This converts absolute `startPoint` and `endPoint` values into
/// the percent-based values expected by Core Animation,
/// with respect to the custom bounds geometry used by this layer type.
func percentBasedPointInBounds(from referencePoint: CGPoint) -> CGPoint {
guard bounds.width > 0, bounds.height > 0 else {
LottieLogger.shared.assertionFailure("Size must be non-zero before an animation can be played")
return .zero
}
let pointInBounds = CGPoint(
x: referencePoint.x + gradientPadding,
y: referencePoint.y + gradientPadding)
return CGPoint(
x: CGFloat(pointInBounds.x) / bounds.width,
y: CGFloat(pointInBounds.y) / bounds.height)
}
// MARK: Private
/// Extra padding around the `gradientReferenceBounds` where the gradient is also rendered
/// - This specific value is arbitrary and can be increased if necessary.
/// Theoretically this should be "infinite", to match the behavior of
/// `CGContext.drawLinearGradient` with `[.drawsAfterEndLocation, .drawsBeforeStartLocation]`.
private let gradientPadding: CGFloat = 2_000
private func updateLayout() {
anchorPoint = .zero
bounds = CGRect(
x: gradientReferenceBounds.origin.x,
y: gradientReferenceBounds.origin.y,
width: gradientPadding + gradientReferenceBounds.width + gradientPadding,
height: gradientPadding + gradientReferenceBounds.height + gradientPadding)
transform = CATransform3DMakeTranslation(
-gradientPadding,
-gradientPadding,
0)
}
}
// MARK: CustomLayoutLayer
extension GradientRenderLayer: CustomLayoutLayer {
func layout(superlayerBounds: CGRect) {
gradientReferenceBounds = superlayerBounds
}
}
@@ -0,0 +1,79 @@
// Created by Cal Stephens on 1/10/22.
// Copyright © 2022 Airbnb Inc. All rights reserved.
import QuartzCore
// MARK: - ImageLayer
/// The `CALayer` type responsible for rendering `ImageLayerModel`s
final class ImageLayer: BaseCompositionLayer {
// MARK: Lifecycle
init(
imageLayer: ImageLayerModel,
context: LayerContext)
{
self.imageLayer = imageLayer
super.init(layerModel: imageLayer)
setupImage(context: context)
}
required init?(coder _: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
/// Called by CoreAnimation to create a shadow copy of this layer
/// More details: https://developer.apple.com/documentation/quartzcore/calayer/1410842-init
override init(layer: Any) {
guard let typedLayer = layer as? Self else {
fatalError("\(Self.self).init(layer:) incorrectly called with \(type(of: layer))")
}
imageLayer = typedLayer.imageLayer
super.init(layer: typedLayer)
}
// MARK: Internal
func setupImage(context: LayerContext) {
guard
let imageAsset = context.animation.assetLibrary?.imageAssets[imageLayer.referenceID],
let image = context.imageProvider.imageForAsset(asset: imageAsset)
else {
self.imageAsset = nil
contents = nil
return
}
self.imageAsset = imageAsset
contents = image
setNeedsLayout()
}
// MARK: Private
private let imageLayer: ImageLayerModel
private var imageAsset: ImageAsset?
}
// MARK: CustomLayoutLayer
extension ImageLayer: CustomLayoutLayer {
func layout(superlayerBounds: CGRect) {
anchorPoint = .zero
guard let imageAsset = imageAsset else {
bounds = superlayerBounds
return
}
// Image layers specifically need to use the size of the image itself
bounds = CGRect(
x: superlayerBounds.origin.x,
y: superlayerBounds.origin.y,
width: CGFloat(imageAsset.width),
height: CGFloat(imageAsset.height))
}
}
@@ -0,0 +1,60 @@
// Created by Cal Stephens on 12/20/21.
// Copyright © 2021 Airbnb Inc. All rights reserved.
import QuartzCore
// MARK: - LayerContext
/// Context available when constructing an `AnimationLayer`
struct LayerContext {
let animation: Animation
let imageProvider: AnimationImageProvider
let fontProvider: AnimationFontProvider
let compatibilityTracker: CompatibilityTracker
var layerName: String
func forLayer(_ layer: LayerModel) -> LayerContext {
var context = self
context.layerName = layer.name
return context
}
}
// MARK: - LayerModel + makeAnimationLayer
extension LayerModel {
/// Constructs an `AnimationLayer` / `CALayer` that represents this `LayerModel`
func makeAnimationLayer(context: LayerContext) throws -> BaseCompositionLayer? {
let context = context.forLayer(self)
switch (type, self) {
case (.precomp, let preCompLayerModel as PreCompLayerModel):
let preCompLayer = PreCompLayer(preCompLayer: preCompLayerModel)
try preCompLayer.setup(context: context)
return preCompLayer
case (.solid, let solidLayerModel as SolidLayerModel):
return SolidLayer(solidLayerModel)
case (.shape, let shapeLayerModel as ShapeLayerModel):
return try ShapeLayer(shapeLayer: shapeLayerModel, context: context)
case (.image, let imageLayerModel as ImageLayerModel):
return ImageLayer(imageLayer: imageLayerModel, context: context)
case (.text, let textLayerModel as TextLayerModel):
return try TextLayer(textLayerModel: textLayerModel, context: context)
case (.null, _):
return TransformLayer(layerModel: self)
default:
try context.logCompatibilityIssue("""
Unexpected layer type combination ("\(type)" and "\(Swift.type(of: self))")
""")
return nil
}
}
}
@@ -0,0 +1,104 @@
// Created by Cal Stephens on 1/6/22.
// Copyright © 2022 Airbnb Inc. All rights reserved.
import QuartzCore
// MARK: - MaskCompositionLayer
/// The CALayer type responsible for rendering the `Mask` of a `BaseCompositionLayer`
final class MaskCompositionLayer: CALayer {
// MARK: Lifecycle
init(masks: [Mask]) {
maskLayers = masks.map(MaskLayer.init(mask:))
super.init()
for maskLayer in maskLayers {
addSublayer(maskLayer)
}
}
required init?(coder _: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
/// Called by CoreAnimation to create a shadow copy of this layer
/// More details: https://developer.apple.com/documentation/quartzcore/calayer/1410842-init
override init(layer: Any) {
guard let typedLayer = layer as? Self else {
fatalError("\(Self.self).init(layer:) incorrectly called with \(type(of: layer))")
}
maskLayers = typedLayer.maskLayers
super.init(layer: typedLayer)
}
// MARK: Internal
override func layoutSublayers() {
super.layoutSublayers()
for sublayer in sublayers ?? [] {
sublayer.fillBoundsOfSuperlayer()
}
}
// MARK: Private
private let maskLayers: [MaskLayer]
}
// MARK: AnimationLayer
extension MaskCompositionLayer: AnimationLayer {
func setupAnimations(context: LayerAnimationContext) throws {
for maskLayer in maskLayers {
try maskLayer.setupAnimations(context: context)
}
}
}
// MARK: - MaskLayer
extension MaskCompositionLayer {
final class MaskLayer: CAShapeLayer {
// MARK: Lifecycle
init(mask: Mask) {
maskModel = mask
super.init()
fillColor = .rgb(0, 0, 0)
}
required init?(coder _: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
/// Called by CoreAnimation to create a shadow copy of this layer
/// More details: https://developer.apple.com/documentation/quartzcore/calayer/1410842-init
override init(layer: Any) {
guard let typedLayer = layer as? Self else {
fatalError("\(Self.self).init(layer:) incorrectly called with \(type(of: layer))")
}
maskModel = typedLayer.maskModel
super.init(layer: typedLayer)
}
// MARK: Private
private let maskModel: Mask
}
}
// MARK: - MaskCompositionLayer.MaskLayer + AnimationLayer
extension MaskCompositionLayer.MaskLayer: AnimationLayer {
func setupAnimations(context: LayerAnimationContext) throws {
try addAnimations(for: maskModel.shape, context: context)
}
}
@@ -0,0 +1,140 @@
// Created by Cal Stephens on 12/14/21.
// Copyright © 2021 Airbnb Inc. All rights reserved.
import QuartzCore
// MARK: - PreCompLayer
/// The `CALayer` type responsible for rendering `PreCompLayerModel`s
final class PreCompLayer: BaseCompositionLayer {
// MARK: Lifecycle
init(preCompLayer: PreCompLayerModel) {
self.preCompLayer = preCompLayer
super.init(layerModel: preCompLayer)
}
required init?(coder _: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
/// Called by CoreAnimation to create a shadow copy of this layer
/// More details: https://developer.apple.com/documentation/quartzcore/calayer/1410842-init
override init(layer: Any) {
guard let typedLayer = layer as? Self else {
fatalError("\(Self.self).init(layer:) incorrectly called with \(type(of: layer))")
}
preCompLayer = typedLayer.preCompLayer
timeRemappingInterpolator = typedLayer.timeRemappingInterpolator
super.init(layer: typedLayer)
}
// MARK: Internal
/// Post-init setup for `PreCompLayer`s.
/// Should always be called after `PreCompLayer.init(preCompLayer:)`.
///
/// This is a workaround for a hard-to-reproduce crash that was
/// triggered when `PreCompLayer.init` was called reentantly. We didn't
/// have any consistent repro steps for this crash (it happened 100% of
/// the time for some testers, and 0% of the time for other testers),
/// but moving this code out of `PreCompLayer.init` does seem to fix it.
///
/// The stack trace looked like:
/// - `_os_unfair_lock_recursive_abort`
/// - `-[CALayerAccessibility__UIKit__QuartzCore dealloc]`
/// - `PreCompLayer.__allocating_init(preCompLayer:context:)` <- reentrant init call
/// - ...
/// - `CALayer.setupLayerHierarchy(for:context:)`
/// - `PreCompLayer.init(preCompLayer:context:)`
///
func setup(context: LayerContext) throws {
if let timeRemappingKeyframes = preCompLayer.timeRemapping {
timeRemappingInterpolator = try .timeRemapping(keyframes: timeRemappingKeyframes, context: context)
} else {
timeRemappingInterpolator = nil
}
try setupLayerHierarchy(
for: context.animation.assetLibrary?.precompAssets[preCompLayer.referenceID]?.layers ?? [],
context: context)
}
override func setupAnimations(context: LayerAnimationContext) throws {
var context = context
context = context.addingKeypathComponent(preCompLayer.name)
try setupLayerAnimations(context: context)
// Precomp layers can adjust the local time of their child layers (relative to the
// animation's global time) via `timeRemapping` or a custom `startTime`
let contextForChildren = context.withTimeRemapping { [preCompLayer, timeRemappingInterpolator] layerLocalFrame in
if let timeRemappingInterpolator = timeRemappingInterpolator {
return timeRemappingInterpolator.value(frame: layerLocalFrame) as? AnimationFrameTime ?? layerLocalFrame
} else {
return layerLocalFrame + AnimationFrameTime(preCompLayer.startTime)
}
}
try setupChildAnimations(context: contextForChildren)
}
// MARK: Private
private let preCompLayer: PreCompLayerModel
private var timeRemappingInterpolator: KeyframeInterpolator<AnimationFrameTime>?
}
// MARK: CustomLayoutLayer
extension PreCompLayer: CustomLayoutLayer {
func layout(superlayerBounds: CGRect) {
anchorPoint = .zero
// Pre-comp layers use a size specified in the layer model,
// and clip the composition to that bounds
bounds = CGRect(
x: superlayerBounds.origin.x,
y: superlayerBounds.origin.y,
width: CGFloat(preCompLayer.width),
height: CGFloat(preCompLayer.height))
masksToBounds = true
}
}
extension KeyframeInterpolator where ValueType == AnimationFrameTime {
/// A `KeyframeInterpolator` for the given `timeRemapping` keyframes
static func timeRemapping(
keyframes timeRemappingKeyframes: KeyframeGroup<Vector1D>,
context: LayerContext)
throws
-> KeyframeInterpolator<AnimationFrameTime>
{
try context.logCompatibilityIssue("""
The Core Animation rendering engine partially supports time remapping keyframes,
but this is somewhat experimental and has some known issues. Since it doesn't work
in all cases, we have to fall back to using the main thread engine when using
`RenderingEngineOption.automatic`.
""")
// `timeRemapping` is a mapping from the animation's global time to the layer's local time.
// In the Core Animation engine, we need to perform the opposite calculation -- convert
// the layer's local time into the animation's global time. We can get this by inverting
// the time remapping, swapping the x axis (global time) and the y axis (local time).
let localTimeToGlobalTimeMapping = timeRemappingKeyframes.keyframes.map { keyframe in
Keyframe(
value: keyframe.time,
time: keyframe.value.cgFloatValue * CGFloat(context.animation.framerate),
isHold: keyframe.isHold,
inTangent: keyframe.inTangent,
outTangent: keyframe.outTangent,
spatialInTangent: keyframe.spatialInTangent,
spatialOutTangent: keyframe.spatialOutTangent)
}
return KeyframeInterpolator(keyframes: .init(localTimeToGlobalTimeMapping))
}
}
@@ -0,0 +1,257 @@
// Created by Cal Stephens on 12/13/21.
// Copyright © 2021 Airbnb Inc. All rights reserved.
import QuartzCore
// MARK: - ShapeItemLayer
/// A CALayer type that renders an array of `[ShapeItem]`s,
/// from a `Group` in a `ShapeLayerModel`.
final class ShapeItemLayer: BaseAnimationLayer {
// MARK: Lifecycle
/// Initializes a `ShapeItemLayer` that renders a `Group` from a `ShapeLayerModel`
/// - Parameters:
/// - shape: The `ShapeItem` in this group that renders a `GGPath`
/// - otherItems: Other items in this group that affect the appearance of the shape
init(shape: Item, otherItems: [Item], context: LayerContext) throws {
self.shape = shape
self.otherItems = otherItems
try context.compatibilityAssert(
shape.item.drawsCGPath,
"`ShapeItemLayer` must contain exactly one `ShapeItem` that draws a `GPPath`")
try context.compatibilityAssert(
!otherItems.contains(where: { $0.item.drawsCGPath }),
"`ShapeItemLayer` must contain exactly one `ShapeItem` that draws a `GPPath`")
super.init()
setupLayerHierarchy()
}
required init?(coder _: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
/// Called by CoreAnimation to create a shadow copy of this layer
/// More details: https://developer.apple.com/documentation/quartzcore/calayer/1410842-init
override init(layer: Any) {
guard let typedLayer = layer as? Self else {
fatalError("\(Self.self).init(layer:) incorrectly called with \(type(of: layer))")
}
shape = typedLayer.shape
otherItems = typedLayer.otherItems
super.init(layer: typedLayer)
}
// MARK: Internal
/// An item that can be displayed by this layer
struct Item {
/// A `ShapeItem` that should be rendered by this layer
let item: ShapeItem
/// The group that contains this `ShapeItem`, if applicable
let parentGroup: Group?
}
override func setupAnimations(context: LayerAnimationContext) throws {
try super.setupAnimations(context: context)
guard let sublayerConfiguration = sublayerConfiguration else { return }
switch sublayerConfiguration.fill {
case .solidFill(let shapeLayer):
try setupSolidFillAnimations(shapeLayer: shapeLayer, context: context)
case .gradientFill(let gradientLayers):
try setupGradientFillAnimations(
gradientLayer: gradientLayers.gradientLayer,
maskLayer: gradientLayers.maskLayer,
context: context)
}
if let gradientStrokeConfiguration = sublayerConfiguration.gradientStroke {
try setupGradientStrokeAnimations(
gradientLayer: gradientStrokeConfiguration.gradientLayer,
maskLayer: gradientStrokeConfiguration.maskLayer,
context: context)
}
}
// MARK: Private
private struct GradientLayers {
/// The `CALayer` that renders the actual gradient
let gradientLayer: GradientRenderLayer
/// The `CAShapeLayer` that clips the gradient layer to the expected shape
let maskLayer: CAShapeLayer
}
/// The configuration of this layer's `fill` sublayers
private enum FillLayerConfiguration {
/// This layer displays a single `CAShapeLayer`
case solidFill(CAShapeLayer)
/// This layer displays a `GradientRenderLayer` masked by a `CAShapeLayer`.
case gradientFill(GradientLayers)
}
/// The `ShapeItem` in this group that renders a `GGPath`
private let shape: Item
/// Other items in this group that affect the appearance of the shape
private let otherItems: [Item]
/// The current configuration of this layer's sublayer(s)
private var sublayerConfiguration: (fill: FillLayerConfiguration, gradientStroke: GradientLayers?)?
private func setupLayerHierarchy() {
// We have to build a different layer hierarchy depending on if
// we're rendering a gradient (a `CAGradientLayer` masked by a `CAShapeLayer`)
// or a solid shape (a simple `CAShapeLayer`).
let fillLayerConfiguration: FillLayerConfiguration
if otherItems.contains(where: { $0.item is GradientFill }) {
fillLayerConfiguration = setupGradientFillLayerHierarchy()
} else {
fillLayerConfiguration = setupSolidFillLayerHierarchy()
}
let gradientStrokeConfiguration: GradientLayers?
if otherItems.contains(where: { $0.item is GradientStroke }) {
gradientStrokeConfiguration = setupGradientStrokeLayerHierarchy()
} else {
gradientStrokeConfiguration = nil
}
sublayerConfiguration = (fillLayerConfiguration, gradientStrokeConfiguration)
}
private func setupSolidFillLayerHierarchy() -> FillLayerConfiguration {
let shapeLayer = CAShapeLayer()
addSublayer(shapeLayer)
// `CAShapeLayer.fillColor` defaults to black, so we have to
// nil out the background color if there isn't an expected fill color
if !otherItems.contains(where: { $0.item is Fill }) {
shapeLayer.fillColor = nil
}
return .solidFill(shapeLayer)
}
private func setupGradientFillLayerHierarchy() -> FillLayerConfiguration {
let pathMask = CAShapeLayer()
pathMask.fillColor = .rgb(0, 0, 0)
mask = pathMask
let gradientLayer = GradientRenderLayer()
addSublayer(gradientLayer)
return .gradientFill(.init(gradientLayer: gradientLayer, maskLayer: pathMask))
}
private func setupGradientStrokeLayerHierarchy() -> GradientLayers {
let container = BaseAnimationLayer()
let pathMask = CAShapeLayer()
pathMask.fillColor = nil
pathMask.strokeColor = .rgb(0, 0, 0)
container.mask = pathMask
let gradientLayer = GradientRenderLayer()
container.addSublayer(gradientLayer)
addSublayer(container)
return .init(gradientLayer: gradientLayer, maskLayer: pathMask)
}
private func setupSolidFillAnimations(
shapeLayer: CAShapeLayer,
context: LayerAnimationContext)
throws
{
try shapeLayer.addAnimations(for: shape.item, context: context.for(shape))
if let (fill, context) = otherItems.first(Fill.self, context: context) {
try shapeLayer.addAnimations(for: fill, context: context)
}
if let (stroke, context) = otherItems.first(Stroke.self, context: context) {
try shapeLayer.addStrokeAnimations(for: stroke, context: context)
}
if let (trim, context) = otherItems.first(Trim.self, context: context) {
try shapeLayer.addAnimations(for: trim, context: context)
}
}
private func setupGradientFillAnimations(
gradientLayer: GradientRenderLayer,
maskLayer: CAShapeLayer,
context: LayerAnimationContext)
throws
{
try maskLayer.addAnimations(for: shape.item, context: context.for(shape))
if let (gradientFill, context) = otherItems.first(GradientFill.self, context: context) {
try gradientLayer.addGradientAnimations(for: gradientFill, context: context)
}
}
private func setupGradientStrokeAnimations(
gradientLayer: GradientRenderLayer,
maskLayer: CAShapeLayer,
context: LayerAnimationContext)
throws
{
try maskLayer.addAnimations(for: shape.item, context: context.for(shape))
if let (gradientStroke, context) = otherItems.first(GradientStroke.self, context: context) {
try gradientLayer.addGradientAnimations(for: gradientStroke, context: context)
try maskLayer.addStrokeAnimations(for: gradientStroke, context: context)
}
if let (trim, context) = otherItems.first(Trim.self, context: context) {
try maskLayer.addAnimations(for: trim, context: context)
}
}
}
// MARK: - [ShapeItem] helpers
extension Array where Element == ShapeItemLayer.Item {
/// The first `ShapeItem` in this array of the given type
func first<ItemType: ShapeItem>(
_: ItemType.Type, context: LayerAnimationContext)
-> (item: ItemType, context: LayerAnimationContext)?
{
for item in self {
if let match = item.item as? ItemType {
return (match, context.for(item))
}
}
return nil
}
}
extension LayerAnimationContext {
/// An updated `LayerAnimationContext` with the`AnimationKeypath`
/// that refers to this specific `ShapeItem`.
func `for`(_ item: ShapeItemLayer.Item) -> LayerAnimationContext {
var context = self
if let group = item.parentGroup {
context.currentKeypath.keys.append(group.name)
}
context.currentKeypath.keys.append(item.item.name)
return context
}
}
@@ -0,0 +1,305 @@
// Created by Cal Stephens on 12/14/21.
// Copyright © 2021 Airbnb Inc. All rights reserved.
import QuartzCore
// MARK: - ShapeLayer
/// The CALayer type responsible for rendering `ShapeLayerModel`s
final class ShapeLayer: BaseCompositionLayer {
// MARK: Lifecycle
init(shapeLayer: ShapeLayerModel, context: LayerContext) throws {
self.shapeLayer = shapeLayer
super.init(layerModel: shapeLayer)
try setupGroups(from: shapeLayer.items, parentGroup: nil, context: context)
}
required init?(coder _: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
/// Called by CoreAnimation to create a shadow copy of this layer
/// More details: https://developer.apple.com/documentation/quartzcore/calayer/1410842-init
override init(layer: Any) {
guard let typedLayer = layer as? Self else {
fatalError("\(Self.self).init(layer:) incorrectly called with \(type(of: layer))")
}
shapeLayer = typedLayer.shapeLayer
super.init(layer: typedLayer)
}
// MARK: Private
private let shapeLayer: ShapeLayerModel
}
// MARK: - GroupLayer
/// The CALayer type responsible for rendering `Group`s
final class GroupLayer: BaseAnimationLayer {
// MARK: Lifecycle
init(group: Group, inheritedItems: [ShapeItemLayer.Item], context: LayerContext) throws {
self.group = group
self.inheritedItems = inheritedItems
super.init()
try setupLayerHierarchy(context: context)
}
required init?(coder _: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
/// Called by CoreAnimation to create a shadow copy of this layer
/// More details: https://developer.apple.com/documentation/quartzcore/calayer/1410842-init
override init(layer: Any) {
guard let typedLayer = layer as? Self else {
fatalError("\(Self.self).init(layer:) incorrectly called with \(type(of: layer))")
}
group = typedLayer.group
inheritedItems = typedLayer.inheritedItems
super.init(layer: typedLayer)
}
// MARK: Internal
override func setupAnimations(context: LayerAnimationContext) throws {
try super.setupAnimations(context: context)
if let (shapeTransform, context) = nonGroupItems.first(ShapeTransform.self, context: context) {
try addTransformAnimations(for: shapeTransform, context: context)
try addOpacityAnimation(for: shapeTransform, context: context)
}
}
// MARK: Private
private let group: Group
/// `ShapeItem`s that were listed in the parent's `items: [ShapeItem]` array
/// - This layer's parent is either the root `ShapeLayerModel` or some other `Group`
private let inheritedItems: [ShapeItemLayer.Item]
/// `ShapeItem`s (other than nested `Group`s) that are included in this group
private lazy var nonGroupItems = group.items
.filter { !($0 is Group) }
.map { ShapeItemLayer.Item(item: $0, parentGroup: group) }
+ inheritedItems
private func setupLayerHierarchy(context: LayerContext) throws {
// Groups can contain other groups, so we may have to continue
// recursively creating more `GroupLayer`s
try setupGroups(from: group.items, parentGroup: group, context: context)
// Create `ShapeItemLayer`s for each subgroup of shapes that should be rendered as a single unit
// - These groups are listed from front-to-back, so we have to add the sublayers in reverse order
for shapeRenderGroup in nonGroupItems.shapeRenderGroups.reversed() {
// If all of the path-drawing `ShapeItem`s have keyframes with the same timing information,
// we can combine the `[KeyframeGroup<BezierPath>]` (which have to animate in separate layers)
// into a single `KeyframeGroup<[BezierPath]>`, which can be combined into a single CGPath animation.
//
// This is how Groups with multiple path-drawing items are supposed to be rendered,
// because combining multiple paths into a single `CGPath` (instead of rendering them in separate layers)
// allows `CAShapeLayerFillRule.evenOdd` to be applied if the paths overlap. We just can't do this
// in all cases, due to limitations of Core Animation.
if
shapeRenderGroup.pathItems.count > 1,
let combinedShapeKeyframes = Keyframes.combinedIfPossible(
shapeRenderGroup.pathItems.map { ($0.item as? Shape)?.path }),
// `Trim`s are currently only applied correctly using individual `ShapeItemLayer`s,
// because each path has to be trimmed separately.
!shapeRenderGroup.otherItems.contains(where: { $0.item is Trim })
{
let combinedShape = CombinedShapeItem(
shapes: combinedShapeKeyframes,
name: group.name)
let sublayer = try ShapeItemLayer(
shape: ShapeItemLayer.Item(item: combinedShape, parentGroup: group),
otherItems: shapeRenderGroup.otherItems,
context: context)
addSublayer(sublayer)
}
// Otherwise, if each `ShapeItem` that draws a `GGPath` animates independently,
// we have to create a separate `ShapeItemLayer` for each one.
else {
for pathDrawingItem in shapeRenderGroup.pathItems {
let sublayer = try ShapeItemLayer(
shape: pathDrawingItem,
otherItems: shapeRenderGroup.otherItems,
context: context)
addSublayer(sublayer)
}
}
}
}
}
extension CALayer {
/// Sets up `GroupLayer`s for each `Group` in the given list of `ShapeItem`s
/// - Each `Group` item becomes its own `GroupLayer` sublayer.
/// - Other `ShapeItem` are applied to all sublayers
fileprivate func setupGroups(from items: [ShapeItem], parentGroup: Group?, context: LayerContext) throws {
let (groupItems, otherItems) = items.grouped(by: { $0 is Group })
// Groups are listed from front to back,
// but `CALayer.sublayers` are listed from back to front.
let groupsInZAxisOrder = groupItems.reversed()
for group in groupsInZAxisOrder {
guard let group = group as? Group else { continue }
// `ShapeItem`s either draw a path, or modify how a path is rendered.
// - If this group doesn't have any items that draw a path, then its
// items are applied to all of this groups children.
let inheritedItems: [ShapeItemLayer.Item]
if !otherItems.contains(where: { $0.drawsCGPath }) {
inheritedItems = otherItems.map {
ShapeItemLayer.Item(item: $0, parentGroup: parentGroup)
}
} else {
inheritedItems = []
}
let groupLayer = try GroupLayer(
group: group,
inheritedItems: inheritedItems,
context: context)
addSublayer(groupLayer)
}
}
}
extension ShapeItem {
/// Whether or not this `ShapeItem` is responsible for rendering a `CGPath`
var drawsCGPath: Bool {
switch type {
case .ellipse, .rectangle, .shape, .star:
return true
case .fill, .gradientFill, .group, .gradientStroke, .merge,
.repeater, .round, .stroke, .trim, .transform, .unknown:
return false
}
}
/// Whether or not this `ShapeItem` provides a fill for a set of shapes
var isFill: Bool {
switch type {
case .fill, .gradientFill:
return true
case .ellipse, .rectangle, .shape, .star, .group, .gradientStroke,
.merge, .repeater, .round, .stroke, .trim, .transform, .unknown:
return false
}
}
/// Whether or not this `ShapeItem` provides a stroke for a set of shapes
var isStroke: Bool {
switch type {
case .stroke, .gradientStroke:
return true
case .ellipse, .rectangle, .shape, .star, .group, .gradientFill,
.merge, .repeater, .round, .fill, .trim, .transform, .unknown:
return false
}
}
}
extension Collection {
/// Splits this collection into two groups, based on the given predicate
func grouped(by predicate: (Element) -> Bool) -> (trueElements: [Element], falseElements: [Element]) {
var trueElements = [Element]()
var falseElements = [Element]()
for element in self {
if predicate(element) {
trueElements.append(element)
} else {
falseElements.append(element)
}
}
return (trueElements, falseElements)
}
}
// MARK: - ShapeRenderGroup
/// A group of `ShapeItem`s that should be rendered together as a single unit
struct ShapeRenderGroup {
/// The items in this group that render `CGPath`s
var pathItems: [ShapeItemLayer.Item] = []
/// Shape items that modify the appearance of the shapes rendered by this group
var otherItems: [ShapeItemLayer.Item] = []
}
extension Array where Element == ShapeItemLayer.Item {
/// Splits this list of `ShapeItem`s into groups that should be rendered together as individual units
var shapeRenderGroups: [ShapeRenderGroup] {
var renderGroups = [ShapeRenderGroup()]
for item in self {
// `renderGroups` is non-empty, so is guaranteed to have a valid end index
let lastIndex = renderGroups.indices.last!
if item.item.drawsCGPath {
renderGroups[lastIndex].pathItems.append(item)
}
// `Fill` items are unique, because they specifically only apply to _previous_ shapes in a `Group`
// - For example, with [Rectangle, Fill(Red), Circle, Fill(Blue)], the Rectangle should be Red
// but the Circle should be Blue.
// - To handle this, we create a new `ShapeRenderGroup` when we encounter a `Fill` item
else if item.item.isFill {
renderGroups[lastIndex].otherItems.append(item)
renderGroups.append(ShapeRenderGroup())
}
// Other items in the list are applied to all subgroups
else {
for index in renderGroups.indices {
renderGroups[index].otherItems.append(item)
}
}
}
// `Fill` and `Stroke` items have an `alpha` property that can be animated separately,
// but each layer only has a single `opacity` property, so we have to create
// separate layers / render groups for each of these if necessary.
return renderGroups.flatMap { group -> [ShapeRenderGroup] in
let (strokesAndFills, otherItems) = group.otherItems.grouped(by: { $0.item.isFill || $0.item.isStroke })
// However, if all of the strokes / fills have the exact same opacity animation configuration,
// then we can continue using a single layer / render group.
let allAlphaAnimationsAreIdentical = strokesAndFills.allSatisfy { item in
(item.item as? OpacityAnimationModel)?.opacity
== (strokesAndFills.first?.item as? OpacityAnimationModel)?.opacity
}
if allAlphaAnimationsAreIdentical {
return [group]
}
// Create a new group for each stroke / fill
return strokesAndFills.map { strokeOrFill in
ShapeRenderGroup(
pathItems: group.pathItems,
otherItems: [strokeOrFill] + otherItems)
}
}
}
}
@@ -0,0 +1,47 @@
// Created by Cal Stephens on 12/13/21.
// Copyright © 2021 Airbnb Inc. All rights reserved.
import QuartzCore
// MARK: - SolidLayer
final class SolidLayer: BaseCompositionLayer {
// MARK: Lifecycle
init(_ solidLayer: SolidLayerModel) {
self.solidLayer = solidLayer
super.init(layerModel: solidLayer)
setupContentLayer()
}
required init?(coder _: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
/// Called by CoreAnimation to create a shadow copy of this layer
/// More details: https://developer.apple.com/documentation/quartzcore/calayer/1410842-init
override init(layer: Any) {
guard let typedLayer = layer as? Self else {
fatalError("\(Self.self).init(layer:) incorrectly called with \(type(of: layer))")
}
solidLayer = typedLayer.solidLayer
super.init(layer: typedLayer)
}
// MARK: Private
private let solidLayer: SolidLayerModel
private func setupContentLayer() {
// Render the fill color in a child `CAShapeLayer`
// - Using a `CAShapeLayer` specifically, instead of a `CALayer` with a `backgroundColor`,
// allows the size of the fill shape to be different from `contentsLayer.size`.
let shapeLayer = CAShapeLayer()
shapeLayer.fillColor = solidLayer.colorHex.cgColor
shapeLayer.path = CGPath(rect: .init(x: 0, y: 0, width: solidLayer.width, height: solidLayer.height), transform: nil)
addSublayer(shapeLayer)
}
}
@@ -0,0 +1,91 @@
// Created by Cal Stephens on 2/9/22.
// Copyright © 2022 Airbnb Inc. All rights reserved.
import QuartzCore
/// The `CALayer` type responsible for rendering `TextLayer`s
final class TextLayer: BaseCompositionLayer {
// MARK: Lifecycle
init(
textLayerModel: TextLayerModel,
context: LayerContext)
throws
{
self.textLayerModel = textLayerModel
super.init(layerModel: textLayerModel)
setupSublayers()
try configureRenderLayer(with: context)
}
required init?(coder _: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
/// Called by CoreAnimation to create a shadow copy of this layer
/// More details: https://developer.apple.com/documentation/quartzcore/calayer/1410842-init
override init(layer: Any) {
guard let typedLayer = layer as? Self else {
fatalError("\(Self.self).init(layer:) incorrectly called with \(type(of: layer))")
}
textLayerModel = typedLayer.textLayerModel
super.init(layer: typedLayer)
}
// MARK: Internal
func configureRenderLayer(with context: LayerContext) throws {
// We can't use `CATextLayer`, because it doesn't support enough features we use.
// Instead, we use the same `CoreTextRenderLayer` (with a custom `draw` implementation)
// used by the Main Thread rendering engine. This means the Core Animation engine can't
// _animate_ text properties, but it can display static text without any issues.
let text = try textLayerModel.text.exactlyOneKeyframe(context: context, description: "text layer text").value
// The Core Animation engine doesn't currently support `TextAnimator`s.
// - We could add support for animating the transform-related properties without much trouble.
// - We may be able to support animating `fillColor` by getting clever with layer blend modes
// or masks (e.g. use `CoreTextRenderLayer` to draw black glyphs, and then fill them in
// using a `CAShapeLayer`).
if !textLayerModel.animators.isEmpty {
try context.logCompatibilityIssue("""
The Core Animation rendering engine currently doesn't support text animators.
""")
}
renderLayer.text = text.text
renderLayer.font = context.fontProvider.fontFor(family: text.fontFamily, size: CGFloat(text.fontSize))
renderLayer.alignment = text.justification.textAlignment
renderLayer.lineHeight = CGFloat(text.lineHeight)
renderLayer.tracking = (CGFloat(text.fontSize) * CGFloat(text.tracking)) / 1000
renderLayer.fillColor = text.fillColorData?.cgColorValue
renderLayer.strokeColor = text.strokeColorData?.cgColorValue
renderLayer.strokeWidth = CGFloat(text.strokeWidth ?? 0)
renderLayer.strokeOnTop = text.strokeOverFill ?? false
renderLayer.preferredSize = text.textFrameSize?.sizeValue
renderLayer.sizeToFit()
renderLayer.transform = CATransform3DIdentity
renderLayer.position = text.textFramePosition?.pointValue ?? .zero
}
// MARK: Private
private let textLayerModel: TextLayerModel
private let renderLayer = CoreTextRenderLayer()
private func setupSublayers() {
// Place the text render layer in an additional container
// - Direct sublayers of a `BaseCompositionLayer` always fill the bounds
// of their superlayer -- so this container will be the bounds of self,
// and the text render layer can be positioned anywhere.
let textContainerLayer = CALayer()
textContainerLayer.addSublayer(renderLayer)
addSublayer(textContainerLayer)
}
}
@@ -0,0 +1,11 @@
// Created by Cal Stephens on 12/21/21.
// Copyright © 2021 Airbnb Inc. All rights reserved.
/// The CALayer type responsible for only rendering the `transform` of a `LayerModel`
final class TransformLayer: BaseCompositionLayer {
/// `TransformLayer`s don't render any visible content,
/// they just `transform` their sublayers
override var renderLayerContents: Bool { false }
}
@@ -0,0 +1,127 @@
// Created by Cal Stephens on 1/13/22.
// Copyright © 2022 Airbnb Inc. All rights reserved.
import QuartzCore
// MARK: - ValueProviderStore
/// Registration and storage for `AnyValueProvider`s that can dynamically
/// provide custom values for `AnimationKeypath`s within an `Animation`.
final class ValueProviderStore {
// MARK: Internal
/// Registers an `AnyValueProvider` for the given `AnimationKeypath`
func setValueProvider(_ valueProvider: AnyValueProvider, keypath: AnimationKeypath) {
LottieLogger.shared.assert(
valueProvider.typeErasedStorage.isSupportedByCoreAnimationRenderingEngine,
"""
The Core Animation rendering engine doesn't support Value Providers that vend a closure,
because that would require calling the closure on the main thread once per frame.
""")
// TODO: Support more value types
LottieLogger.shared.assert(
keypath.keys.last == PropertyName.color.rawValue,
"The Core Animation rendering engine currently only supports customizing color values")
valueProviders.append((keypath: keypath, valueProvider: valueProvider))
}
// Retrieves the custom value keyframes for the given property,
// if an `AnyValueProvider` was registered for the given keypath.
func customKeyframes<Value>(
of customizableProperty: CustomizableProperty<Value>,
for keypath: AnimationKeypath,
context: LayerAnimationContext)
throws
-> KeyframeGroup<Value>?
{
guard let anyValueProvider = valueProvider(for: keypath) else {
return nil
}
// Retrieve the type-erased keyframes from the custom `ValueProvider`
let typeErasedKeyframes: [Keyframe<Any>]
switch anyValueProvider.typeErasedStorage {
case .singleValue(let typeErasedValue):
typeErasedKeyframes = [Keyframe(typeErasedValue)]
case .keyframes(let keyframes, _):
typeErasedKeyframes = keyframes
case .closure:
try context.logCompatibilityIssue("""
The Core Animation rendering engine doesn't support Value Providers that vend a closure,
because that would require calling the closure on the main thread once per frame.
""")
return nil
}
// Convert the type-erased keyframe values using this `CustomizableProperty`'s conversion closure
let typedKeyframes = typeErasedKeyframes.compactMap { typeErasedKeyframe -> Keyframe<Value>? in
guard let convertedValue = customizableProperty.conversion(typeErasedKeyframe.value) else {
LottieLogger.shared.assertionFailure("""
Could not convert value of type \(type(of: typeErasedKeyframe.value)) to expected type \(Value.self)
""")
return nil
}
return typeErasedKeyframe.withValue(convertedValue)
}
// Verify that all of the keyframes were successfully converted to the expected type
guard typedKeyframes.count == typeErasedKeyframes.count else {
return nil
}
return KeyframeGroup(keyframes: ContiguousArray(typedKeyframes))
}
// MARK: Private
private var valueProviders = [(keypath: AnimationKeypath, valueProvider: AnyValueProvider)]()
/// Retrieves the most-recently-registered Value Provider that matches the given keypat
private func valueProvider(for keypath: AnimationKeypath) -> AnyValueProvider? {
// Find the last keypath matching the given keypath,
// so we return the value provider that was registered most-recently
valueProviders.last(where: { registeredKeypath, _ in
keypath.matches(registeredKeypath)
})?.valueProvider
}
}
extension AnyValueProviderStorage {
/// Whether or not this type of value provider is supported
/// by the new Core Animation rendering engine
var isSupportedByCoreAnimationRenderingEngine: Bool {
switch self {
case .singleValue, .keyframes:
return true
case .closure:
return false
}
}
}
extension AnimationKeypath {
/// Whether or not this keypath from the animation hierarchy
/// matches the given keypath (which may contain wildcards)
func matches(_ keypath: AnimationKeypath) -> Bool {
var regex = "^" // match the start of the string
+ keypath.keys.joined(separator: "\\.") // match this keypath, escaping "." characters
+ "$" // match the end of the string
// ** wildcards match anything
// - "**.Color" matches both "Layer 1.Color" and "Layer 1.Layer 2.Color"
regex = regex.replacingOccurrences(of: "**", with: ".+")
// * wildcards match any individual path component
// - "*.Color" matches "Layer 1.Color" but not "Layer 1.Layer 2.Color"
regex = regex.replacingOccurrences(of: "*", with: "[^.]+")
return fullPath.range(of: regex, options: .regularExpression) != nil
}
}
@@ -0,0 +1,161 @@
//
// LayerContainer.swift
// lottie-swift
//
// Created by Brandon Withrow on 1/22/19.
//
import Foundation
import QuartzCore
// MARK: - CompositionLayer
/// The base class for a child layer of CompositionContainer
class CompositionLayer: CALayer, KeypathSearchable {
// MARK: Lifecycle
init(layer: LayerModel, size: CGSize) {
transformNode = LayerTransformNode(transform: layer.transform)
if let masks = layer.masks {
maskLayer = MaskContainerLayer(masks: masks)
} else {
maskLayer = nil
}
matteType = layer.matte
inFrame = layer.inFrame.cgFloat
outFrame = layer.outFrame.cgFloat
timeStretch = layer.timeStretch.cgFloat
startFrame = layer.startTime.cgFloat
keypathName = layer.name
childKeypaths = [transformNode.transformProperties]
super.init()
anchorPoint = .zero
actions = [
"opacity" : NSNull(),
"transform" : NSNull(),
"bounds" : NSNull(),
"anchorPoint" : NSNull(),
"sublayerTransform" : NSNull(),
]
contentsLayer.anchorPoint = .zero
contentsLayer.bounds = CGRect(origin: .zero, size: size)
contentsLayer.actions = [
"opacity" : NSNull(),
"transform" : NSNull(),
"bounds" : NSNull(),
"anchorPoint" : NSNull(),
"sublayerTransform" : NSNull(),
"hidden" : NSNull(),
]
compositingFilter = layer.blendMode.filterName
addSublayer(contentsLayer)
if let maskLayer = maskLayer {
contentsLayer.mask = maskLayer
}
name = layer.name
}
override init(layer: Any) {
/// Used for creating shadow model layers. Read More here: https://developer.apple.com/documentation/quartzcore/calayer/1410842-init
guard let layer = layer as? CompositionLayer else {
fatalError("Wrong Layer Class")
}
transformNode = layer.transformNode
matteType = layer.matteType
inFrame = layer.inFrame
outFrame = layer.outFrame
timeStretch = layer.timeStretch
startFrame = layer.startFrame
keypathName = layer.keypathName
childKeypaths = [transformNode.transformProperties]
maskLayer = nil
super.init(layer: layer)
}
required init?(coder _: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// MARK: Internal
weak var layerDelegate: CompositionLayerDelegate?
let transformNode: LayerTransformNode
let contentsLayer = CALayer()
let maskLayer: MaskContainerLayer?
let matteType: MatteType?
let inFrame: CGFloat
let outFrame: CGFloat
let startFrame: CGFloat
let timeStretch: CGFloat
// MARK: Keypath Searchable
let keypathName: String
final var childKeypaths: [KeypathSearchable]
var renderScale: CGFloat = 1 {
didSet {
updateRenderScale()
}
}
var matteLayer: CompositionLayer? {
didSet {
if let matte = matteLayer {
if let type = matteType, type == .invert {
mask = InvertedMatteLayer(inputMatte: matte)
} else {
mask = matte
}
} else {
mask = nil
}
}
}
var keypathProperties: [String: AnyNodeProperty] {
[:]
}
var keypathLayer: CALayer? {
contentsLayer
}
final func displayWithFrame(frame: CGFloat, forceUpdates: Bool) {
transformNode.updateTree(frame, forceUpdates: forceUpdates)
let layerVisible = frame.isInRangeOrEqual(inFrame, outFrame)
/// Only update contents if current time is within the layers time bounds.
if layerVisible {
displayContentsWithFrame(frame: frame, forceUpdates: forceUpdates)
maskLayer?.updateWithFrame(frame: frame, forceUpdates: forceUpdates)
}
contentsLayer.transform = transformNode.globalTransform
contentsLayer.opacity = transformNode.opacity
contentsLayer.isHidden = !layerVisible
layerDelegate?.frameUpdated(frame: frame)
}
func displayContentsWithFrame(frame _: CGFloat, forceUpdates _: Bool) {
/// To be overridden by subclass
}
func updateRenderScale() {
contentsScale = renderScale
}
}
// MARK: - CompositionLayerDelegate
protocol CompositionLayerDelegate: AnyObject {
func frameUpdated(frame: CGFloat)
}
@@ -0,0 +1,50 @@
//
// ImageCompositionLayer.swift
// lottie-swift
//
// Created by Brandon Withrow on 1/25/19.
//
import CoreGraphics
import Foundation
import QuartzCore
final class ImageCompositionLayer: CompositionLayer {
// MARK: Lifecycle
init(imageLayer: ImageLayerModel, size: CGSize) {
imageReferenceID = imageLayer.referenceID
super.init(layer: imageLayer, size: size)
contentsLayer.masksToBounds = true
contentsLayer.contentsGravity = CALayerContentsGravity.resize
}
override init(layer: Any) {
/// Used for creating shadow model layers. Read More here: https://developer.apple.com/documentation/quartzcore/calayer/1410842-init
guard let layer = layer as? ImageCompositionLayer else {
fatalError("init(layer:) Wrong Layer Class")
}
imageReferenceID = layer.imageReferenceID
image = nil
super.init(layer: layer)
}
required init?(coder _: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// MARK: Internal
let imageReferenceID: String
var image: CGImage? = nil {
didSet {
if let image = image {
contentsLayer.contents = image
} else {
contentsLayer.contents = nil
}
}
}
}
@@ -0,0 +1,191 @@
//
// MaskContainerLayer.swift
// lottie-swift
//
// Created by Brandon Withrow on 1/25/19.
//
import Foundation
import QuartzCore
extension MaskMode {
var usableMode: MaskMode {
switch self {
case .add:
return .add
case .subtract:
return .subtract
case .intersect:
return .intersect
case .lighten:
return .add
case .darken:
return .darken
case .difference:
return .intersect
case .none:
return .none
}
}
}
// MARK: - MaskContainerLayer
final class MaskContainerLayer: CALayer {
// MARK: Lifecycle
init(masks: [Mask]) {
super.init()
anchorPoint = .zero
var containerLayer = CALayer()
var firstObject = true
for mask in masks {
let maskLayer = MaskLayer(mask: mask)
maskLayers.append(maskLayer)
if mask.mode.usableMode == .none {
continue
} else if mask.mode.usableMode == .add || firstObject {
firstObject = false
containerLayer.addSublayer(maskLayer)
} else {
containerLayer.mask = maskLayer
let newContainer = CALayer()
newContainer.addSublayer(containerLayer)
containerLayer = newContainer
}
}
addSublayer(containerLayer)
}
override init(layer: Any) {
/// Used for creating shadow model layers. Read More here: https://developer.apple.com/documentation/quartzcore/calayer/1410842-init
guard let layer = layer as? MaskContainerLayer else {
fatalError("init(layer:) Wrong Layer Class")
}
super.init(layer: layer)
}
required init?(coder _: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// MARK: Internal
func updateWithFrame(frame: CGFloat, forceUpdates: Bool) {
maskLayers.forEach({ $0.updateWithFrame(frame: frame, forceUpdates: forceUpdates) })
}
// MARK: Fileprivate
fileprivate var maskLayers: [MaskLayer] = []
}
extension CGRect {
static var veryLargeRect: CGRect {
CGRect(
x: -100_000_000,
y: -100_000_000,
width: 200_000_000,
height: 200_000_000)
}
}
// MARK: - MaskLayer
private class MaskLayer: CALayer {
// MARK: Lifecycle
init(mask: Mask) {
properties = MaskNodeProperties(mask: mask)
super.init()
addSublayer(maskLayer)
anchorPoint = .zero
maskLayer.fillColor = mask.mode == .add
? CGColor(colorSpace: CGColorSpaceCreateDeviceRGB(), components: [1, 0, 0, 1])
: CGColor(colorSpace: CGColorSpaceCreateDeviceRGB(), components: [0, 1, 0, 1])
maskLayer.fillRule = CAShapeLayerFillRule.evenOdd
actions = [
"opacity" : NSNull(),
]
}
override init(layer: Any) {
properties = nil
super.init(layer: layer)
}
required init?(coder _: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// MARK: Internal
let properties: MaskNodeProperties?
let maskLayer = CAShapeLayer()
func updateWithFrame(frame: CGFloat, forceUpdates: Bool) {
guard let properties = properties else { return }
if properties.opacity.needsUpdate(frame: frame) || forceUpdates {
properties.opacity.update(frame: frame)
opacity = Float(properties.opacity.value.cgFloatValue)
}
if properties.shape.needsUpdate(frame: frame) || forceUpdates {
properties.shape.update(frame: frame)
properties.expansion.update(frame: frame)
let shapePath = properties.shape.value.cgPath()
var path = shapePath
if
properties.mode.usableMode == .subtract && !properties.inverted ||
(properties.mode.usableMode == .add && properties.inverted)
{
/// Add a bounds rect to invert the mask
let newPath = CGMutablePath()
newPath.addRect(CGRect.veryLargeRect)
newPath.addPath(shapePath)
path = newPath
}
maskLayer.path = path
}
}
}
// MARK: - MaskNodeProperties
private class MaskNodeProperties: NodePropertyMap {
// MARK: Lifecycle
init(mask: Mask) {
mode = mask.mode
inverted = mask.inverted
opacity = NodeProperty(provider: KeyframeInterpolator(keyframes: mask.opacity.keyframes))
shape = NodeProperty(provider: KeyframeInterpolator(keyframes: mask.shape.keyframes))
expansion = NodeProperty(provider: KeyframeInterpolator(keyframes: mask.expansion.keyframes))
propertyMap = [
"Opacity" : opacity,
"Shape" : shape,
"Expansion" : expansion,
]
properties = Array(propertyMap.values)
}
// MARK: Internal
var propertyMap: [String: AnyNodeProperty]
var properties: [AnyNodeProperty]
let mode: MaskMode
let inverted: Bool
let opacity: NodeProperty<Vector1D>
let shape: NodeProperty<BezierPath>
let expansion: NodeProperty<Vector1D>
}
@@ -0,0 +1,28 @@
//
// NullCompositionLayer.swift
// lottie-swift
//
// Created by Brandon Withrow on 1/25/19.
//
import Foundation
final class NullCompositionLayer: CompositionLayer {
init(layer: LayerModel) {
super.init(layer: layer, size: .zero)
}
required init?(coder _: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override init(layer: Any) {
/// Used for creating shadow model layers. Read More here: https://developer.apple.com/documentation/quartzcore/calayer/1410842-init
guard let layer = layer as? NullCompositionLayer else {
fatalError("init(layer:) Wrong Layer Class")
}
super.init(layer: layer)
}
}
@@ -0,0 +1,121 @@
//
// PreCompositionLayer.swift
// lottie-swift
//
// Created by Brandon Withrow on 1/25/19.
//
import Foundation
import QuartzCore
final class PreCompositionLayer: CompositionLayer {
// MARK: Lifecycle
init(
precomp: PreCompLayerModel,
asset: PrecompAsset,
layerImageProvider: LayerImageProvider,
textProvider: AnimationTextProvider,
fontProvider: AnimationFontProvider,
assetLibrary: AssetLibrary?,
frameRate: CGFloat)
{
animationLayers = []
if let keyframes = precomp.timeRemapping?.keyframes {
remappingNode = NodeProperty(provider: KeyframeInterpolator(keyframes: keyframes))
} else {
remappingNode = nil
}
self.frameRate = frameRate
super.init(layer: precomp, size: CGSize(width: precomp.width, height: precomp.height))
bounds = CGRect(origin: .zero, size: CGSize(width: precomp.width, height: precomp.height))
contentsLayer.masksToBounds = true
contentsLayer.bounds = bounds
let layers = asset.layers.initializeCompositionLayers(
assetLibrary: assetLibrary,
layerImageProvider: layerImageProvider,
textProvider: textProvider,
fontProvider: fontProvider,
frameRate: frameRate)
var imageLayers = [ImageCompositionLayer]()
var mattedLayer: CompositionLayer? = nil
for layer in layers.reversed() {
layer.bounds = bounds
animationLayers.append(layer)
if let imageLayer = layer as? ImageCompositionLayer {
imageLayers.append(imageLayer)
}
if let matte = mattedLayer {
/// The previous layer requires this layer to be its matte
matte.matteLayer = layer
mattedLayer = nil
continue
}
if
let matte = layer.matteType,
matte == .add || matte == .invert
{
/// We have a layer that requires a matte.
mattedLayer = layer
}
contentsLayer.addSublayer(layer)
}
childKeypaths.append(contentsOf: layers)
layerImageProvider.addImageLayers(imageLayers)
}
override init(layer: Any) {
/// Used for creating shadow model layers. Read More here: https://developer.apple.com/documentation/quartzcore/calayer/1410842-init
guard let layer = layer as? PreCompositionLayer else {
fatalError("init(layer:) Wrong Layer Class")
}
frameRate = layer.frameRate
remappingNode = nil
animationLayers = []
super.init(layer: layer)
}
required init?(coder _: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// MARK: Internal
let frameRate: CGFloat
let remappingNode: NodeProperty<Vector1D>?
override var keypathProperties: [String: AnyNodeProperty] {
guard let remappingNode = remappingNode else {
return super.keypathProperties
}
return ["Time Remap" : remappingNode]
}
override func displayContentsWithFrame(frame: CGFloat, forceUpdates: Bool) {
let localFrame: CGFloat
if let remappingNode = remappingNode {
remappingNode.update(frame: frame)
localFrame = remappingNode.value.cgFloatValue * frameRate
} else {
localFrame = (frame - startFrame) / timeStretch
}
animationLayers.forEach( { $0.displayWithFrame(frame: localFrame, forceUpdates: forceUpdates) })
}
override func updateRenderScale() {
super.updateRenderScale()
animationLayers.forEach( { $0.renderScale = renderScale } )
}
// MARK: Fileprivate
fileprivate var animationLayers: [CompositionLayer]
}
@@ -0,0 +1,58 @@
//
// ShapeLayerContainer.swift
// lottie-swift
//
// Created by Brandon Withrow on 1/22/19.
//
import CoreGraphics
import Foundation
/// A CompositionLayer responsible for initializing and rendering shapes
final class ShapeCompositionLayer: CompositionLayer {
// MARK: Lifecycle
init(shapeLayer: ShapeLayerModel) {
let results = shapeLayer.items.initializeNodeTree()
let renderContainer = ShapeContainerLayer()
self.renderContainer = renderContainer
rootNode = results.rootNode
super.init(layer: shapeLayer, size: .zero)
contentsLayer.addSublayer(renderContainer)
for container in results.renderContainers {
renderContainer.insertRenderLayer(container)
}
rootNode?.updateTree(0, forceUpdates: true)
childKeypaths.append(contentsOf: results.childrenNodes)
}
override init(layer: Any) {
guard let layer = layer as? ShapeCompositionLayer else {
fatalError("init(layer:) wrong class.")
}
rootNode = nil
renderContainer = nil
super.init(layer: layer)
}
required init?(coder _: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// MARK: Internal
let rootNode: AnimatorNode?
let renderContainer: ShapeContainerLayer?
override func displayContentsWithFrame(frame: CGFloat, forceUpdates: Bool) {
rootNode?.updateTree(frame, forceUpdates: forceUpdates)
renderContainer?.markRenderUpdates(forFrame: frame)
}
override func updateRenderScale() {
super.updateRenderScale()
renderContainer?.renderScale = renderScale
}
}
@@ -0,0 +1,57 @@
//
// SolidCompositionLayer.swift
// lottie-swift
//
// Created by Brandon Withrow on 1/25/19.
//
import Foundation
import QuartzCore
final class SolidCompositionLayer: CompositionLayer {
// MARK: Lifecycle
init(solid: SolidLayerModel) {
let components = solid.colorHex.hexColorComponents()
colorProperty =
NodeProperty(provider: SingleValueProvider(Color(
r: Double(components.red),
g: Double(components.green),
b: Double(components.blue),
a: 1)))
super.init(layer: solid, size: .zero)
solidShape.path = CGPath(rect: CGRect(x: 0, y: 0, width: solid.width, height: solid.height), transform: nil)
contentsLayer.addSublayer(solidShape)
}
override init(layer: Any) {
/// Used for creating shadow model layers. Read More here: https://developer.apple.com/documentation/quartzcore/calayer/1410842-init
guard let layer = layer as? SolidCompositionLayer else {
fatalError("init(layer:) Wrong Layer Class")
}
colorProperty = layer.colorProperty
super.init(layer: layer)
}
required init?(coder _: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// MARK: Internal
let colorProperty: NodeProperty<Color>?
let solidShape = CAShapeLayer()
override var keypathProperties: [String: AnyNodeProperty] {
guard let colorProperty = colorProperty else { return super.keypathProperties }
return [PropertyName.color.rawValue : colorProperty]
}
override func displayContentsWithFrame(frame: CGFloat, forceUpdates _: Bool) {
guard let colorProperty = colorProperty else { return }
colorProperty.update(frame: frame)
solidShape.fillColor = colorProperty.value.cgColorValue
}
}
@@ -0,0 +1,149 @@
//
// TextCompositionLayer.swift
// lottie-swift
//
// Created by Brandon Withrow on 1/25/19.
//
import CoreGraphics
import CoreText
import Foundation
import QuartzCore
/// Needed for NSMutableParagraphStyle...
#if os(OSX)
import AppKit
#else
import UIKit
#endif
extension TextJustification {
var textAlignment: NSTextAlignment {
switch self {
case .left:
return .left
case .right:
return .right
case .center:
return .center
}
}
var caTextAlignement: CATextLayerAlignmentMode {
switch self {
case .left:
return .left
case .right:
return .right
case .center:
return .center
}
}
}
// MARK: - TextCompositionLayer
final class TextCompositionLayer: CompositionLayer {
// MARK: Lifecycle
init(textLayer: TextLayerModel, textProvider: AnimationTextProvider, fontProvider: AnimationFontProvider) {
var rootNode: TextAnimatorNode?
for animator in textLayer.animators {
rootNode = TextAnimatorNode(parentNode: rootNode, textAnimator: animator)
}
self.rootNode = rootNode
textDocument = KeyframeInterpolator(keyframes: textLayer.text.keyframes)
self.textProvider = textProvider
self.fontProvider = fontProvider
super.init(layer: textLayer, size: .zero)
contentsLayer.addSublayer(self.textLayer)
self.textLayer.masksToBounds = false
self.textLayer.isGeometryFlipped = true
if let rootNode = rootNode {
childKeypaths.append(rootNode)
}
}
required init?(coder _: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override init(layer: Any) {
/// Used for creating shadow model layers. Read More here: https://developer.apple.com/documentation/quartzcore/calayer/1410842-init
guard let layer = layer as? TextCompositionLayer else {
fatalError("init(layer:) Wrong Layer Class")
}
rootNode = nil
textDocument = nil
textProvider = DefaultTextProvider()
fontProvider = DefaultFontProvider()
super.init(layer: layer)
}
// MARK: Internal
let rootNode: TextAnimatorNode?
let textDocument: KeyframeInterpolator<TextDocument>?
let textLayer = CoreTextRenderLayer()
var textProvider: AnimationTextProvider
var fontProvider: AnimationFontProvider
override func displayContentsWithFrame(frame: CGFloat, forceUpdates: Bool) {
guard let textDocument = textDocument else { return }
textLayer.contentsScale = renderScale
let documentUpdate = textDocument.hasUpdate(frame: frame)
let animatorUpdate = rootNode?.updateContents(frame, forceLocalUpdate: forceUpdates) ?? false
guard documentUpdate == true || animatorUpdate == true else { return }
rootNode?.rebuildOutputs(frame: frame)
// Get Text Attributes
let text = textDocument.value(frame: frame) as! TextDocument
let strokeColor = rootNode?.textOutputNode.strokeColor ?? text.strokeColorData?.cgColorValue
let strokeWidth = rootNode?.textOutputNode.strokeWidth ?? CGFloat(text.strokeWidth ?? 0)
let tracking = (CGFloat(text.fontSize) * (rootNode?.textOutputNode.tracking ?? CGFloat(text.tracking))) / 1000.0
let matrix = rootNode?.textOutputNode.xform ?? CATransform3DIdentity
let textString = textProvider.textFor(keypathName: keypathName, sourceText: text.text)
let ctFont = fontProvider.fontFor(family: text.fontFamily, size: CGFloat(text.fontSize))
// Set all of the text layer options
textLayer.text = textString
textLayer.font = ctFont
textLayer.alignment = text.justification.textAlignment
textLayer.lineHeight = CGFloat(text.lineHeight)
textLayer.tracking = tracking
if let fillColor = rootNode?.textOutputNode.fillColor {
textLayer.fillColor = fillColor
} else if let fillColor = text.fillColorData?.cgColorValue {
textLayer.fillColor = fillColor
} else {
textLayer.fillColor = nil
}
textLayer.preferredSize = text.textFrameSize?.sizeValue
textLayer.strokeOnTop = text.strokeOverFill ?? false
textLayer.strokeWidth = strokeWidth
textLayer.strokeColor = strokeColor
textLayer.sizeToFit()
textLayer.opacity = Float(rootNode?.textOutputNode.opacity ?? 1)
textLayer.transform = CATransform3DIdentity
textLayer.position = text.textFramePosition?.pointValue ?? CGPoint.zero
textLayer.transform = matrix
}
override func updateRenderScale() {
super.updateRenderScale()
textLayer.contentsScale = renderScale
}
}
@@ -0,0 +1,287 @@
//
// MainThreadAnimationLayer.swift
// lottie-swift
//
// Created by Brandon Withrow on 1/24/19.
//
import Foundation
import QuartzCore
import UIKit
// MARK: - MainThreadAnimationLayer
/// The base `CALayer` for the Main Thread rendering engine
///
/// This layer holds a single composition container and allows for animation of
/// the currentFrame property.
final class MainThreadAnimationLayer: CALayer, RootAnimationLayer {
// MARK: Lifecycle
init(
animation: Animation,
imageProvider: AnimationImageProvider,
textProvider: AnimationTextProvider,
fontProvider: AnimationFontProvider)
{
layerImageProvider = LayerImageProvider(imageProvider: imageProvider, assets: animation.assetLibrary?.imageAssets)
layerTextProvider = LayerTextProvider(textProvider: textProvider)
layerFontProvider = LayerFontProvider(fontProvider: fontProvider)
animationLayers = []
super.init()
bounds = animation.bounds
let layers = animation.layers.initializeCompositionLayers(
assetLibrary: animation.assetLibrary,
layerImageProvider: layerImageProvider,
textProvider: textProvider,
fontProvider: fontProvider,
frameRate: CGFloat(animation.framerate))
var imageLayers = [ImageCompositionLayer]()
var textLayers = [TextCompositionLayer]()
var mattedLayer: CompositionLayer? = nil
for layer in layers.reversed() {
layer.bounds = bounds
animationLayers.append(layer)
if let imageLayer = layer as? ImageCompositionLayer {
imageLayers.append(imageLayer)
}
if let textLayer = layer as? TextCompositionLayer {
textLayers.append(textLayer)
}
if let matte = mattedLayer {
/// The previous layer requires this layer to be its matte
matte.matteLayer = layer
mattedLayer = nil
continue
}
if
let matte = layer.matteType,
matte == .add || matte == .invert
{
/// We have a layer that requires a matte.
mattedLayer = layer
}
addSublayer(layer)
}
layerImageProvider.addImageLayers(imageLayers)
layerImageProvider.reloadImages()
layerTextProvider.addTextLayers(textLayers)
layerTextProvider.reloadTexts()
layerFontProvider.addTextLayers(textLayers)
layerFontProvider.reloadTexts()
setNeedsDisplay()
}
/// For CAAnimation Use
public override init(layer: Any) {
animationLayers = []
layerImageProvider = LayerImageProvider(imageProvider: BlankImageProvider(), assets: nil)
layerTextProvider = LayerTextProvider(textProvider: DefaultTextProvider())
layerFontProvider = LayerFontProvider(fontProvider: DefaultFontProvider())
super.init(layer: layer)
guard let animationLayer = layer as? MainThreadAnimationLayer else { return }
currentFrame = animationLayer.currentFrame
}
required init?(coder _: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// MARK: Public
public var respectAnimationFrameRate = false
// MARK: CALayer Animations
override public class func needsDisplay(forKey key: String) -> Bool {
if key == "currentFrame" {
return true
}
return super.needsDisplay(forKey: key)
}
override public func action(forKey event: String) -> CAAction? {
if event == "currentFrame" {
let animation = CABasicAnimation(keyPath: event)
animation.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.linear)
animation.fromValue = presentation()?.currentFrame
return animation
}
return super.action(forKey: event)
}
public override func display() {
guard Thread.isMainThread else { return }
var newFrame: CGFloat
if
let animationKeys = animationKeys(),
!animationKeys.isEmpty
{
newFrame = presentation()?.currentFrame ?? currentFrame
} else {
// We ignore the presentation's frame if there's no animation in the layer.
newFrame = currentFrame
}
if respectAnimationFrameRate {
newFrame = floor(newFrame)
}
animationLayers.forEach { $0.displayWithFrame(frame: newFrame, forceUpdates: false) }
}
// MARK: Internal
/// The animatable Current Frame Property
@NSManaged var currentFrame: CGFloat
var animationLayers: ContiguousArray<CompositionLayer>
var primaryAnimationKey: AnimationKey {
.managed
}
var isAnimationPlaying: Bool? {
nil // this state is managed by `AnimationView`
}
var _animationLayers: [CALayer] {
Array(animationLayers)
}
var imageProvider: AnimationImageProvider {
get {
layerImageProvider.imageProvider
}
set {
layerImageProvider.imageProvider = newValue
}
}
var renderScale: CGFloat = 1 {
didSet {
animationLayers.forEach({ $0.renderScale = renderScale })
}
}
var textProvider: AnimationTextProvider {
get { layerTextProvider.textProvider }
set { layerTextProvider.textProvider = newValue }
}
var fontProvider: AnimationFontProvider {
get { layerFontProvider.fontProvider }
set { layerFontProvider.fontProvider = newValue }
}
func reloadImages() {
layerImageProvider.reloadImages()
}
func removeAnimations() {
// no-op, since the primary animation is managed by the `AnimationView`.
}
/// Forces the view to update its drawing.
func forceDisplayUpdate() {
animationLayers.forEach( { $0.displayWithFrame(frame: currentFrame, forceUpdates: true) })
}
func logHierarchyKeypaths() {
print("Lottie: Logging Animation Keypaths")
animationLayers.forEach({ $0.logKeypaths(for: nil) })
}
func allKeypaths(predicate: (AnimationKeypath) -> Bool) -> [String] {
var result: [String] = []
for animationLayer in animationLayers {
result.append(contentsOf: animationLayer.allKeypaths(for: nil, predicate: predicate))
}
return result
}
func setValueProvider(_ valueProvider: AnyValueProvider, keypath: AnimationKeypath) {
for layer in animationLayers {
if let foundProperties = layer.nodeProperties(for: keypath) {
for property in foundProperties {
property.setProvider(provider: valueProvider)
}
layer.displayWithFrame(frame: presentation()?.currentFrame ?? currentFrame, forceUpdates: true)
}
}
}
func getValue(for keypath: AnimationKeypath, atFrame: CGFloat?) -> Any? {
for layer in animationLayers {
if
let foundProperties = layer.nodeProperties(for: keypath),
let first = foundProperties.first
{
return first.valueProvider.value(frame: atFrame ?? currentFrame)
}
}
return nil
}
func getOriginalValue(for keypath: AnimationKeypath, atFrame: AnimationFrameTime?) -> Any? {
for layer in animationLayers {
if
let foundProperties = layer.nodeProperties(for: keypath),
let first = foundProperties.first
{
return first.originalValueProvider.value(frame: atFrame ?? currentFrame)
}
}
return nil
}
func layer(for keypath: AnimationKeypath) -> CALayer? {
for layer in animationLayers {
if let foundLayer = layer.layer(for: keypath) {
return foundLayer
}
}
return nil
}
func allLayers(for keypath: AnimationKeypath) -> [CALayer] {
var result: [CALayer] = []
for layer in animationLayers {
result.append(contentsOf: layer.allLayers(for: keypath))
}
return result
}
func animatorNodes(for keypath: AnimationKeypath) -> [AnimatorNode]? {
var results = [AnimatorNode]()
for layer in animationLayers {
if let nodes = layer.animatorNodes(for: keypath) {
results.append(contentsOf: nodes)
}
}
if results.count == 0 {
return nil
}
return results
}
// MARK: Fileprivate
fileprivate let layerImageProvider: LayerImageProvider
fileprivate let layerTextProvider: LayerTextProvider
fileprivate let layerFontProvider: LayerFontProvider
}
// MARK: - BlankImageProvider
private class BlankImageProvider: AnimationImageProvider {
func imageForAsset(asset _: ImageAsset) -> CGImage? {
nil
}
}
@@ -0,0 +1,47 @@
// Created by Jianjun Wu on 2022/5/12.
// Copyright © 2022 Airbnb Inc. All rights reserved.
import CoreGraphics
import Foundation
// MARK: - CachedImageProvider
private final class CachedImageProvider: AnimationImageProvider {
// MARK: Lifecycle
/// Initializes an image provider with an image provider
///
/// - Parameter imageProvider: The provider to load image from asset
///
public init(imageProvider: AnimationImageProvider) {
self.imageProvider = imageProvider
}
// MARK: Public
public func imageForAsset(asset: ImageAsset) -> CGImage? {
if let image = imageCache.object(forKey: asset.id as NSString) {
return image
}
if let image = imageProvider.imageForAsset(asset: asset) {
imageCache.setObject(image, forKey: asset.id as NSString)
return image
}
return nil
}
// MARK: Internal
let imageCache: NSCache<NSString, CGImage> = .init()
let imageProvider: AnimationImageProvider
}
extension AnimationImageProvider {
/// Create a cache enabled image provider which will reuse the asset image with the same asset id
/// It wraps the current provider as image loader, and uses `NSCache` to cache the images for resue.
/// The cache will be reset when the `animation` is reset.
var cachedImageProvider: AnimationImageProvider {
CachedImageProvider(imageProvider: self)
}
}
@@ -0,0 +1,90 @@
//
// CompositionLayersInitializer.swift
// lottie-swift
//
// Created by Brandon Withrow on 1/25/19.
//
import CoreGraphics
import Foundation
extension Array where Element == LayerModel {
func initializeCompositionLayers(
assetLibrary: AssetLibrary?,
layerImageProvider: LayerImageProvider,
textProvider: AnimationTextProvider,
fontProvider: AnimationFontProvider,
frameRate: CGFloat) -> [CompositionLayer]
{
var compositionLayers = [CompositionLayer]()
var layerMap = [Int : CompositionLayer]()
/// Organize the assets into a dictionary of [ID : ImageAsset]
var childLayers = [LayerModel]()
for layer in self {
if layer.hidden == true {
let genericLayer = NullCompositionLayer(layer: layer)
compositionLayers.append(genericLayer)
layerMap[layer.index] = genericLayer
} else if let shapeLayer = layer as? ShapeLayerModel {
let shapeContainer = ShapeCompositionLayer(shapeLayer: shapeLayer)
compositionLayers.append(shapeContainer)
layerMap[layer.index] = shapeContainer
} else if let solidLayer = layer as? SolidLayerModel {
let solidContainer = SolidCompositionLayer(solid: solidLayer)
compositionLayers.append(solidContainer)
layerMap[layer.index] = solidContainer
} else if
let precompLayer = layer as? PreCompLayerModel,
let assetLibrary = assetLibrary,
let precompAsset = assetLibrary.precompAssets[precompLayer.referenceID]
{
let precompContainer = PreCompositionLayer(
precomp: precompLayer,
asset: precompAsset,
layerImageProvider: layerImageProvider,
textProvider: textProvider,
fontProvider: fontProvider,
assetLibrary: assetLibrary,
frameRate: frameRate)
compositionLayers.append(precompContainer)
layerMap[layer.index] = precompContainer
} else if
let imageLayer = layer as? ImageLayerModel,
let assetLibrary = assetLibrary,
let imageAsset = assetLibrary.imageAssets[imageLayer.referenceID]
{
let imageContainer = ImageCompositionLayer(
imageLayer: imageLayer,
size: CGSize(width: imageAsset.width, height: imageAsset.height))
compositionLayers.append(imageContainer)
layerMap[layer.index] = imageContainer
} else if let textLayer = layer as? TextLayerModel {
let textContainer = TextCompositionLayer(textLayer: textLayer, textProvider: textProvider, fontProvider: fontProvider)
compositionLayers.append(textContainer)
layerMap[layer.index] = textContainer
} else {
let genericLayer = NullCompositionLayer(layer: layer)
compositionLayers.append(genericLayer)
layerMap[layer.index] = genericLayer
}
if layer.parent != nil {
childLayers.append(layer)
}
}
/// Now link children with their parents
for layerModel in childLayers {
if let parentID = layerModel.parent {
let childLayer = layerMap[layerModel.index]
let parentLayer = layerMap[parentID]
childLayer?.transformNode.parentNode = parentLayer?.transformNode
}
}
return compositionLayers
}
}
@@ -0,0 +1,320 @@
//
// TextLayer.swift
// Pods
//
// Created by Brandon Withrow on 8/3/20.
//
import CoreGraphics
import CoreText
import Foundation
import QuartzCore
/// Needed for NSMutableParagraphStyle...
#if os(OSX)
import AppKit
#else
import UIKit
#endif
// MARK: - CoreTextRenderLayer
/// A CALayer subclass that renders text content using CoreText
final class CoreTextRenderLayer: CALayer {
// MARK: Public
public var text: String? {
didSet {
needsContentUpdate = true
setNeedsLayout()
setNeedsDisplay()
}
}
public var font: CTFont? {
didSet {
needsContentUpdate = true
setNeedsLayout()
setNeedsDisplay()
}
}
public var alignment: NSTextAlignment = .left {
didSet {
needsContentUpdate = true
setNeedsLayout()
setNeedsDisplay()
}
}
public var lineHeight: CGFloat = 0 {
didSet {
needsContentUpdate = true
setNeedsLayout()
setNeedsDisplay()
}
}
public var tracking: CGFloat = 0 {
didSet {
needsContentUpdate = true
setNeedsLayout()
setNeedsDisplay()
}
}
public var fillColor: CGColor? {
didSet {
needsContentUpdate = true
setNeedsLayout()
setNeedsDisplay()
}
}
public var strokeColor: CGColor? {
didSet {
needsContentUpdate = true
setNeedsLayout()
setNeedsDisplay()
}
}
public var strokeWidth: CGFloat = 0 {
didSet {
needsContentUpdate = true
setNeedsLayout()
setNeedsDisplay()
}
}
public var strokeOnTop = false {
didSet {
setNeedsLayout()
setNeedsDisplay()
}
}
public var preferredSize: CGSize? {
didSet {
needsContentUpdate = true
setNeedsLayout()
setNeedsDisplay()
}
}
public func sizeToFit() {
updateTextContent()
bounds = drawingRect
anchorPoint = drawingAnchor
setNeedsLayout()
setNeedsDisplay()
}
// MARK: Internal
override func action(forKey _: String) -> CAAction? {
nil
}
override func draw(in ctx: CGContext) {
guard let attributedString = attributedString else { return }
updateTextContent()
guard fillFrameSetter != nil || strokeFrameSetter != nil else { return }
ctx.textMatrix = .identity
ctx.setAllowsAntialiasing(true)
ctx.setAllowsFontSubpixelPositioning(true)
ctx.setAllowsFontSubpixelQuantization(true)
ctx.setShouldAntialias(true)
ctx.setShouldSubpixelPositionFonts(true)
ctx.setShouldSubpixelQuantizeFonts(true)
if contentsAreFlipped() {
ctx.translateBy(x: 0, y: drawingRect.height)
ctx.scaleBy(x: 1.0, y: -1.0)
}
let drawingPath = CGPath(rect: drawingRect, transform: nil)
let fillFrame: CTFrame?
if let setter = fillFrameSetter {
fillFrame = CTFramesetterCreateFrame(setter, CFRangeMake(0, attributedString.length), drawingPath, nil)
} else {
fillFrame = nil
}
let strokeFrame: CTFrame?
if let setter = strokeFrameSetter {
strokeFrame = CTFramesetterCreateFrame(setter, CFRangeMake(0, attributedString.length), drawingPath, nil)
} else {
strokeFrame = nil
}
if !strokeOnTop, let strokeFrame = strokeFrame {
CTFrameDraw(strokeFrame, ctx)
}
if let fillFrame = fillFrame {
CTFrameDraw(fillFrame, ctx)
}
if strokeOnTop, let strokeFrame = strokeFrame {
CTFrameDraw(strokeFrame, ctx)
}
}
// MARK: Private
private var drawingRect: CGRect = .zero
private var drawingAnchor: CGPoint = .zero
private var fillFrameSetter: CTFramesetter?
private var attributedString: NSAttributedString?
private var strokeFrameSetter: CTFramesetter?
private var needsContentUpdate = false
// Draws Debug colors for the font alignment.
@available(macOS 10.15, iOS 13, watchOS 6, tvOS 13, *)
private func drawDebug(_ ctx: CGContext) {
if let font = font {
let ascent = CTFontGetAscent(font)
let descent = CTFontGetDescent(font)
let capHeight = CTFontGetCapHeight(font)
let leading = CTFontGetLeading(font)
// Ascent Red
ctx.setFillColor(CGColor(srgbRed: 1, green: 0, blue: 0, alpha: 0.5))
ctx.fill(CGRect(x: 0, y: 0, width: drawingRect.width, height: ascent))
// Descent Blue
ctx.setFillColor(CGColor(srgbRed: 0, green: 0, blue: 1, alpha: 0.5))
ctx.fill(CGRect(x: 0, y: ascent, width: drawingRect.width, height: descent))
// Leading Yellow
ctx.setFillColor(CGColor(srgbRed: 1, green: 1, blue: 0, alpha: 0.5))
ctx.fill(CGRect(x: 0, y: ascent + descent, width: drawingRect.width, height: leading))
// Cap height Green
ctx.setFillColor(CGColor(srgbRed: 0, green: 1, blue: 0, alpha: 0.5))
ctx.fill(CGRect(x: 0, y: ascent - capHeight, width: drawingRect.width, height: capHeight))
if drawingRect.height - ascent + descent + leading > 0 {
// Remainder
ctx.setFillColor(CGColor(srgbRed: 0, green: 1, blue: 1, alpha: 0.5))
ctx
.fill(CGRect(
x: 0,
y: ascent + descent + leading,
width: drawingRect.width,
height: drawingRect.height - ascent + descent + leading))
}
}
}
private func updateTextContent() {
guard needsContentUpdate else { return }
needsContentUpdate = false
guard let font = font, let text = text, text.count > 0, fillColor != nil || strokeColor != nil else {
drawingRect = .zero
drawingAnchor = .zero
attributedString = nil
fillFrameSetter = nil
strokeFrameSetter = nil
return
}
// Get Font properties
let ascent = CTFontGetAscent(font)
let descent = CTFontGetDescent(font)
let capHeight = CTFontGetCapHeight(font)
let leading = CTFontGetLeading(font)
let minLineHeight = -(ascent + descent + leading)
// Calculate line spacing
let lineSpacing = max(CGFloat(minLineHeight) + lineHeight, CGFloat(minLineHeight))
// Build Attributes
let paragraphStyle = NSMutableParagraphStyle()
paragraphStyle.lineSpacing = lineSpacing
paragraphStyle.lineHeightMultiple = 1
paragraphStyle.maximumLineHeight = ascent + descent + leading
paragraphStyle.alignment = alignment
paragraphStyle.lineBreakMode = NSLineBreakMode.byWordWrapping
var attributes: [NSAttributedString.Key: Any] = [
NSAttributedString.Key.ligature: 0,
NSAttributedString.Key.font: font,
NSAttributedString.Key.kern: tracking,
NSAttributedString.Key.paragraphStyle: paragraphStyle,
]
if let fillColor = fillColor {
attributes[NSAttributedString.Key.foregroundColor] = fillColor
}
let attrString = NSAttributedString(string: text, attributes: attributes)
attributedString = attrString
if fillColor != nil {
let setter = CTFramesetterCreateWithAttributedString(attrString as CFAttributedString)
fillFrameSetter = setter
} else {
fillFrameSetter = nil
}
if let strokeColor = strokeColor {
attributes[NSAttributedString.Key.foregroundColor] = nil
attributes[NSAttributedString.Key.strokeWidth] = strokeWidth
attributes[NSAttributedString.Key.strokeColor] = strokeColor
let strokeAttributedString = NSAttributedString(string: text, attributes: attributes)
strokeFrameSetter = CTFramesetterCreateWithAttributedString(strokeAttributedString as CFAttributedString)
} else {
strokeFrameSetter = nil
strokeWidth = 0
}
guard let setter = fillFrameSetter ?? strokeFrameSetter else {
return
}
// Calculate drawing size and anchor offset
let textAnchor: CGPoint
if let preferredSize = preferredSize {
drawingRect = CGRect(origin: .zero, size: preferredSize)
drawingRect.size.height += (ascent - capHeight)
drawingRect.size.height += descent
textAnchor = CGPoint(x: 0, y: ascent - capHeight)
} else {
let size = CTFramesetterSuggestFrameSizeWithConstraints(
setter,
CFRange(location: 0, length: attrString.length),
nil,
CGSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude),
nil)
switch alignment {
case .left:
textAnchor = CGPoint(x: 0, y: ascent)
case .right:
textAnchor = CGPoint(x: size.width, y: ascent)
case .center:
textAnchor = CGPoint(x: size.width * 0.5, y: ascent)
default:
textAnchor = .zero
}
drawingRect = CGRect(
x: 0,
y: 0,
width: ceil(size.width),
height: ceil(size.height))
}
// Now Calculate Anchor
drawingAnchor = CGPoint(
x: textAnchor.x.remap(fromLow: 0, fromHigh: drawingRect.size.width, toLow: 0, toHigh: 1),
y: textAnchor.y.remap(fromLow: 0, fromHigh: drawingRect.size.height, toLow: 0, toHigh: 1))
if fillFrameSetter != nil && strokeFrameSetter != nil {
drawingRect.size.width += strokeWidth
drawingRect.size.height += strokeWidth
}
}
}
@@ -0,0 +1,58 @@
//
// InvertedMatteLayer.swift
// lottie-swift
//
// Created by Brandon Withrow on 1/28/19.
//
import Foundation
import QuartzCore
/// A layer that inverses the alpha output of its input layer.
///
/// WARNING: This is experimental and probably not very performant.
final class InvertedMatteLayer: CALayer, CompositionLayerDelegate {
// MARK: Lifecycle
init(inputMatte: CompositionLayer) {
self.inputMatte = inputMatte
super.init()
inputMatte.layerDelegate = self
anchorPoint = .zero
bounds = inputMatte.bounds
setNeedsDisplay()
}
override init(layer: Any) {
guard let layer = layer as? InvertedMatteLayer else {
fatalError("init(layer:) wrong class.")
}
inputMatte = nil
super.init(layer: layer)
}
required init?(coder _: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// MARK: Internal
let inputMatte: CompositionLayer?
let wrapperLayer = CALayer()
func frameUpdated(frame _: CGFloat) {
displayIfNeeded()
}
override func draw(in ctx: CGContext) {
guard let inputMatte = inputMatte else { return }
guard let fillColor = CGColor(colorSpace: CGColorSpaceCreateDeviceRGB(), components: [0, 0, 0, 1])
else { return }
ctx.setFillColor(fillColor)
ctx.fill(bounds)
ctx.setBlendMode(.destinationOut)
inputMatte.render(in: ctx)
}
}
@@ -0,0 +1,41 @@
//
// LayerFontProvider.swift
// Lottie
//
// Created by Brandon Withrow on 8/5/20.
// Copyright © 2020 YurtvilleProds. All rights reserved.
//
import Foundation
/// Connects a LottieFontProvider to a group of text layers
final class LayerFontProvider {
// MARK: Lifecycle
init(fontProvider: AnimationFontProvider) {
self.fontProvider = fontProvider
textLayers = []
reloadTexts()
}
// MARK: Internal
private(set) var textLayers: [TextCompositionLayer]
var fontProvider: AnimationFontProvider {
didSet {
reloadTexts()
}
}
func addTextLayers(_ layers: [TextCompositionLayer]) {
textLayers += layers
}
func reloadTexts() {
textLayers.forEach {
$0.fontProvider = fontProvider
}
}
}
@@ -0,0 +1,53 @@
//
// LayerImageProvider.swift
// lottie-swift
//
// Created by Brandon Withrow on 1/25/19.
//
import Foundation
/// Connects a LottieImageProvider to a group of image layers
final class LayerImageProvider {
// MARK: Lifecycle
init(imageProvider: AnimationImageProvider, assets: [String: ImageAsset]?) {
self.imageProvider = imageProvider
imageLayers = [ImageCompositionLayer]()
if let assets = assets {
imageAssets = assets
} else {
imageAssets = [:]
}
reloadImages()
}
// MARK: Internal
private(set) var imageLayers: [ImageCompositionLayer]
let imageAssets: [String: ImageAsset]
var imageProvider: AnimationImageProvider {
didSet {
reloadImages()
}
}
func addImageLayers(_ layers: [ImageCompositionLayer]) {
for layer in layers {
if imageAssets[layer.imageReferenceID] != nil {
/// Found a linking asset in our asset library. Add layer
imageLayers.append(layer)
}
}
}
func reloadImages() {
for imageLayer in imageLayers {
if let asset = imageAssets[imageLayer.imageReferenceID] {
imageLayer.image = imageProvider.imageForAsset(asset: asset)
}
}
}
}
@@ -0,0 +1,40 @@
//
// LayerTextProvider.swift
// lottie-ios-iOS
//
// Created by Alexandr Goncharov on 07/06/2019.
//
import Foundation
/// Connects a LottieTextProvider to a group of text layers
final class LayerTextProvider {
// MARK: Lifecycle
init(textProvider: AnimationTextProvider) {
self.textProvider = textProvider
textLayers = []
reloadTexts()
}
// MARK: Internal
private(set) var textLayers: [TextCompositionLayer]
var textProvider: AnimationTextProvider {
didSet {
reloadTexts()
}
}
func addTextLayers(_ layers: [TextCompositionLayer]) {
textLayers += layers
}
func reloadTexts() {
textLayers.forEach {
$0.textProvider = textProvider
}
}
}
@@ -0,0 +1,144 @@
//
// LayerTransformPropertyMap.swift
// lottie-swift
//
// Created by Brandon Withrow on 2/4/19.
//
import CoreGraphics
import Foundation
import QuartzCore
// MARK: - LayerTransformProperties
final class LayerTransformProperties: NodePropertyMap, KeypathSearchable {
// MARK: Lifecycle
init(transform: Transform) {
anchor = NodeProperty(provider: KeyframeInterpolator(keyframes: transform.anchorPoint.keyframes))
scale = NodeProperty(provider: KeyframeInterpolator(keyframes: transform.scale.keyframes))
rotation = NodeProperty(provider: KeyframeInterpolator(keyframes: transform.rotation.keyframes))
opacity = NodeProperty(provider: KeyframeInterpolator(keyframes: transform.opacity.keyframes))
var propertyMap: [String: AnyNodeProperty] = [
"Anchor Point" : anchor,
"Scale" : scale,
"Rotation" : rotation,
"Opacity" : opacity,
]
if
let positionKeyframesX = transform.positionX?.keyframes,
let positionKeyframesY = transform.positionY?.keyframes
{
let xPosition: NodeProperty<Vector1D> = NodeProperty(provider: KeyframeInterpolator(keyframes: positionKeyframesX))
let yPosition: NodeProperty<Vector1D> = NodeProperty(provider: KeyframeInterpolator(keyframes: positionKeyframesY))
propertyMap["X Position"] = xPosition
propertyMap["Y Position"] = yPosition
positionX = xPosition
positionY = yPosition
position = nil
} else if let positionKeyframes = transform.position?.keyframes {
let position: NodeProperty<Vector3D> = NodeProperty(provider: KeyframeInterpolator(keyframes: positionKeyframes))
propertyMap["Position"] = position
self.position = position
positionX = nil
positionY = nil
} else {
position = nil
positionY = nil
positionX = nil
}
keypathProperties = propertyMap
properties = Array(propertyMap.values)
}
// MARK: Internal
let keypathProperties: [String: AnyNodeProperty]
var keypathName = "Transform"
let properties: [AnyNodeProperty]
let anchor: NodeProperty<Vector3D>
let scale: NodeProperty<Vector3D>
let rotation: NodeProperty<Vector1D>
let position: NodeProperty<Vector3D>?
let positionX: NodeProperty<Vector1D>?
let positionY: NodeProperty<Vector1D>?
let opacity: NodeProperty<Vector1D>
var childKeypaths: [KeypathSearchable] {
[]
}
}
// MARK: - LayerTransformNode
class LayerTransformNode: AnimatorNode {
// MARK: Lifecycle
init(transform: Transform) {
transformProperties = LayerTransformProperties(transform: transform)
}
// MARK: Internal
let outputNode: NodeOutput = PassThroughOutputNode(parent: nil)
let transformProperties: LayerTransformProperties
var parentNode: AnimatorNode?
var hasLocalUpdates = false
var hasUpstreamUpdates = false
var lastUpdateFrame: CGFloat? = nil
var isEnabled = true
var opacity: Float = 1
var localTransform: CATransform3D = CATransform3DIdentity
var globalTransform: CATransform3D = CATransform3DIdentity
// MARK: Animator Node Protocol
var propertyMap: NodePropertyMap & KeypathSearchable {
transformProperties
}
func shouldRebuildOutputs(frame _: CGFloat) -> Bool {
hasLocalUpdates || hasUpstreamUpdates
}
func rebuildOutputs(frame _: CGFloat) {
opacity = Float(transformProperties.opacity.value.cgFloatValue) * 0.01
let position: CGPoint
if let point = transformProperties.position?.value.pointValue {
position = point
} else if
let xPos = transformProperties.positionX?.value.cgFloatValue,
let yPos = transformProperties.positionY?.value.cgFloatValue
{
position = CGPoint(x: xPos, y: yPos)
} else {
position = .zero
}
localTransform = CATransform3D.makeTransform(
anchor: transformProperties.anchor.value.pointValue,
position: position,
scale: transformProperties.scale.value.sizeValue,
rotation: transformProperties.rotation.value.cgFloatValue,
skew: nil,
skewAxis: nil)
if let parentNode = parentNode as? LayerTransformNode {
globalTransform = CATransform3DConcat(localTransform, parentNode.globalTransform)
} else {
globalTransform = localTransform
}
}
}
@@ -0,0 +1,97 @@
//
// ItemsExtension.swift
// lottie-swift
//
// Created by Brandon Withrow on 1/18/19.
//
import Foundation
// MARK: - NodeTree
final class NodeTree {
var rootNode: AnimatorNode? = nil
var transform: ShapeTransform? = nil
var renderContainers: [ShapeContainerLayer] = []
var paths: [PathOutputNode] = []
var childrenNodes: [AnimatorNode] = []
}
extension Array where Element == ShapeItem {
func initializeNodeTree() -> NodeTree {
let nodeTree = NodeTree()
for item in self {
guard item.hidden == false, item.type != .unknown else { continue }
if let fill = item as? Fill {
let node = FillNode(parentNode: nodeTree.rootNode, fill: fill)
nodeTree.rootNode = node
nodeTree.childrenNodes.append(node)
} else if let stroke = item as? Stroke {
let node = StrokeNode(parentNode: nodeTree.rootNode, stroke: stroke)
nodeTree.rootNode = node
nodeTree.childrenNodes.append(node)
} else if let gradientFill = item as? GradientFill {
let node = GradientFillNode(parentNode: nodeTree.rootNode, gradientFill: gradientFill)
nodeTree.rootNode = node
nodeTree.childrenNodes.append(node)
} else if let gradientStroke = item as? GradientStroke {
let node = GradientStrokeNode(parentNode: nodeTree.rootNode, gradientStroke: gradientStroke)
nodeTree.rootNode = node
nodeTree.childrenNodes.append(node)
} else if let ellipse = item as? Ellipse {
let node = EllipseNode(parentNode: nodeTree.rootNode, ellipse: ellipse)
nodeTree.rootNode = node
nodeTree.childrenNodes.append(node)
} else if let rect = item as? Rectangle {
let node = RectangleNode(parentNode: nodeTree.rootNode, rectangle: rect)
nodeTree.rootNode = node
nodeTree.childrenNodes.append(node)
} else if let star = item as? Star {
switch star.starType {
case .none:
continue
case .polygon:
let node = PolygonNode(parentNode: nodeTree.rootNode, star: star)
nodeTree.rootNode = node
nodeTree.childrenNodes.append(node)
case .star:
let node = StarNode(parentNode: nodeTree.rootNode, star: star)
nodeTree.rootNode = node
nodeTree.childrenNodes.append(node)
}
} else if let shape = item as? Shape {
let node = ShapeNode(parentNode: nodeTree.rootNode, shape: shape)
nodeTree.rootNode = node
nodeTree.childrenNodes.append(node)
} else if let trim = item as? Trim {
let node = TrimPathNode(parentNode: nodeTree.rootNode, trim: trim, upstreamPaths: nodeTree.paths)
nodeTree.rootNode = node
nodeTree.childrenNodes.append(node)
} else if let xform = item as? ShapeTransform {
nodeTree.transform = xform
continue
} else if let group = item as? Group {
let tree = group.items.initializeNodeTree()
let node = GroupNode(name: group.name, parentNode: nodeTree.rootNode, tree: tree)
nodeTree.rootNode = node
nodeTree.childrenNodes.append(node)
/// Now add all child paths to current tree
nodeTree.paths.append(contentsOf: tree.paths)
nodeTree.renderContainers.append(node.container)
}
if let pathNode = nodeTree.rootNode as? PathNode {
//// Add path container to the node tree
nodeTree.paths.append(pathNode.pathOutput)
}
if let renderNode = nodeTree.rootNode as? RenderNode {
nodeTree.renderContainers.append(ShapeRenderLayer(renderer: renderNode.renderer))
}
}
return nodeTree
}
}
@@ -0,0 +1,55 @@
//
// NodeProperty.swift
// lottie-swift
//
// Created by Brandon Withrow on 1/30/19.
//
import CoreGraphics
import Foundation
/// A node property that holds a reference to a T ValueProvider and a T ValueContainer.
class NodeProperty<T>: AnyNodeProperty {
// MARK: Lifecycle
init(provider: AnyValueProvider) {
valueProvider = provider
originalValueProvider = valueProvider
typedContainer = ValueContainer<T>(provider.value(frame: 0) as! T)
typedContainer.setNeedsUpdate()
}
// MARK: Internal
var valueProvider: AnyValueProvider
var originalValueProvider: AnyValueProvider
var valueType: Any.Type { T.self }
var value: T {
typedContainer.outputValue
}
var valueContainer: AnyValueContainer {
typedContainer
}
func needsUpdate(frame: CGFloat) -> Bool {
valueContainer.needsUpdate || valueProvider.hasUpdate(frame: frame)
}
func setProvider(provider: AnyValueProvider) {
guard provider.valueType == valueType else { return }
valueProvider = provider
valueContainer.setNeedsUpdate()
}
func update(frame: CGFloat) {
typedContainer.setValue(valueProvider.value(frame: frame), forFrame: frame)
}
// MARK: Fileprivate
fileprivate var typedContainer: ValueContainer<T>
}
@@ -0,0 +1,50 @@
//
// AnyNodeProperty.swift
// lottie-swift
//
// Created by Brandon Withrow on 1/30/19.
//
import CoreGraphics
import Foundation
// MARK: - AnyNodeProperty
/// A property of a node. The node property holds a provider and a container
protocol AnyNodeProperty {
/// Returns true if the property needs to recompute its stored value
func needsUpdate(frame: CGFloat) -> Bool
/// Updates the property for the frame
func update(frame: CGFloat)
/// The stored value container for the property
var valueContainer: AnyValueContainer { get }
/// The value provider for the property
var valueProvider: AnyValueProvider { get }
/// The original value provider for the property
var originalValueProvider: AnyValueProvider { get }
/// The Type of the value provider
var valueType: Any.Type { get }
/// Sets the value provider for the property.
func setProvider(provider: AnyValueProvider)
}
extension AnyNodeProperty {
/// Returns the most recently computed value for the keypath, returns nil if property wasn't found
func getValueOfType<T>() -> T? {
valueContainer.value as? T
}
/// Returns the most recently computed value for the keypath, returns nil if property wasn't found
func getValue() -> Any? {
valueContainer.value
}
}
@@ -0,0 +1,26 @@
//
// AnyValueContainer.swift
// lottie-swift
//
// Created by Brandon Withrow on 1/30/19.
//
import CoreGraphics
import Foundation
/// The container for the value of a property.
protocol AnyValueContainer: AnyObject {
/// The stored value of the container
var value: Any { get }
/// Notifies the provider that it should update its container
func setNeedsUpdate()
/// When true the container needs to have its value updated by its provider
var needsUpdate: Bool { get }
/// The frame time of the last provided update
var lastUpdateFrame: CGFloat { get }
}
@@ -0,0 +1,24 @@
//
// KeypathSettable.swift
// lottie-swift
//
// Created by Brandon Withrow on 2/4/19.
//
import Foundation
import QuartzCore
/// Protocol that provides keypath search functionality. Returns all node properties associated with a keypath.
protocol KeypathSearchable {
/// The name of the Keypath
var keypathName: String { get }
/// A list of properties belonging to the keypath.
var keypathProperties: [String: AnyNodeProperty] { get }
/// Children Keypaths
var childKeypaths: [KeypathSearchable] { get }
var keypathLayer: CALayer? { get }
}
@@ -0,0 +1,44 @@
//
// NodePropertyMap.swift
// lottie-swift
//
// Created by Brandon Withrow on 1/21/19.
//
import Foundation
import QuartzCore
// MARK: - NodePropertyMap
protocol NodePropertyMap {
var properties: [AnyNodeProperty] { get }
}
extension NodePropertyMap {
var childKeypaths: [KeypathSearchable] {
[]
}
var keypathLayer: CALayer? {
nil
}
/// Checks if the node's local contents need to be rebuilt.
func needsLocalUpdate(frame: CGFloat) -> Bool {
for property in properties {
if property.needsUpdate(frame: frame) {
return true
}
}
return false
}
/// Rebuilds only the local nodes that have an update for the frame
func updateNodeProperties(frame: CGFloat) {
properties.forEach { property in
property.update(frame: frame)
}
}
}
@@ -0,0 +1,47 @@
//
// ValueContainer.swift
// lottie-swift
//
// Created by Brandon Withrow on 1/30/19.
//
import CoreGraphics
import Foundation
/// A container for a node value that is Typed to T.
class ValueContainer<T>: AnyValueContainer {
// MARK: Lifecycle
init(_ value: T) {
outputValue = value
}
// MARK: Internal
private(set) var lastUpdateFrame = CGFloat.infinity
fileprivate(set) var needsUpdate = true
var value: Any {
outputValue as Any
}
var outputValue: T {
didSet {
needsUpdate = false
}
}
func setValue(_ value: Any, forFrame: CGFloat) {
if let typedValue = value as? T {
needsUpdate = false
lastUpdateFrame = forFrame
outputValue = typedValue
}
}
func setNeedsUpdate() {
needsUpdate = true
}
}
@@ -0,0 +1,39 @@
//
// KeyframeGroupInterpolator.swift
// lottie-swift
//
// Created by Brandon Withrow on 1/22/19.
//
import CoreGraphics
import Foundation
/// A value provider that produces an array of values from an array of Keyframe Interpolators
final class GroupInterpolator<ValueType>: ValueProvider where ValueType: Interpolatable {
// MARK: Lifecycle
/// Initialize with an array of array of keyframes.
init(keyframeGroups: ContiguousArray<ContiguousArray<Keyframe<ValueType>>>) {
keyframeInterpolators = ContiguousArray(keyframeGroups.map({ KeyframeInterpolator(keyframes: $0) }))
}
// MARK: Internal
let keyframeInterpolators: ContiguousArray<KeyframeInterpolator<ValueType>>
var valueType: Any.Type {
[ValueType].self
}
var storage: ValueProviderStorage<[ValueType]> {
.closure { frame in
self.keyframeInterpolators.map({ $0.value(frame: frame) as! ValueType })
}
}
func hasUpdate(frame: CGFloat) -> Bool {
let updated = keyframeInterpolators.first(where: { $0.hasUpdate(frame: frame) })
return updated != nil
}
}
@@ -0,0 +1,253 @@
//
// KeyframeInterpolator.swift
// lottie-swift
//
// Created by Brandon Withrow on 1/15/19.
//
import CoreGraphics
import Foundation
// MARK: - KeyframeInterpolator
/// A value provider that produces a value at Time from a group of keyframes
final class KeyframeInterpolator<ValueType>: ValueProvider where ValueType: AnyInterpolatable {
// MARK: Lifecycle
init(keyframes: ContiguousArray<Keyframe<ValueType>>) {
self.keyframes = keyframes
}
// MARK: Internal
let keyframes: ContiguousArray<Keyframe<ValueType>>
var valueType: Any.Type {
ValueType.self
}
var storage: ValueProviderStorage<ValueType> {
.closure { [self] frame in
// First set the keyframe span for the frame.
updateSpanIndices(frame: frame)
lastUpdatedFrame = frame
// If only one keyframe return its value
let progress: CGFloat
let value: ValueType
if
let leading = leadingKeyframe,
let trailing = trailingKeyframe
{
/// We have leading and trailing keyframe.
progress = leading.interpolatedProgress(trailing, keyTime: frame)
value = leading.interpolate(to: trailing, progress: progress)
} else if let leading = leadingKeyframe {
progress = 0
value = leading.value
} else if let trailing = trailingKeyframe {
progress = 1
value = trailing.value
} else {
/// Satisfy the compiler.
progress = 0
value = keyframes[0].value
}
return value
}
}
/// Returns true to trigger a frame update for this interpolator.
///
/// An interpolator will be asked if it needs to update every frame.
/// If the interpolator needs updating it will be asked to compute its value for
/// the given frame.
///
/// Cases a keyframe should not be updated:
/// - If time is in span and leading keyframe is hold
/// - If time is after the last keyframe.
/// - If time is before the first keyframe
///
/// Cases for updating a keyframe:
/// - If time is in the span, and is not a hold
/// - If time is outside of the span, and there are more keyframes
/// - If a value delegate is set
/// - If leading and trailing are both nil.
func hasUpdate(frame: CGFloat) -> Bool {
if lastUpdatedFrame == nil {
return true
}
if
let leading = leadingKeyframe,
trailingKeyframe == nil,
leading.time < frame
{
/// Frame is after bounds of keyframes
return false
}
if
let trailing = trailingKeyframe,
leadingKeyframe == nil,
frame < trailing.time
{
/// Frame is before bounds of keyframes
return false
}
if
let leading = leadingKeyframe,
let trailing = trailingKeyframe,
leading.isHold,
leading.time < frame,
frame < trailing.time
{
return false
}
return true
}
// MARK: Fileprivate
fileprivate var lastUpdatedFrame: CGFloat?
fileprivate var leadingIndex: Int? = nil
fileprivate var trailingIndex: Int? = nil
fileprivate var leadingKeyframe: Keyframe<ValueType>? = nil
fileprivate var trailingKeyframe: Keyframe<ValueType>? = nil
/// Finds the appropriate Leading and Trailing keyframe index for the given time.
fileprivate func updateSpanIndices(frame: CGFloat) {
guard keyframes.count > 0 else {
leadingIndex = nil
trailingIndex = nil
leadingKeyframe = nil
trailingKeyframe = nil
return
}
// This function searches through the array to find the span of two keyframes
// that contain the current time.
//
// We could use Array.first(where:) but that would search through the entire array
// each frame.
// Instead we track the last used index and search either forwards or
// backwards from there. This reduces the iterations and complexity from
//
// O(n), where n is the length of the sequence to
// O(n), where n is the number of items after or before the last used index.
//
if keyframes.count == 1 {
/// Only one keyframe. Set it as first and move on.
leadingIndex = 0
trailingIndex = nil
leadingKeyframe = keyframes[0]
trailingKeyframe = nil
return
}
/// Sets the initial keyframes. This is often only needed for the first check.
if
leadingIndex == nil &&
trailingIndex == nil
{
if frame < keyframes[0].time {
/// Time is before the first keyframe. Set it as the trailing.
trailingIndex = 0
} else {
/// Time is after the first keyframe. Set the keyframe and the trailing.
leadingIndex = 0
trailingIndex = 1
}
}
if
let currentTrailing = trailingIndex,
keyframes[currentTrailing].time <= frame
{
/// Time is after the current span. Iterate forward.
var newLeading = currentTrailing
var keyframeFound = false
while !keyframeFound {
leadingIndex = newLeading
trailingIndex = keyframes.validIndex(newLeading + 1)
guard let trailing = trailingIndex else {
/// We have reached the end of our keyframes. Time is after the last keyframe.
keyframeFound = true
continue
}
if frame < keyframes[trailing].time {
/// Keyframe in current span.
keyframeFound = true
continue
}
/// Advance the array.
newLeading = trailing
}
} else if
let currentLeading = leadingIndex,
frame < keyframes[currentLeading].time
{
/// Time is before the current span. Iterate backwards
var newTrailing = currentLeading
var keyframeFound = false
while !keyframeFound {
leadingIndex = keyframes.validIndex(newTrailing - 1)
trailingIndex = newTrailing
guard let leading = leadingIndex else {
/// We have reached the end of our keyframes. Time is after the last keyframe.
keyframeFound = true
continue
}
if keyframes[leading].time <= frame {
/// Keyframe in current span.
keyframeFound = true
continue
}
/// Step back
newTrailing = leading
}
}
if let keyFrame = leadingIndex {
leadingKeyframe = keyframes[keyFrame]
} else {
leadingKeyframe = nil
}
if let keyFrame = trailingIndex {
trailingKeyframe = keyframes[keyFrame]
} else {
trailingKeyframe = nil
}
}
}
extension Array {
fileprivate func validIndex(_ index: Int) -> Int? {
if 0 <= index, index < endIndex {
return index
}
return nil
}
}
extension ContiguousArray {
fileprivate func validIndex(_ index: Int) -> Int? {
if 0 <= index, index < endIndex {
return index
}
return nil
}
}
@@ -0,0 +1,43 @@
//
// SingleValueProvider.swift
// lottie-swift
//
// Created by Brandon Withrow on 1/30/19.
//
import Foundation
import QuartzCore
/// Returns a value for every frame.
final class SingleValueProvider<ValueType: AnyInterpolatable>: ValueProvider {
// MARK: Lifecycle
init(_ value: ValueType) {
self.value = value
}
// MARK: Internal
var value: ValueType {
didSet {
hasUpdate = true
}
}
var storage: ValueProviderStorage<ValueType> {
.singleValue(value)
}
var valueType: Any.Type {
ValueType.self
}
func hasUpdate(frame _: CGFloat) -> Bool {
hasUpdate
}
// MARK: Private
private var hasUpdate = true
}
@@ -0,0 +1,281 @@
//
// TrimPathNode.swift
// lottie-swift
//
// Created by Brandon Withrow on 1/23/19.
//
import Foundation
import QuartzCore
// MARK: - TrimPathProperties
final class TrimPathProperties: NodePropertyMap, KeypathSearchable {
// MARK: Lifecycle
init(trim: Trim) {
keypathName = trim.name
start = NodeProperty(provider: KeyframeInterpolator(keyframes: trim.start.keyframes))
end = NodeProperty(provider: KeyframeInterpolator(keyframes: trim.end.keyframes))
offset = NodeProperty(provider: KeyframeInterpolator(keyframes: trim.offset.keyframes))
type = trim.trimType
keypathProperties = [
"Start" : start,
"End" : end,
"Offset" : offset,
]
properties = Array(keypathProperties.values)
}
// MARK: Internal
let keypathProperties: [String: AnyNodeProperty]
let properties: [AnyNodeProperty]
let keypathName: String
let start: NodeProperty<Vector1D>
let end: NodeProperty<Vector1D>
let offset: NodeProperty<Vector1D>
let type: TrimType
}
// MARK: - TrimPathNode
final class TrimPathNode: AnimatorNode {
// MARK: Lifecycle
init(parentNode: AnimatorNode?, trim: Trim, upstreamPaths: [PathOutputNode]) {
outputNode = PassThroughOutputNode(parent: parentNode?.outputNode)
self.parentNode = parentNode
properties = TrimPathProperties(trim: trim)
self.upstreamPaths = upstreamPaths
}
// MARK: Internal
let properties: TrimPathProperties
let parentNode: AnimatorNode?
let outputNode: NodeOutput
var hasLocalUpdates = false
var hasUpstreamUpdates = false
var lastUpdateFrame: CGFloat? = nil
var isEnabled = true
// MARK: Animator Node
var propertyMap: NodePropertyMap & KeypathSearchable {
properties
}
func forceUpstreamOutputUpdates() -> Bool {
hasLocalUpdates || hasUpstreamUpdates
}
func rebuildOutputs(frame: CGFloat) {
/// Make sure there is a trim.
let startValue = properties.start.value.cgFloatValue * 0.01
let endValue = properties.end.value.cgFloatValue * 0.01
let start = min(startValue, endValue)
let end = max(startValue, endValue)
let offset = properties.offset.value.cgFloatValue.truncatingRemainder(dividingBy: 360) / 360
/// No need to trim, it's a full path
if start == 0, end == 1 {
return
}
/// All paths are empty.
if start == end {
for pathContainer in upstreamPaths {
pathContainer.removePaths(updateFrame: frame)
}
return
}
if properties.type == .simultaneously {
/// Just trim each path
for pathContainer in upstreamPaths {
let pathObjects = pathContainer.removePaths(updateFrame: frame)
for path in pathObjects {
// We are treating each compount path as an individual path. Its subpaths are treated as a whole.
pathContainer.appendPath(
path.trim(fromPosition: start, toPosition: end, offset: offset, trimSimultaneously: false),
updateFrame: frame)
}
}
return
}
/// Individual path trimming.
/// Brace yourself for the below code.
/// Normalize lengths with offset.
var startPosition = (start + offset).truncatingRemainder(dividingBy: 1)
var endPosition = (end + offset).truncatingRemainder(dividingBy: 1)
if startPosition < 0 {
startPosition = 1 + startPosition
}
if endPosition < 0 {
endPosition = 1 + endPosition
}
if startPosition == 1 {
startPosition = 0
}
if endPosition == 0 {
endPosition = 1
}
/// First get the total length of all paths.
var totalLength: CGFloat = 0
upstreamPaths.forEach({ totalLength = totalLength + $0.totalLength })
/// Now determine the start and end cut lengths
let startLength = startPosition * totalLength
let endLength = endPosition * totalLength
var pathStart: CGFloat = 0
/// Now loop through all path containers
for pathContainer in upstreamPaths {
let pathEnd = pathStart + pathContainer.totalLength
if
!startLength.isInRange(pathStart, pathEnd) &&
endLength.isInRange(pathStart, pathEnd)
{
// pathStart|=======E----------------------|pathEnd
// Cut path components, removing after end.
let pathCutLength = endLength - pathStart
let subpaths = pathContainer.removePaths(updateFrame: frame)
var subpathStart: CGFloat = 0
for path in subpaths {
let subpathEnd = subpathStart + path.length
if pathCutLength < subpathEnd {
/// This is the subpath that needs to be cut.
let cutLength = pathCutLength - subpathStart
let newPath = path.trim(fromPosition: 0, toPosition: cutLength / path.length, offset: 0, trimSimultaneously: false)
pathContainer.appendPath(newPath, updateFrame: frame)
break
} else {
/// Add to container and move on
pathContainer.appendPath(path, updateFrame: frame)
}
if pathCutLength == subpathEnd {
/// Right on the end. The next subpath is not included. Break.
break
}
subpathStart = subpathEnd
}
} else if
!endLength.isInRange(pathStart, pathEnd) &&
startLength.isInRange(pathStart, pathEnd)
{
// pathStart|-------S======================|pathEnd
//
// Cut path components, removing before beginning.
let pathCutLength = startLength - pathStart
// Clear paths from container
let subpaths = pathContainer.removePaths(updateFrame: frame)
var subpathStart: CGFloat = 0
for path in subpaths {
let subpathEnd = subpathStart + path.length
if subpathStart < pathCutLength, pathCutLength < subpathEnd {
/// This is the subpath that needs to be cut.
let cutLength = pathCutLength - subpathStart
let newPath = path.trim(fromPosition: cutLength / path.length, toPosition: 1, offset: 0, trimSimultaneously: false)
pathContainer.appendPath(newPath, updateFrame: frame)
} else if pathCutLength <= subpathStart {
pathContainer.appendPath(path, updateFrame: frame)
}
subpathStart = subpathEnd
}
} else if
endLength.isInRange(pathStart, pathEnd) &&
startLength.isInRange(pathStart, pathEnd)
{
// pathStart|-------S============E---------|endLength
// pathStart|=====E----------------S=======|endLength
// trim from path beginning to endLength.
// Cut path components, removing before beginnings.
let startCutLength = startLength - pathStart
let endCutLength = endLength - pathStart
// Clear paths from container
let subpaths = pathContainer.removePaths(updateFrame: frame)
var subpathStart: CGFloat = 0
for path in subpaths {
let subpathEnd = subpathStart + path.length
if
!startCutLength.isInRange(subpathStart, subpathEnd) &&
!endCutLength.isInRange(subpathStart, subpathEnd)
{
// The whole path is included. Add
// S|==============================|E
pathContainer.appendPath(path, updateFrame: frame)
} else if
startCutLength.isInRange(subpathStart, subpathEnd) &&
!endCutLength.isInRange(subpathStart, subpathEnd)
{
/// The start of the path needs to be trimmed
// |-------S======================|E
let cutLength = startCutLength - subpathStart
let newPath = path.trim(fromPosition: cutLength / path.length, toPosition: 1, offset: 0, trimSimultaneously: false)
pathContainer.appendPath(newPath, updateFrame: frame)
} else if
!startCutLength.isInRange(subpathStart, subpathEnd) &&
endCutLength.isInRange(subpathStart, subpathEnd)
{
// S|=======E----------------------|
let cutLength = endCutLength - subpathStart
let newPath = path.trim(fromPosition: 0, toPosition: cutLength / path.length, offset: 0, trimSimultaneously: false)
pathContainer.appendPath(newPath, updateFrame: frame)
break
} else if
startCutLength.isInRange(subpathStart, subpathEnd) &&
endCutLength.isInRange(subpathStart, subpathEnd)
{
// |-------S============E---------|
let cutFromLength = startCutLength - subpathStart
let cutToLength = endCutLength - subpathStart
let newPath = path.trim(
fromPosition: cutFromLength / path.length,
toPosition: cutToLength / path.length,
offset: 0,
trimSimultaneously: false)
pathContainer.appendPath(newPath, updateFrame: frame)
break
}
subpathStart = subpathEnd
}
} else if
(endLength <= pathStart && pathEnd <= startLength) ||
(startLength <= pathStart && endLength <= pathStart) ||
(pathEnd <= startLength && pathEnd <= endLength)
{
/// The Path needs to be cleared
pathContainer.removePaths(updateFrame: frame)
}
pathStart = pathEnd
}
}
// MARK: Fileprivate
fileprivate let upstreamPaths: [PathOutputNode]
}
@@ -0,0 +1,76 @@
//
// TransformNodeOutput.swift
// lottie-swift
//
// Created by Brandon Withrow on 1/30/19.
//
import CoreGraphics
import Foundation
import QuartzCore
class GroupOutputNode: NodeOutput {
// MARK: Lifecycle
init(parent: NodeOutput?, rootNode: NodeOutput?) {
self.parent = parent
self.rootNode = rootNode
}
// MARK: Internal
let parent: NodeOutput?
let rootNode: NodeOutput?
var isEnabled = true
private(set) var outputPath: CGPath? = nil
private(set) var transform: CATransform3D = CATransform3DIdentity
func setTransform(_ xform: CATransform3D, forFrame _: CGFloat) {
transform = xform
outputPath = nil
}
func hasOutputUpdates(_ forFrame: CGFloat) -> Bool {
guard isEnabled else {
let upstreamUpdates = parent?.hasOutputUpdates(forFrame) ?? false
outputPath = parent?.outputPath
return upstreamUpdates
}
let upstreamUpdates = parent?.hasOutputUpdates(forFrame) ?? false
if upstreamUpdates {
outputPath = nil
}
let rootUpdates = rootNode?.hasOutputUpdates(forFrame) ?? false
if rootUpdates {
outputPath = nil
}
var localUpdates = false
if outputPath == nil {
localUpdates = true
let newPath = CGMutablePath()
if let parentNode = parent, let parentPath = parentNode.outputPath {
/// First add parent path.
newPath.addPath(parentPath)
}
var xform = CATransform3DGetAffineTransform(transform)
if
let rootNode = rootNode,
let rootPath = rootNode.outputPath,
let xformedPath = rootPath.copy(using: &xform)
{
/// Now add root path. Note root path is transformed.
newPath.addPath(xformedPath)
}
outputPath = newPath
}
return upstreamUpdates || localUpdates
}
}
@@ -0,0 +1,47 @@
//
// PassThroughOutputNode.swift
// lottie-swift
//
// Created by Brandon Withrow on 1/30/19.
//
import CoreGraphics
import Foundation
class PassThroughOutputNode: NodeOutput {
// MARK: Lifecycle
init(parent: NodeOutput?) {
self.parent = parent
}
// MARK: Internal
let parent: NodeOutput?
var hasUpdate = false
var isEnabled = true
var outputPath: CGPath? {
if let parent = parent {
return parent.outputPath
}
return nil
}
func hasOutputUpdates(_ forFrame: CGFloat) -> Bool {
/// Changes to this node do not affect downstream nodes.
let parentUpdate = parent?.hasOutputUpdates(forFrame) ?? false
/// Changes to upstream nodes do, however, affect this nodes state.
hasUpdate = hasUpdate || parentUpdate
return parentUpdate
}
func hasRenderUpdates(_ forFrame: CGFloat) -> Bool {
/// Return true if there are upstream updates or if this node has updates
let upstreamUpdates = parent?.hasOutputUpdates(forFrame) ?? false
hasUpdate = hasUpdate || upstreamUpdates
return hasUpdate
}
}
@@ -0,0 +1,90 @@
//
// PathNodeOutput.swift
// lottie-swift
//
// Created by Brandon Withrow on 1/30/19.
//
import CoreGraphics
import Foundation
/// A node that has an output of a BezierPath
class PathOutputNode: NodeOutput {
// MARK: Lifecycle
init(parent: NodeOutput?) {
self.parent = parent
}
// MARK: Internal
let parent: NodeOutput?
fileprivate(set) var outputPath: CGPath? = nil
var lastUpdateFrame: CGFloat? = nil
var lastPathBuildFrame: CGFloat? = nil
var isEnabled = true
fileprivate(set) var totalLength: CGFloat = 0
fileprivate(set) var pathObjects: [CompoundBezierPath] = []
func hasOutputUpdates(_ forFrame: CGFloat) -> Bool {
guard isEnabled else {
let upstreamUpdates = parent?.hasOutputUpdates(forFrame) ?? false
outputPath = parent?.outputPath
return upstreamUpdates
}
/// Ask if parent was updated
let upstreamUpdates = parent?.hasOutputUpdates(forFrame) ?? false
/// If parent was updated and the path hasn't been built for this frame, clear the path.
if upstreamUpdates && lastPathBuildFrame != forFrame {
outputPath = nil
}
if outputPath == nil {
/// If the path is clear, build the new path.
lastPathBuildFrame = forFrame
let newPath = CGMutablePath()
if let parentNode = parent, let parentPath = parentNode.outputPath {
newPath.addPath(parentPath)
}
for path in pathObjects {
for subPath in path.paths {
newPath.addPath(subPath.cgPath())
}
}
outputPath = newPath
}
/// Return true if there were upstream updates or if this node was updated.
return upstreamUpdates || (lastUpdateFrame == forFrame)
}
@discardableResult
func removePaths(updateFrame: CGFloat?) -> [CompoundBezierPath] {
lastUpdateFrame = updateFrame
let returnPaths = pathObjects
outputPath = nil
totalLength = 0
pathObjects = []
return returnPaths
}
func setPath(_ path: BezierPath, updateFrame: CGFloat) {
lastUpdateFrame = updateFrame
outputPath = nil
totalLength = path.length
pathObjects = [CompoundBezierPath(path: path)]
}
func appendPath(_ path: CompoundBezierPath, updateFrame: CGFloat) {
lastUpdateFrame = updateFrame
outputPath = nil
totalLength = totalLength + path.length
pathObjects.append(path)
}
}
@@ -0,0 +1,71 @@
//
// FillRenderer.swift
// lottie-swift
//
// Created by Brandon Withrow on 1/30/19.
//
import CoreGraphics
import Foundation
import QuartzCore
extension FillRule {
var cgFillRule: CGPathFillRule {
switch self {
case .evenOdd:
return .evenOdd
default:
return .winding
}
}
var caFillRule: CAShapeLayerFillRule {
switch self {
case .evenOdd:
return CAShapeLayerFillRule.evenOdd
default:
return CAShapeLayerFillRule.nonZero
}
}
}
// MARK: - FillRenderer
/// A rendered for a Path Fill
final class FillRenderer: PassThroughOutputNode, Renderable {
var shouldRenderInContext = false
var color: CGColor? {
didSet {
hasUpdate = true
}
}
var opacity: CGFloat = 0 {
didSet {
hasUpdate = true
}
}
var fillRule: FillRule = .none {
didSet {
hasUpdate = true
}
}
func render(_: CGContext) {
// do nothing
}
func setupSublayers(layer _: CAShapeLayer) {
// do nothing
}
func updateShapeLayer(layer: CAShapeLayer) {
layer.fillColor = color
layer.opacity = Float(opacity)
layer.fillRule = fillRule.caFillRule
hasUpdate = false
}
}
@@ -0,0 +1,242 @@
//
// GradientFillRenderer.swift
// lottie-swift
//
// Created by Brandon Withrow on 1/30/19.
//
import Foundation
import QuartzCore
// MARK: - GradientFillLayer
private final class GradientFillLayer: CALayer {
var start: CGPoint = .zero {
didSet {
setNeedsDisplay()
}
}
var numberOfColors = 0 {
didSet {
setNeedsDisplay()
}
}
var colors: [CGFloat] = [] {
didSet {
setNeedsDisplay()
}
}
var end: CGPoint = .zero {
didSet {
setNeedsDisplay()
}
}
var type: GradientType = .none {
didSet {
setNeedsDisplay()
}
}
override func draw(in ctx: CGContext) {
var alphaColors = [CGColor]()
var alphaLocations = [CGFloat]()
var gradientColors = [CGColor]()
var colorLocations = [CGFloat]()
let colorSpace = CGColorSpaceCreateDeviceRGB()
let maskColorSpace = CGColorSpaceCreateDeviceGray()
for i in 0..<numberOfColors {
let ix = i * 4
if
colors.count > ix, let color = CGColor(
colorSpace: colorSpace,
components: [colors[ix + 1], colors[ix + 2], colors[ix + 3], 1])
{
gradientColors.append(color)
colorLocations.append(colors[ix])
}
}
var drawMask = false
for i in stride(from: numberOfColors * 4, to: colors.endIndex, by: 2) {
let alpha = colors[i + 1]
if alpha < 1 {
drawMask = true
}
if let color = CGColor(colorSpace: maskColorSpace, components: [alpha, 1]) {
alphaLocations.append(colors[i])
alphaColors.append(color)
}
}
/// First draw a mask is necessary.
if drawMask {
guard
let maskGradient = CGGradient(
colorsSpace: maskColorSpace,
colors: alphaColors as CFArray,
locations: alphaLocations),
let maskContext = CGContext(
data: nil,
width: ctx.width,
height: ctx.height,
bitsPerComponent: 8,
bytesPerRow: ctx.width,
space: maskColorSpace,
bitmapInfo: 0) else { return }
let flipVertical = CGAffineTransform(a: 1, b: 0, c: 0, d: -1, tx: 0, ty: CGFloat(maskContext.height))
maskContext.concatenate(flipVertical)
maskContext.concatenate(ctx.ctm)
if type == .linear {
maskContext.drawLinearGradient(
maskGradient,
start: start,
end: end,
options: [.drawsAfterEndLocation, .drawsBeforeStartLocation])
} else {
maskContext.drawRadialGradient(
maskGradient,
startCenter: start,
startRadius: 0,
endCenter: start,
endRadius: start.distanceTo(end),
options: [.drawsAfterEndLocation, .drawsBeforeStartLocation])
}
/// Clips the gradient
if let alphaMask = maskContext.makeImage() {
ctx.clip(to: ctx.boundingBoxOfClipPath, mask: alphaMask)
}
}
/// Now draw the gradient
guard let gradient = CGGradient(colorsSpace: colorSpace, colors: gradientColors as CFArray, locations: colorLocations)
else { return }
if type == .linear {
ctx.drawLinearGradient(gradient, start: start, end: end, options: [.drawsAfterEndLocation, .drawsBeforeStartLocation])
} else {
ctx.drawRadialGradient(
gradient,
startCenter: start,
startRadius: 0,
endCenter: start,
endRadius: start.distanceTo(end),
options: [.drawsAfterEndLocation, .drawsBeforeStartLocation])
}
}
}
// MARK: - GradientFillRenderer
/// A rendered for a Path Fill
final class GradientFillRenderer: PassThroughOutputNode, Renderable {
// MARK: Lifecycle
override init(parent: NodeOutput?) {
super.init(parent: parent)
maskLayer.fillColor = CGColor(colorSpace: CGColorSpaceCreateDeviceRGB(), components: [1, 1, 1, 1])
gradientLayer.mask = maskLayer
maskLayer.actions = [
"startPoint" : NSNull(),
"endPoint" : NSNull(),
"opacity" : NSNull(),
"locations" : NSNull(),
"colors" : NSNull(),
"bounds" : NSNull(),
"anchorPoint" : NSNull(),
"isRadial" : NSNull(),
"path" : NSNull(),
]
gradientLayer.actions = maskLayer.actions
}
// MARK: Internal
var shouldRenderInContext = false
var start: CGPoint = .zero {
didSet {
hasUpdate = true
}
}
var numberOfColors = 0 {
didSet {
hasUpdate = true
}
}
var colors: [CGFloat] = [] {
didSet {
hasUpdate = true
}
}
var end: CGPoint = .zero {
didSet {
hasUpdate = true
}
}
var opacity: CGFloat = 0 {
didSet {
hasUpdate = true
}
}
var type: GradientType = .none {
didSet {
hasUpdate = true
}
}
func render(_: CGContext) {
// do nothing
}
func setupSublayers(layer: CAShapeLayer) {
layer.addSublayer(gradientLayer)
layer.fillColor = nil
}
func updateShapeLayer(layer: CAShapeLayer) {
hasUpdate = false
guard let path = layer.path else {
return
}
let frame = path.boundingBox
let anchor = CGPoint(
x: -frame.origin.x / frame.size.width,
y: -frame.origin.y / frame.size.height)
maskLayer.path = path
maskLayer.bounds = path.boundingBox
maskLayer.anchorPoint = anchor
gradientLayer.bounds = maskLayer.bounds
gradientLayer.anchorPoint = anchor
// setup gradient properties
gradientLayer.start = start
gradientLayer.end = end
gradientLayer.numberOfColors = numberOfColors
gradientLayer.colors = colors
gradientLayer.opacity = Float(opacity)
gradientLayer.type = type
}
// MARK: Private
private let gradientLayer = GradientFillLayer()
private let maskLayer = CAShapeLayer()
}
@@ -0,0 +1,66 @@
//
// GradientStrokeRenderer.swift
// lottie-swift
//
// Created by Brandon Withrow on 1/30/19.
//
import Foundation
import QuartzCore
// MARK: - Renderer
final class GradientStrokeRenderer: PassThroughOutputNode, Renderable {
// MARK: Lifecycle
override init(parent: NodeOutput?) {
strokeRender = StrokeRenderer(parent: nil)
gradientRender = LegacyGradientFillRenderer(parent: nil)
strokeRender.color = CGColor(colorSpace: CGColorSpaceCreateDeviceRGB(), components: [1, 1, 1, 1])
super.init(parent: parent)
}
// MARK: Internal
var shouldRenderInContext = true
let strokeRender: StrokeRenderer
let gradientRender: LegacyGradientFillRenderer
override func hasOutputUpdates(_ forFrame: CGFloat) -> Bool {
let updates = super.hasOutputUpdates(forFrame)
return updates || strokeRender.hasUpdate || gradientRender.hasUpdate
}
func updateShapeLayer(layer _: CAShapeLayer) {
/// Not Applicable
}
func setupSublayers(layer _: CAShapeLayer) {
/// Not Applicable
}
func render(_ inContext: CGContext) {
guard inContext.path != nil && inContext.path!.isEmpty == false else {
return
}
strokeRender.hasUpdate = false
hasUpdate = false
gradientRender.hasUpdate = false
strokeRender.setupForStroke(inContext)
inContext.replacePathWithStrokedPath()
/// Now draw the gradient.
gradientRender.render(inContext)
}
func renderBoundsFor(_ boundingBox: CGRect) -> CGRect {
strokeRender.renderBoundsFor(boundingBox)
}
}
@@ -0,0 +1,153 @@
//
// LegacyGradientFillRenderer.swift
// lottie-swift
//
// Created by Brandon Withrow on 1/30/19.
//
import Foundation
import QuartzCore
/// A rendered for a Path Fill
final class LegacyGradientFillRenderer: PassThroughOutputNode, Renderable {
var shouldRenderInContext = true
var start: CGPoint = .zero {
didSet {
hasUpdate = true
}
}
var numberOfColors = 0 {
didSet {
hasUpdate = true
}
}
var colors: [CGFloat] = [] {
didSet {
hasUpdate = true
}
}
var end: CGPoint = .zero {
didSet {
hasUpdate = true
}
}
var opacity: CGFloat = 0 {
didSet {
hasUpdate = true
}
}
var type: GradientType = .none {
didSet {
hasUpdate = true
}
}
func updateShapeLayer(layer _: CAShapeLayer) {
// Not applicable
}
func setupSublayers(layer _: CAShapeLayer) {
// Not applicable
}
func render(_ inContext: CGContext) {
guard inContext.path != nil && inContext.path!.isEmpty == false else {
return
}
hasUpdate = false
var alphaColors = [CGColor]()
var alphaLocations = [CGFloat]()
var gradientColors = [CGColor]()
var colorLocations = [CGFloat]()
let colorSpace = CGColorSpaceCreateDeviceRGB()
let maskColorSpace = CGColorSpaceCreateDeviceGray()
for i in 0..<numberOfColors {
let ix = i * 4
if
colors.count > ix, let color = CGColor(
colorSpace: colorSpace,
components: [colors[ix + 1], colors[ix + 2], colors[ix + 3], 1])
{
gradientColors.append(color)
colorLocations.append(colors[ix])
}
}
var drawMask = false
for i in stride(from: numberOfColors * 4, to: colors.endIndex, by: 2) {
let alpha = colors[i + 1]
if alpha < 1 {
drawMask = true
}
if let color = CGColor(colorSpace: maskColorSpace, components: [alpha, 1]) {
alphaLocations.append(colors[i])
alphaColors.append(color)
}
}
inContext.setAlpha(opacity)
inContext.clip()
/// First draw a mask is necessary.
if drawMask {
guard
let maskGradient = CGGradient(
colorsSpace: maskColorSpace,
colors: alphaColors as CFArray,
locations: alphaLocations),
let maskContext = CGContext(
data: nil,
width: inContext.width,
height: inContext.height,
bitsPerComponent: 8,
bytesPerRow: inContext.width,
space: maskColorSpace,
bitmapInfo: 0) else { return }
let flipVertical = CGAffineTransform(a: 1, b: 0, c: 0, d: -1, tx: 0, ty: CGFloat(maskContext.height))
maskContext.concatenate(flipVertical)
maskContext.concatenate(inContext.ctm)
if type == .linear {
maskContext.drawLinearGradient(
maskGradient,
start: start,
end: end,
options: [.drawsAfterEndLocation, .drawsBeforeStartLocation])
} else {
maskContext.drawRadialGradient(
maskGradient,
startCenter: start,
startRadius: 0,
endCenter: start,
endRadius: start.distanceTo(end),
options: [.drawsAfterEndLocation, .drawsBeforeStartLocation])
}
/// Clips the gradient
if let alphaMask = maskContext.makeImage() {
inContext.clip(to: inContext.boundingBoxOfClipPath, mask: alphaMask)
}
}
/// Now draw the gradient
guard let gradient = CGGradient(colorsSpace: colorSpace, colors: gradientColors as CFArray, locations: colorLocations)
else { return }
if type == .linear {
inContext.drawLinearGradient(gradient, start: start, end: end, options: [.drawsAfterEndLocation, .drawsBeforeStartLocation])
} else {
inContext.drawRadialGradient(
gradient,
startCenter: start,
startRadius: 0,
endCenter: start,
endRadius: start.distanceTo(end),
options: [.drawsAfterEndLocation, .drawsBeforeStartLocation])
}
}
}
@@ -0,0 +1,166 @@
//
// StrokeRenderer.swift
// lottie-swift
//
// Created by Brandon Withrow on 1/30/19.
//
import Foundation
import QuartzCore
extension LineJoin {
var cgLineJoin: CGLineJoin {
switch self {
case .bevel:
return .bevel
case .none:
return .miter
case .miter:
return .miter
case .round:
return .round
}
}
var caLineJoin: CAShapeLayerLineJoin {
switch self {
case .none:
return CAShapeLayerLineJoin.miter
case .miter:
return CAShapeLayerLineJoin.miter
case .round:
return CAShapeLayerLineJoin.round
case .bevel:
return CAShapeLayerLineJoin.bevel
}
}
}
extension LineCap {
var cgLineCap: CGLineCap {
switch self {
case .none:
return .butt
case .butt:
return .butt
case .round:
return .round
case .square:
return .square
}
}
var caLineCap: CAShapeLayerLineCap {
switch self {
case .none:
return CAShapeLayerLineCap.butt
case .butt:
return CAShapeLayerLineCap.butt
case .round:
return CAShapeLayerLineCap.round
case .square:
return CAShapeLayerLineCap.square
}
}
}
// MARK: - StrokeRenderer
/// A rendered that renders a stroke on a path.
final class StrokeRenderer: PassThroughOutputNode, Renderable {
var shouldRenderInContext = false
var color: CGColor? {
didSet {
hasUpdate = true
}
}
var opacity: CGFloat = 0 {
didSet {
hasUpdate = true
}
}
var width: CGFloat = 0 {
didSet {
hasUpdate = true
}
}
var miterLimit: CGFloat = 0 {
didSet {
hasUpdate = true
}
}
var lineCap: LineCap = .none {
didSet {
hasUpdate = true
}
}
var lineJoin: LineJoin = .none {
didSet {
hasUpdate = true
}
}
var dashPhase: CGFloat? {
didSet {
hasUpdate = true
}
}
var dashLengths: [CGFloat]? {
didSet {
hasUpdate = true
}
}
func setupSublayers(layer _: CAShapeLayer) {
// empty
}
func renderBoundsFor(_ boundingBox: CGRect) -> CGRect {
boundingBox.insetBy(dx: -width, dy: -width)
}
func setupForStroke(_ inContext: CGContext) {
inContext.setLineWidth(width)
inContext.setMiterLimit(miterLimit)
inContext.setLineCap(lineCap.cgLineCap)
inContext.setLineJoin(lineJoin.cgLineJoin)
if let dashPhase = dashPhase, let lengths = dashLengths {
inContext.setLineDash(phase: dashPhase, lengths: lengths)
} else {
inContext.setLineDash(phase: 0, lengths: [])
}
}
func render(_ inContext: CGContext) {
guard inContext.path != nil && inContext.path!.isEmpty == false else {
return
}
guard let color = color else { return }
hasUpdate = false
setupForStroke(inContext)
inContext.setAlpha(opacity)
inContext.setStrokeColor(color)
inContext.strokePath()
}
func updateShapeLayer(layer: CAShapeLayer) {
layer.strokeColor = color
layer.opacity = Float(opacity)
layer.lineWidth = width
layer.lineJoin = lineJoin.caLineJoin
layer.lineCap = lineCap.caLineCap
layer.lineDashPhase = dashPhase ?? 0
layer.fillColor = nil
if let dashPattern = dashLengths {
layer.lineDashPattern = dashPattern.map({ NSNumber(value: Double($0)) })
}
}
}
@@ -0,0 +1,139 @@
//
// EllipseNode.swift
// lottie-swift
//
// Created by Brandon Withrow on 1/17/19.
//
import Foundation
import QuartzCore
// MARK: - EllipseNodeProperties
final class EllipseNodeProperties: NodePropertyMap, KeypathSearchable {
// MARK: Lifecycle
init(ellipse: Ellipse) {
keypathName = ellipse.name
direction = ellipse.direction
position = NodeProperty(provider: KeyframeInterpolator(keyframes: ellipse.position.keyframes))
size = NodeProperty(provider: KeyframeInterpolator(keyframes: ellipse.size.keyframes))
keypathProperties = [
"Position" : position,
"Size" : size,
]
properties = Array(keypathProperties.values)
}
// MARK: Internal
var keypathName: String
let direction: PathDirection
let position: NodeProperty<Vector3D>
let size: NodeProperty<Vector3D>
let keypathProperties: [String: AnyNodeProperty]
let properties: [AnyNodeProperty]
}
// MARK: - EllipseNode
final class EllipseNode: AnimatorNode, PathNode {
// MARK: Lifecycle
init(parentNode: AnimatorNode?, ellipse: Ellipse) {
pathOutput = PathOutputNode(parent: parentNode?.outputNode)
properties = EllipseNodeProperties(ellipse: ellipse)
self.parentNode = parentNode
}
// MARK: Internal
static let ControlPointConstant: CGFloat = 0.55228
let pathOutput: PathOutputNode
let properties: EllipseNodeProperties
let parentNode: AnimatorNode?
var hasLocalUpdates = false
var hasUpstreamUpdates = false
var lastUpdateFrame: CGFloat? = nil
// MARK: Animator Node
var propertyMap: NodePropertyMap & KeypathSearchable {
properties
}
var isEnabled = true {
didSet {
pathOutput.isEnabled = isEnabled
}
}
func rebuildOutputs(frame: CGFloat) {
pathOutput.setPath(
.ellipse(
size: properties.size.value.sizeValue,
center: properties.position.value.pointValue,
direction: properties.direction),
updateFrame: frame)
}
}
extension BezierPath {
/// Constructs a `BezierPath` in the shape of an ellipse
static func ellipse(
size: CGSize,
center: CGPoint,
direction: PathDirection)
-> BezierPath
{
// Unfortunately we HAVE to manually build out the ellipse.
// Every Apple method constructs an ellipse from the 3 o-clock position
// After effects constructs from the Noon position.
// After effects does clockwise, but also has a flag for reversed.
var half = size * 0.5
if direction == .counterClockwise {
half.width = half.width * -1
}
let q1 = CGPoint(x: center.x, y: center.y - half.height)
let q2 = CGPoint(x: center.x + half.width, y: center.y)
let q3 = CGPoint(x: center.x, y: center.y + half.height)
let q4 = CGPoint(x: center.x - half.width, y: center.y)
let cp = half * EllipseNode.ControlPointConstant
var path = BezierPath(startPoint: CurveVertex(
point: q1,
inTangentRelative: CGPoint(x: -cp.width, y: 0),
outTangentRelative: CGPoint(x: cp.width, y: 0)))
path.addVertex(CurveVertex(
point: q2,
inTangentRelative: CGPoint(x: 0, y: -cp.height),
outTangentRelative: CGPoint(x: 0, y: cp.height)))
path.addVertex(CurveVertex(
point: q3,
inTangentRelative: CGPoint(x: cp.width, y: 0),
outTangentRelative: CGPoint(x: -cp.width, y: 0)))
path.addVertex(CurveVertex(
point: q4,
inTangentRelative: CGPoint(x: 0, y: cp.height),
outTangentRelative: CGPoint(x: 0, y: -cp.height)))
path.addVertex(CurveVertex(
point: q1,
inTangentRelative: CGPoint(x: -cp.width, y: 0),
outTangentRelative: CGPoint(x: cp.width, y: 0)))
path.close()
return path
}
}
@@ -0,0 +1,170 @@
//
// PolygonNode.swift
// lottie-swift
//
// Created by Brandon Withrow on 1/21/19.
//
import Foundation
import QuartzCore
// MARK: - PolygonNodeProperties
final class PolygonNodeProperties: NodePropertyMap, KeypathSearchable {
// MARK: Lifecycle
init(star: Star) {
keypathName = star.name
direction = star.direction
position = NodeProperty(provider: KeyframeInterpolator(keyframes: star.position.keyframes))
outerRadius = NodeProperty(provider: KeyframeInterpolator(keyframes: star.outerRadius.keyframes))
outerRoundedness = NodeProperty(provider: KeyframeInterpolator(keyframes: star.outerRoundness.keyframes))
rotation = NodeProperty(provider: KeyframeInterpolator(keyframes: star.rotation.keyframes))
points = NodeProperty(provider: KeyframeInterpolator(keyframes: star.points.keyframes))
keypathProperties = [
"Position" : position,
"Outer Radius" : outerRadius,
"Outer Roundedness" : outerRoundedness,
"Rotation" : rotation,
"Points" : points,
]
properties = Array(keypathProperties.values)
}
// MARK: Internal
var keypathName: String
var childKeypaths: [KeypathSearchable] = []
let keypathProperties: [String: AnyNodeProperty]
let properties: [AnyNodeProperty]
let direction: PathDirection
let position: NodeProperty<Vector3D>
let outerRadius: NodeProperty<Vector1D>
let outerRoundedness: NodeProperty<Vector1D>
let rotation: NodeProperty<Vector1D>
let points: NodeProperty<Vector1D>
}
// MARK: - PolygonNode
final class PolygonNode: AnimatorNode, PathNode {
// MARK: Lifecycle
init(parentNode: AnimatorNode?, star: Star) {
pathOutput = PathOutputNode(parent: parentNode?.outputNode)
properties = PolygonNodeProperties(star: star)
self.parentNode = parentNode
}
// MARK: Internal
/// Magic number needed for constructing path.
static let PolygonConstant: CGFloat = 0.25
let properties: PolygonNodeProperties
let pathOutput: PathOutputNode
let parentNode: AnimatorNode?
var hasLocalUpdates = false
var hasUpstreamUpdates = false
var lastUpdateFrame: CGFloat? = nil
// MARK: Animator Node
var propertyMap: NodePropertyMap & KeypathSearchable {
properties
}
var isEnabled = true {
didSet {
pathOutput.isEnabled = isEnabled
}
}
func rebuildOutputs(frame: CGFloat) {
let path = BezierPath.polygon(
position: properties.position.value.pointValue,
numberOfPoints: properties.points.value.cgFloatValue,
outerRadius: properties.outerRadius.value.cgFloatValue,
outerRoundedness: properties.outerRoundedness.value.cgFloatValue,
rotation: properties.rotation.value.cgFloatValue,
direction: properties.direction)
pathOutput.setPath(path, updateFrame: frame)
}
}
extension BezierPath {
/// Creates a `BezierPath` in the shape of a polygon
static func polygon(
position: CGPoint,
numberOfPoints: CGFloat,
outerRadius: CGFloat,
outerRoundedness inputOuterRoundedness: CGFloat,
rotation: CGFloat,
direction: PathDirection)
-> BezierPath
{
var currentAngle = (rotation - 90).toRadians()
let anglePerPoint = ((2 * CGFloat.pi) / numberOfPoints)
let outerRoundedness = inputOuterRoundedness * 0.01
var point = CGPoint(
x: outerRadius * cos(currentAngle),
y: outerRadius * sin(currentAngle))
var vertices = [CurveVertex(point: point + position, inTangentRelative: .zero, outTangentRelative: .zero)]
var previousPoint = point
currentAngle += anglePerPoint;
for _ in 0..<Int(ceil(numberOfPoints)) {
previousPoint = point
point = CGPoint(
x: outerRadius * cos(currentAngle),
y: outerRadius * sin(currentAngle))
if outerRoundedness != 0 {
let cp1Theta = (atan2(previousPoint.y, previousPoint.x) - CGFloat.pi / 2)
let cp1Dx = cos(cp1Theta);
let cp1Dy = sin(cp1Theta);
let cp2Theta = (atan2(point.y, point.x) - CGFloat.pi / 2)
let cp2Dx = cos(cp2Theta)
let cp2Dy = sin(cp2Theta)
let cp1 = CGPoint(
x: outerRadius * outerRoundedness * PolygonNode.PolygonConstant * cp1Dx,
y: outerRadius * outerRoundedness * PolygonNode.PolygonConstant * cp1Dy)
let cp2 = CGPoint(
x: outerRadius * outerRoundedness * PolygonNode.PolygonConstant * cp2Dx,
y: outerRadius * outerRoundedness * PolygonNode.PolygonConstant * cp2Dy)
let previousVertex = vertices[vertices.endIndex - 1]
vertices[vertices.endIndex - 1] = CurveVertex(
previousVertex.inTangent,
previousVertex.point,
previousVertex.point - cp1)
vertices.append(CurveVertex(point: point + position, inTangentRelative: cp2, outTangentRelative: .zero))
} else {
vertices.append(CurveVertex(point: point + position, inTangentRelative: .zero, outTangentRelative: .zero))
}
currentAngle += anglePerPoint;
}
let reverse = direction == .counterClockwise
if reverse {
vertices = vertices.reversed()
}
var path = BezierPath()
for vertex in vertices {
path.addVertex(reverse ? vertex.reversed() : vertex)
}
path.close()
return path
}
}
@@ -0,0 +1,225 @@
//
// RectNode.swift
// lottie-swift
//
// Created by Brandon Withrow on 1/21/19.
//
import CoreGraphics
import Foundation
// MARK: - RectNodeProperties
final class RectNodeProperties: NodePropertyMap, KeypathSearchable {
// MARK: Lifecycle
init(rectangle: Rectangle) {
keypathName = rectangle.name
direction = rectangle.direction
position = NodeProperty(provider: KeyframeInterpolator(keyframes: rectangle.position.keyframes))
size = NodeProperty(provider: KeyframeInterpolator(keyframes: rectangle.size.keyframes))
cornerRadius = NodeProperty(provider: KeyframeInterpolator(keyframes: rectangle.cornerRadius.keyframes))
keypathProperties = [
"Position" : position,
"Size" : size,
"Roundness" : cornerRadius,
]
properties = Array(keypathProperties.values)
}
// MARK: Internal
var keypathName: String
let keypathProperties: [String: AnyNodeProperty]
let properties: [AnyNodeProperty]
let direction: PathDirection
let position: NodeProperty<Vector3D>
let size: NodeProperty<Vector3D>
let cornerRadius: NodeProperty<Vector1D>
}
// MARK: - RectangleNode
final class RectangleNode: AnimatorNode, PathNode {
// MARK: Lifecycle
init(parentNode: AnimatorNode?, rectangle: Rectangle) {
properties = RectNodeProperties(rectangle: rectangle)
pathOutput = PathOutputNode(parent: parentNode?.outputNode)
self.parentNode = parentNode
}
// MARK: Internal
let properties: RectNodeProperties
let pathOutput: PathOutputNode
let parentNode: AnimatorNode?
var hasLocalUpdates = false
var hasUpstreamUpdates = false
var lastUpdateFrame: CGFloat? = nil
// MARK: Animator Node
var propertyMap: NodePropertyMap & KeypathSearchable {
properties
}
var isEnabled = true {
didSet {
pathOutput.isEnabled = isEnabled
}
}
func rebuildOutputs(frame: CGFloat) {
pathOutput.setPath(
.rectangle(
position: properties.position.value.pointValue,
size: properties.size.value.sizeValue,
cornerRadius: properties.cornerRadius.value.cgFloatValue,
direction: properties.direction),
updateFrame: frame)
}
}
// MARK: - BezierPath + rectangle
extension BezierPath {
/// Constructs a `BezierPath` in the shape of a rectangle, optionally with rounded corners
static func rectangle(
position: CGPoint,
size inputSize: CGSize,
cornerRadius: CGFloat,
direction: PathDirection)
-> BezierPath
{
let size = inputSize * 0.5
let radius = min(min(cornerRadius, size.width) , size.height)
var bezierPath = BezierPath()
let points: [CurveVertex]
if radius <= 0 {
/// No Corners
points = [
/// Lead In
CurveVertex(
point: CGPoint(x: size.width, y: -size.height),
inTangentRelative: .zero,
outTangentRelative: .zero)
.translated(position),
/// Corner 1
CurveVertex(
point: CGPoint(x: size.width, y: size.height),
inTangentRelative: .zero,
outTangentRelative: .zero)
.translated(position),
/// Corner 2
CurveVertex(
point: CGPoint(x: -size.width, y: size.height),
inTangentRelative: .zero,
outTangentRelative: .zero)
.translated(position),
/// Corner 3
CurveVertex(
point: CGPoint(x: -size.width, y: -size.height),
inTangentRelative: .zero,
outTangentRelative: .zero)
.translated(position),
/// Corner 4
CurveVertex(
point: CGPoint(x: size.width, y: -size.height),
inTangentRelative: .zero,
outTangentRelative: .zero)
.translated(position),
]
} else {
let controlPoint = radius * EllipseNode.ControlPointConstant
points = [
/// Lead In
CurveVertex(
CGPoint(x: radius, y: 0),
CGPoint(x: radius, y: 0),
CGPoint(x: radius, y: 0))
.translated(CGPoint(x: -radius, y: radius))
.translated(CGPoint(x: size.width, y: -size.height))
.translated(position),
/// Corner 1
CurveVertex(
CGPoint(x: radius, y: 0), // In tangent
CGPoint(x: radius, y: 0), // Point
CGPoint(x: radius, y: controlPoint))
.translated(CGPoint(x: -radius, y: -radius))
.translated(CGPoint(x: size.width, y: size.height))
.translated(position),
CurveVertex(
CGPoint(x: controlPoint, y: radius), // In tangent
CGPoint(x: 0, y: radius), // Point
CGPoint(x: 0, y: radius)) // Out Tangent
.translated(CGPoint(x: -radius, y: -radius))
.translated(CGPoint(x: size.width, y: size.height))
.translated(position),
/// Corner 2
CurveVertex(
CGPoint(x: 0, y: radius), // In tangent
CGPoint(x: 0, y: radius), // Point
CGPoint(x: -controlPoint, y: radius))// Out tangent
.translated(CGPoint(x: radius, y: -radius))
.translated(CGPoint(x: -size.width, y: size.height))
.translated(position),
CurveVertex(
CGPoint(x: -radius, y: controlPoint), // In tangent
CGPoint(x: -radius, y: 0), // Point
CGPoint(x: -radius, y: 0)) // Out tangent
.translated(CGPoint(x: radius, y: -radius))
.translated(CGPoint(x: -size.width, y: size.height))
.translated(position),
/// Corner 3
CurveVertex(
CGPoint(x: -radius, y: 0), // In tangent
CGPoint(x: -radius, y: 0), // Point
CGPoint(x: -radius, y: -controlPoint)) // Out tangent
.translated(CGPoint(x: radius, y: radius))
.translated(CGPoint(x: -size.width, y: -size.height))
.translated(position),
CurveVertex(
CGPoint(x: -controlPoint, y: -radius), // In tangent
CGPoint(x: 0, y: -radius), // Point
CGPoint(x: 0, y: -radius)) // Out tangent
.translated(CGPoint(x: radius, y: radius))
.translated(CGPoint(x: -size.width, y: -size.height))
.translated(position),
/// Corner 4
CurveVertex(
CGPoint(x: 0, y: -radius), // In tangent
CGPoint(x: 0, y: -radius), // Point
CGPoint(x: controlPoint, y: -radius)) // Out tangent
.translated(CGPoint(x: -radius, y: radius))
.translated(CGPoint(x: size.width, y: -size.height))
.translated(position),
CurveVertex(
CGPoint(x: radius, y: -controlPoint), // In tangent
CGPoint(x: radius, y: 0), // Point
CGPoint(x: radius, y: 0)) // Out tangent
.translated(CGPoint(x: -radius, y: radius))
.translated(CGPoint(x: size.width, y: -size.height))
.translated(position),
]
}
let reversed = direction == .counterClockwise
let pathPoints = reversed ? points.reversed() : points
for point in pathPoints {
bezierPath.addVertex(reversed ? point.reversed() : point)
}
bezierPath.close()
return bezierPath
}
}
@@ -0,0 +1,74 @@
//
// PathNode.swift
// lottie-swift
//
// Created by Brandon Withrow on 1/16/19.
//
import CoreGraphics
import Foundation
// MARK: - ShapeNodeProperties
final class ShapeNodeProperties: NodePropertyMap, KeypathSearchable {
// MARK: Lifecycle
init(shape: Shape) {
keypathName = shape.name
path = NodeProperty(provider: KeyframeInterpolator(keyframes: shape.path.keyframes))
keypathProperties = [
"Path" : path,
]
properties = Array(keypathProperties.values)
}
// MARK: Internal
var keypathName: String
let path: NodeProperty<BezierPath>
let keypathProperties: [String: AnyNodeProperty]
let properties: [AnyNodeProperty]
}
// MARK: - ShapeNode
final class ShapeNode: AnimatorNode, PathNode {
// MARK: Lifecycle
init(parentNode: AnimatorNode?, shape: Shape) {
pathOutput = PathOutputNode(parent: parentNode?.outputNode)
properties = ShapeNodeProperties(shape: shape)
self.parentNode = parentNode
}
// MARK: Internal
let properties: ShapeNodeProperties
let pathOutput: PathOutputNode
let parentNode: AnimatorNode?
var hasLocalUpdates = false
var hasUpstreamUpdates = false
var lastUpdateFrame: CGFloat? = nil
// MARK: Animator Node
var propertyMap: NodePropertyMap & KeypathSearchable {
properties
}
var isEnabled = true {
didSet {
pathOutput.isEnabled = isEnabled
}
}
func rebuildOutputs(frame: CGFloat) {
pathOutput.setPath(properties.path.value, updateFrame: frame)
}
}
@@ -0,0 +1,222 @@
//
// StarNode.swift
// lottie-swift
//
// Created by Brandon Withrow on 1/21/19.
//
import Foundation
import QuartzCore
// MARK: - StarNodeProperties
final class StarNodeProperties: NodePropertyMap, KeypathSearchable {
// MARK: Lifecycle
init(star: Star) {
keypathName = star.name
direction = star.direction
position = NodeProperty(provider: KeyframeInterpolator(keyframes: star.position.keyframes))
outerRadius = NodeProperty(provider: KeyframeInterpolator(keyframes: star.outerRadius.keyframes))
outerRoundedness = NodeProperty(provider: KeyframeInterpolator(keyframes: star.outerRoundness.keyframes))
if let innerRadiusKeyframes = star.innerRadius?.keyframes {
innerRadius = NodeProperty(provider: KeyframeInterpolator(keyframes: innerRadiusKeyframes))
} else {
innerRadius = NodeProperty(provider: SingleValueProvider(Vector1D(0)))
}
if let innderRoundedness = star.innerRoundness?.keyframes {
innerRoundedness = NodeProperty(provider: KeyframeInterpolator(keyframes: innderRoundedness))
} else {
innerRoundedness = NodeProperty(provider: SingleValueProvider(Vector1D(0)))
}
rotation = NodeProperty(provider: KeyframeInterpolator(keyframes: star.rotation.keyframes))
points = NodeProperty(provider: KeyframeInterpolator(keyframes: star.points.keyframes))
keypathProperties = [
"Position" : position,
"Outer Radius" : outerRadius,
"Outer Roundedness" : outerRoundedness,
"Inner Radius" : innerRadius,
"Inner Roundedness" : innerRoundedness,
"Rotation" : rotation,
"Points" : points,
]
properties = Array(keypathProperties.values)
}
// MARK: Internal
var keypathName: String
let keypathProperties: [String: AnyNodeProperty]
let properties: [AnyNodeProperty]
let direction: PathDirection
let position: NodeProperty<Vector3D>
let outerRadius: NodeProperty<Vector1D>
let outerRoundedness: NodeProperty<Vector1D>
let innerRadius: NodeProperty<Vector1D>
let innerRoundedness: NodeProperty<Vector1D>
let rotation: NodeProperty<Vector1D>
let points: NodeProperty<Vector1D>
}
// MARK: - StarNode
final class StarNode: AnimatorNode, PathNode {
// MARK: Lifecycle
init(parentNode: AnimatorNode?, star: Star) {
pathOutput = PathOutputNode(parent: parentNode?.outputNode)
properties = StarNodeProperties(star: star)
self.parentNode = parentNode
}
// MARK: Internal
/// Magic number needed for building path data
static let PolystarConstant: CGFloat = 0.47829
let properties: StarNodeProperties
let pathOutput: PathOutputNode
let parentNode: AnimatorNode?
var hasLocalUpdates = false
var hasUpstreamUpdates = false
var lastUpdateFrame: CGFloat? = nil
// MARK: Animator Node
var propertyMap: NodePropertyMap & KeypathSearchable {
properties
}
var isEnabled = true {
didSet {
pathOutput.isEnabled = isEnabled
}
}
func rebuildOutputs(frame: CGFloat) {
let path = BezierPath.star(
position: properties.position.value.pointValue,
outerRadius: properties.outerRadius.value.cgFloatValue,
innerRadius: properties.innerRadius.value.cgFloatValue,
outerRoundedness: properties.outerRoundedness.value.cgFloatValue,
innerRoundedness: properties.innerRoundedness.value.cgFloatValue,
numberOfPoints: properties.points.value.cgFloatValue,
rotation: properties.rotation.value.cgFloatValue,
direction: properties.direction)
pathOutput.setPath(path, updateFrame: frame)
}
}
extension BezierPath {
/// Constructs a `BezierPath` in the shape of a star
static func star(
position: CGPoint,
outerRadius: CGFloat,
innerRadius: CGFloat,
outerRoundedness inoutOuterRoundedness: CGFloat,
innerRoundedness inputInnerRoundedness: CGFloat,
numberOfPoints: CGFloat,
rotation: CGFloat,
direction: PathDirection)
-> BezierPath
{
var currentAngle = (rotation - 90).toRadians()
let anglePerPoint = (2 * CGFloat.pi) / numberOfPoints
let halfAnglePerPoint = anglePerPoint / 2.0
let partialPointAmount = numberOfPoints - floor(numberOfPoints)
let outerRoundedness = inoutOuterRoundedness * 0.01
let innerRoundedness = inputInnerRoundedness * 0.01
var point: CGPoint = .zero
var partialPointRadius: CGFloat = 0
if partialPointAmount != 0 {
currentAngle += halfAnglePerPoint * (1 - partialPointAmount)
partialPointRadius = innerRadius + partialPointAmount * (outerRadius - innerRadius)
point.x = (partialPointRadius * cos(currentAngle))
point.y = (partialPointRadius * sin(currentAngle))
currentAngle += anglePerPoint * partialPointAmount / 2
} else {
point.x = (outerRadius * cos(currentAngle))
point.y = (outerRadius * sin(currentAngle))
currentAngle += halfAnglePerPoint
}
var vertices = [CurveVertex]()
vertices.append(CurveVertex(point: point + position, inTangentRelative: .zero, outTangentRelative: .zero))
var previousPoint = point
var longSegment = false
let numPoints = Int(ceil(numberOfPoints) * 2)
for i in 0..<numPoints {
var radius = longSegment ? outerRadius : innerRadius
var dTheta = halfAnglePerPoint
if partialPointRadius != 0 && i == numPoints - 2 {
dTheta = anglePerPoint * partialPointAmount / 2
}
if partialPointRadius != 0 && i == numPoints - 1 {
radius = partialPointRadius
}
previousPoint = point
point.x = (radius * cos(currentAngle))
point.y = (radius * sin(currentAngle))
if innerRoundedness == 0 && outerRoundedness == 0 {
vertices.append(CurveVertex(point: point + position, inTangentRelative: .zero, outTangentRelative: .zero))
} else {
let cp1Theta = (atan2(previousPoint.y, previousPoint.x) - CGFloat.pi / 2)
let cp1Dx = cos(cp1Theta)
let cp1Dy = sin(cp1Theta)
let cp2Theta = (atan2(point.y, point.x) - CGFloat.pi / 2)
let cp2Dx = cos(cp2Theta)
let cp2Dy = sin(cp2Theta)
let cp1Roundedness = longSegment ? innerRoundedness : outerRoundedness
let cp2Roundedness = longSegment ? outerRoundedness : innerRoundedness
let cp1Radius = longSegment ? innerRadius : outerRadius
let cp2Radius = longSegment ? outerRadius : innerRadius
var cp1 = CGPoint(
x: cp1Radius * cp1Roundedness * StarNode.PolystarConstant * cp1Dx,
y: cp1Radius * cp1Roundedness * StarNode.PolystarConstant * cp1Dy)
var cp2 = CGPoint(
x: cp2Radius * cp2Roundedness * StarNode.PolystarConstant * cp2Dx,
y: cp2Radius * cp2Roundedness * StarNode.PolystarConstant * cp2Dy)
if partialPointAmount != 0 {
if i == 0 {
cp1 = cp1 * partialPointAmount
} else if i == numPoints - 1 {
cp2 = cp2 * partialPointAmount
}
}
let previousVertex = vertices[vertices.endIndex - 1]
vertices[vertices.endIndex - 1] = CurveVertex(
previousVertex.inTangent,
previousVertex.point,
previousVertex.point - cp1)
vertices.append(CurveVertex(point: point + position, inTangentRelative: cp2, outTangentRelative: .zero))
}
currentAngle += dTheta
longSegment = !longSegment
}
let reverse = direction == .counterClockwise
if reverse {
vertices = vertices.reversed()
}
var path = BezierPath()
for vertex in vertices {
path.addVertex(reverse ? vertex.reversed() : vertex)
}
path.close()
return path
}
}
@@ -0,0 +1,155 @@
//
// GroupNode.swift
// lottie-swift
//
// Created by Brandon Withrow on 1/18/19.
//
import CoreGraphics
import Foundation
import QuartzCore
// MARK: - GroupNodeProperties
final class GroupNodeProperties: NodePropertyMap, KeypathSearchable {
// MARK: Lifecycle
init(transform: ShapeTransform?) {
if let transform = transform {
anchor = NodeProperty(provider: KeyframeInterpolator(keyframes: transform.anchor.keyframes))
position = NodeProperty(provider: KeyframeInterpolator(keyframes: transform.position.keyframes))
scale = NodeProperty(provider: KeyframeInterpolator(keyframes: transform.scale.keyframes))
rotation = NodeProperty(provider: KeyframeInterpolator(keyframes: transform.rotation.keyframes))
opacity = NodeProperty(provider: KeyframeInterpolator(keyframes: transform.opacity.keyframes))
skew = NodeProperty(provider: KeyframeInterpolator(keyframes: transform.skew.keyframes))
skewAxis = NodeProperty(provider: KeyframeInterpolator(keyframes: transform.skewAxis.keyframes))
} else {
/// Transform node missing. Default to empty transform.
anchor = NodeProperty(provider: SingleValueProvider(Vector3D(x: CGFloat(0), y: CGFloat(0), z: CGFloat(0))))
position = NodeProperty(provider: SingleValueProvider(Vector3D(x: CGFloat(0), y: CGFloat(0), z: CGFloat(0))))
scale = NodeProperty(provider: SingleValueProvider(Vector3D(x: CGFloat(100), y: CGFloat(100), z: CGFloat(100))))
rotation = NodeProperty(provider: SingleValueProvider(Vector1D(0)))
opacity = NodeProperty(provider: SingleValueProvider(Vector1D(100)))
skew = NodeProperty(provider: SingleValueProvider(Vector1D(0)))
skewAxis = NodeProperty(provider: SingleValueProvider(Vector1D(0)))
}
keypathProperties = [
"Anchor Point" : anchor,
"Position" : position,
"Scale" : scale,
"Rotation" : rotation,
"Opacity" : opacity,
"Skew" : skew,
"Skew Axis" : skewAxis,
]
properties = Array(keypathProperties.values)
}
// MARK: Internal
var keypathName = "Transform"
var childKeypaths: [KeypathSearchable] = []
let keypathProperties: [String: AnyNodeProperty]
let properties: [AnyNodeProperty]
let anchor: NodeProperty<Vector3D>
let position: NodeProperty<Vector3D>
let scale: NodeProperty<Vector3D>
let rotation: NodeProperty<Vector1D>
let opacity: NodeProperty<Vector1D>
let skew: NodeProperty<Vector1D>
let skewAxis: NodeProperty<Vector1D>
var caTransform: CATransform3D {
CATransform3D.makeTransform(
anchor: anchor.value.pointValue,
position: position.value.pointValue,
scale: scale.value.sizeValue,
rotation: rotation.value.cgFloatValue,
skew: skew.value.cgFloatValue,
skewAxis: skewAxis.value.cgFloatValue)
}
}
// MARK: - GroupNode
final class GroupNode: AnimatorNode {
// MARK: Lifecycle
// MARK: Initializer
init(name: String, parentNode: AnimatorNode?, tree: NodeTree) {
self.parentNode = parentNode
keypathName = name
rootNode = tree.rootNode
properties = GroupNodeProperties(transform: tree.transform)
groupOutput = GroupOutputNode(parent: parentNode?.outputNode, rootNode: rootNode?.outputNode)
var childKeypaths: [KeypathSearchable] = tree.childrenNodes
childKeypaths.append(properties)
self.childKeypaths = childKeypaths
for childContainer in tree.renderContainers {
container.insertRenderLayer(childContainer)
}
}
// MARK: Internal
// MARK: Properties
let groupOutput: GroupOutputNode
let properties: GroupNodeProperties
let rootNode: AnimatorNode?
var container = ShapeContainerLayer()
// MARK: Keypath Searchable
let keypathName: String
let childKeypaths: [KeypathSearchable]
let parentNode: AnimatorNode?
var hasLocalUpdates = false
var hasUpstreamUpdates = false
var lastUpdateFrame: CGFloat? = nil
var keypathLayer: CALayer? {
container
}
// MARK: Animator Node Protocol
var propertyMap: NodePropertyMap & KeypathSearchable {
properties
}
var outputNode: NodeOutput {
groupOutput
}
var isEnabled = true {
didSet {
container.isHidden = !isEnabled
}
}
func performAdditionalLocalUpdates(frame: CGFloat, forceLocalUpdate: Bool) -> Bool {
rootNode?.updateContents(frame, forceLocalUpdate: forceLocalUpdate) ?? false
}
func performAdditionalOutputUpdates(_ frame: CGFloat, forceOutputUpdate: Bool) {
rootNode?.updateOutputs(frame, forceOutputUpdate: forceOutputUpdate)
}
func rebuildOutputs(frame: CGFloat) {
container.opacity = Float(properties.opacity.value.cgFloatValue) * 0.01
container.transform = properties.caTransform
groupOutput.setTransform(container.transform, forFrame: frame)
}
}
@@ -0,0 +1,90 @@
//
// FillNode.swift
// lottie-swift
//
// Created by Brandon Withrow on 1/17/19.
//
import CoreGraphics
import Foundation
// MARK: - FillNodeProperties
final class FillNodeProperties: NodePropertyMap, KeypathSearchable {
// MARK: Lifecycle
init(fill: Fill) {
keypathName = fill.name
color = NodeProperty(provider: KeyframeInterpolator(keyframes: fill.color.keyframes))
opacity = NodeProperty(provider: KeyframeInterpolator(keyframes: fill.opacity.keyframes))
type = fill.fillRule
keypathProperties = [
"Opacity" : opacity,
PropertyName.color.rawValue : color,
]
properties = Array(keypathProperties.values)
}
// MARK: Internal
var keypathName: String
let opacity: NodeProperty<Vector1D>
let color: NodeProperty<Color>
let type: FillRule
let keypathProperties: [String: AnyNodeProperty]
let properties: [AnyNodeProperty]
}
// MARK: - FillNode
final class FillNode: AnimatorNode, RenderNode {
// MARK: Lifecycle
init(parentNode: AnimatorNode?, fill: Fill) {
fillRender = FillRenderer(parent: parentNode?.outputNode)
fillProperties = FillNodeProperties(fill: fill)
self.parentNode = parentNode
}
// MARK: Internal
let fillRender: FillRenderer
let fillProperties: FillNodeProperties
let parentNode: AnimatorNode?
var hasLocalUpdates = false
var hasUpstreamUpdates = false
var lastUpdateFrame: CGFloat? = nil
var renderer: NodeOutput & Renderable {
fillRender
}
// MARK: Animator Node Protocol
var propertyMap: NodePropertyMap & KeypathSearchable {
fillProperties
}
var isEnabled = true {
didSet {
fillRender.isEnabled = isEnabled
}
}
func localUpdatesPermeateDownstream() -> Bool {
false
}
func rebuildOutputs(frame _: CGFloat) {
fillRender.color = fillProperties.color.value.cgColorValue
fillRender.opacity = fillProperties.opacity.value.cgFloatValue * 0.01
fillRender.fillRule = fillProperties.type
}
}
@@ -0,0 +1,102 @@
//
// GradientFillNode.swift
// lottie-swift
//
// Created by Brandon Withrow on 1/22/19.
//
import Foundation
import QuartzCore
// MARK: - GradientFillProperties
final class GradientFillProperties: NodePropertyMap, KeypathSearchable {
// MARK: Lifecycle
init(gradientfill: GradientFill) {
keypathName = gradientfill.name
opacity = NodeProperty(provider: KeyframeInterpolator(keyframes: gradientfill.opacity.keyframes))
startPoint = NodeProperty(provider: KeyframeInterpolator(keyframes: gradientfill.startPoint.keyframes))
endPoint = NodeProperty(provider: KeyframeInterpolator(keyframes: gradientfill.endPoint.keyframes))
colors = NodeProperty(provider: KeyframeInterpolator(keyframes: gradientfill.colors.keyframes))
gradientType = gradientfill.gradientType
numberOfColors = gradientfill.numberOfColors
keypathProperties = [
"Opacity" : opacity,
"Start Point" : startPoint,
"End Point" : endPoint,
"Colors" : colors,
]
properties = Array(keypathProperties.values)
}
// MARK: Internal
var keypathName: String
let opacity: NodeProperty<Vector1D>
let startPoint: NodeProperty<Vector3D>
let endPoint: NodeProperty<Vector3D>
let colors: NodeProperty<[Double]>
let gradientType: GradientType
let numberOfColors: Int
let keypathProperties: [String: AnyNodeProperty]
let properties: [AnyNodeProperty]
}
// MARK: - GradientFillNode
final class GradientFillNode: AnimatorNode, RenderNode {
// MARK: Lifecycle
init(parentNode: AnimatorNode?, gradientFill: GradientFill) {
fillRender = GradientFillRenderer(parent: parentNode?.outputNode)
fillProperties = GradientFillProperties(gradientfill: gradientFill)
self.parentNode = parentNode
}
// MARK: Internal
let fillRender: GradientFillRenderer
let fillProperties: GradientFillProperties
let parentNode: AnimatorNode?
var hasLocalUpdates = false
var hasUpstreamUpdates = false
var lastUpdateFrame: CGFloat? = nil
var renderer: NodeOutput & Renderable {
fillRender
}
// MARK: Animator Node Protocol
var propertyMap: NodePropertyMap & KeypathSearchable {
fillProperties
}
var isEnabled = true {
didSet {
fillRender.isEnabled = isEnabled
}
}
func localUpdatesPermeateDownstream() -> Bool {
false
}
func rebuildOutputs(frame _: CGFloat) {
fillRender.start = fillProperties.startPoint.value.pointValue
fillRender.end = fillProperties.endPoint.value.pointValue
fillRender.opacity = fillProperties.opacity.value.cgFloatValue * 0.01
fillRender.colors = fillProperties.colors.value.map { CGFloat($0) }
fillRender.type = fillProperties.gradientType
fillRender.numberOfColors = fillProperties.numberOfColors
}
}
@@ -0,0 +1,151 @@
//
// GradientStrokeNode.swift
// lottie-swift
//
// Created by Brandon Withrow on 1/23/19.
//
import CoreGraphics
import Foundation
// MARK: - GradientStrokeProperties
final class GradientStrokeProperties: NodePropertyMap, KeypathSearchable {
// MARK: Lifecycle
init(gradientStroke: GradientStroke) {
keypathName = gradientStroke.name
opacity = NodeProperty(provider: KeyframeInterpolator(keyframes: gradientStroke.opacity.keyframes))
startPoint = NodeProperty(provider: KeyframeInterpolator(keyframes: gradientStroke.startPoint.keyframes))
endPoint = NodeProperty(provider: KeyframeInterpolator(keyframes: gradientStroke.endPoint.keyframes))
colors = NodeProperty(provider: KeyframeInterpolator(keyframes: gradientStroke.colors.keyframes))
gradientType = gradientStroke.gradientType
numberOfColors = gradientStroke.numberOfColors
width = NodeProperty(provider: KeyframeInterpolator(keyframes: gradientStroke.width.keyframes))
miterLimit = CGFloat(gradientStroke.miterLimit)
lineCap = gradientStroke.lineCap
lineJoin = gradientStroke.lineJoin
if let dashes = gradientStroke.dashPattern {
var dashPatterns = ContiguousArray<ContiguousArray<Keyframe<Vector1D>>>()
var dashPhase = ContiguousArray<Keyframe<Vector1D>>()
for dash in dashes {
if dash.type == .offset {
dashPhase = dash.value.keyframes
} else {
dashPatterns.append(dash.value.keyframes)
}
}
dashPattern = NodeProperty(provider: GroupInterpolator(keyframeGroups: dashPatterns))
self.dashPhase = NodeProperty(provider: KeyframeInterpolator(keyframes: dashPhase))
} else {
dashPattern = NodeProperty(provider: SingleValueProvider([Vector1D]()))
dashPhase = NodeProperty(provider: SingleValueProvider(Vector1D(0)))
}
keypathProperties = [
"Opacity" : opacity,
"Start Point" : startPoint,
"End Point" : endPoint,
"Colors" : colors,
"Stroke Width" : width,
"Dashes" : dashPattern,
"Dash Phase" : dashPhase,
]
properties = Array(keypathProperties.values)
}
// MARK: Internal
var keypathName: String
let opacity: NodeProperty<Vector1D>
let startPoint: NodeProperty<Vector3D>
let endPoint: NodeProperty<Vector3D>
let colors: NodeProperty<[Double]>
let width: NodeProperty<Vector1D>
let dashPattern: NodeProperty<[Vector1D]>
let dashPhase: NodeProperty<Vector1D>
let lineCap: LineCap
let lineJoin: LineJoin
let miterLimit: CGFloat
let gradientType: GradientType
let numberOfColors: Int
let keypathProperties: [String: AnyNodeProperty]
let properties: [AnyNodeProperty]
}
// MARK: - GradientStrokeNode
final class GradientStrokeNode: AnimatorNode, RenderNode {
// MARK: Lifecycle
init(parentNode: AnimatorNode?, gradientStroke: GradientStroke) {
strokeRender = GradientStrokeRenderer(parent: parentNode?.outputNode)
strokeProperties = GradientStrokeProperties(gradientStroke: gradientStroke)
self.parentNode = parentNode
}
// MARK: Internal
let strokeRender: GradientStrokeRenderer
let strokeProperties: GradientStrokeProperties
let parentNode: AnimatorNode?
var hasLocalUpdates = false
var hasUpstreamUpdates = false
var lastUpdateFrame: CGFloat? = nil
var renderer: NodeOutput & Renderable {
strokeRender
}
// MARK: Animator Node Protocol
var propertyMap: NodePropertyMap & KeypathSearchable {
strokeProperties
}
var isEnabled = true {
didSet {
strokeRender.isEnabled = isEnabled
}
}
func localUpdatesPermeateDownstream() -> Bool {
false
}
func rebuildOutputs(frame _: CGFloat) {
/// Update gradient properties
strokeRender.gradientRender.start = strokeProperties.startPoint.value.pointValue
strokeRender.gradientRender.end = strokeProperties.endPoint.value.pointValue
strokeRender.gradientRender.opacity = strokeProperties.opacity.value.cgFloatValue
strokeRender.gradientRender.colors = strokeProperties.colors.value.map { CGFloat($0) }
strokeRender.gradientRender.type = strokeProperties.gradientType
strokeRender.gradientRender.numberOfColors = strokeProperties.numberOfColors
/// Now update stroke properties
strokeRender.strokeRender.opacity = strokeProperties.opacity.value.cgFloatValue
strokeRender.strokeRender.width = strokeProperties.width.value.cgFloatValue
strokeRender.strokeRender.miterLimit = strokeProperties.miterLimit
strokeRender.strokeRender.lineCap = strokeProperties.lineCap
strokeRender.strokeRender.lineJoin = strokeProperties.lineJoin
/// Get dash lengths
let dashLengths = strokeProperties.dashPattern.value.map { $0.cgFloatValue }
if dashLengths.count > 0 {
strokeRender.strokeRender.dashPhase = strokeProperties.dashPhase.value.cgFloatValue
strokeRender.strokeRender.dashLengths = dashLengths
} else {
strokeRender.strokeRender.dashLengths = nil
strokeRender.strokeRender.dashPhase = nil
}
}
}
@@ -0,0 +1,153 @@
//
// StrokeNode.swift
// lottie-swift
//
// Created by Brandon Withrow on 1/22/19.
//
import Foundation
import QuartzCore
// MARK: - StrokeNodeProperties
final class StrokeNodeProperties: NodePropertyMap, KeypathSearchable {
// MARK: Lifecycle
init(stroke: Stroke) {
keypathName = stroke.name
color = NodeProperty(provider: KeyframeInterpolator(keyframes: stroke.color.keyframes))
opacity = NodeProperty(provider: KeyframeInterpolator(keyframes: stroke.opacity.keyframes))
width = NodeProperty(provider: KeyframeInterpolator(keyframes: stroke.width.keyframes))
miterLimit = CGFloat(stroke.miterLimit)
lineCap = stroke.lineCap
lineJoin = stroke.lineJoin
if let dashes = stroke.dashPattern {
let (dashPatterns, dashPhase) = dashes.shapeLayerConfiguration
dashPattern = NodeProperty(provider: GroupInterpolator(keyframeGroups: dashPatterns))
if dashPhase.count == 0 {
self.dashPhase = NodeProperty(provider: SingleValueProvider(Vector1D(0)))
} else {
self.dashPhase = NodeProperty(provider: KeyframeInterpolator(keyframes: dashPhase))
}
} else {
dashPattern = NodeProperty(provider: SingleValueProvider([Vector1D]()))
dashPhase = NodeProperty(provider: SingleValueProvider(Vector1D(0)))
}
keypathProperties = [
"Opacity" : opacity,
PropertyName.color.rawValue : color,
"Stroke Width" : width,
"Dashes" : dashPattern,
"Dash Phase" : dashPhase,
]
properties = Array(keypathProperties.values)
}
// MARK: Internal
let keypathName: String
let keypathProperties: [String: AnyNodeProperty]
let properties: [AnyNodeProperty]
let opacity: NodeProperty<Vector1D>
let color: NodeProperty<Color>
let width: NodeProperty<Vector1D>
let dashPattern: NodeProperty<[Vector1D]>
let dashPhase: NodeProperty<Vector1D>
let lineCap: LineCap
let lineJoin: LineJoin
let miterLimit: CGFloat
}
// MARK: - StrokeNode
/// Node that manages stroking a path
final class StrokeNode: AnimatorNode, RenderNode {
// MARK: Lifecycle
init(parentNode: AnimatorNode?, stroke: Stroke) {
strokeRender = StrokeRenderer(parent: parentNode?.outputNode)
strokeProperties = StrokeNodeProperties(stroke: stroke)
self.parentNode = parentNode
}
// MARK: Internal
let strokeRender: StrokeRenderer
let strokeProperties: StrokeNodeProperties
let parentNode: AnimatorNode?
var hasLocalUpdates = false
var hasUpstreamUpdates = false
var lastUpdateFrame: CGFloat? = nil
var renderer: NodeOutput & Renderable {
strokeRender
}
// MARK: Animator Node Protocol
var propertyMap: NodePropertyMap & KeypathSearchable {
strokeProperties
}
var isEnabled = true {
didSet {
strokeRender.isEnabled = isEnabled
}
}
func localUpdatesPermeateDownstream() -> Bool {
false
}
func rebuildOutputs(frame _: CGFloat) {
strokeRender.color = strokeProperties.color.value.cgColorValue
strokeRender.opacity = strokeProperties.opacity.value.cgFloatValue * 0.01
strokeRender.width = strokeProperties.width.value.cgFloatValue
strokeRender.miterLimit = strokeProperties.miterLimit
strokeRender.lineCap = strokeProperties.lineCap
strokeRender.lineJoin = strokeProperties.lineJoin
/// Get dash lengths
let dashLengths = strokeProperties.dashPattern.value.map { $0.cgFloatValue }
if dashLengths.count > 0 {
strokeRender.dashPhase = strokeProperties.dashPhase.value.cgFloatValue
strokeRender.dashLengths = dashLengths
} else {
strokeRender.dashLengths = nil
strokeRender.dashPhase = nil
}
}
}
// MARK: - [DashElement] + shapeLayerConfiguration
extension Array where Element == DashElement {
typealias ShapeLayerConfiguration = (
dashPatterns: ContiguousArray<ContiguousArray<Keyframe<Vector1D>>>,
dashPhase: ContiguousArray<Keyframe<Vector1D>>)
/// Converts the `[DashElement]` data model into `lineDashPattern` and `lineDashPhase`
/// representations usable in a `CAShapeLayer`
var shapeLayerConfiguration: ShapeLayerConfiguration {
var dashPatterns = ContiguousArray<ContiguousArray<Keyframe<Vector1D>>>()
var dashPhase = ContiguousArray<Keyframe<Vector1D>>()
for dash in self {
if dash.type == .offset {
dashPhase = dash.value.keyframes
} else {
dashPatterns.append(dash.value.keyframes)
}
}
return (dashPatterns, dashPhase)
}
}
@@ -0,0 +1,270 @@
//
// TextAnimatorNode.swift
// lottie-ios-iOS
//
// Created by Brandon Withrow on 2/19/19.
//
import CoreGraphics
import Foundation
import QuartzCore
// MARK: - TextAnimatorNodeProperties
final class TextAnimatorNodeProperties: NodePropertyMap, KeypathSearchable {
// MARK: Lifecycle
init(textAnimator: TextAnimator) {
keypathName = textAnimator.name
var properties = [String : AnyNodeProperty]()
if let keyframeGroup = textAnimator.anchor {
anchor = NodeProperty(provider: KeyframeInterpolator(keyframes: keyframeGroup.keyframes))
properties["Anchor"] = anchor
} else {
anchor = nil
}
if let keyframeGroup = textAnimator.position {
position = NodeProperty(provider: KeyframeInterpolator(keyframes: keyframeGroup.keyframes))
properties["Position"] = position
} else {
position = nil
}
if let keyframeGroup = textAnimator.scale {
scale = NodeProperty(provider: KeyframeInterpolator(keyframes: keyframeGroup.keyframes))
properties["Scale"] = scale
} else {
scale = nil
}
if let keyframeGroup = textAnimator.skew {
skew = NodeProperty(provider: KeyframeInterpolator(keyframes: keyframeGroup.keyframes))
properties["Skew"] = skew
} else {
skew = nil
}
if let keyframeGroup = textAnimator.skewAxis {
skewAxis = NodeProperty(provider: KeyframeInterpolator(keyframes: keyframeGroup.keyframes))
properties["Skew Axis"] = skewAxis
} else {
skewAxis = nil
}
if let keyframeGroup = textAnimator.rotation {
rotation = NodeProperty(provider: KeyframeInterpolator(keyframes: keyframeGroup.keyframes))
properties["Rotation"] = rotation
} else {
rotation = nil
}
if let keyframeGroup = textAnimator.opacity {
opacity = NodeProperty(provider: KeyframeInterpolator(keyframes: keyframeGroup.keyframes))
properties["Opacity"] = opacity
} else {
opacity = nil
}
if let keyframeGroup = textAnimator.strokeColor {
strokeColor = NodeProperty(provider: KeyframeInterpolator(keyframes: keyframeGroup.keyframes))
properties["Stroke Color"] = strokeColor
} else {
strokeColor = nil
}
if let keyframeGroup = textAnimator.fillColor {
fillColor = NodeProperty(provider: KeyframeInterpolator(keyframes: keyframeGroup.keyframes))
properties["Fill Color"] = fillColor
} else {
fillColor = nil
}
if let keyframeGroup = textAnimator.strokeWidth {
strokeWidth = NodeProperty(provider: KeyframeInterpolator(keyframes: keyframeGroup.keyframes))
properties["Stroke Width"] = strokeWidth
} else {
strokeWidth = nil
}
if let keyframeGroup = textAnimator.tracking {
tracking = NodeProperty(provider: KeyframeInterpolator(keyframes: keyframeGroup.keyframes))
properties["Tracking"] = tracking
} else {
tracking = nil
}
keypathProperties = properties
self.properties = Array(keypathProperties.values)
}
// MARK: Internal
let keypathName: String
let anchor: NodeProperty<Vector3D>?
let position: NodeProperty<Vector3D>?
let scale: NodeProperty<Vector3D>?
let skew: NodeProperty<Vector1D>?
let skewAxis: NodeProperty<Vector1D>?
let rotation: NodeProperty<Vector1D>?
let opacity: NodeProperty<Vector1D>?
let strokeColor: NodeProperty<Color>?
let fillColor: NodeProperty<Color>?
let strokeWidth: NodeProperty<Vector1D>?
let tracking: NodeProperty<Vector1D>?
let keypathProperties: [String: AnyNodeProperty]
let properties: [AnyNodeProperty]
var caTransform: CATransform3D {
CATransform3D.makeTransform(
anchor: anchor?.value.pointValue ?? .zero,
position: position?.value.pointValue ?? .zero,
scale: scale?.value.sizeValue ?? CGSize(width: 100, height: 100),
rotation: rotation?.value.cgFloatValue ?? 0,
skew: skew?.value.cgFloatValue,
skewAxis: skewAxis?.value.cgFloatValue)
}
}
// MARK: - TextOutputNode
final class TextOutputNode: NodeOutput {
// MARK: Lifecycle
init(parent: TextOutputNode?) {
parentTextNode = parent
}
// MARK: Internal
var parentTextNode: TextOutputNode?
var isEnabled = true
var outputPath: CGPath?
var parent: NodeOutput? {
parentTextNode
}
var xform: CATransform3D {
get {
_xform ?? parentTextNode?.xform ?? CATransform3DIdentity
}
set {
_xform = newValue
}
}
var opacity: CGFloat {
get {
_opacity ?? parentTextNode?.opacity ?? 1
}
set {
_opacity = newValue
}
}
var strokeColor: CGColor? {
get {
_strokeColor ?? parentTextNode?.strokeColor
}
set {
_strokeColor = newValue
}
}
var fillColor: CGColor? {
get {
_fillColor ?? parentTextNode?.fillColor
}
set {
_fillColor = newValue
}
}
var tracking: CGFloat {
get {
_tracking ?? parentTextNode?.tracking ?? 0
}
set {
_tracking = newValue
}
}
var strokeWidth: CGFloat {
get {
_strokeWidth ?? parentTextNode?.strokeWidth ?? 0
}
set {
_strokeWidth = newValue
}
}
func hasOutputUpdates(_: CGFloat) -> Bool {
// TODO Fix This
true
}
// MARK: Fileprivate
fileprivate var _xform: CATransform3D?
fileprivate var _opacity: CGFloat?
fileprivate var _strokeColor: CGColor?
fileprivate var _fillColor: CGColor?
fileprivate var _tracking: CGFloat?
fileprivate var _strokeWidth: CGFloat?
}
// MARK: - TextAnimatorNode
class TextAnimatorNode: AnimatorNode {
// MARK: Lifecycle
init(parentNode: TextAnimatorNode?, textAnimator: TextAnimator) {
textOutputNode = TextOutputNode(parent: parentNode?.textOutputNode)
textAnimatorProperties = TextAnimatorNodeProperties(textAnimator: textAnimator)
self.parentNode = parentNode
}
// MARK: Internal
let textOutputNode: TextOutputNode
let textAnimatorProperties: TextAnimatorNodeProperties
let parentNode: AnimatorNode?
var hasLocalUpdates = false
var hasUpstreamUpdates = false
var lastUpdateFrame: CGFloat? = nil
var isEnabled = true
var outputNode: NodeOutput {
textOutputNode
}
// MARK: Animator Node Protocol
var propertyMap: NodePropertyMap & KeypathSearchable {
textAnimatorProperties
}
func localUpdatesPermeateDownstream() -> Bool {
true
}
func rebuildOutputs(frame _: CGFloat) {
textOutputNode.xform = textAnimatorProperties.caTransform
textOutputNode.opacity = (textAnimatorProperties.opacity?.value.cgFloatValue ?? 100) * 0.01
textOutputNode.strokeColor = textAnimatorProperties.strokeColor?.value.cgColorValue
textOutputNode.fillColor = textAnimatorProperties.fillColor?.value.cgColorValue
textOutputNode.tracking = textAnimatorProperties.tracking?.value.cgFloatValue ?? 1
textOutputNode.strokeWidth = textAnimatorProperties.strokeWidth?.value.cgFloatValue ?? 0
}
}
@@ -0,0 +1,198 @@
//
// AnimatorNode.swift
// lottie-swift
//
// Created by Brandon Withrow on 1/15/19.
//
import Foundation
import QuartzCore
// MARK: - NodeOutput
/// Defines the basic outputs of an animator node.
///
protocol NodeOutput {
/// The parent node.
var parent: NodeOutput? { get }
/// Returns true if there are any updates upstream. OutputPath must be built before returning.
func hasOutputUpdates(_ forFrame: CGFloat) -> Bool
var outputPath: CGPath? { get }
var isEnabled: Bool { get set }
}
// MARK: - AnimatorNode
/// The Animator Node is the base node in the render system tree.
///
/// It defines a single node that has an output path and option input node.
/// At animation time the root animation node is asked to update its contents for
/// the current frame.
/// The node reaches up its chain of nodes until the first node that does not need
/// updating is found. Then each node updates its contents down the render pipeline.
/// Each node adds its local path to its input path and passes it forward.
///
/// An animator node holds a group of interpolators. These interpolators determine
/// if the node needs an update for the current frame.
///
protocol AnimatorNode: AnyObject, KeypathSearchable {
/// The available properties of the Node.
///
/// These properties are automatically updated each frame.
/// These properties are also settable and gettable through the dynamic
/// property system.
///
var propertyMap: NodePropertyMap & KeypathSearchable { get }
/// The upstream input node
var parentNode: AnimatorNode? { get }
/// The output of the node.
var outputNode: NodeOutput { get }
/// Update the outputs of the node. Called if local contents were update or if outputsNeedUpdate returns true.
func rebuildOutputs(frame: CGFloat)
/// Setters for marking current node state.
var isEnabled: Bool { get set }
var hasLocalUpdates: Bool { get set }
var hasUpstreamUpdates: Bool { get set }
var lastUpdateFrame: CGFloat? { get set }
// MARK: Optional
/// Marks if updates to this node affect nodes downstream.
func localUpdatesPermeateDownstream() -> Bool
func forceUpstreamOutputUpdates() -> Bool
/// Called at the end of this nodes update cycle. Always called. Optional.
func performAdditionalLocalUpdates(frame: CGFloat, forceLocalUpdate: Bool) -> Bool
func performAdditionalOutputUpdates(_ frame: CGFloat, forceOutputUpdate: Bool)
/// The default simply returns `hasLocalUpdates`
func shouldRebuildOutputs(frame: CGFloat) -> Bool
}
/// Basic Node Logic
extension AnimatorNode {
func shouldRebuildOutputs(frame _: CGFloat) -> Bool {
hasLocalUpdates
}
func localUpdatesPermeateDownstream() -> Bool {
/// Optional override
true
}
func forceUpstreamOutputUpdates() -> Bool {
/// Optional
false
}
func performAdditionalLocalUpdates(frame _: CGFloat, forceLocalUpdate: Bool) -> Bool {
/// Optional
forceLocalUpdate
}
func performAdditionalOutputUpdates(_: CGFloat, forceOutputUpdate _: Bool) {
/// Optional
}
@discardableResult
func updateOutputs(_ frame: CGFloat, forceOutputUpdate: Bool) -> Bool {
guard isEnabled else {
// Disabled node, pass through.
lastUpdateFrame = frame
return parentNode?.updateOutputs(frame, forceOutputUpdate: forceOutputUpdate) ?? false
}
if forceOutputUpdate == false && lastUpdateFrame != nil && lastUpdateFrame! == frame {
/// This node has already updated for this frame. Go ahead and return the results.
return hasUpstreamUpdates || hasLocalUpdates
}
/// Ask if this node should force output updates upstream.
let forceUpstreamUpdates = forceOutputUpdate || forceUpstreamOutputUpdates()
/// Perform upstream output updates. Optionally mark upstream updates if any.
hasUpstreamUpdates = (
parentNode?
.updateOutputs(frame, forceOutputUpdate: forceUpstreamUpdates) ?? false || hasUpstreamUpdates)
/// Perform additional local output updates
performAdditionalOutputUpdates(frame, forceOutputUpdate: forceUpstreamUpdates)
/// If there are local updates, or if updates have been force, rebuild outputs
if forceUpstreamUpdates || shouldRebuildOutputs(frame: frame) {
lastUpdateFrame = frame
rebuildOutputs(frame: frame)
}
return hasUpstreamUpdates || hasLocalUpdates
}
/// Rebuilds the content of this node, and upstream nodes if necessary.
@discardableResult
func updateContents(_ frame: CGFloat, forceLocalUpdate: Bool) -> Bool {
guard isEnabled else {
// Disabled node, pass through.
return parentNode?.updateContents(frame, forceLocalUpdate: forceLocalUpdate) ?? false
}
if forceLocalUpdate == false && lastUpdateFrame != nil && lastUpdateFrame! == frame {
/// This node has already updated for this frame. Go ahead and return the results.
return localUpdatesPermeateDownstream() ? hasUpstreamUpdates || hasLocalUpdates : hasUpstreamUpdates
}
/// Are there local updates? If so mark the node.
hasLocalUpdates = forceLocalUpdate ? forceLocalUpdate : propertyMap.needsLocalUpdate(frame: frame)
/// Were there upstream updates? If so mark the node
hasUpstreamUpdates = parentNode?.updateContents(frame, forceLocalUpdate: forceLocalUpdate) ?? false
/// Perform property updates if necessary.
if hasLocalUpdates {
/// Rebuild local properties
propertyMap.updateNodeProperties(frame: frame)
}
/// Ask the node to perform any other updates it might have.
hasUpstreamUpdates = performAdditionalLocalUpdates(frame: frame, forceLocalUpdate: forceLocalUpdate) || hasUpstreamUpdates
/// If the node can update nodes downstream, notify them, otherwise pass on any upstream updates downstream.
return localUpdatesPermeateDownstream() ? hasUpstreamUpdates || hasLocalUpdates : hasUpstreamUpdates
}
func updateTree(_ frame: CGFloat, forceUpdates: Bool = false) {
updateContents(frame, forceLocalUpdate: forceUpdates)
updateOutputs(frame, forceOutputUpdate: forceUpdates)
}
}
extension AnimatorNode {
/// Default implementation for Keypath searchable.
/// Forward all calls to the propertyMap.
var keypathName: String {
propertyMap.keypathName
}
var keypathProperties: [String: AnyNodeProperty] {
propertyMap.keypathProperties
}
var childKeypaths: [KeypathSearchable] {
propertyMap.childKeypaths
}
var keypathLayer: CALayer? {
nil
}
}
@@ -0,0 +1,22 @@
//
// PathNode.swift
// lottie-swift
//
// Created by Brandon Withrow on 1/17/19.
//
import Foundation
// MARK: - PathNode
protocol PathNode {
var pathOutput: PathOutputNode { get }
}
extension PathNode where Self: AnimatorNode {
var outputNode: NodeOutput {
pathOutput
}
}
@@ -0,0 +1,62 @@
//
// RenderNode.swift
// lottie-swift
//
// Created by Brandon Withrow on 1/17/19.
//
import CoreGraphics
import Foundation
import QuartzCore
// MARK: - RenderNode
/// A protocol that defines a node that holds render instructions
protocol RenderNode {
var renderer: Renderable & NodeOutput { get }
}
// MARK: - Renderable
/// A protocol that defines anything with render instructions
protocol Renderable {
/// The last frame in which this node was updated.
var hasUpdate: Bool { get }
func hasRenderUpdates(_ forFrame: CGFloat) -> Bool
/// Determines if the renderer requires a custom context for drawing.
/// If yes the shape layer will perform a custom drawing pass.
/// If no the shape layer will be a standard CAShapeLayer
var shouldRenderInContext: Bool { get }
/// Passes in the CAShapeLayer to update
func updateShapeLayer(layer: CAShapeLayer)
/// Asks the renderer what the renderable bounds is for the given box.
func renderBoundsFor(_ boundingBox: CGRect) -> CGRect
/// Opportunity for renderers to inject sublayers
func setupSublayers(layer: CAShapeLayer)
/// Renders the shape in a custom context
func render(_ inContext: CGContext)
}
extension RenderNode where Self: AnimatorNode {
var outputNode: NodeOutput {
renderer
}
}
extension Renderable {
func renderBoundsFor(_ boundingBox: CGRect) -> CGRect {
/// Optional
boundingBox
}
}
@@ -0,0 +1,75 @@
//
// ShapeContainerLayer.swift
// lottie-swift
//
// Created by Brandon Withrow on 1/30/19.
//
import Foundation
import QuartzCore
/// The base layer that holds Shapes and Shape Renderers
class ShapeContainerLayer: CALayer {
// MARK: Lifecycle
override init() {
super.init()
actions = [
"position" : NSNull(),
"bounds" : NSNull(),
"anchorPoint" : NSNull(),
"transform" : NSNull(),
"opacity" : NSNull(),
"hidden" : NSNull(),
]
}
required init?(coder _: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
override init(layer: Any) {
guard let layer = layer as? ShapeContainerLayer else {
fatalError("init(layer:) wrong class.")
}
super.init(layer: layer)
}
// MARK: Internal
private(set) var renderLayers: [ShapeContainerLayer] = []
var renderScale: CGFloat = 1 {
didSet {
updateRenderScale()
}
}
func insertRenderLayer(_ layer: ShapeContainerLayer) {
renderLayers.append(layer)
insertSublayer(layer, at: 0)
}
func markRenderUpdates(forFrame: CGFloat) {
if hasRenderUpdate(forFrame: forFrame) {
rebuildContents(forFrame: forFrame)
}
guard isHidden == false else { return }
renderLayers.forEach { $0.markRenderUpdates(forFrame: forFrame) }
}
func hasRenderUpdate(forFrame _: CGFloat) -> Bool {
false
}
func rebuildContents(forFrame _: CGFloat) {
/// Override
}
func updateRenderScale() {
contentsScale = renderScale
renderLayers.forEach( { $0.renderScale = renderScale } )
}
}
@@ -0,0 +1,100 @@
//
// RenderLayer.swift
// lottie-swift
//
// Created by Brandon Withrow on 1/18/19.
//
import Foundation
import QuartzCore
/// The layer responsible for rendering shape objects
final class ShapeRenderLayer: ShapeContainerLayer {
// MARK: Lifecycle
init(renderer: Renderable & NodeOutput) {
self.renderer = renderer
super.init()
anchorPoint = .zero
actions = [
"position" : NSNull(),
"bounds" : NSNull(),
"anchorPoint" : NSNull(),
"path" : NSNull(),
"transform" : NSNull(),
"opacity" : NSNull(),
"hidden" : NSNull(),
]
shapeLayer.actions = [
"position" : NSNull(),
"bounds" : NSNull(),
"anchorPoint" : NSNull(),
"path" : NSNull(),
"fillColor" : NSNull(),
"strokeColor" : NSNull(),
"lineWidth" : NSNull(),
"miterLimit" : NSNull(),
"lineDashPhase" : NSNull(),
"opacity": NSNull(),
"hidden" : NSNull(),
]
addSublayer(shapeLayer)
renderer.setupSublayers(layer: shapeLayer)
}
override init(layer: Any) {
guard let layer = layer as? ShapeRenderLayer else {
fatalError("init(layer:) wrong class.")
}
renderer = layer.renderer
super.init(layer: layer)
}
required init?(coder _: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// MARK: Internal
fileprivate(set) var renderer: Renderable & NodeOutput
let shapeLayer = CAShapeLayer()
override func hasRenderUpdate(forFrame: CGFloat) -> Bool {
isHidden = !renderer.isEnabled
guard isHidden == false else { return false }
return renderer.hasRenderUpdates(forFrame)
}
override func rebuildContents(forFrame _: CGFloat) {
if renderer.shouldRenderInContext {
if let newPath = renderer.outputPath {
bounds = renderer.renderBoundsFor(newPath.boundingBox)
} else {
bounds = .zero
}
position = bounds.origin
setNeedsDisplay()
} else {
shapeLayer.path = renderer.outputPath
renderer.updateShapeLayer(layer: shapeLayer)
}
}
override func draw(in ctx: CGContext) {
if let path = renderer.outputPath {
if !path.isEmpty {
ctx.addPath(path)
}
}
renderer.render(ctx)
}
override func updateRenderScale() {
super.updateRenderScale()
shapeLayer.contentsScale = renderScale
}
}
@@ -0,0 +1,160 @@
//
// Animation.swift
// lottie-swift
//
// Created by Brandon Withrow on 1/7/19.
//
import Foundation
// MARK: - CoordinateSpace
public enum CoordinateSpace: Int, Codable {
case type2d
case type3d
}
// MARK: - Animation
/// The `Animation` model is the top level model object in Lottie.
///
/// An `Animation` holds all of the animation data backing a Lottie Animation.
/// Codable, see JSON schema [here](https://github.com/airbnb/lottie-web/tree/master/docs/json).
public final class Animation: Codable, DictionaryInitializable {
// MARK: Lifecycle
required public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: Animation.CodingKeys.self)
version = try container.decode(String.self, forKey: .version)
type = try container.decodeIfPresent(CoordinateSpace.self, forKey: .type) ?? .type2d
startFrame = try container.decode(AnimationFrameTime.self, forKey: .startFrame)
endFrame = try container.decode(AnimationFrameTime.self, forKey: .endFrame)
framerate = try container.decode(Double.self, forKey: .framerate)
width = try container.decode(Int.self, forKey: .width)
height = try container.decode(Int.self, forKey: .height)
layers = try container.decode([LayerModel].self, ofFamily: LayerType.self, forKey: .layers)
glyphs = try container.decodeIfPresent([Glyph].self, forKey: .glyphs)
fonts = try container.decodeIfPresent(FontList.self, forKey: .fonts)
assetLibrary = try container.decodeIfPresent(AssetLibrary.self, forKey: .assetLibrary)
markers = try container.decodeIfPresent([Marker].self, forKey: .markers)
if let markers = markers {
var markerMap: [String: Marker] = [:]
for marker in markers {
markerMap[marker.name] = marker
}
self.markerMap = markerMap
} else {
markerMap = nil
}
}
public init(dictionary: [String: Any]) throws {
version = try dictionary.value(for: CodingKeys.version)
if
let typeRawValue = dictionary[CodingKeys.type.rawValue] as? Int,
let type = CoordinateSpace(rawValue: typeRawValue)
{
self.type = type
} else {
type = .type2d
}
startFrame = try dictionary.value(for: CodingKeys.startFrame)
endFrame = try dictionary.value(for: CodingKeys.endFrame)
framerate = try dictionary.value(for: CodingKeys.framerate)
width = try dictionary.value(for: CodingKeys.width)
height = try dictionary.value(for: CodingKeys.height)
let layerDictionaries: [[String: Any]] = try dictionary.value(for: CodingKeys.layers)
layers = try [LayerModel].fromDictionaries(layerDictionaries)
if let glyphDictionaries = dictionary[CodingKeys.glyphs.rawValue] as? [[String: Any]] {
glyphs = try glyphDictionaries.map({ try Glyph(dictionary: $0) })
} else {
glyphs = nil
}
if let fontsDictionary = dictionary[CodingKeys.fonts.rawValue] as? [String: Any] {
fonts = try FontList(dictionary: fontsDictionary)
} else {
fonts = nil
}
if let assetLibraryDictionaries = dictionary[CodingKeys.assetLibrary.rawValue] as? [[String: Any]] {
assetLibrary = try AssetLibrary(value: assetLibraryDictionaries)
} else {
assetLibrary = nil
}
if let markerDictionaries = dictionary[CodingKeys.markers.rawValue] as? [[String: Any]] {
let markers = try markerDictionaries.map({ try Marker(dictionary: $0) })
var markerMap: [String: Marker] = [:]
for marker in markers {
markerMap[marker.name] = marker
}
self.markers = markers
self.markerMap = markerMap
} else {
markers = nil
markerMap = nil
}
}
// MARK: Public
/// The start time of the composition in frameTime.
public let startFrame: AnimationFrameTime
/// The end time of the composition in frameTime.
public let endFrame: AnimationFrameTime
/// The frame rate of the composition.
public let framerate: Double
/// Return all marker names, in order, or an empty list if none are specified
public var markerNames: [String] {
guard let markers = markers else { return [] }
return markers.map { $0.name }
}
// MARK: Internal
enum CodingKeys: String, CodingKey {
case version = "v"
case type = "ddd"
case startFrame = "ip"
case endFrame = "op"
case framerate = "fr"
case width = "w"
case height = "h"
case layers
case glyphs = "chars"
case fonts
case assetLibrary = "assets"
case markers
}
/// The version of the JSON Schema.
let version: String
/// The coordinate space of the composition.
let type: CoordinateSpace
/// The height of the composition in points.
let width: Int
/// The width of the composition in points.
let height: Int
/// The list of animation layers
let layers: [LayerModel]
/// The list of glyphs used for text rendering
let glyphs: [Glyph]?
/// The list of fonts used for text rendering
let fonts: FontList?
/// Asset Library
let assetLibrary: AssetLibrary?
/// Markers
let markers: [Marker]?
let markerMap: [String: Marker]?
}
@@ -0,0 +1,43 @@
//
// Asset.swift
// lottie-swift
//
// Created by Brandon Withrow on 1/9/19.
//
import Foundation
public class Asset: Codable, DictionaryInitializable {
// MARK: Lifecycle
required public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: Asset.CodingKeys.self)
if let id = try? container.decode(String.self, forKey: .id) {
self.id = id
} else {
id = String(try container.decode(Int.self, forKey: .id))
}
}
required init(dictionary: [String: Any]) throws {
if let id = dictionary[CodingKeys.id.rawValue] as? String {
self.id = id
} else if let id = dictionary[CodingKeys.id.rawValue] as? Int {
self.id = String(id)
} else {
throw InitializableError.invalidInput
}
}
// MARK: Public
/// The ID of the asset
public let id: String
// MARK: Private
private enum CodingKeys: String, CodingKey {
case id
}
}
@@ -0,0 +1,75 @@
//
// AssetLibrary.swift
// lottie-swift
//
// Created by Brandon Withrow on 1/9/19.
//
import Foundation
final class AssetLibrary: Codable, AnyInitializable {
// MARK: Lifecycle
required init(from decoder: Decoder) throws {
var container = try decoder.unkeyedContainer()
var containerForKeys = container
var decodedAssets = [String : Asset]()
var imageAssets = [String : ImageAsset]()
var precompAssets = [String : PrecompAsset]()
while !container.isAtEnd {
let keyContainer = try containerForKeys.nestedContainer(keyedBy: PrecompAsset.CodingKeys.self)
if keyContainer.contains(.layers) {
let precompAsset = try container.decode(PrecompAsset.self)
decodedAssets[precompAsset.id] = precompAsset
precompAssets[precompAsset.id] = precompAsset
} else {
let imageAsset = try container.decode(ImageAsset.self)
decodedAssets[imageAsset.id] = imageAsset
imageAssets[imageAsset.id] = imageAsset
}
}
assets = decodedAssets
self.precompAssets = precompAssets
self.imageAssets = imageAssets
}
init(value: Any) throws {
guard let dictionaries = value as? [[String: Any]] else {
throw InitializableError.invalidInput
}
var decodedAssets = [String : Asset]()
var imageAssets = [String : ImageAsset]()
var precompAssets = [String : PrecompAsset]()
try dictionaries.forEach { dictionary in
if dictionary[PrecompAsset.CodingKeys.layers.rawValue] != nil {
let asset = try PrecompAsset(dictionary: dictionary)
decodedAssets[asset.id] = asset
precompAssets[asset.id] = asset
} else {
let asset = try ImageAsset(dictionary: dictionary)
decodedAssets[asset.id] = asset
imageAssets[asset.id] = asset
}
}
assets = decodedAssets
self.precompAssets = precompAssets
self.imageAssets = imageAssets
}
// MARK: Internal
/// The Assets
let assets: [String: Asset]
let imageAssets: [String: ImageAsset]
let precompAssets: [String: PrecompAsset]
func encode(to encoder: Encoder) throws {
var container = encoder.unkeyedContainer()
try container.encode(contentsOf: Array(assets.values))
}
}
@@ -0,0 +1,112 @@
//
// ImageAsset.swift
// lottie-swift
//
// Created by Brandon Withrow on 1/9/19.
//
import Foundation
// MARK: - ImageAsset
public final class ImageAsset: Asset {
// MARK: Lifecycle
required init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: ImageAsset.CodingKeys.self)
name = try container.decode(String.self, forKey: .name)
directory = try container.decode(String.self, forKey: .directory)
width = try container.decode(Double.self, forKey: .width)
height = try container.decode(Double.self, forKey: .height)
try super.init(from: decoder)
}
required init(dictionary: [String: Any]) throws {
name = try dictionary.value(for: CodingKeys.name)
directory = try dictionary.value(for: CodingKeys.directory)
width = try dictionary.value(for: CodingKeys.width)
height = try dictionary.value(for: CodingKeys.height)
try super.init(dictionary: dictionary)
}
// MARK: Public
/// Image name
public let name: String
/// Image Directory
public let directory: String
/// Image Size
public let width: Double
public let height: Double
override public func encode(to encoder: Encoder) throws {
try super.encode(to: encoder)
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(name, forKey: .name)
try container.encode(directory, forKey: .directory)
try container.encode(width, forKey: .width)
try container.encode(height, forKey: .height)
}
// MARK: Internal
enum CodingKeys: String, CodingKey {
case name = "p"
case directory = "u"
case width = "w"
case height = "h"
}
}
extension Data {
// MARK: Lifecycle
/// Initializes `Data` from an `ImageAsset`.
///
/// Returns nil when the input is not recognized as valid Data URL.
/// - parameter imageAsset: The image asset that contains Data URL.
internal init?(imageAsset: ImageAsset) {
self.init(dataString: imageAsset.name)
}
/// Initializes `Data` from a [Data URL](https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/Data_URIs) String.
///
/// Returns nil when the input is not recognized as valid Data URL.
/// - parameter dataString: The data string to parse.
/// - parameter options: Options for the string parsing. Default value is `[]`.
internal init?(dataString: String, options: DataURLReadOptions = []) {
guard
dataString.hasPrefix("data:"),
let url = URL(string: dataString)
else {
return nil
}
// The code below is needed because Data(contentsOf:) floods logs
// with messages since url doesn't have a host. This only fixes flooding logs
// when data inside Data URL is base64 encoded.
if
let base64Range = dataString.range(of: ";base64,"),
!options.contains(DataURLReadOptions.legacy)
{
let encodedString = String(dataString[base64Range.upperBound...])
self.init(base64Encoded: encodedString)
} else {
try? self.init(contentsOf: url)
}
}
// MARK: Internal
internal struct DataURLReadOptions: OptionSet {
let rawValue: Int
/// Will read Data URL using Data(contentsOf:)
static let legacy = DataURLReadOptions(rawValue: 1 << 0)
}
}
@@ -0,0 +1,40 @@
//
// PrecompAsset.swift
// lottie-swift
//
// Created by Brandon Withrow on 1/9/19.
//
import Foundation
final class PrecompAsset: Asset {
// MARK: Lifecycle
required init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: PrecompAsset.CodingKeys.self)
layers = try container.decode([LayerModel].self, ofFamily: LayerType.self, forKey: .layers)
try super.init(from: decoder)
}
required init(dictionary: [String: Any]) throws {
let layerDictionaries: [[String: Any]] = try dictionary.value(for: CodingKeys.layers)
layers = try [LayerModel].fromDictionaries(layerDictionaries)
try super.init(dictionary: dictionary)
}
// MARK: Internal
enum CodingKeys: String, CodingKey {
case layers
}
/// Layers of the precomp
let layers: [LayerModel]
override func encode(to encoder: Encoder) throws {
try super.encode(to: encoder)
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(layers, forKey: .layers)
}
}
@@ -0,0 +1,67 @@
//
// DictionaryInitializable.swift
// Lottie
//
// Created by Marcelo Fabri on 5/5/22.
//
import Foundation
// MARK: - InitializableError
enum InitializableError: Error {
case invalidInput
}
// MARK: - DictionaryInitializable
protocol DictionaryInitializable {
init(dictionary: [String: Any]) throws
}
// MARK: - AnyInitializable
protocol AnyInitializable {
init(value: Any) throws
}
extension Dictionary {
@_disfavoredOverload
func value<T, KeyType: RawRepresentable>(for key: KeyType) throws -> T where KeyType.RawValue == Key {
guard let value = self[key.rawValue] as? T else {
throw InitializableError.invalidInput
}
return value
}
func value<T: AnyInitializable, KeyType: RawRepresentable>(for key: KeyType) throws -> T where KeyType.RawValue == Key {
if let value = self[key.rawValue] as? T {
return value
}
if let value = self[key.rawValue] {
return try T(value: value)
}
throw InitializableError.invalidInput
}
}
// MARK: - Array + AnyInitializable
extension Array: AnyInitializable where Element == Double {
init(value: Any) throws {
guard let array = value as? [Double] else {
throw InitializableError.invalidInput
}
self = array
}
}
@@ -0,0 +1,34 @@
import Foundation
#if os(iOS) || os(tvOS) || os(watchOS) || targetEnvironment(macCatalyst)
import UIKit
#endif
extension Bundle {
func getAnimationData(_ name: String, subdirectory: String? = nil) throws -> Data? {
// Check for files in the bundle at the given path
let name = name.removingJSONSuffix()
if let url = url(forResource: name, withExtension: "json", subdirectory: subdirectory) {
return try Data(contentsOf: url)
}
// Check for data assets (not available on macOS)
#if os(iOS) || os(tvOS) || os(watchOS) || targetEnvironment(macCatalyst)
let assetKey = subdirectory != nil ? "\(subdirectory ?? "")/\(name)" : name
return NSDataAsset(name: assetKey, bundle: self)?.data
#else
return nil
#endif
}
}
extension String {
fileprivate func removingJSONSuffix() -> String {
// Allow filenames to be passed with a ".json" extension (but not other extensions)
// to keep the behavior from Lottie 2.x - instead of failing to load the animation
guard hasSuffix(".json") else {
return self
}
return (self as NSString).deletingPathExtension
}
}
@@ -0,0 +1,44 @@
// From: https://medium.com/@kewindannerfjordremeczki/swift-4-0-decodable-heterogeneous-collections-ecc0e6b468cf
import Foundation
// MARK: - ClassFamily
/// To support a new class family, create an enum that conforms to this protocol and contains the different types.
protocol ClassFamily: Decodable {
/// The discriminator key.
static var discriminator: Discriminator { get }
/// Returns the class type of the object corresponding to the value.
func getType() -> AnyObject.Type
}
// MARK: - Discriminator
/// Discriminator key enum used to retrieve discriminator fields in JSON payloads.
enum Discriminator: String, CodingKey {
case type = "ty"
}
extension KeyedDecodingContainer {
/// Decode a heterogeneous list of objects for a given family.
/// - Parameters:
/// - heterogeneousType: The decodable type of the list.
/// - family: The ClassFamily enum for the type family.
/// - key: The CodingKey to look up the list in the current container.
/// - Returns: The resulting list of heterogeneousType elements.
func decode<T: Decodable, U: ClassFamily>(_: [T].Type, ofFamily family: U.Type, forKey key: K) throws -> [T] {
var container = try nestedUnkeyedContainer(forKey: key)
var list = [T]()
var tmpContainer = container
while !container.isAtEnd {
let typeContainer = try container.nestedContainer(keyedBy: Discriminator.self)
let family: U = try typeContainer.decode(U.self, forKey: U.discriminator)
if let type = family.getType() as? T.Type {
list.append(try tmpContainer.decode(type))
}
}
return list
}
}
@@ -0,0 +1,113 @@
//
// Keyframe.swift
// lottie-swift
//
// Created by Brandon Withrow on 1/7/19.
//
import CoreGraphics
import Foundation
// MARK: - KeyframeData
/// A generic class used to parse and remap keyframe json.
///
/// Keyframe json has a couple of different variations and formats depending on the
/// type of keyframea and also the version of the JSON. By parsing the raw data
/// we can reconfigure it into a constant format.
final class KeyframeData<T> {
// MARK: Lifecycle
init(
startValue: T?,
endValue: T?,
time: AnimationFrameTime?,
hold: Int?,
inTangent: Vector2D?,
outTangent: Vector2D?,
spatialInTangent: Vector3D?,
spatialOutTangent: Vector3D?)
{
self.startValue = startValue
self.endValue = endValue
self.time = time
self.hold = hold
self.inTangent = inTangent
self.outTangent = outTangent
self.spatialInTangent = spatialInTangent
self.spatialOutTangent = spatialOutTangent
}
// MARK: Internal
enum CodingKeys: String, CodingKey {
case startValue = "s"
case endValue = "e"
case time = "t"
case hold = "h"
case inTangent = "i"
case outTangent = "o"
case spatialInTangent = "ti"
case spatialOutTangent = "to"
}
/// The start value of the keyframe
let startValue: T?
/// The End value of the keyframe. Note: Newer versions animation json do not have this field.
let endValue: T?
/// The time in frames of the keyframe.
let time: AnimationFrameTime?
/// A hold keyframe freezes interpolation until the next keyframe that is not a hold.
let hold: Int?
/// The in tangent for the time interpolation curve.
let inTangent: Vector2D?
/// The out tangent for the time interpolation curve.
let outTangent: Vector2D?
/// The spacial in tangent of the vector.
let spatialInTangent: Vector3D?
/// The spacial out tangent of the vector.
let spatialOutTangent: Vector3D?
var isHold: Bool {
if let hold = hold {
return hold > 0
}
return false
}
}
// MARK: Encodable
extension KeyframeData: Encodable where T: Encodable { }
// MARK: Decodable
extension KeyframeData: Decodable where T: Decodable { }
// MARK: DictionaryInitializable
extension KeyframeData: DictionaryInitializable where T: AnyInitializable {
convenience init(dictionary: [String: Any]) throws {
let startValue = try? dictionary[CodingKeys.startValue.rawValue].flatMap(T.init)
let endValue = try? dictionary[CodingKeys.endValue.rawValue].flatMap(T.init)
let time: AnimationFrameTime? = try? dictionary.value(for: CodingKeys.time)
let hold: Int? = try? dictionary.value(for: CodingKeys.hold)
let inTangent: Vector2D? = try? dictionary.value(for: CodingKeys.inTangent)
let outTangent: Vector2D? = try? dictionary.value(for: CodingKeys.outTangent)
let spatialInTangent: Vector3D? = try? dictionary.value(for: CodingKeys.spatialInTangent)
let spatialOutTangent: Vector3D? = try? dictionary.value(for: CodingKeys.spatialOutTangent)
self.init(
startValue: startValue,
endValue: endValue,
time: time,
hold: hold,
inTangent: inTangent,
outTangent: outTangent,
spatialInTangent: spatialInTangent,
spatialOutTangent: spatialOutTangent)
}
}
@@ -0,0 +1,197 @@
//
// KeyframeGroup.swift
// lottie-swift
//
// Created by Brandon Withrow on 1/14/19.
//
import Foundation
// MARK: - KeyframeGroup
/// Used for coding/decoding a group of Keyframes by type.
///
/// Keyframe data is wrapped in a dictionary { "k" : KeyframeData }.
/// The keyframe data can either be an array of keyframes or, if no animation is present, the raw value.
/// This helper object is needed to properly decode the json.
final class KeyframeGroup<T> {
// MARK: Lifecycle
init(keyframes: ContiguousArray<Keyframe<T>>) {
self.keyframes = keyframes
}
init(_ value: T) {
keyframes = [Keyframe(value)]
}
// MARK: Internal
enum KeyframeWrapperKey: String, CodingKey {
case keyframeData = "k"
}
let keyframes: ContiguousArray<Keyframe<T>>
}
// MARK: Decodable
extension KeyframeGroup: Decodable where T: Decodable {
convenience init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: KeyframeWrapperKey.self)
if let keyframeData: T = try? container.decode(T.self, forKey: .keyframeData) {
/// Try to decode raw value; No keyframe data.
self.init(keyframes: [Keyframe<T>(keyframeData)])
} else {
// Decode and array of keyframes.
//
// Body Movin and Lottie deal with keyframes in different ways.
//
// A keyframe object in Body movin defines a span of time with a START
// and an END, from the current keyframe time to the next keyframe time.
//
// A keyframe object in Lottie defines a singular point in time/space.
// This point has an in-tangent and an out-tangent.
//
// To properly decode this we must iterate through keyframes while holding
// reference to the previous keyframe.
var keyframesContainer = try container.nestedUnkeyedContainer(forKey: .keyframeData)
var keyframes = ContiguousArray<Keyframe<T>>()
var previousKeyframeData: KeyframeData<T>?
while !keyframesContainer.isAtEnd {
// Ensure that Time and Value are present.
let keyframeData = try keyframesContainer.decode(KeyframeData<T>.self)
guard
let value: T = keyframeData.startValue ?? previousKeyframeData?.endValue,
let time = keyframeData.time else
{
/// Missing keyframe data. JSON must be corrupt.
throw DecodingError.dataCorruptedError(
forKey: KeyframeWrapperKey.keyframeData,
in: container,
debugDescription: "Missing keyframe data.")
}
keyframes.append(Keyframe<T>(
value: value,
time: AnimationFrameTime(time),
isHold: keyframeData.isHold,
inTangent: previousKeyframeData?.inTangent,
outTangent: keyframeData.outTangent,
spatialInTangent: previousKeyframeData?.spatialInTangent,
spatialOutTangent: keyframeData.spatialOutTangent))
previousKeyframeData = keyframeData
}
self.init(keyframes: keyframes)
}
}
}
// MARK: Encodable
extension KeyframeGroup: Encodable where T: Encodable {
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: KeyframeWrapperKey.self)
if keyframes.count == 1 {
let keyframe = keyframes[0]
try container.encode(keyframe.value, forKey: .keyframeData)
} else {
var keyframeContainer = container.nestedUnkeyedContainer(forKey: .keyframeData)
for i in 1..<keyframes.endIndex {
let keyframe = keyframes[i - 1]
let nextKeyframe = keyframes[i]
let keyframeData = KeyframeData<T>(
startValue: keyframe.value,
endValue: nextKeyframe.value,
time: keyframe.time,
hold: keyframe.isHold ? 1 : nil,
inTangent: nextKeyframe.inTangent,
outTangent: keyframe.outTangent,
spatialInTangent: nil,
spatialOutTangent: nil)
try keyframeContainer.encode(keyframeData)
}
}
}
}
// MARK: DictionaryInitializable
extension KeyframeGroup: DictionaryInitializable where T: AnyInitializable {
convenience init(dictionary: [String: Any]) throws {
var keyframes = ContiguousArray<Keyframe<T>>()
if
let rawValue = dictionary[KeyframeWrapperKey.keyframeData.rawValue],
let value = try? T(value: rawValue)
{
keyframes = [Keyframe<T>(value)]
} else {
var frameDictionaries: [[String: Any]]
if let singleFrameDictionary = dictionary[KeyframeWrapperKey.keyframeData.rawValue] as? [String: Any] {
frameDictionaries = [singleFrameDictionary]
} else {
frameDictionaries = try dictionary.value(for: KeyframeWrapperKey.keyframeData)
}
var previousKeyframeData: KeyframeData<T>?
for frameDictionary in frameDictionaries {
let data = try KeyframeData<T>(dictionary: frameDictionary)
guard
let value: T = data.startValue ?? previousKeyframeData?.endValue,
let time = data.time else
{
throw InitializableError.invalidInput
}
keyframes.append(Keyframe<T>(
value: value,
time: time,
isHold: data.isHold,
inTangent: previousKeyframeData?.inTangent,
outTangent: data.outTangent,
spatialInTangent: previousKeyframeData?.spatialInTangent,
spatialOutTangent: data.spatialOutTangent))
previousKeyframeData = data
}
}
self.init(keyframes: keyframes)
}
}
// MARK: Equatable
extension KeyframeGroup: Equatable where T: Equatable {
static func == (_ lhs: KeyframeGroup<T>, _ rhs: KeyframeGroup<T>) -> Bool {
lhs.keyframes == rhs.keyframes
}
}
// MARK: Hashable
extension KeyframeGroup: Hashable where T: Hashable {
func hash(into hasher: inout Hasher) {
hasher.combine(keyframes)
}
}
extension Keyframe {
/// Creates a copy of this `Keyframe` with the same timing data, but a different value
func withValue<Value>(_ newValue: Value) -> Keyframe<Value> {
Keyframe<Value>(
value: newValue,
time: time,
isHold: isHold,
inTangent: inTangent,
outTangent: outTangent,
spatialInTangent: spatialInTangent,
spatialOutTangent: spatialOutTangent)
}
}
@@ -0,0 +1,42 @@
//
// ImageLayer.swift
// lottie-swift
//
// Created by Brandon Withrow on 1/8/19.
//
import Foundation
/// A layer that holds an image.
final class ImageLayerModel: LayerModel {
// MARK: Lifecycle
required init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: ImageLayerModel.CodingKeys.self)
referenceID = try container.decode(String.self, forKey: .referenceID)
try super.init(from: decoder)
}
required init(dictionary: [String: Any]) throws {
referenceID = try dictionary.value(for: CodingKeys.referenceID)
try super.init(dictionary: dictionary)
}
// MARK: Internal
/// The reference ID of the image.
let referenceID: String
override func encode(to encoder: Encoder) throws {
try super.encode(to: encoder)
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(referenceID, forKey: .referenceID)
}
// MARK: Private
private enum CodingKeys: String, CodingKey {
case referenceID = "refId"
}
}
@@ -0,0 +1,228 @@
//
// Layer.swift
// lottie-swift
//
// Created by Brandon Withrow on 1/7/19.
//
import Foundation
// MARK: - LayerType + ClassFamily
/// Used for mapping a heterogeneous list to classes for parsing.
extension LayerType: ClassFamily {
static var discriminator: Discriminator = .type
func getType() -> AnyObject.Type {
switch self {
case .precomp:
return PreCompLayerModel.self
case .solid:
return SolidLayerModel.self
case .image:
return ImageLayerModel.self
case .null:
return LayerModel.self
case .shape:
return ShapeLayerModel.self
case .text:
return TextLayerModel.self
}
}
}
// MARK: - LayerType
public enum LayerType: Int, Codable {
case precomp
case solid
case image
case null
case shape
case text
public init(from decoder: Decoder) throws {
self = try LayerType(rawValue: decoder.singleValueContainer().decode(RawValue.self)) ?? .null
}
}
// MARK: - MatteType
public enum MatteType: Int, Codable {
case none
case add
case invert
case unknown
}
// MARK: - BlendMode
public enum BlendMode: Int, Codable {
case normal
case multiply
case screen
case overlay
case darken
case lighten
case colorDodge
case colorBurn
case hardLight
case softLight
case difference
case exclusion
case hue
case saturation
case color
case luminosity
}
// MARK: - LayerModel
/// A base top container for shapes, images, and other view objects.
class LayerModel: Codable, DictionaryInitializable {
// MARK: Lifecycle
required init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: LayerModel.CodingKeys.self)
name = try container.decodeIfPresent(String.self, forKey: .name) ?? "Layer"
index = try container.decodeIfPresent(Int.self, forKey: .index) ?? .random(in: Int.min...Int.max)
type = try container.decode(LayerType.self, forKey: .type)
coordinateSpace = try container.decodeIfPresent(CoordinateSpace.self, forKey: .coordinateSpace) ?? .type2d
inFrame = try container.decode(Double.self, forKey: .inFrame)
outFrame = try container.decode(Double.self, forKey: .outFrame)
startTime = try container.decode(Double.self, forKey: .startTime)
transform = try container.decode(Transform.self, forKey: .transform)
parent = try container.decodeIfPresent(Int.self, forKey: .parent)
blendMode = try container.decodeIfPresent(BlendMode.self, forKey: .blendMode) ?? .normal
masks = try container.decodeIfPresent([Mask].self, forKey: .masks)
timeStretch = try container.decodeIfPresent(Double.self, forKey: .timeStretch) ?? 1
matte = try container.decodeIfPresent(MatteType.self, forKey: .matte)
hidden = try container.decodeIfPresent(Bool.self, forKey: .hidden) ?? false
}
required init(dictionary: [String: Any]) throws {
name = (try? dictionary.value(for: CodingKeys.name)) ?? "Layer"
index = try dictionary.value(for: CodingKeys.index) ?? .random(in: Int.min...Int.max)
type = LayerType(rawValue: try dictionary.value(for: CodingKeys.type)) ?? .null
if
let coordinateSpaceRawValue = dictionary[CodingKeys.coordinateSpace.rawValue] as? Int,
let coordinateSpace = CoordinateSpace(rawValue: coordinateSpaceRawValue)
{
self.coordinateSpace = coordinateSpace
} else {
coordinateSpace = .type2d
}
inFrame = try dictionary.value(for: CodingKeys.inFrame)
outFrame = try dictionary.value(for: CodingKeys.outFrame)
startTime = try dictionary.value(for: CodingKeys.startTime)
transform = try Transform(dictionary: try dictionary.value(for: CodingKeys.transform))
parent = try? dictionary.value(for: CodingKeys.parent)
if
let blendModeRawValue = dictionary[CodingKeys.blendMode.rawValue] as? Int,
let blendMode = BlendMode(rawValue: blendModeRawValue)
{
self.blendMode = blendMode
} else {
blendMode = .normal
}
if let maskDictionaries = dictionary[CodingKeys.masks.rawValue] as? [[String: Any]] {
masks = try maskDictionaries.map({ try Mask(dictionary: $0) })
} else {
masks = nil
}
timeStretch = (try? dictionary.value(for: CodingKeys.timeStretch)) ?? 1
if let matteRawValue = dictionary[CodingKeys.matte.rawValue] as? Int {
matte = MatteType(rawValue: matteRawValue)
} else {
matte = nil
}
hidden = (try? dictionary.value(for: CodingKeys.hidden)) ?? false
}
// MARK: Internal
/// The readable name of the layer
let name: String
/// The index of the layer
let index: Int
/// The type of the layer.
let type: LayerType
/// The coordinate space
let coordinateSpace: CoordinateSpace
/// The in time of the layer in frames.
let inFrame: Double
/// The out time of the layer in frames.
let outFrame: Double
/// The start time of the layer in frames.
let startTime: Double
/// The transform of the layer
let transform: Transform
/// The index of the parent layer, if applicable.
let parent: Int?
/// The blending mode for the layer
let blendMode: BlendMode
/// An array of masks for the layer.
let masks: [Mask]?
/// A number that stretches time by a multiplier
let timeStretch: Double
/// The type of matte if any.
let matte: MatteType?
let hidden: Bool
// MARK: Fileprivate
fileprivate enum CodingKeys: String, CodingKey {
case name = "nm"
case index = "ind"
case type = "ty"
case coordinateSpace = "ddd"
case inFrame = "ip"
case outFrame = "op"
case startTime = "st"
case transform = "ks"
case parent
case blendMode = "bm"
case masks = "masksProperties"
case timeStretch = "sr"
case matte = "tt"
case hidden = "hd"
}
}
extension Array where Element == LayerModel {
static func fromDictionaries(_ dictionaries: [[String: Any]]) throws -> [LayerModel] {
try dictionaries.compactMap { dictionary in
let layerType = dictionary[LayerModel.CodingKeys.type.rawValue] as? Int
switch LayerType(rawValue: layerType ?? LayerType.null.rawValue) {
case .precomp:
return try PreCompLayerModel(dictionary: dictionary)
case .solid:
return try SolidLayerModel(dictionary: dictionary)
case .image:
return try ImageLayerModel(dictionary: dictionary)
case .null:
return try LayerModel(dictionary: dictionary)
case .shape:
return try ShapeLayerModel(dictionary: dictionary)
case .text:
return try TextLayerModel(dictionary: dictionary)
case .none:
return nil
}
}
}
}

Some files were not shown because too many files have changed in this diff Show More