The purpose of this document is to explain how Lottie's render system works.
For questions reach out to the author: Brandon Withrow
Before submitting a PR to Lottie please run through the following checklist.
- Run 'pod install' in
/Example
- Ensure that all targets in
/Example/lottie-swift.xcworkspace
build - Add any new files to all of the targets in
/Lottie.xcodeproj
- Ensure that all targets build in
/Lottie.xcodeproj
After making a PR please watch for PR notifications. We will run a series of tests on the PR to ensure that it does not break existing animations.
NOTE: PRs must be approved by the Maintainer of Lottie-ios before they can be merged.
Lottie is available on iOS and MacOS via CocoaPods, NPM, and Carthage. Because of this, there are some things to consider when adding files to the project. All of the under-the-hood code in Lottie is written to compile in all environments. Specialty wrappers for both iOS
and MacOS
are written to give access to Lottie in each environment. These wrappers are designed to be as thin as possible, to avoid code fragmentation.
For example, UIKit
is only available on iOS, whereas MacOS uses AppKit
.
All of the source code for Lottie is located in /lottie-swift/src
in the repo. Here's a quick run down of the directory structure:
src
: The Root directory for all Lottie source filesPublic
: Public facing files.Animation
: Files relating toAnimation
andAnimationView
. Files in this directory are compiled on both iOS and MacOS.AnimationCache
: Files relating toAnimationCache
. Files in this directory are compiled on both iOS and MacOS.DynamicProperties
: All public facing files relating to the Dynamic Properties API. Files in this directory are compiled on both iOS and MacOS.ImageProvider
: Holds theImageProvider
protocol. Files in this directory are compiled on both iOS and MacOS.MacOS
: Files that are only compiled for MacOS. Files in this directory are compiled on MacOS.Primitives
: Primitive data structures. Files in this directory are compiled on both iOS and MacOS.iOS
: Files that are only compiled for iOS. Files in this directory are compiled on iOS.
Private
: Privateinternal
files. Files in this directory are compiled on both iOS and MacOS.
Because Lottie supports multiple distributions/platforms, adding a file to the project takes a couple of steps.
- Add the new source file into the appropriate directory. Think about the new source file's purpose and what platform it will be available on.
- After adding the new file, test that it can install and compile with CocoaPods. Navigate to
/Example
in terminal and runpod install
. Afterwards open lottie-swift.xcworkspace and build all of the target platforms to ensure that nothing is broken. - Add the files to the Carthage build. Open Lottie.xcodeproj. Add the new file to the project. Uncheck Copy File when adding new files to this project. Check the files Target Membership in the right panel and make sure it is added to the appropriate targets. There are two targets, on dynamic and one static, for each platform (iOS, tvOS, macOS). After adding the targets run through and build all of the targets.
- Celebrate! You've done it!
Before digging into Lottie, let's take a look at how After Effects builds animation. Lottie structures a lot of its render system in a similar way to After Effects.
An After Effects Composition
is a top level object that holds an animation timeline and several Layers
. These Layers
are different from Layers in iOS, which can describe any rendered frame. A Layer
is more of a top level container, that holds its contents and can transform it. There are a couple of types of Layer
, each with it's own unique contents. They are:
Image layer
: Contains an image and aTransform
Null Layer
: Contains only aTransform
Shape Layer
: Contains a group ofShape Objects
Solid Layer
: Contains a colored rectangleText Layer
: Contains renderedText
Precomp Layer
: Contains another composition, with its own group ofLayers
A Composition
has a timeline, and almost every property in After Effects can be keyframed to change over time. As the current time on a timeline is changed every property with keyframe data is interpolated and updated, creating animation.
Each Layer
has a Transform
which transforms the layer's contents in space. A Transform
can position, rotate, scale, skew, and change the opacity of a layer's contents. Each of these properties can be animated.
Layers
can be parented to another layer. A layer is affect not only by it's own Transform
but also by its Parent
. Layers
also each hold a list of Masks
that affect their appearance. Masks
are bezier shape paths that are used to cut out portions of the layer.
The Shape Layer
is the bread and butter of Lottie. A Shape Layer
can hold several Shape Objects
. You can think of a Shape Object
as a single render instruction. At render time, each Shape Object
is read in order and together create a rendering on screen. Each Shape Object
has its own list of animatable properties that defines it's output. There are three basic classes of Shape Objects
, each with a handful of subtypes.
-
Path Generators: Create bezier path data to be rendered and adds it to the current state. Some path generators are
Ellipse
Rect
Polystar
-
Modifiers: Alters bezier path data from the current state. Some Modifiers are
Trim Path
Merge Path
Transform Path
-
Renderers: Renders the current bezier path data on screen. Some Renderers are
Fill Path
Stroke path
A set of Shape Objects
are held in a Group
, which can be nested inside other Groups
. A Shape Layer
can hold an unlimited number of Shape Objects
The simplest way to recreate After Effects' render system would be to nest all of the render instruction into a CALayer
and have the layer draw it's contents. This wouldn't be very performant however, as a layer would have to redraw it's entire contents if there was even the slightest of updates. In fact this is how After Effects works, each frame is entirely redrawn when anything changes. After Affects can afford to work this way, as it is not a realtime renderer.
Lottie works in a different way. Every renderable instruction is nested in it's own CALayer
. Every frame a Node Tree
determines which properties have updates, and only updates the affected layers. If a renderer doesn't have any updates, it is not redrawn. This greatly improves performance and allows for realtime rendering of animations.
The Node System is designed to efficiently updates render contents. Additionally the Node System was designed to be as clean and composable as possible.
The Animator Node
is a protocol that defines an object with a group of animatable properties. Every Shape Object
is an Animator Node
. A Shape Layer
holds a linked-list tree of Animator Nodes
that it updates each frame. Animator Nodes
are not directly responsible for rendering on screen, they are only responsible for checking for updates and building the data used for rendering.
An Animator Node
will build it's output and put it into an Output Node
. This node is later referenced by a ShapeRenderLayer
which is responsible for rendering the output.
Every frame a Shape Layer
asks it's root Animator Node
to update. The Animator Node
check's if it has any updates, updates its properties if necessary, and then recursively updates it's parent Animator Node
. If an Animator Node
has any updates it will rebuild its outputs, and mark them as updates. At render time anything marked with an update is rendered.
An Animator Node
holds reference to a Node Property Map
. The Node Property Map
holds a list of Node Property
objects, and is responsible for updating them each frame. Additionally the Node Property Map
can map to its Node Property
objects by a key.
A Node Property
holds both a Value Provider
and a Value Container
. During an update the Node Property
will ask the Value Provider
if it has an update. If it does the Node Property
will get the new value from the Value Provider
and store it in the Value Container
. The property and the container will be marked as having an update.
Additionally, the Value Provider
of a Node Property
can be dynamically changed, allowing animations to be altered at runtime.
The Value Provider
protocol defines a handful of methods for retrieving a typed value over time. Each frame a Value Provider
is asked if it has an update, and then is asked for it's value.
A Value Provider
can be a list of keyframes that interpolates over time, a single unchanging value, or a dynamic object that is changed from outside of Lottie.
The Value Container
holds reference to a single output value. The node is marked if it has been updated, and can be references from many sources. Ultimately an Animator Node
will read the value from the container and build its output.
The Output Node
holds reference to the final output of an Animator Node
. Output Node
objects are linked together into their own tree that is held by the render layers. After the Animator Node
tree is updated the Output Node
tree is used by the render layers to redraw it's contents. Every Output Node
has a parent node and an outputPath
. The outputPath
is the sum of its parent's output and its out data.
An Output Node
generally falls into one of three types, which match the three classes of Shape Objects
in After Effects.
Path Output Node
: Holds a generated bezier pathPath Modifier
: Modifies its input path and set the output.Renderer
: Holds instructions for rendering the current path data.
A ShapeCompositionLayer
is a top level CALayer
that holds a Node Tree
and a Render Container
. Each frame of animation the ShapeCompositionLayer
is given a frame. Render updates happen in two passes:
- The Node Tree is updated
- The child Render Layers are updated.
The Animator Node update cycle
This is the update cycle for a single Animator Node
. When the ShapeCompositionLayer
receives a frame it tells its root Animator Node
to update with the frame. The Animator Node
calls recursively upstream to start updates at the top of the tree. An Animator Node
asks its Node Property Map
if there are updates. The property map holds a list of Node Property
objects. Each one is asked if it has an update. That call is passed through to the Value Provider
, and also the Value Container
if either return true
then the property is marked for update. Next the Node Property Map
loops through its properties and asks them to update if necessary. The Node Property
asks its Value Provider
for its value and then stores it in the Value Container
.
After all of the Node Properties
have updates the Animator Node
passes its update state down stream. Once the entire tree has updated its properties it starts to rebuild its outputs. Outputs are rebuilt from the bottom of the tree up to the top. If an Animator Node
was marked as updated during its update pass it rebuilds its output. updateOutputs
is called. Here an Animator Node
executes its custom code for building its outputs. It reads the values of its properties Value Container
and builds the output that is stored in its Output Node
. Afterwards it calls up the tree to continue the update process.
Once all of the nodes have marked themselves, and updated their outputs, the ShapeCompositionLayer
moves on to the render side of the update.
The Render Node update cycle
The Shape Composition Layer
tells it's Shape Container Layer
to mark it's updates. The Shape Container Layer
loops through its child Shape Render Layers
.
Each Shape Render Layer
holds reference to a Renderer
. A Renderer
is a type of Output Node
that has render instructions in addition to an outputPath
. The Shape Render Layer
asks its renderer if there are updates for the frame. If the renderer returns true
the Shape Render Layer
calls setNeedsDisplay
on itself which loops into CALayer
update system.
When display
is called on the Shape Render Layer
it asks its render for render instructions and the layer is redrawn.
🎉🎉🎉
TrimPathNode
: Trims a collection of paths by a percentage of their length
FillNode
: Fills all input paths with a solid colorStrokeNode
: Strokes all input paths with a solid colorGradientFillNode
: Fills all input paths with a gradient colorGradientStrokeNode
: Strokes all input paths with a gradient color
EllipseNode
: Generates an Ellipse PathPolygonNode
: Generates a Polygon Path with N sidesRectNode
: Generates an Rectangular PathShapeNode
: Generates an Path with bezier path dataStarNode
: Generates an Star Path
GroupNode
: Holds and renders a group of node objects
TransformNode
: Supplies top level Layers with transformsTextAnimatorNode
: Supplies a Text Layer with its text contents
For example, let us implement one of the simplest Animator Nodes
, the Fill Node
.
The Fill Node
is a node that renders a filled shape with a solid color. It only has a few properties: color
opacity
and fillRule
.
An Animator Node
has a Node Property Map
that maps its properties. Lets create a property map fot the Fill Node
:
class FillNodeProperties: NodePropertyMap, KeypathSearchable {
var keypathName: String
init(fill: Fill) {
/// The node is initialized with a `Fill` model.
self.keypathName = fill.name
/// Create a Node Property with a group of Color Keyframes
self.color = NodeProperty(provider: KeyframeInterpolator(keyframes: fill.color.keyframes))
/// Create a Node Property with a group of Float Keyframes
self.opacity = NodeProperty(provider: KeyframeInterpolator(keyframes: fill.opacity.keyframes))
/// Set the fill rule.
self.type = fill.fillRule
/// Make a key map of the properties, enabling dynamic property setting.
self.keypathProperties = [
"Opacity" : opacity,
"Color" : color
]
/// Set the properties.
self.properties = Array(keypathProperties.values)
}
let opacity: NodeProperty<Vector1D>
let color: NodeProperty<Color>
let type: FillRule
let keypathProperties: [String : AnyNodeProperty]
let properties: [AnyNodeProperty]
}
Now we have created a robust property map for our Fill Node.
An Animator Node
also needs an OutputNode
. The Fill Node has a Renderer output type, that renders path objects with a fill. Let's create the Renderer OutputNode
/// An OutputNode that holds render instructions for Fill
class FillRenderer: PassThroughOutputNode, Renderable {
/// A Render Node can either update a CAShapeLayer, or render directly into a context.
/// This node can accomplish its rendering with a CAShapeLayer
let shouldRenderInContext: Bool = false
/// Output Node Properties. Notice how setting these properties sets hasUpdate to `true`
/// The fill color.
var color: CGColor? {
didSet {
hasUpdate = true
}
}
/// The fill opacity.
var opacity: CGFloat = 0 {
didSet {
hasUpdate = true
}
}
//// The fill rule.
var fillRule: FillRule = .none {
didSet {
hasUpdate = true
}
}
/// The function that is called when render updates happen.
func updateShapeLayer(layer: CAShapeLayer) {
layer.fillColor = color
layer.opacity = Float(opacity)
layer.fillRule = fillRule.caFillRule
/// Clear the update flag. The job is done.
hasUpdate = false
}
/// Optional, the context renderer.
/// setting shouldRenderInContext to `true` would cause this method to be called.
func render(_ inContext: CGContext) {
guard inContext.path != nil && inContext.path!.isEmpty == false else {
return
}
guard let color = color else { return }
hasUpdate = false
inContext.setAlpha(opacity * 0.01)
inContext.setFillColor(color)
inContext.fillPath(using: fillRule.cgFillRule)
}
}
Now we have a Renderer
OutputNode
capable of rendering a fill. We are now ready to create our FillNode
/// An `Animator Node` capable of fill rendering.
class FillNode: AnimatorNode, RenderNode {
/// The fill renderer.
let fillRender: FillRenderer
/// Protocol RenderNode requires a `Renderable`
var renderer: NodeOutput & Renderable {
return fillRender
}
/// The Fill properties.
let fillProperties: FillNodeProperties
/// Initialized with a `Fill` model.
init(parentNode: AnimatorNode?, fill: Fill) {
/// Create the Renderer
self.fillRender = FillRenderer(parent: parentNode?.outputNode)
/// Create the Properties
self.fillProperties = FillNodeProperties(fill: fill)
/// Set the upstream parent node.
self.parentNode = parentNode
}
// MARK: Animator Node Protocol
var propertyMap: NodePropertyMap & KeypathSearchable {
return fillProperties
}
let parentNode: AnimatorNode?
var hasLocalUpdates: Bool = false
var hasUpstreamUpdates: Bool = false
var lastUpdateFrame: CGFloat? = nil
/// Changes to this node do not affect downstream nodes.
func localUpdatesPermeateDownstream() -> Bool {
return false
}
/// Set up the renderer.
func rebuildOutputs(frame: CGFloat) {
fillRender.color = fillProperties.color.value.cgColorValue
fillRender.opacity = fillProperties.opacity.value.cgFloatValue * 0.01
fillRender.fillRule = fillProperties.type
}
}
And that's that! Now, when connected to a Node Tree, the fill node will render its contents only when its contents, or its upstream nodes, have updated.