Search Docs
Loading...
Skip to content

Keyframes

Animate single block properties over time with keyframes, such as an opacity fade, a color change or a growing corner radius.

A purple block with a stroke and rounded top corners, exported at 2.5 seconds

10 mins
estimated time
GitHub

Preset in, out and loop animations cover common motion. Keyframes give you control over each value, each time and each curve. A keyframe track belongs to one property of one block and renders at the current playback time, in frame exports and in video export. For preset animations, see Create Animations.

This guide covers how to check which properties accept keyframes, how time and values work, easing, absolute and relative tracks, anchor, skew and auto-orient, how a block follows another block, reading tracks back, removing them, and the errors to expect.

Creating the Animated Block#

We create a rectangle with a color fill and a rect shape, and give it a duration of 5 seconds. Keyframes belong to the block that owns the property. The graphic owns opacity and position/x, the fill owns fill/color/value and the shape owns the corner radii.

// A 5 second rectangle with a color fill and a rect shape
let block = try engine.block.create(.graphic)
try engine.block.setShape(block, shape: engine.block.createShape(.rect))
try engine.block.setFill(block, fill: engine.block.createFill(.color))
try engine.block.setWidth(block, value: 600)
try engine.block.setHeight(block, value: 400)
try engine.block.setPositionX(block, value: 360)
try engine.block.setPositionY(block, value: 340)
try engine.block.appendChild(to: page, child: block)
try engine.block.setDuration(block, duration: 5)
// The fill and the shape own their properties and their keyframes
let fill = try engine.block.getFill(block)
let shape = try engine.block.getShape(block)

Checking Animatable Properties#

isPropertyAnimatable(_:property:) tells you whether a property of a block accepts keyframes. It returns false for a property that the block does not have, for example a star property on a rect shape, and for a property that cannot be animated, such as visible.

let opacityIsAnimatable = try engine.block.isPropertyAnimatable(block, property: "opacity")
let fillColorIsAnimatable = try engine.block.isPropertyAnimatable(fill, property: "fill/color/value")
let visibleIsAnimatable = try engine.block.isPropertyAnimatable(block, property: "visible")
print("opacity: \(opacityIsAnimatable), fill color: \(fillColorIsAnimatable), visible: \(visibleIsAnimatable)")

Pass the block that owns the property. fill/color/value is animatable on the fill block, not on the graphic.

Setting Keyframes#

setKeyframes(_:property:keyframes:mode:) replaces the whole track of a property. Each Keyframe has a time from 0 to 1, normalized over the block’s duration. A fill, shape or effect uses the duration of the block it belongs to. Here the fade ends at 0.4, which is 2 seconds into the 5 second block.

// Fade in during the first 40% of the block's duration (2 seconds)
try engine.block.setKeyframes(block, property: "opacity", keyframes: [
Keyframe(time: 0, value: .float(0), easing: .preset(.easeOut)),
Keyframe(time: 0.4, value: .float(1)),
])

The engine sorts the keyframes by time. Before the first keyframe the property keeps the first value, and after the last keyframe it keeps the last value. When you change the block’s duration, the animation stretches with it.

Keyframe Values#

The KeyframeValue case must match the kind of the property, and all keyframes of one track use the same kind.

Value Holds Example property
.float A Float opacity, rotation, shape/rect/cornerRadiusTL
.color A Color: RGBA, CMYK or spot fill/color/value
.vec2 A SIMD2<Float> position, shape/position
.list An array of Float stroke/dashArray, fill/gradient/colors
.path PathVertex items and a closed flag shape/vector_path/path
.discrete A DiscreteValue: .bool, .int or .string stroke/enabled, blend/mode, text/text

All color keyframes of a track must use one color space.

// Orange, then a hold on purple, then a fade to teal
try engine.block.setKeyframes(fill, property: "fill/color/value", keyframes: [
Keyframe(time: 0, value: .color(.rgba(r: 1, g: 0.5, b: 0, a: 1))),
Keyframe(time: 0.5, value: .color(.rgba(r: 0.5, g: 0.2, b: 0.9, a: 1)), easing: .hold),
Keyframe(time: 0.75, value: .color(.rgba(r: 0.4, g: 0.8, b: 0.8, a: 1))),
Keyframe(time: 1, value: .color(.rgba(r: 0, g: 0.6, b: 0.6, a: 1))),
])

A .discrete value never interpolates. It changes at the time of the next keyframe. An .int value selects an enum option by its index, and an index that names no option throws .blockEnumValueInvalid.

// Switch the stroke on halfway; discrete values never interpolate
try engine.block.setStrokeWidth(block, width: 12)
try engine.block.setStrokeColor(block, color: .rgba(r: 0, g: 0, b: 0, a: 1))
try engine.block.setKeyframes(block, property: "stroke/enabled", keyframes: [
Keyframe(time: 0, value: .discrete(.bool(false))),
Keyframe(time: 0.5, value: .discrete(.bool(true))),
])

Easing Between Keyframes#

The easing of a keyframe shapes the curve from that keyframe to the next one. It defaults to .preset(.linear).

  • .preset(_:) uses one of the AnimationEasing presets, the same values as the animationEasing property of preset animations.
  • .bezier(out:in:) is a cubic bezier, as in CSS cubic-bezier(out.x, out.y, in.x, in.y). Both x values must be from 0 to 1.
  • .hold keeps the value until the next keyframe. In the color track above, the purple keyframe holds from 50% to 75% and then jumps.

We grow all four corner radii with a bezier curve that starts slow and ends slow.

// Grow every corner radius with a CSS-style cubic bezier
for corner in ["TL", "TR", "BL", "BR"] {
try engine.block.setKeyframes(shape, property: "shape/rect/cornerRadius\(corner)", keyframes: [
Keyframe(time: 0, value: .float(0), easing: .bezier(out: SIMD2(0.7, 0), in: SIMD2(0.3, 1))),
Keyframe(time: 1, value: .float(200)),
])
}

Absolute and Relative Tracks#

A track is .absolute by default, so its value replaces the static value of the property. Pass mode: .relative to add the track value to the static value. Relative tracks work for number and vec2 properties only.

// Slide 600 units to the right of the block's static x position
try engine.block.setKeyframes(block, property: "position/x", keyframes: [
Keyframe(time: 0, value: .float(0)),
Keyframe(time: 1, value: .float(600)),
], mode: .relative)

This relative position/x track moves the block 600 units to the right of wherever it is placed. When you move the block, the whole motion moves with it.

Motion Paths and Scale#

position of a block is a vec2 property, so one track moves both axes. The dot follows a curved path: its spatialOut and spatialIn tangents bend the path between two keyframes.

// A dot follows a curved path. Vec2 keyframes animate `position/x` and
// `position/y` together, and the tangents bend the path upward.
let dot = try engine.block.create(.graphic)
try engine.block.setShape(dot, shape: engine.block.createShape(.ellipse))
let dotFill = try engine.block.createFill(.color)
try engine.block.setColor(dotFill, property: "fill/color/value", color: .rgba(r: 0.9, g: 0.2, b: 0.4, a: 1))
try engine.block.setFill(dot, fill: dotFill)
try engine.block.setWidth(dot, value: 120)
try engine.block.setHeight(dot, value: 120)
try engine.block.appendChild(to: page, child: dot)
try engine.block.setDuration(dot, duration: 5)
// The static position is the start of the path.
try engine.block.setVec2(dot, property: "position", value: [200, 900])
try engine.block.setKeyframes(dot, property: "position", keyframes: [
Keyframe(time: 0, value: .vec2([200, 900]), spatialOut: [300, -600], spatialIn: [-300, -600]),
Keyframe(time: 1, value: .vec2([1600, 900])),
])

For which keyframe holds which tangent, straight paths, single-axis tracks and the errors to expect, see Motion Paths.

Rotation and scale tracks pivot on the block’s anchor, transform/anchor/x and transform/anchor/y, in design units from the top-left corner of the frame. A block without an anchor turns about its center, as setRotation(_:radians:) turns it, and scales about its top-left corner. Set the anchor, even to (0, 0), to make it the pivot of the rotation too. hasTransformAnchor(_:) tells whether a block has an anchor.

transform/scale/x and transform/scale/y scale a graphic, text or caption block about its anchor without changing its frame. A negative value mirrors the block, so a track that shows another sign than the static scale also needs the layer/flip scope.

// Pulse the dot to 1.5 times its size and back. Its frame does not change.
for axis in ["x", "y"] {
try engine.block.setKeyframes(dot, property: "transform/scale/\(axis)", keyframes: [
Keyframe(time: 0, value: .float(1)),
Keyframe(time: 0.5, value: .float(1.5)),
Keyframe(time: 1, value: .float(1)),
])
}
print(try engine.block.getAnimatedProperties(dot)) // ["position", "transform/scale/x", "transform/scale/y"]

Scale tracks take .float keyframes. playback/timeRemap of a composition group also takes them; see Time Remap.

Anchor and Skew#

transform/anchor/x and transform/anchor/y set the point that rotation, scale and skew pivot on, and transform/skew shears the block. The bar below has its anchor at its center and leans by 20 degrees, so its rotation track turns it in place.

// A bar that leans by 20 degrees and turns half a turn about its centre.
// The anchor is in design units from the top-left corner of the frame.
let bar = try engine.block.create(.graphic)
try engine.block.setShape(bar, shape: engine.block.createShape(.rect))
try engine.block.setFill(bar, fill: engine.block.createFill(.color))
try engine.block.setWidth(bar, value: 320)
try engine.block.setHeight(bar, value: 80)
try engine.block.setPositionX(bar, value: 120)
try engine.block.setPositionY(bar, value: 120)
try engine.block.appendChild(to: page, child: bar)
try engine.block.setDuration(bar, duration: 5)
try engine.block.setFloat(bar, property: "transform/anchor/x", value: 160)
try engine.block.setFloat(bar, property: "transform/anchor/y", value: 40)
try engine.block.setFloat(bar, property: "transform/skew", value: 20)
try engine.block.setKeyframes(bar, property: "rotation", keyframes: [
Keyframe(time: 0, value: .float(0)),
Keyframe(time: 1, value: .float(.pi)),
])

hasTransformAnchor(_:) tells whether a block has an anchor. Setting transform/anchor/x or transform/anchor/y, even to 0, or giving one of them keyframes, gives a block an anchor. A rotation track turns a graphic or text with an anchor about the anchor, and one without about its frame center.

// The bar has an anchor, so its rotation track turns it about the anchor.
// A graphic or text without an anchor turns about its frame centre.
print(try engine.block.hasTransformAnchor(bar)) // true

Auto-Orient#

When transform/autoOrient is on, a block turns to follow the direction of its position keyframes.

// A bar on a wavy path turns to follow the direction of its motion
let arrow = try engine.block.create(.graphic)
try engine.block.setShape(arrow, shape: engine.block.createShape(.rect))
let arrowFill = try engine.block.createFill(.color)
try engine.block.setColor(arrowFill, property: "fill/color/value", color: .rgba(r: 0.1, g: 0.6, b: 0.3, a: 1))
try engine.block.setFill(arrow, fill: arrowFill)
try engine.block.setWidth(arrow, value: 160)
try engine.block.setHeight(arrow, value: 40)
try engine.block.appendChild(to: page, child: arrow)
try engine.block.setDuration(arrow, duration: 5)
try engine.block.setFloat(arrow, property: "transform/anchor/x", value: 80)
try engine.block.setFloat(arrow, property: "transform/anchor/y", value: 20)
try engine.block.setVec2(arrow, property: "position", value: [700, 140])
try engine.block.setKeyframes(arrow, property: "position", keyframes: [
Keyframe(time: 0, value: .vec2([700, 140]), spatialOut: [300, 150], spatialIn: [-300, -150]),
Keyframe(time: 1, value: .vec2([1600, 140])),
])
try engine.block.setBool(arrow, property: "transform/autoOrient", value: true)

The arrow turns about its anchor at its center. For a block without an anchor, the skew axis, animating the skew, how auto-orient adds to the rotation and the errors to expect, see Anchor, Skew and Auto-Orient.

Follow Another Block#

setTransformParent(_:parent:) makes a block follow the transform of another block, so it moves with the block also while that block plays keyframes. In the example, the square rides along the arrow’s path. getTransformParent(_:) returns the transform parent, or nil.

// A square rides in front of the arrow. Its position is read in the
// arrow's local space, so it moves and turns with the arrow.
let rider = try engine.block.create(.graphic)
try engine.block.setShape(rider, shape: engine.block.createShape(.rect))
let riderFill = try engine.block.createFill(.color)
try engine.block.setColor(riderFill, property: "fill/color/value", color: .rgba(r: 0.95, g: 0.7, b: 0.1, a: 1))
try engine.block.setFill(rider, fill: riderFill)
try engine.block.setWidth(rider, value: 40)
try engine.block.setHeight(rider, value: 40)
try engine.block.setPositionX(rider, value: 170)
try engine.block.setPositionY(rider, value: 0)
try engine.block.appendChild(to: page, child: rider)
try engine.block.setTransformParent(rider, parent: arrow)
print(try engine.block.getTransformParent(rider) == arrow) // true
do {
// The arrow cannot follow a block that follows the arrow
try engine.block.setTransformParent(arrow, parent: rider)
} catch let error as EngineError {
print(error.catalogCode == .blockTransformParentCycle) // true
}

For what a block inherits from its transform parent, chains of links, removing a link and the errors to expect, see Follow Another Block.

Reading Keyframes Back#

getAnimatedProperties(_:) lists the paths of a block that have keyframes, sorted. getKeyframes(_:property:) returns the keyframes of a property sorted by time, with the default easing filled in. It returns an empty array for an animatable property without keyframes. getKeyframeMode(_:property:) returns the TrackMode of a track.

// ["opacity", "position/x", "stroke/enabled"]
let animatedProperties = try engine.block.getAnimatedProperties(block)
// The second keyframe reports the default easing, .preset(.linear)
let opacityKeyframes = try engine.block.getKeyframes(block, property: "opacity")
// .relative
let positionMode = try engine.block.getKeyframeMode(block, property: "position/x")
print(animatedProperties, opacityKeyframes[1].easing, positionMode == .relative)

getKeyframeMode throws for a property without keyframes, so check getAnimatedProperties first when you do not know.

Static Values and Keyframes#

Setters such as setOpacity(_:value:) or setFloat(_:property:value:) change the static value and leave the keyframes as they are. setKeyframes does not change the static value either.

// Changes the static value only. The absolute opacity track still replaces it.
try engine.block.setOpacity(block, value: 0.5)
let opacityKeyframeCount = try engine.block.getKeyframes(block, property: "opacity").count
print(opacityKeyframeCount) // 2

An absolute track replaces the static value, so the new opacity does not show while the track exists. A relative track adds to it.

Removing Keyframes#

removeKeyframes(_:property:) removes the track of one property, so the static value applies again. Removing from a property without keyframes does nothing. To change single keyframes, read the track with getKeyframes, edit the array and pass it to setKeyframes.

// The bottom corners return to their static radius of 0
try engine.block.removeKeyframes(shape, property: "shape/rect/cornerRadiusBL")
try engine.block.removeKeyframes(shape, property: "shape/rect/cornerRadiusBR")
let shapeAnimatedProperties = try engine.block.getAnimatedProperties(shape)
print(shapeAnimatedProperties) // ["shape/rect/cornerRadiusTL", "shape/rect/cornerRadiusTR"]

Handling Errors#

Invalid input throws an EngineError. Its catalogCode names the problem as an EngineErrorCode. A failed setKeyframes leaves the existing track unchanged.

do {
try engine.block.setKeyframes(block, property: "visible", keyframes: [
Keyframe(time: 0, value: .discrete(.bool(true))),
])
} catch let error as EngineError {
print(error.catalogCode == .blockKeyframesPropertyNotAnimatable) // true
}

Exporting a Frame#

We set the page’s playback time to 2.5 seconds and export the page as PNG. The export shows every track at that time: the block is halfway along its path, purple, with a stroke and rounded top corners.

// Export the frame at 2.5 seconds, halfway through the animation
try engine.block.setPlaybackTime(page, time: 2.5)
let png = try await engine.block.export(page, mimeType: .png)
print("Exported \(png.count) bytes")

Keyframes and Other Block Operations#

Keyframes take part in the usual block operations:

  • Undo and redo restore keyframe tracks.
  • Saving and loading a scene keeps the tracks, their easing and their mode.
  • Duplicating a block copies the tracks of the block, its fill and its shape.
  • Replacing a fill, shape or effect does not move the keyframes to the new block. They stay with the old one.
  • Preset animations run on top of keyframe tracks. A fade animation scales the keyframed opacity. A scale animation (Zoom, Pop, Grow, Scale Loop, Squeeze) ignores the static or keyframed transform/scale of the block while it runs. Pulsating and Breathing scale the static or keyframed scale, so a flipped block stays flipped.
  • Scaling a block or changing the design unit also converts the keyframes of length and position properties, such as corner radii and position/x.
  • Rotating or flipping a block turns or mirrors the keyframes of its position, rotation and skew with it. The keyframes of a group turn and mirror with the group. A turn other than a half turn joins position/x and position/y tracks into one position track when they share the mode, the times and the easings and no position track exists. Other axis tracks cannot turn: their Absolute values move with the block, and their Relative values stay.

setKeyframes and removeKeyframes need the appearance/animation scope and the scope of the property’s setter, such as layer/move for position, layer/rotate for rotation and layer/resize for width and the scale. A transform/scale/x or transform/scale/y track that shows another sign than the static scale also needs layer/flip. A transform lock (setTransformLocked) does not apply to keyframes: tracks on the position, rotation, size, scale, anchor or skew still animate a locked block.

Troubleshooting#

  • The property does not accept keyframes (.blockKeyframesPropertyNotAnimatable): check isPropertyAnimatable and pass the block that owns the property, for example the fill for fill/color/value. All keyframe methods throw this error for such a property.
  • The keyframe list is empty (.blockKeyframesEmpty): pass at least one keyframe. Use removeKeyframes to remove a track.
  • A time is outside 0 to 1 (.blockKeyframesTimeOutOfRange): times are fractions of the block’s duration, not seconds.
  • Two keyframes have the same time (.blockKeyframesDuplicateTime): give each keyframe its own time.
  • The value does not fit the property (.blockKeyframesValueKindMismatch): use the value type of the property, for example .color for fill/color/value.
  • The keyframes hold different value types (.blockKeyframesMixedValueKinds): use one value type for the whole track.
  • The colors use different color spaces (.blockKeyframesMixedColorSpaces): use one color space per track. For a spot color, keep one spot color name and animate its tint.
  • A bezier handle is out of range (.blockKeyframesEasingOutOfRange): keep the x values of out and in from 0 to 1.
  • The relative mode fails (.blockKeyframesRelativeUnsupported): use .relative only for number and vec2 properties.
  • Motion path tangents fail (.blockKeyframesSpatialTangentUnsupported): spatialOut and spatialIn work only on keyframes with a .vec2 value.
  • Reading the mode fails (.blockKeyframesNoTrack): the property has no keyframes.
  • A block cannot follow another block (.blockTransformParentCycle): the parent is the block itself, sits inside it or follows it, directly or through other blocks. Pick a parent outside that chain.

API Reference#

Methods#

Method Description
engine.block.isPropertyAnimatable(_:property:) Check whether a property of a block accepts keyframes
engine.block.setKeyframes(_:property:keyframes:mode:) Replace the keyframe track of a property
engine.block.getKeyframes(_:property:) Get the keyframes of a property, sorted by time
engine.block.getKeyframeMode(_:property:) Get the TrackMode of a track
engine.block.getAnimatedProperties(_:) List the properties of a block that have keyframes
engine.block.removeKeyframes(_:property:) Remove the keyframe track of a property
engine.block.setVec2(_:property:value:) Set the static value of position or shape/position
engine.block.setFloat(_:property:value:) Set transform/anchor/x, transform/anchor/y or transform/skew
engine.block.setBool(_:property:value:) Turn transform/autoOrient on
engine.block.setTransformParent(_:parent:) Make a block follow the transform of another block, or pass nil to remove the link
engine.block.getTransformParent(_:) Get the block that a block follows, or nil
engine.block.hasTransformAnchor(_:) Check whether a block has an anchor, the pivot of a rotation track
engine.block.getFill(_:) Get the fill block that owns the fill color
engine.block.getShape(_:) Get the shape block that owns the corner radii
engine.block.setDuration(_:duration:) Set the duration that keyframe times refer to
engine.block.setOpacity(_:value:) Set the static opacity
engine.block.setPlaybackTime(_:time:) Set the time to render
engine.block.export(_:mimeType:options:onPreExport:uriResolver:onProgress:) Export the page at the current playback time

Next Steps#