提交 030fb758 编写于 作者: 杜庆泉's avatar 杜庆泉 提交者: DCloud-yyl

移除animation-view#0

上级 b72f802d
......@@ -477,17 +477,6 @@
"navigationBarTitleText": "涂鸦"
}
},
// #ifndef WEB
{
"path": "pages/component/animation-view/animation-view",
"group": "0,5,2",
"style": {
"navigationBarTitleText": "animation-view | Lottie动画",
"enablePullDownRefresh": false
}
},
// #endif
{
"path": "pages/tabBar/API",
"style": {
......@@ -2009,10 +1998,6 @@
{
"id": "component.media.video",
"name": "video"
},
{
"id": "component.media.animation-view",
"name": "animation-view"
}
]
},
......
<template>
<div>
<button @tap="changeUrl">播放本地动画资源</button>
<button @tap="changeServerUrl">播放远程动画资源</button>
<button @tap="changeAutoPlay">测试AutoPlay</button>
<button @tap="changeLoop">测试Loop</button>
<button @tap="changeAction(1)">测试action play</button>
<button @tap="changeAction(2)">测试action pause</button>
<button @tap="changeAction(3)">测试action stop</button>
<animation-view ref="animView" v-if="!androidStandardLauncher" :path="animUrl"
:autoplay="autoplay"
:loop="loop"
:action="action"
:hidden="hidden" @bindended="testAnimEnd"
:style="{width:widthNum+'rpx',height:heightNum+'px',background:yanse}">
</animation-view>
</div>
</template>
<script>
export default {
data() {
return {
hidden: false,
autoplay: false,
action: "play",
loop: false,
yanse: "red",
widthNum: 750,
heightNum: 200,
comShow: true,
animUrl: "/static/anim_a.json",
androidStandardLauncher:false,
}
},
onLoad() {
// #ifdef APP-ANDROID
try{
Class.forName("com.airbnb.lottie.LottieAnimationView")
androidStandardLauncher = false
}catch(e:Exception){
androidStandardLauncher = true
}
if(androidStandardLauncher){
uni.showModal({
title:'当前示例需要自定义基座运行',
showCancel:false
})
}
// #endif
},
methods: {
changeAutoPlay: function() {
this.autoplay = !this.autoplay
},
changeUrl: function() {
if (this.animUrl == "/static/anim_a.json") {
this.animUrl = "/static/anim_b.json"
} else {
this.animUrl = "/static/anim_a.json"
}
},
changeServerUrl: function() {
this.animUrl = "https://b.bdstatic.com/miniapp/images/lottie_example_one.json"
},
changeAction: function(type:number) {
if (type == 1) {
this.action = "play"
} else if (type == 2) {
this.action = "pause"
} else if (type == 3) {
this.action = "stop"
}
},
changeLoop: function() {
this.loop = !this.loop
},
testAnimEnd: function() {
console.log("testAnimEnd");
}
}
}
</script>
## 1.0.5(2024-06-14)
调整iOS平台组件内的默认样式
## 1.0.4(2024-05-24)
+ 修复 Android uni-app x 正式包模式下,可能不展示动画的Bug
## 1.0.3(2024-04-11)
修复Android平台 修正组件名称为 `animation-view`
## 1.0.2(2024-03-22)
修复Android平台云打包编译失败的Bug
## 1.0.1(2023-04-07)
修复ios平台本地编译会报 swift 版本不兼容的bug
## 1.0.0(2023-01-16)
实现android/ios平台animation-view组件,仅支持nvue页面
{
"id": "uni-animation-view",
"displayName": "animation-view",
"version": "1.0.5",
"description": "使用uts组件开发,实现animation-view组件",
"keywords": [
"animation-view",
"lottie"
],
"repository": "",
"engines": {
"HBuilderX": "^3.7.0"
},
"dcloudext": {
"type": "component-uts",
"sale": {
"regular": {
"price": "0.00"
},
"sourcecode": {
"price": "0.00"
}
},
"contact": {
"qq": ""
},
"declaration": {
"ads": "无",
"data": "插件不采集任何数据",
"permissions": "无"
},
"npmurl": ""
},
"uni_modules": {
"dependencies": [],
"encrypt": [],
"platforms": {
"cloud": {
"tcb": "y",
"aliyun": "y",
"alipay": "n"
},
"client": {
"Vue": {
"vue2": "y",
"vue3": "y"
},
"App": {
"app-android": {
"minVersion": "21"
},
"app-ios": {
"minVersion": "11"
}
},
"H5-mobile": {
"Safari": "u",
"Android Browser": "u",
"微信浏览器(Android)": "u",
"QQ浏览器(Android)": "u"
},
"H5-pc": {
"Chrome": "u",
"IE": "u",
"Edge": "u",
"Firefox": "u",
"Safari": "u"
},
"小程序": {
"微信": "u",
"阿里": "u",
"百度": "u",
"字节跳动": "u",
"QQ": "u",
"钉钉": "u",
"快手": "u",
"飞书": "u",
"京东": "u"
},
"快应用": {
"华为": "u",
"联盟": "u"
}
}
}
}
}
# animation-view
> animation-view组件是[uts插件](https://uniapp.dcloud.net.cn/plugin/uts-component.html),需 HBuilderX 3.7.0+
> 使用文档:[https://uniapp.dcloud.net.cn/component/animation-view.html](https://uniapp.dcloud.net.cn/component/animation-view.html)
### 属性说明
|属性名|类型|默认值|说明|
|:-|:-|:-|:-|
| path | String | | 动画资源地址,支持本地路径和网络路径 |
| loop | Boolean | false | 动画是否循环播放 |
| autoplay | Boolean | true | 动画是否自动播放 |
| action | String | play | 动画操作,可取值 play、pause、stop |
| hidden | Boolean | true | 是否隐藏动画 |
| @bindended | EventHandle | | 当播放到末尾时触发 ended 事件(自然播放结束会触发回调,循环播放结束及手动停止动画不会触发) |
**注意**
* animation-view 仅App端nvue页面支持
* App端实现使用了Lottie三方SDK,参考开源项目:[Lottie for Android](https://github.com/airbnb/lottie-android)[Lottie for iOS](https://github.com/airbnb/lottie-ios)
* App-Android平台要求Android5(API Leavel 21)及以上系统
* App-iOS平台要求iOS11及以上版本系统
### 代码示例
```html
<template>
<div>
<animation-view class="animation" :path="path" :loop="loop" :autoplay="autoplay" :action="action"
:hidden="hidden" @bindended="lottieEnd">
</animation-view>
<button @click="playLottie" type="primary">{{status}}lottie动画</button>
</div>
</template>
<script>
export default {
data() {
return {
path: 'https://b.bdstatic.com/miniapp/images/lottie_example_one.json',
loop: false,
autoplay: false,
action: 'play',
hidden: false,
status: '暂停'
}
},
methods: {
playLottie() {
this.action = ('play' !== this.action) ? 'play' : 'pause';
this.status = ('pause' === this.action) ? '播放' : '暂停';
},
lottieEnd() {
this.status = '播放';
this.action = 'stop';
console.log('动画播放结束');
}
}
}
</script>
<style>
.animation {
width: 750rpx;
height: 300rpx;
background-color: #FF0000;
margin-bottom: 20px;
}
</style>
```
{
"minSdkVersion": "21",
"dependencies": [
"com.airbnb.android:lottie:5.2.0",
"androidx.appcompat:appcompat:1.0.0"
]
}
<template>
<view class="defaultStyles">
</view>
</template>
<script lang="uts">
import Animator from 'android.animation.Animator'
import TextUtils from 'android.text.TextUtils'
import View from 'android.view.View'
import LottieAnimationView from 'com.airbnb.lottie.LottieAnimationView'
import LottieDrawable from 'com.airbnb.lottie.LottieDrawable'
import FileInputStream from 'java.io.FileInputStream'
class CustomAnimListener extends Animator.AnimatorListener {
comp: UTSComponent < LottieAnimationView >
constructor(com: UTSComponent < LottieAnimationView > ) {
super();
this.comp = com
}
override onAnimationStart(animation: Animator) {}
override onAnimationEnd(animation: Animator, isReverse: Boolean) {
this.comp.$emit("bindended")
}
override onAnimationEnd(animation: Animator) {}
override onAnimationCancel(animation: Animator) {}
override onAnimationRepeat(animation: Animator) {}
}
//原生提供以下属性或方法的实现
export default {
name: "animation-view",
/**
* 当播放到末尾时触发 ended 事件(自然播放结束会触发回调,循环播放结束及手动停止动画不会触发)
*/
emits: ['bindended'],
props: {
/**
* 动画资源地址,目前只支持绝对路径
*/
"path": {
type: String,
default: ""
},
/**
* 动画是否自动播放
*/
"autoplay": {
type: Boolean,
default: false
},
/**
* 动画是否循环播放
*/
"loop": {
type: Boolean,
default: false
},
/**
* 是否隐藏动画
*/
"hidden": {
type: Boolean,
default: false
},
/**
* 动画操作,可取值 play、pause、stop
*/
"action": {
type: String,
default: "stop"
}
},
data() {
return {
}
},
watch: {
"path": {
handler(newPath: string) {
if(this.$el != null){
let lottieAnimationView = this.$el!
if (!TextUtils.isEmpty(newPath)) {
if (newPath.startsWith("http://") || newPath.startsWith("https://")) {
lottieAnimationView.setAnimationFromUrl(newPath)
} else {
// uni-app x 正式打包会放在asset中,需要特殊处理
let realJsonPath = UTSAndroid.getResourcePath(newPath)
if(realJsonPath.startsWith("/android_asset")){
lottieAnimationView.setAnimation(realJsonPath.substring(15))
}else{
lottieAnimationView.setAnimation(new FileInputStream(realJsonPath),newPath)
}
}
}
if (this.autoplay) {
lottieAnimationView.playAnimation()
}
}
},
immediate: false
},
"loop": {
handler(newLoop: Boolean) {
if(this.$el != null){
if (newLoop) {
this.$el!.repeatCount = Int.MAX_VALUE
} else {
// 不循环则设置成1次
this.$el!.repeatCount = 0
}
if (this.autoplay) {
this.$el!.playAnimation()
}
}
},
immediate: false
},
"autoplay": {
handler(newValue: boolean) {
if(this.$el != null){
if (newValue) {
this.$el!.playAnimation()
}
}
},
immediate: false
},
"action": {
handler(newAction: string) {
if (newAction == "play" || newAction == "pause" || newAction == "stop") {
if(this.$el != null){
if (this.action == "play") {
this.$el!.playAnimation()
} else if (this.action == "pause") {
this.$el!.pauseAnimation()
} else if (this.action == "stop") {
this.$el!.cancelAnimation()
this.$el!.clearAnimation()
}
}
} else {
// 非法入参,不管
}
},
immediate: false
},
"hidden": {
handler(newValue: boolean) {
if(this.$el != null){
if (newValue) {
this.$el!.visibility = View.GONE
} else {
this.$el!.visibility = View.VISIBLE
}
}
},
immediate: false
},
},
methods: {
setRepeatMode(repeat: string) {
if(this.$el != null){
if ("RESTART" == repeat) {
this.$el!.repeatMode = LottieDrawable.RESTART
} else if ("REVERSE" == repeat) {
this.$el!.repeatMode = LottieDrawable.RESTART
}
}
},
},
NVLoad(): LottieAnimationView {
let lottieAnimationView = new LottieAnimationView($androidContext)
return lottieAnimationView
},
NVLoaded() {
if(this.$el != null){
this.$el!.repeatMode = LottieDrawable.RESTART;
this.$el!.visibility = View.GONE
this.$el!.repeatCount = 0
this.$el!.addAnimatorListener(new CustomAnimListener(this))
}
}
}
</script>
<style>
/* 定义默认样式值, 组件使用者没有配置时使用 */
.defaultStyles {
width: 750rpx;
height: 240rpx;
}
</style>
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSPrivacyTracking</key>
<false/>
<key>NSPrivacyTrackingDomains</key>
<array/>
<key>NSPrivacyCollectedDataTypes</key>
<array/>
<key>NSPrivacyAccessedAPITypes</key>
<array>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>3B52.1</string>
</array>
</dict>
</array>
</dict>
</plist>
{
"deploymentTarget": "12.0",
"validArchitectures": [
"arm64",
"x86_64"
]
}
<template>
<view style="width: 375px;height: 120px;">
</view>
</template>
<script lang="uts">
// import {
// LottieAnimationView,
// LottieAnimation,
// LottieLoopMode
// } from 'Lottie'
import {
URL,
Bundle
} from 'Foundation'
import {
UIView
} from "UIKit"
import {
UTSiOS
} from "DCloudUTSFoundation"
//原生提供以下属性或方法的实现
export default {
/**
* 组件名称,也就是开发者使用的标签
*/
name: "animation-view",
/**
* 组件涉及的事件声明,只有声明过的事件,才能被正常发送
*/
emits: ['bindended'], // 当播放到末尾时触发 ended 事件(自然播放结束会触发回调,循环播放结束及手动停止动画不会触发)
/**
* 属性声明,组件的使用者会传递这些属性值到组件
*/
props: {
/**
* 动画资源地址,支持远程 URL 地址和本地绝对路径
*/
"path": {
type: String,
default: ""
},
/**
* 动画是否自动播放
*/
"autoplay": {
type: Boolean,
default: false
},
/**
* 动画是否循环播放
*/
"loop": {
type: Boolean,
default: false
},
/**
* 是否隐藏动画
*/
"hidden": {
type: Boolean,
default: false
},
/**
* 动画操作,可取值 play、pause、stop
*/
"action": {
type: String,
default: "stop"
}
},
data() {
return {
}
},
watch: {
"path": {
handler(newValue: string, oldValue: string) {
if (this.autoplay) {
this.playAnimation()
}
},
immediate: false //创建时是否通过此方法更新属性,默认值为false
},
"loop": {
handler(newValue: boolean, oldValue: boolean) {
if (newValue) {
this.$el.loopMode = LottieLoopMode.loop
} else {
this.$el.loopMode = LottieLoopMode.playOnce
}
},
immediate: false //创建时是否通过此方法更新属性,默认值为false
},
"autoplay": {
handler(newValue: boolean, oldValue: boolean) {
if (newValue) {
this.playAnimation()
}
},
immediate: false //创建时是否通过此方法更新属性,默认值为false
},
"action": {
handler(newValue: string, oldValue: string) {
const action = newValue
if (action == "play" || action == "pause" || action == "stop") {
switch (action) {
case "play":
this.playAnimation()
break;
case "pause":
this.$el.pause()
break;
case "stop":
this.$el.stop()
break;
default:
break;
}
} else {
// 非法入参,不管
}
},
immediate: false //创建时是否通过此方法更新属性,默认值为false
},
"hidden": {
handler(newValue: boolean, oldValue: boolean) {
this.$el.isHidden = this.hidden
},
immediate: false //创建时是否通过此方法更新属性,默认值为false
},
},
expose: ['setRepeatMode'],
methods: {
// 需要对外暴露的方法
// 设置 RepeatMode
setRepeatMode(repeatMode: string) {
if (repeatMode == "RESTART") {
if (this.loop) {
this.$el.loopMode = LottieLoopMode.loop
} else {
this.$el.loopMode = LottieLoopMode.playOnce
}
} else if (repeatMode == "REVERSE") {
if (this.loop) {
this.$el.loopMode = LottieLoopMode.autoReverse
} else {
this.$el.loopMode = LottieLoopMode.repeatBackwards(1)
}
}
},
// 不对外暴露的方法
// 播放动画
playAnimation() {
// 构建动画资源 url
var animationUrl: URL | null
if (this.path.hasPrefix("http")) {
animationUrl = new URL(string = this.path)
} else {
const filePath = UTSiOS.getResourcePath(this.path)
animationUrl = new URL(fileURLWithPath = filePath)
}
if (animationUrl != null) {
// 加载动画 LottieAnimation
LottieAnimation.loadedFrom(url = animationUrl!, closure = (animation: LottieAnimation | null):
void => {
if (animation != null) {
// 加载成功开始播放
this.$el.animation = animation
this.$el.play(completion = (isFinish: boolean): void => {
if (isFinish) {
// 播放完成回调事件
this.fireEvent("bindended")
}
})
}
})
} else {
console.log("url 构建失败,请检查 path 是否正确")
}
}
},
created() { //创建组件,替换created
},
NVBeforeLoad() { //组件将要创建,对应前端beforeMount
//可选实现,这里可以提前做一些操作
},
NVLoad(): LottieAnimationView { //创建原生View,必须定义返回值类型(Android需要明确知道View类型,需特殊校验)
// 初始化 Lottie$el
const animationView = new LottieAnimationView()
// 默认只播放一次动画
animationView.loopMode = LottieLoopMode.playOnce
return animationView
},
NVLoaded() { //原生View已创建
/// 更新 props 中定义的属性值
if (this.loop) {
this.$el.loopMode = LottieLoopMode.loop
}
this.$el.isHidden = this.hidden
if (this.autoplay) {
this.playAnimation()
}
},
NVLayouted() { //原生View布局完成
//可选实现,这里可以做布局后续操作
},
NVBeforeUnload() { //原生View将释放
//可选实现,这里可以做释放View之前的操作
},
NVUnloaded() { //原生View已释放
//可选实现,这里可以做释放View之后的操作
},
unmounted() { //组件销毁
//可选实现
}
}
</script>
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSPrivacyTracking</key>
<false/>
<key>NSPrivacyTrackingDomains</key>
<array/>
<key>NSPrivacyCollectedDataTypes</key>
<array/>
<key>NSPrivacyAccessedAPITypes</key>
<array>
<dict>
<key>NSPrivacyAccessedAPIType</key>
<string>NSPrivacyAccessedAPICategoryFileTimestamp</string>
<key>NSPrivacyAccessedAPITypeReasons</key>
<array>
<string>3B52.1</string>
</array>
</dict>
</array>
</dict>
</plist>
// 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`.
/// - This animation should start at the beginning of the animation and
/// last the entire duration of the animation. It will be trimmed and retimed
/// to match the current playback state / looping configuration of the animation view.
@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.animationDuration
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
/// - The given animation should start at the beginning of the animation and
/// last the entire duration of the animation. It will be trimmed and retimed
/// to match the current playback state / looping configuration of the animation view.
@nonobjc
func add(_ animation: CAPropertyAnimation, timedWith context: LayerAnimationContext) {
add(animation.timed(with: context, for: self), forKey: animation.keyPath)
}
}
// Created by Cal Stephens on 12/14/21.
// Copyright © 2021 Airbnb Inc. All rights reserved.
import QuartzCore
extension CALayer {
// MARK: Internal
/// Constructs a `CAKeyframeAnimation` that reflects the given keyframes,
/// and adds it to this `CALayer`.
@nonobjc
func addAnimation<KeyframeValue: AnyInterpolatable, ValueRepresentation>(
for property: LayerProperty<ValueRepresentation>,
keyframes: KeyframeGroup<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)
{
let timedAnimation = defaultAnimation.timed(with: context, for: self)
add(timedAnimation, forKey: property.caLayerKeypath)
}
}
// 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: AnyInterpolatable, ValueRepresentation>(
for property: LayerProperty<ValueRepresentation>,
keyframes keyframeGroup: KeyframeGroup<KeyframeValue>,
value keyframeValueMapping: (KeyframeValue) throws -> ValueRepresentation,
context: LayerAnimationContext)
throws -> CAAnimation?
{
let keyframes = keyframeGroup.keyframes
guard !keyframes.isEmpty else { return nil }
// Check if this set of keyframes uses After Effects expressions, which aren't supported.
// - We only log this once per `CoreAnimationLayer` instance.
if keyframeGroup.unsupportedAfterEffectsExpression != nil, !context.loggingState.hasLoggedAfterEffectsExpressionsWarning {
context.loggingState.hasLoggedAfterEffectsExpressionsWarning = true
context.logger.info("""
`\(property.caLayerKeypath)` animation for "\(context.currentKeypath.fullPath)" \
includes an After Effects expression (https://helpx.adobe.com/after-effects/using/expression-language.html), \
which is not supported by lottie-ios (expressions are only supported by lottie-web). \
This animation may not play correctly.
""")
}
// If there is exactly one keyframe value that doesn't animate,
// we can improve performance by applying that value directly to the layer
// instead of creating a relatively expensive `CAKeyframeAnimation`.
if keyframes.count == 1 {
return singleKeyframeAnimation(
for: property,
keyframeValue: try keyframeValueMapping(keyframes[0].value),
writeDirectlyToPropertyIfPossible: true)
}
/// If we're required to use the `complexTimeRemapping` from some parent `PreCompLayer`,
/// we have to manually interpolate the keyframes with the time remapping applied.
if context.mustUseComplexTimeRemapping {
return try defaultAnimation(
for: property,
keyframes: Keyframes.manuallyInterpolatedWithTimeRemapping(keyframeGroup, context: context),
value: keyframeValueMapping,
context: context.withoutTimeRemapping())
}
// Split the keyframes into segments with the same `CAAnimationCalculationMode` value
// - Each of these segments will become their own `CAKeyframeAnimation`
let animationSegments = keyframes.segmentsSplitByCalculationMode()
// If we only have a single segment, we can just create a single `CAKeyframeAnimation`
// instead of wrapping it in a `CAAnimationGroup` -- this reduces allocation overhead a bit.
if animationSegments.count == 1 {
return try keyframeAnimation(
for: property,
keyframes: animationSegments[0],
value: keyframeValueMapping,
context: context)
} else {
return try animationGroup(
for: property,
animationSegments: animationSegments,
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 `LottieAnimationView.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 CAAnimation and can't write directly
// to the layer property
if
customKeyframes.keyframes.count == 1,
let singleKeyframeAnimation = singleKeyframeAnimation(
for: property,
keyframeValue: customKeyframes.keyframes[0].value,
writeDirectlyToPropertyIfPossible: false)
{
return singleKeyframeAnimation
}
return try keyframeAnimation(
for: property,
keyframes: Array(customKeyframes.keyframes),
value: { $0 },
context: context)
}
/// Creates an animation that applies a single keyframe to this layer property
/// - In many cases this animation can be omitted entirely, and the underlying
/// property can be set directly. In that case, no animation will be created.
private func singleKeyframeAnimation<ValueRepresentation>(
for property: LayerProperty<ValueRepresentation>,
keyframeValue: ValueRepresentation,
writeDirectlyToPropertyIfPossible: Bool)
-> CABasicAnimation?
{
if writeDirectlyToPropertyIfPossible {
// 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 property.isDefaultValue(keyframeValue) {
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`.
let currentValue = value(forKey: property.caLayerKeypath) as? ValueRepresentation
if property.isDefaultValue(currentValue) {
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
}
/// Creates a `CAAnimationGroup` that wraps a `CAKeyframeAnimation` for each
/// of the given `animationSegments`
private func animationGroup<KeyframeValue, ValueRepresentation>(
for property: LayerProperty<ValueRepresentation>,
animationSegments: [[Keyframe<KeyframeValue>]],
value keyframeValueMapping: (KeyframeValue) throws -> ValueRepresentation,
context: LayerAnimationContext)
throws -> CAAnimationGroup
{
// Build the `CAKeyframeAnimation` for each segment of keyframes
// with the same `CAAnimationCalculationMode`.
// - Here we have a non-zero number of animation segments,
// all of which have a non-zero number of keyframes.
let segmentAnimations: [CAKeyframeAnimation] = try animationSegments.indices.map { index in
let animationSegment = animationSegments[index]
var segmentStartTime = try context.time(forFrame: animationSegment.first!.time)
var segmentEndTime = try context.time(forFrame: animationSegment.last!.time)
// Every portion of the animation timeline has to be covered by a `CAKeyframeAnimation`,
// so if this is the first or last segment then the start/end time should be exactly
// the start/end time of the animation itself.
let isFirstSegment = (index == animationSegments.indices.first!)
let isLastSegment = (index == animationSegments.indices.last!)
if isFirstSegment {
segmentStartTime = min(
try context.time(forFrame: context.animation.startFrame),
segmentStartTime)
}
if isLastSegment {
segmentEndTime = max(
try context.time(forFrame: context.animation.endFrame),
segmentEndTime)
}
let segmentDuration = segmentEndTime - segmentStartTime
// We're building `CAKeyframeAnimation`s, so the `keyTimes` are expressed
// relative to 0 (`segmentStartTime`) and 1 (`segmentEndTime`). This is different
// from the default behavior of the `keyframeAnimation` method, where times
// are expressed relative to the entire animation duration.
let customKeyTimes = try animationSegment.map { keyframeModel -> NSNumber in
let keyframeTime = try context.time(forFrame: keyframeModel.time)
let segmentProgressTime = ((keyframeTime - segmentStartTime) / segmentDuration)
return segmentProgressTime as NSNumber
}
let animation = try keyframeAnimation(
for: property,
keyframes: animationSegment,
value: keyframeValueMapping,
customKeyTimes: customKeyTimes,
context: context)
animation.duration = segmentDuration
animation.beginTime = segmentStartTime
return animation
}
let fullAnimation = CAAnimationGroup()
fullAnimation.animations = segmentAnimations
return fullAnimation
}
/// Creates and validates a `CAKeyframeAnimation` for the given keyframes
private func keyframeAnimation<KeyframeValue, ValueRepresentation>(
for property: LayerProperty<ValueRepresentation>,
keyframes: [Keyframe<KeyframeValue>],
value keyframeValueMapping: (KeyframeValue) throws -> ValueRepresentation,
customKeyTimes: [NSNumber]? = nil,
context: LayerAnimationContext)
throws
-> CAKeyframeAnimation
{
// Convert the list of `Keyframe<T>` into
// the representation used by `CAKeyframeAnimation`
var keyTimes = try customKeyTimes ?? keyframes.map { keyframeModel -> NSNumber in
NSNumber(value: Float(try context.progressTime(for: keyframeModel.time)))
}
var timingFunctions = timingFunctions(for: keyframes)
let calculationMode = calculationMode(for: keyframes)
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 {
context.logger.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,
context: context)
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: [Keyframe<KeyframeValue>])
-> CAAnimationCalculationMode
{
// At this point we expect all of the animations to have been split in
// to segments based on the `CAAnimationCalculationMode`, so we can just
// check the first keyframe.
if keyframes[0].isHold {
return .discrete
} else {
return .linear
}
}
/// `timingFunctions` to apply to a `CAKeyframeAnimation` animating the given keyframes
private func timingFunctions<KeyframeValue>(
for keyframes: [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: [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,
context: LayerAnimationContext)
{
// 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
context.logger.assert(
values.count == keyTimes.count,
"`values.count` must exactly equal `keyTimes.count`")
context.logger.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()
context.logger.assert(
keyTimes.count == values.count + 1,
"`keyTimes.count` must exactly equal `values.count + 1`")
default:
context.logger.assertionFailure("""
Unexpected keyframe calculation mode \(calculationMode)
""")
}
}
}
extension RandomAccessCollection {
/// Splits this array of `Keyframe`s into segments with the same `CAAnimationCalculationMode`
/// - Keyframes with `isHold=true` become `discrete`, and keyframes with `isHold=false`
/// become linear. Each `CAKeyframeAnimation` can only be one or the other, so each
/// `calculationModeSegment` becomes its own `CAKeyframeAnimation`.
func segmentsSplitByCalculationMode<KeyframeValue>() -> [[Element]]
where Element == Keyframe<KeyframeValue>, Index == Int
{
var segments: [[Element]] = []
var currentSegment: [Element] = []
for keyframe in self {
guard let mostRecentKeyframe = currentSegment.last else {
currentSegment.append(keyframe)
continue
}
// When `isHold` changes between any two given keyframes, we have to create a new segment
if keyframe.isHold != mostRecentKeyframe.isHold {
// Add this keyframe to both the existing segment that is ending,
// so we know how long that segment is, and the new segment,
// so we know when that segment starts.
currentSegment.append(keyframe)
segments.append(currentSegment)
currentSegment = [keyframe]
}
else {
currentSegment.append(keyframe)
}
}
segments.append(currentSegment)
return segments
}
}
// 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,
pathMultiplier: PathMultiplier)
throws
{
try addAnimation(
for: .path,
keyframes: combinedShapes.shapes,
value: { paths in
let combinedPath = CGMutablePath()
for path in paths {
combinedPath.addPath(path.cgPath().duplicated(times: pathMultiplier))
}
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]>
}
extension CombinedShapeItem {
/// Manually combines the given shape keyframes by manually interpolating at each frame
static func manuallyInterpolating(
shapes: [KeyframeGroup<BezierPath>],
name: String)
-> CombinedShapeItem
{
let interpolators = shapes.map { shape in
KeyframeInterpolator(keyframes: shape.keyframes)
}
let times = shapes.flatMap { $0.keyframes.map { $0.time } }
let minimumTime = times.min() ?? 0
let maximumTime = times.max() ?? 0
let animationLocalTimeRange = Int(minimumTime)...Int(maximumTime)
let interpolatedKeyframes = animationLocalTimeRange.map { localTime in
Keyframe(
value: interpolators.compactMap { interpolator in
interpolator.value(frame: AnimationFrameTime(localTime)) as? BezierPath
},
time: AnimationFrameTime(localTime))
}
return CombinedShapeItem(
shapes: KeyframeGroup(keyframes: ContiguousArray(interpolatedKeyframes)),
name: name)
}
}
// 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,
pathMultiplier: PathMultiplier = 1,
transformPath: (CGPath) -> CGPath = { $0 },
roundedCorners: RoundedCorners? = nil)
throws
{
let combinedKeyframes = try BezierPathKeyframe.combining(
path: customPath,
cornerRadius: roundedCorners?.radius)
try addAnimation(
for: .path,
keyframes: combinedKeyframes,
value: { pathKeyframe in
var path = pathKeyframe.path
if let cornerRadius = pathKeyframe.cornerRadius {
path = path.roundCorners(radius: cornerRadius.cgFloatValue)
}
return transformPath(path.cgPath().duplicated(times: pathMultiplier))
},
context: context)
}
}
extension CGPath {
/// Duplicates this `CGPath` so that it is repeated the given number of times
func duplicated(times: Int) -> CGPath {
if times <= 1 {
return self
}
let cgPath = CGMutablePath()
for _ in 0..<times {
cgPath.addPath(self)
}
return cgPath
}
}
// MARK: - BezierPathKeyframe
/// Data that represents how to render a bezier path at a specific point in time
struct BezierPathKeyframe: Interpolatable {
let path: BezierPath
let cornerRadius: LottieVector1D?
/// Creates a single array of animatable keyframes from the given sets of keyframes
/// that can have different counts / timing parameters
static func combining(
path: KeyframeGroup<BezierPath>,
cornerRadius: KeyframeGroup<LottieVector1D>?) throws
-> KeyframeGroup<BezierPathKeyframe>
{
guard
let cornerRadius,
cornerRadius.keyframes.contains(where: { $0.value.cgFloatValue > 0 })
else {
return path.map { path in
BezierPathKeyframe(path: path, cornerRadius: nil)
}
}
return Keyframes.combined(
path, cornerRadius,
makeCombinedResult: BezierPathKeyframe.init)
}
func interpolate(to: BezierPathKeyframe, amount: CGFloat) -> BezierPathKeyframe {
BezierPathKeyframe(
path: path.interpolate(to: to.path, amount: amount),
cornerRadius: cornerRadius.interpolate(to: to.cornerRadius, amount: amount))
}
}
// Created by Cal Stephens on 8/15/23.
// Copyright © 2023 Airbnb Inc. All rights reserved.
import QuartzCore
// MARK: - DropShadowModel
protocol DropShadowModel {
/// The opacity of the drop shadow, from 0 to 100.
var _opacity: KeyframeGroup<LottieVector1D>? { get }
/// The shadow radius of the blur
var _radius: KeyframeGroup<LottieVector1D>? { get }
/// The color of the drop shadow
var _color: KeyframeGroup<LottieColor>? { get }
/// The angle of the drop shadow, in degrees,
/// with "90" resulting in a shadow directly beneath the layer.
/// Combines with the `distance` to form the `shadowOffset`.
var _angle: KeyframeGroup<LottieVector1D>? { get }
/// The distance of the drop shadow offset.
/// Combines with the `angle` to form the `shadowOffset`.
var _distance: KeyframeGroup<LottieVector1D>? { get }
}
// MARK: - DropShadowStyle + DropShadowModel
extension DropShadowStyle: DropShadowModel {
var _opacity: KeyframeGroup<LottieVector1D>? { opacity }
var _color: KeyframeGroup<LottieColor>? { color }
var _angle: KeyframeGroup<LottieVector1D>? { angle }
var _distance: KeyframeGroup<LottieVector1D>? { distance }
var _radius: KeyframeGroup<LottieVector1D>? {
size.map { sizeValue in
// After Effects shadow softness uses a different range of values than CALayer.shadowRadius,
// so shadows render too softly if we directly use the value from After Effects. We find that
// dividing this value from After Effects by 2 produces results that are visually similar.
LottieVector1D(sizeValue.cgFloatValue / 2)
}
}
}
// MARK: - DropShadowEffect + DropShadowModel
extension DropShadowEffect: DropShadowModel {
var _color: KeyframeGroup<LottieColor>? { color?.value }
var _distance: KeyframeGroup<LottieVector1D>? { distance?.value }
var _radius: KeyframeGroup<LottieVector1D>? {
softness?.value?.map { softnessValue in
// After Effects shadow softness uses a different range of values than CALayer.shadowRadius,
// so shadows render too softly if we directly use the value from After Effects. We find that
// dividing this value from After Effects by 5 produces results that are visually similar.
LottieVector1D(softnessValue.cgFloatValue / 5)
}
}
var _opacity: KeyframeGroup<LottieVector1D>? {
opacity?.value?.map { originalOpacityValue in
// `DropShadowEffect.opacity` is a value between 0 and 255,
// but `DropShadowModel._opacity` expects a value between 0 and 100.
LottieVector1D((originalOpacityValue.value / 255.0) * 100)
}
}
var _angle: KeyframeGroup<LottieVector1D>? {
direction?.value?.map { originalAngleValue in
// `DropShadowEffect.distance` is rotated 90º from the
// angle value representation expected by `DropShadowModel._angle`
LottieVector1D(originalAngleValue.value - 90)
}
}
}
// MARK: - CALayer + DropShadowModel
extension CALayer {
// MARK: Internal
/// Adds drop shadow animations from the given `DropShadowModel` to this layer
@nonobjc
func addDropShadowAnimations(
for dropShadowModel: DropShadowModel,
context: LayerAnimationContext)
throws
{
try addShadowOpacityAnimation(from: dropShadowModel, context: context)
try addShadowColorAnimation(from: dropShadowModel, context: context)
try addShadowRadiusAnimation(from: dropShadowModel, context: context)
try addShadowOffsetAnimation(from: dropShadowModel, context: context)
}
// MARK: Private
private func addShadowOpacityAnimation(from model: DropShadowModel, context: LayerAnimationContext) throws {
guard let opacityKeyframes = model._opacity else { return }
try addAnimation(
for: .shadowOpacity,
keyframes: opacityKeyframes,
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)
}
private func addShadowColorAnimation(from model: DropShadowModel, context: LayerAnimationContext) throws {
guard let shadowColorKeyframes = model._color else { return }
try addAnimation(
for: .shadowColor,
keyframes: shadowColorKeyframes,
value: \.cgColorValue,
context: context)
}
private func addShadowRadiusAnimation(from model: DropShadowModel, context: LayerAnimationContext) throws {
guard let shadowSizeKeyframes = model._radius else { return }
try addAnimation(
for: .shadowRadius,
keyframes: shadowSizeKeyframes,
value: \.cgFloatValue,
context: context)
}
private func addShadowOffsetAnimation(from model: DropShadowModel, context: LayerAnimationContext) throws {
guard
let angleKeyframes = model._angle,
let distanceKeyframes = model._distance
else { return }
let offsetKeyframes = Keyframes.combined(angleKeyframes, distanceKeyframes) { angleDegrees, distance -> CGSize in
// Lottie animation files express rotation in degrees
// (e.g. 90º, 180º, 360º) so we convert to radians to get the
// values expected by Core Animation (e.g. π/2, π, 2π)
let angleRadians = (angleDegrees.cgFloatValue * .pi) / 180
// Lottie animation files express the `shadowOffset` as (angle, distance) pair,
// which we convert to the expected x / y offset values:
let offsetX = distance.cgFloatValue * cos(angleRadians)
let offsetY = distance.cgFloatValue * sin(angleRadians)
return CGSize(width: offsetX, height: offsetY)
}
try addAnimation(
for: .shadowOffset,
keyframes: offsetKeyframes,
value: { $0 },
context: context)
}
}
// 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,
pathMultiplier: PathMultiplier)
throws
{
try addAnimation(
for: .path,
keyframes: ellipse.combinedKeyframes(),
value: { keyframe in
BezierPath.ellipse(
size: keyframe.size.sizeValue,
center: keyframe.position.pointValue,
direction: ellipse.direction)
.cgPath()
.duplicated(times: pathMultiplier)
},
context: context)
}
}
extension Ellipse {
/// Data that represents how to render an ellipse at a specific point in time
struct Keyframe: Interpolatable {
let size: LottieVector3D
let position: LottieVector3D
func interpolate(to: Ellipse.Keyframe, amount: CGFloat) -> Ellipse.Keyframe {
Keyframe(
size: size.interpolate(to: to.size, amount: amount),
position: position.interpolate(to: to.position, amount: amount))
}
}
/// Creates a single array of animatable keyframes from the separate arrays of keyframes in this Ellipse
func combinedKeyframes() throws -> KeyframeGroup<Ellipse.Keyframe> {
Keyframes.combined(
size, position,
makeCombinedResult: Ellipse.Keyframe.init)
}
}
// 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<LottieVector3D> { get }
var endPoint: KeyframeGroup<LottieVector3D> { 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`
/// - The RGB components and alpha components can have different color stops / locations,
/// so have to be rendered in separate `CAGradientLayer`s.
func addGradientAnimations(
for gradient: GradientShapeItem,
type: GradientContentType,
context: LayerAnimationContext)
throws
{
// We have to set `colors` and `locations` to non-nil values
// for the animations below to actually take effect
locations = []
// The initial value for `colors` must be an array with the exact same number of colors
// as the gradient that will be applied in the `CAAnimation`
switch type {
case .rgb:
colors = .init(
repeating: CGColor.rgb(0, 0, 0),
count: gradient.numberOfColors)
case .alpha:
colors = .init(
repeating: CGColor.rgb(0, 0, 0),
count: gradient.colorConfiguration(from: gradient.colors.keyframes[0].value, type: .alpha).count)
}
try addAnimation(
for: .colors,
keyframes: gradient.colors,
value: { colorComponents in
gradient.colorConfiguration(from: colorComponents, type: type).map { $0.color }
},
context: context)
try addAnimation(
for: .locations,
keyframes: gradient.colors,
value: { colorComponents in
gradient.colorConfiguration(from: colorComponents, type: type).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,
value: { absoluteStartPoint in
percentBasedPointInBounds(from: absoluteStartPoint.pointValue)
},
context: context)
try addAnimation(
for: .endPoint,
keyframes: gradient.endPoint,
value: { absoluteEndPoint in
percentBasedPointInBounds(from: absoluteEndPoint.pointValue)
},
context: context)
}
private func addRadialGradientAnimations(for gradient: GradientShapeItem, context: LayerAnimationContext) throws {
type = .radial
let combinedKeyframes = Keyframes.combined(
gradient.startPoint, gradient.endPoint,
makeCombinedResult: { absoluteStartPoint, absoluteEndPoint -> RadialGradientKeyframes in
// Convert the absolute start / end points to the relative structure used by Core Animation
let relativeStartPoint = percentBasedPointInBounds(from: absoluteStartPoint.pointValue)
let radius = absoluteStartPoint.pointValue.distanceTo(absoluteEndPoint.pointValue)
let relativeEndPoint = percentBasedPointInBounds(
from: CGPoint(
x: absoluteStartPoint.x + radius,
y: absoluteStartPoint.y + radius))
return RadialGradientKeyframes(startPoint: relativeStartPoint, endPoint: relativeEndPoint)
})
try addAnimation(
for: .startPoint,
keyframes: combinedKeyframes,
value: \.startPoint,
context: context)
try addAnimation(
for: .endPoint,
keyframes: combinedKeyframes,
value: \.endPoint,
context: context)
}
}
// MARK: - RadialGradientKeyframes
private struct RadialGradientKeyframes: Interpolatable {
let startPoint: CGPoint
let endPoint: CGPoint
func interpolate(to: RadialGradientKeyframes, amount: CGFloat) -> RadialGradientKeyframes {
RadialGradientKeyframes(
startPoint: startPoint.interpolate(to: to.startPoint, amount: amount),
endPoint: endPoint.interpolate(to: to.endPoint, amount: amount))
}
}
// MARK: - GradientContentType
/// Each type of gradient that can be constructed from a `GradientShapeItem`
enum GradientContentType {
case rgb
case alpha
}
/// `colors` and `locations` configuration for a `CAGradientLayer`
typealias GradientColorConfiguration = [(color: CGColor, location: CGFloat)]
extension GradientShapeItem {
// MARK: Internal
/// Whether or not this `GradientShapeItem` includes an alpha component
/// that has to be rendered as a separate `CAGradientLayer` from the
/// layer that renders the rgb color components
var hasAlphaComponent: Bool {
for colorComponentsKeyframe in colors.keyframes {
let colorComponents = colorComponentsKeyframe.value
let alphaConfiguration = colorConfiguration(from: colorComponents, type: .alpha)
let notFullyOpaque = alphaConfiguration.contains(where: { color, _ in
color.alpha < 0.999
})
if notFullyOpaque {
return true
}
}
return false
}
// MARK: Fileprivate
/// Converts the compact `[Double]` color components representation
/// into an array of `CGColor`s and the location of those colors within the gradient.
/// - The color components array is a repeating list of `[location, red, green, blue]` values
/// for each color in the gradient, followed by an optional repeating list of
/// `[location, alpha]` values that control the colors' alpha values.
/// - The RGB and alpha values can have different color stops / locations,
/// so each has to be rendered in a separate `CAGradientLayer`.
fileprivate func colorConfiguration(
from colorComponents: [Double],
type: GradientContentType)
-> GradientColorConfiguration
{
switch type {
case .rgb:
precondition(
colorComponents.count >= numberOfColors * 4,
"Each color must have RGB components and a location component")
// Each group of four `Double` values represents a single `CGColor`,
// and its relative location within the gradient.
var colors = GradientColorConfiguration()
for colorIndex in 0..<numberOfColors {
let colorStartIndex = colorIndex * 4
let colorLocation = CGFloat(colorComponents[colorStartIndex])
let color = CGColor.rgb(
CGFloat(colorComponents[colorStartIndex + 1]),
CGFloat(colorComponents[colorStartIndex + 2]),
CGFloat(colorComponents[colorStartIndex + 3]))
colors.append((color: color, location: colorLocation))
}
return colors
case .alpha:
// After the rgb color components, there can be arbitrary number of repeating
// `[alphaLocation, alphaValue]` pairs that define a separate alpha gradient.
var alphaValues = GradientColorConfiguration()
for alphaIndex in stride(from: numberOfColors * 4, to: colorComponents.endIndex, by: 2) {
let alphaLocation = CGFloat(colorComponents[alphaIndex])
let alphaValue = CGFloat(colorComponents[alphaIndex + 1])
alphaValues.append((color: .rgba(0, 0, 0, alphaValue), location: alphaLocation))
}
return alphaValues
}
}
}
// 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> {
/// The `CALayer` KVC key path that this value should be assigned to
let caLayerKeypath: String
/// Whether or not the given value is the default value for this property
/// - 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 isDefaultValue: (ValueRepresentation?) -> Bool
/// A description of how this property can be customized dynamically
/// at runtime using `AnimationView.setValueProvider(_:keypath:)`
let customizableProperty: CustomizableProperty<ValueRepresentation>?
}
extension LayerProperty where ValueRepresentation: Equatable {
/// Initializes a `LayerProperty` that corresponds to a property on `CALayer`
/// or some other `CALayer` subclass like `CAShapeLayer`.
/// - Parameters:
/// - caLayerKeypath: The Objective-C `#keyPath` to the `CALayer` property,
/// e.g. `#keyPath(CALayer.opacity)` or `#keyPath(CAShapeLayer.path)`.
/// - defaultValue: The default value of the property (e.g. the value of the
/// property immediately after calling `CALayer.init()`). Knowing this value
/// lets us perform some optimizations in `CALayer+addAnimation`.
/// - customizableProperty: A description of how this property can be customized
/// dynamically at runtime using `AnimationView.setValueProvider(_:keypath:)`.
init(
caLayerKeypath: String,
defaultValue: ValueRepresentation?,
customizableProperty: CustomizableProperty<ValueRepresentation>?)
{
self.init(
caLayerKeypath: caLayerKeypath,
isDefaultValue: { $0 == defaultValue },
customizableProperty: customizableProperty)
}
}
// MARK: - CustomizableProperty
/// A description of how a `CALayer` property can be customized dynamically
/// at runtime using `LottieAnimationView.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.
/// - `value` is the value for the current frame that should be converted,
/// as returned by `AnyValueProvider.typeErasedStorage`.
/// - `valueProvider` is the `AnyValueProvider` that returned the type-erased value.
let conversion: (_ value: Any, _ valueProvider: AnyValueProvider) -> 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, CaseIterable {
case color = "Color"
case opacity = "Opacity"
case scale = "Scale"
case position = "Position"
case rotation = "Rotation"
case strokeWidth = "Stroke Width"
case gradientColors = "Colors"
}
// MARK: CALayer properties
extension LayerProperty {
static var position: LayerProperty<CGPoint> {
.init(
caLayerKeypath: "transform.translation",
defaultValue: CGPoint(x: 0, y: 0),
customizableProperty: .position)
}
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: .scaleX)
}
static var scaleY: LayerProperty<CGFloat> {
.init(
caLayerKeypath: "transform.scale.y",
defaultValue: 1,
customizableProperty: .scaleY)
}
static var rotationX: LayerProperty<CGFloat> {
.init(
caLayerKeypath: "transform.rotation.x",
defaultValue: 0,
customizableProperty: nil /* currently unsupported */ )
}
static var rotationY: LayerProperty<CGFloat> {
.init(
caLayerKeypath: "transform.rotation.y",
defaultValue: 0,
customizableProperty: nil /* currently unsupported */ )
}
static var rotationZ: LayerProperty<CGFloat> {
.init(
caLayerKeypath: "transform.rotation.z",
defaultValue: 0,
customizableProperty: .rotation)
}
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: .opacity)
}
static var isHidden: LayerProperty<Bool> {
.init(
caLayerKeypath: #keyPath(CALayer.isHidden),
defaultValue: false,
customizableProperty: nil /* unsupported */ )
}
static var transform: LayerProperty<CATransform3D> {
.init(
caLayerKeypath: #keyPath(CALayer.transform),
isDefaultValue: { transform in
guard let transform else { return false }
return CATransform3DIsIdentity(transform)
},
customizableProperty: nil /* currently unsupported */ )
}
static var shadowOpacity: LayerProperty<CGFloat> {
.init(
caLayerKeypath: #keyPath(CALayer.shadowOpacity),
defaultValue: 0,
customizableProperty: nil /* currently unsupported */ )
}
static var shadowColor: LayerProperty<CGColor> {
.init(
caLayerKeypath: #keyPath(CALayer.shadowColor),
defaultValue: .rgb(0, 0, 0),
customizableProperty: nil /* currently unsupported */ )
}
static var shadowRadius: LayerProperty<CGFloat> {
.init(
caLayerKeypath: #keyPath(CALayer.shadowRadius),
defaultValue: 3.0,
customizableProperty: nil /* currently unsupported */ )
}
static var shadowOffset: LayerProperty<CGSize> {
.init(
caLayerKeypath: #keyPath(CALayer.shadowOffset),
defaultValue: CGSize(width: 0, height: -3.0),
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: .floatValue(.strokeWidth))
}
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: .gradientColors)
}
static var locations: LayerProperty<[CGFloat]> {
.init(
caLayerKeypath: #keyPath(CAGradientLayer.locations),
defaultValue: nil,
customizableProperty: .gradientLocations)
}
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? LottieColor else {
return nil
}
return .rgba(CGFloat(color.r), CGFloat(color.g), CGFloat(color.b), CGFloat(color.a))
})
}
static var opacity: CustomizableProperty<CGFloat> {
.init(
name: [.opacity],
conversion: { typeErasedValue, _ in
guard let vector = typeErasedValue as? LottieVector1D else { return nil }
// Lottie animation files express opacity 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).
return vector.cgFloatValue / 100
})
}
static var scaleX: CustomizableProperty<CGFloat> {
.init(
name: [.scale],
conversion: { typeErasedValue, _ in
guard let vector = typeErasedValue as? LottieVector3D else { return nil }
// 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).
return vector.pointValue.x / 100
})
}
static var scaleY: CustomizableProperty<CGFloat> {
.init(
name: [.scale],
conversion: { typeErasedValue, _ in
guard let vector = typeErasedValue as? LottieVector3D else { return nil }
// 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).
return vector.pointValue.y / 100
})
}
static var rotation: CustomizableProperty<CGFloat> {
.init(
name: [.rotation],
conversion: { typeErasedValue, _ in
guard let vector = typeErasedValue as? LottieVector1D else { return nil }
// 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π)
return vector.cgFloatValue * .pi / 180
})
}
static var position: CustomizableProperty<CGPoint> {
.init(
name: [.position],
conversion: { typeErasedValue, _ in
guard let vector = typeErasedValue as? LottieVector3D else { return nil }
return vector.pointValue
})
}
static var gradientColors: CustomizableProperty<[CGColor]> {
.init(
name: [.gradientColors],
conversion: { _, typeErasedValueProvider in
guard let gradientValueProvider = typeErasedValueProvider as? GradientValueProvider else { return nil }
return gradientValueProvider.colors.map { $0.cgColorValue }
})
}
static var gradientLocations: CustomizableProperty<[CGFloat]> {
.init(
name: [.gradientColors],
conversion: { _, typeErasedValueProvider in
guard let gradientValueProvider = typeErasedValueProvider as? GradientValueProvider else { return nil }
return gradientValueProvider.locations.map { CGFloat($0) }
})
}
static func floatValue(_ name: PropertyName...) -> CustomizableProperty<CGFloat> {
.init(
name: name,
conversion: { typeErasedValue, _ in
guard let vector = typeErasedValue as? LottieVector1D else { return nil }
return vector.cgFloatValue
})
}
}
// 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<LottieVector1D> { 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,
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)
}
}
// 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,
pathMultiplier: PathMultiplier,
roundedCorners: RoundedCorners?)
throws
{
try addAnimation(
for: .path,
keyframes: try rectangle.combinedKeyframes(roundedCorners: roundedCorners),
value: { keyframe in
BezierPath.rectangle(
position: keyframe.position.pointValue,
size: keyframe.size.sizeValue,
cornerRadius: keyframe.cornerRadius.cgFloatValue,
direction: rectangle.direction)
.cgPath()
.duplicated(times: pathMultiplier)
},
context: context)
}
}
extension Rectangle {
/// Data that represents how to render a rectangle at a specific point in time
struct Keyframe: Interpolatable {
let size: LottieVector3D
let position: LottieVector3D
let cornerRadius: LottieVector1D
func interpolate(to: Rectangle.Keyframe, amount: CGFloat) -> Rectangle.Keyframe {
Rectangle.Keyframe(
size: size.interpolate(to: to.size, amount: amount),
position: position.interpolate(to: to.position, amount: amount),
cornerRadius: cornerRadius.interpolate(to: to.cornerRadius, amount: amount))
}
}
/// Creates a single array of animatable keyframes from the separate arrays of keyframes in this Rectangle
func combinedKeyframes(roundedCorners: RoundedCorners?) throws -> KeyframeGroup<Rectangle.Keyframe> {
let cornerRadius = roundedCorners?.radius ?? cornerRadius
return Keyframes.combined(
size, position, cornerRadius,
makeCombinedResult: Rectangle.Keyframe.init)
}
}
// 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,
pathMultiplier: PathMultiplier,
roundedCorners: RoundedCorners?)
throws
{
switch shape {
case let customShape as Shape:
try addAnimations(
for: customShape.path,
context: context,
pathMultiplier: pathMultiplier,
roundedCorners: roundedCorners)
case let combinedShape as CombinedShapeItem:
try addAnimations(for: combinedShape, context: context, pathMultiplier: pathMultiplier)
try context.compatibilityAssert(roundedCorners == nil, """
Rounded corners support is not currently implemented for combined shape items
""")
case let ellipse as Ellipse:
try addAnimations(for: ellipse, context: context, pathMultiplier: pathMultiplier)
case let rectangle as Rectangle:
try addAnimations(
for: rectangle,
context: context,
pathMultiplier: pathMultiplier,
roundedCorners: roundedCorners)
case let star as Star:
try addAnimations(for: star, context: context, pathMultiplier: pathMultiplier)
try context.compatibilityAssert(roundedCorners == nil, """
Rounded corners support is currently not implemented for polygon items
""")
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,
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 -> PathMultiplier {
let (strokeStartKeyframes, strokeEndKeyframes, pathMultiplier) = try trim.caShapeLayerKeyframes()
try addAnimation(
for: .strokeStart,
keyframes: strokeStartKeyframes,
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) / CGFloat(pathMultiplier) / 100
}, context: context)
try addAnimation(
for: .strokeEnd,
keyframes: strokeEndKeyframes,
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) / CGFloat(pathMultiplier) / 100
}, context: context)
return pathMultiplier
}
}
/// The number of times that a `CGPath` needs to be duplicated in order to support the animation's `Trim` keyframes
typealias PathMultiplier = Int
extension Trim {
// MARK: Fileprivate
/// The `strokeStart` and `strokeEnd` keyframes to apply to a `CAShapeLayer`,
/// plus a `pathMultiplier` that should be applied to the layer's `path` so that
/// trim values larger than 100% can be displayed properly.
fileprivate func caShapeLayerKeyframes()
throws
-> (strokeStart: KeyframeGroup<LottieVector1D>, strokeEnd: KeyframeGroup<LottieVector1D>, pathMultiplier: PathMultiplier)
{
let strokeStart: KeyframeGroup<LottieVector1D>
let strokeEnd: KeyframeGroup<LottieVector1D>
// CAShapeLayer requires strokeStart to be less than strokeEnd. This
// isn't required by the Lottie schema, so some animations may have
// strokeStart and strokeEnd flipped.
if startValueIsAlwaysLessOrEqualToThanEndValue() {
// If the start value is always _less than_ or equal to the end value
// then we can use the given values without any modifications
strokeStart = start
strokeEnd = end
} else if startValueIsAlwaysGreaterThanOrEqualToEndValue() {
// If the start value is always _greater than_ or equal to the end value,
// then we can just swap the start / end keyframes. This lets us avoid
// manually interpolating the keyframes values at each frame, which
// would be more expensive.
strokeStart = end
strokeEnd = start
} else {
// Otherwise if the start / end values ever swap places we have to
// fix the order on a per-keyframe basis, which may require manually
// interpolating the keyframe values at each frame.
(strokeStart, strokeEnd) = interpolatedAtEachFrame()
}
// If there are no offsets, then the stroke values can be used as-is
guard
!offset.keyframes.isEmpty,
offset.keyframes.contains(where: { $0.value.cgFloatValue != 0 })
else {
return (strokeStart, strokeEnd, 1)
}
// Apply the offset to the start / end values at each frame
let offsetStrokeKeyframes = Keyframes.combined(
strokeStart,
strokeEnd,
offset,
makeCombinedResult: { start, end, offset -> (start: LottieVector1D, end: LottieVector1D) in
// Compute the adjusted value by converting the offset value to a stroke value
let offsetStart = start.cgFloatValue + (offset.cgFloatValue / 360 * 100)
let offsetEnd = end.cgFloatValue + (offset.cgFloatValue / 360 * 100)
return (start: LottieVector1D(offsetStart), end: LottieVector1D(offsetEnd))
})
var adjustedStrokeStart = offsetStrokeKeyframes.map { $0.start }
var adjustedStrokeEnd = offsetStrokeKeyframes.map { $0.end }
// If maximum stroke value is larger than 100%, then we have to create copies of the path
// so the total path length includes the maximum stroke
let startStrokes = adjustedStrokeStart.keyframes.map { $0.value.cgFloatValue }
let endStrokes = adjustedStrokeEnd.keyframes.map { $0.value.cgFloatValue }
let minimumStrokeMultiplier = Double(floor((startStrokes.min() ?? 0) / 100.0))
let maximumStrokeMultiplier = Double(ceil((endStrokes.max() ?? 100) / 100.0))
if minimumStrokeMultiplier < 0 {
// Core Animation doesn't support negative stroke offsets, so we have to
// shift all of the offset values up by the minimum
adjustedStrokeStart = adjustedStrokeStart.map { LottieVector1D($0.value + (abs(minimumStrokeMultiplier) * 100.0)) }
adjustedStrokeEnd = adjustedStrokeEnd.map { LottieVector1D($0.value + (abs(minimumStrokeMultiplier) * 100.0)) }
}
return (
strokeStart: adjustedStrokeStart,
strokeEnd: adjustedStrokeEnd,
pathMultiplier: Int(abs(maximumStrokeMultiplier) + abs(minimumStrokeMultiplier)))
}
// MARK: Private
/// Checks whether or not the value for `trim.start` is less than
/// or equal to the value for every `trim.end` at every frame.
private func startValueIsAlwaysLessOrEqualToThanEndValue() -> Bool {
startAndEndValuesAllSatisfy { startValue, endValue in
startValue <= endValue
}
}
/// Checks whether or not the value for `trim.start` is greater than
/// or equal to the value for every `trim.end` at every frame.
private func startValueIsAlwaysGreaterThanOrEqualToEndValue() -> Bool {
startAndEndValuesAllSatisfy { startValue, endValue in
startValue >= endValue
}
}
private func startAndEndValuesAllSatisfy(_ condition: (_ start: CGFloat, _ end: CGFloat) -> Bool) -> 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? LottieVector1D,
let endAtTime = endInterpolator.value(frame: keyframeTime) as? LottieVector1D
else { continue }
if !condition(startAtTime.cgFloatValue, endAtTime.cgFloatValue) {
return false
}
}
return true
}
/// Interpolates the start and end keyframes, at each frame if necessary,
/// so that the value of `strokeStart` is always less than `strokeEnd`.
private func interpolatedAtEachFrame()
-> (strokeStart: KeyframeGroup<LottieVector1D>, strokeEnd: KeyframeGroup<LottieVector1D>)
{
let combinedKeyframes = Keyframes.combined(
start,
end,
makeCombinedResult: { startValue, endValue -> (start: LottieVector1D, end: LottieVector1D) in
if startValue.cgFloatValue < endValue.cgFloatValue {
return (start: startValue, end: endValue)
} else {
return (start: endValue, end: startValue)
}
})
return (
strokeStart: combinedKeyframes.map { $0.start },
strokeEnd: combinedKeyframes.map { $0.end })
}
}
// 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,
pathMultiplier: PathMultiplier)
throws
{
switch star.starType {
case .star:
try addStarAnimation(for: star, context: context, pathMultiplier: pathMultiplier)
case .polygon:
try addPolygonAnimation(for: star, context: context, pathMultiplier: pathMultiplier)
case .none:
break
}
}
// MARK: Private
@nonobjc
private func addStarAnimation(
for star: Star,
context: LayerAnimationContext,
pathMultiplier: PathMultiplier)
throws
{
try addAnimation(
for: .path,
keyframes: try star.combinedKeyframes(),
value: { keyframe in
BezierPath.star(
position: keyframe.position.pointValue,
outerRadius: keyframe.outerRadius.cgFloatValue,
innerRadius: keyframe.innerRadius.cgFloatValue,
outerRoundedness: keyframe.outerRoundness.cgFloatValue,
innerRoundedness: keyframe.innerRoundness.cgFloatValue,
numberOfPoints: keyframe.points.cgFloatValue,
rotation: keyframe.rotation.cgFloatValue,
direction: star.direction)
.cgPath()
.duplicated(times: pathMultiplier)
},
context: context)
}
@nonobjc
private func addPolygonAnimation(
for star: Star,
context: LayerAnimationContext,
pathMultiplier: PathMultiplier)
throws
{
try addAnimation(
for: .path,
keyframes: try star.combinedKeyframes(),
value: { keyframe in
BezierPath.polygon(
position: keyframe.position.pointValue,
numberOfPoints: keyframe.points.cgFloatValue,
outerRadius: keyframe.outerRadius.cgFloatValue,
outerRoundedness: keyframe.outerRoundness.cgFloatValue,
rotation: keyframe.rotation.cgFloatValue,
direction: star.direction)
.cgPath()
.duplicated(times: pathMultiplier)
},
context: context)
}
}
extension Star {
/// Data that represents how to render a star at a specific point in time
struct Keyframe: Interpolatable {
let position: LottieVector3D
let outerRadius: LottieVector1D
let innerRadius: LottieVector1D
let outerRoundness: LottieVector1D
let innerRoundness: LottieVector1D
let points: LottieVector1D
let rotation: LottieVector1D
func interpolate(to: Star.Keyframe, amount: CGFloat) -> Star.Keyframe {
Star.Keyframe(
position: position.interpolate(to: to.position, amount: amount),
outerRadius: outerRadius.interpolate(to: to.outerRadius, amount: amount),
innerRadius: innerRadius.interpolate(to: to.innerRadius, amount: amount),
outerRoundness: outerRoundness.interpolate(to: to.outerRoundness, amount: amount),
innerRoundness: innerRoundness.interpolate(to: to.innerRoundness, amount: amount),
points: points.interpolate(to: to.points, amount: amount),
rotation: rotation.interpolate(to: to.rotation, amount: amount))
}
}
/// Creates a single array of animatable keyframes from the separate arrays of keyframes in this star/polygon
func combinedKeyframes() throws -> KeyframeGroup<Keyframe> {
Keyframes.combined(
position,
outerRadius,
innerRadius ?? KeyframeGroup(LottieVector1D(0)),
outerRoundness,
innerRoundness ?? KeyframeGroup(LottieVector1D(0)),
points,
rotation,
makeCombinedResult: Star.Keyframe.init)
}
}
// Created by Cal Stephens on 2/10/22.
// Copyright © 2022 Airbnb Inc. All rights reserved.
import QuartzCore
// MARK: - StrokeShapeItem
/// A `ShapeItem` that represents a stroke
protocol StrokeShapeItem: ShapeItem, OpacityAnimationModel {
var strokeColor: KeyframeGroup<LottieColor>? { get }
var width: KeyframeGroup<LottieVector1D> { get }
var lineCap: LineCap { get }
var lineJoin: LineJoin { get }
var miterLimit: Double { get }
var dashPattern: [DashElement]? { get }
func copy(width: KeyframeGroup<LottieVector1D>) -> StrokeShapeItem
}
// MARK: - Stroke + StrokeShapeItem
extension Stroke: StrokeShapeItem {
var strokeColor: KeyframeGroup<LottieColor>? { color }
func copy(width: KeyframeGroup<LottieVector1D>) -> StrokeShapeItem {
// Type-erase the copy from `Stroke` to `StrokeShapeItem`
let copy: Stroke = copy(width: width)
return copy
}
}
// MARK: - GradientStroke + StrokeShapeItem
extension GradientStroke: StrokeShapeItem {
var strokeColor: KeyframeGroup<LottieColor>? { nil }
func copy(width: KeyframeGroup<LottieVector1D>) -> StrokeShapeItem {
// Type-erase the copy from `GradientStroke` to `StrokeShapeItem`
let copy: GradientStroke = copy(width: width)
return copy
}
}
// 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,
value: \.cgColorValue,
context: context)
}
try addAnimation(
for: .lineWidth,
keyframes: stroke.width,
value: \.cgFloatValue,
context: context)
try addOpacityAnimation(for: stroke, context: context)
if let (dashPattern, dashPhase) = stroke.dashPattern?.shapeLayerConfiguration {
let lineDashPattern = try dashPattern.map {
try KeyframeGroup(keyframes: $0)
.exactlyOneKeyframe(context: context, description: "stroke dashPattern").cgFloatValue
}
if lineDashPattern.isSupportedLayerDashPattern {
self.lineDashPattern = lineDashPattern as [NSNumber]
}
try addAnimation(
for: .lineDashPhase,
keyframes: KeyframeGroup(keyframes: dashPhase),
value: \.cgFloatValue,
context: context)
}
}
}
// 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 is 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<LottieVector3D> { get }
/// The position of the transform. This is nil if the position data was split.
var _position: KeyframeGroup<LottieVector3D>? { get }
/// The positionX of the transform. This is nil if the position property is set.
var _positionX: KeyframeGroup<LottieVector1D>? { get }
/// The positionY of the transform. This is nil if the position property is set.
var _positionY: KeyframeGroup<LottieVector1D>? { get }
/// The scale of the transform
var scale: KeyframeGroup<LottieVector3D> { get }
/// The rotation of the transform on X axis.
var rotationX: KeyframeGroup<LottieVector1D> { get }
/// The rotation of the transform on Y axis.
var rotationY: KeyframeGroup<LottieVector1D> { get }
/// The rotation of the transform on Z axis.
var rotationZ: KeyframeGroup<LottieVector1D> { get }
/// The skew of the transform (only present on `ShapeTransform`s)
var _skew: KeyframeGroup<LottieVector1D>? { get }
/// The skew axis of the transform (only present on `ShapeTransform`s)
var _skewAxis: KeyframeGroup<LottieVector1D>? { get }
}
// MARK: - Transform + TransformModel
extension Transform: TransformModel {
var _position: KeyframeGroup<LottieVector3D>? { position }
var _positionX: KeyframeGroup<LottieVector1D>? { positionX }
var _positionY: KeyframeGroup<LottieVector1D>? { positionY }
var _skew: KeyframeGroup<LottieVector1D>? { nil }
var _skewAxis: KeyframeGroup<LottieVector1D>? { nil }
}
// MARK: - ShapeTransform + TransformModel
extension ShapeTransform: TransformModel {
var anchorPoint: KeyframeGroup<LottieVector3D> { anchor }
var _position: KeyframeGroup<LottieVector3D>? { position }
var _positionX: KeyframeGroup<LottieVector1D>? { nil }
var _positionY: KeyframeGroup<LottieVector1D>? { nil }
var _skew: KeyframeGroup<LottieVector1D>? { skew }
var _skewAxis: KeyframeGroup<LottieVector1D>? { skewAxis }
}
// 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
{
if
// CALayers don't support animating skew with its own set of keyframes.
// If the transform includes a skew, we have to combine all of the transform
// components into a single set of keyframes.
transformModel.hasSkew
// Negative `scale.x` values aren't applied correctly by Core Animation when animating
// `transform.scale.x` and `transform.scale.y` using separate `CAKeyframeAnimation`s
// (https://openradar.appspot.com/FB9862872). If the transform includes negative `scale.x`
// values, we have to combine all of the transform components into a single set of keyframes.
|| transformModel.hasNegativeXScaleValues
{
try addCombinedTransformAnimation(for: transformModel, context: context)
}
else {
try addPositionAnimations(from: transformModel, context: context)
try addAnchorPointAnimation(from: transformModel, context: context)
try addScaleAnimations(from: transformModel, context: context)
try addRotationAnimations(from: transformModel, context: context)
}
}
// MARK: Private
@nonobjc
private func addPositionAnimations(
from transformModel: TransformModel,
context: LayerAnimationContext)
throws
{
if let positionKeyframes = transformModel._position {
try addAnimation(
for: .position,
keyframes: positionKeyframes,
value: \.pointValue,
context: context)
} else if
let xKeyframes = transformModel._positionX,
let yKeyframes = transformModel._positionY
{
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,
value: { absoluteAnchorPoint in
guard bounds.width > 0, bounds.height > 0 else {
context.logger.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,
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).
CGFloat(scale.x) / 100
},
context: context)
try addAnimation(
for: .scaleY,
keyframes: transformModel.scale,
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).
CGFloat(scale.y) / 100
},
context: context)
}
private func addRotationAnimations(
from transformModel: TransformModel,
context: LayerAnimationContext)
throws
{
let containsXRotationValues = transformModel.rotationX.keyframes.contains(where: { $0.value.cgFloatValue != 0 })
let containsYRotationValues = transformModel.rotationY.keyframes.contains(where: { $0.value.cgFloatValue != 0 })
// When `rotation.x` or `rotation.y` is used, it doesn't render property in test snapshots
// but do renders correctly on the simulator / device
if TestHelpers.snapshotTestsAreRunning {
if containsXRotationValues {
context.logger.warn("""
`rotation.x` values are not displayed correctly in snapshot tests
""")
}
if containsYRotationValues {
context.logger.warn("""
`rotation.y` values are not displayed correctly in snapshot tests
""")
}
}
// Lottie animation files express rotation in degrees
// (e.g. 90º, 180º, 360º) so we convert to radians to get the
// values expected by Core Animation (e.g. π/2, π, 2π)
try addAnimation(
for: .rotationX,
keyframes: transformModel.rotationX,
value: { rotationDegrees in
rotationDegrees.cgFloatValue * .pi / 180
},
context: context)
try addAnimation(
for: .rotationY,
keyframes: transformModel.rotationY,
value: { rotationDegrees in
rotationDegrees.cgFloatValue * .pi / 180
},
context: context)
try addAnimation(
for: .rotationZ,
keyframes: transformModel.rotationZ,
value: { rotationDegrees in
// Lottie animation files express rotation in degrees
// (e.g. 90º, 180º, 360º) so we convert to radians to get the
// values expected by Core Animation (e.g. π/2, π, 2π)
rotationDegrees.cgFloatValue * .pi / 180
},
context: context)
}
/// Adds an animation for the entire `transform` key by combining all of the
/// position / size / rotation / skew animations into a single set of keyframes.
/// This is more expensive that animating each component separately, since
/// it may require manually interpolating the keyframes at each frame.
private func addCombinedTransformAnimation(
for transformModel: TransformModel,
context: LayerAnimationContext)
throws
{
let requiresManualInterpolation =
// Core Animation doesn't animate skew changes properly. If the skew value
// changes over the course of the animation then we have to manually
// compute the `CATransform3D` for each frame individually.
transformModel.hasSkewAnimation
// `addAnimation` requires that we use an `Interpolatable` value, but we can't interpolate a `CATransform3D`.
// Since this is only necessary when using `complexTimeRemapping`, we can avoid this by manually interpolating
// when `context.mustUseComplexTimeRemapping` is true and just returning a `Hold` container.
// Since our keyframes are already manually interpolated, they won't need to be interpolated again.
|| context.mustUseComplexTimeRemapping
let combinedTransformKeyframes = Keyframes.combined(
transformModel.anchorPoint,
transformModel._position ?? KeyframeGroup(LottieVector3D(x: 0.0, y: 0.0, z: 0.0)),
transformModel._positionX ?? KeyframeGroup(LottieVector1D(0)),
transformModel._positionY ?? KeyframeGroup(LottieVector1D(0)),
transformModel.scale,
transformModel.rotationX,
transformModel.rotationY,
transformModel.rotationZ,
transformModel._skew ?? KeyframeGroup(LottieVector1D(0)),
transformModel._skewAxis ?? KeyframeGroup(LottieVector1D(0)),
requiresManualInterpolation: requiresManualInterpolation,
makeCombinedResult: {
anchor, position, positionX, positionY, scale, rotationX, rotationY, rotationZ, skew, skewAxis
-> Hold<CATransform3D> in
let transformPosition: CGPoint
if transformModel._positionX != nil, transformModel._positionY != nil {
transformPosition = CGPoint(x: positionX.cgFloatValue, y: positionY.cgFloatValue)
} else {
transformPosition = position.pointValue
}
let transform = CATransform3D.makeTransform(
anchor: anchor.pointValue,
position: transformPosition,
scale: scale.sizeValue,
rotationX: rotationX.cgFloatValue,
rotationY: rotationY.cgFloatValue,
rotationZ: rotationZ.cgFloatValue,
skew: skew.cgFloatValue,
skewAxis: skewAxis.cgFloatValue)
return Hold(value: transform)
})
try addAnimation(
for: .transform,
keyframes: combinedTransformKeyframes,
value: { $0.value },
context: context)
}
}
extension TransformModel {
/// Whether or not this transform has a non-zero skew value
var hasSkew: Bool {
guard
let _skew,
let _skewAxis,
!_skew.keyframes.isEmpty,
!_skewAxis.keyframes.isEmpty
else {
return false
}
return _skew.keyframes.contains(where: { $0.value.cgFloatValue != 0 })
}
/// Whether or not this transform has a non-zero skew value which animates
var hasSkewAnimation: Bool {
guard
hasSkew,
let _skew,
let _skewAxis
else { return false }
return _skew.keyframes.count > 1
|| _skewAxis.keyframes.count > 1
}
/// Whether or not this `TransformModel` has any negative X scale values
var hasNegativeXScaleValues: Bool {
scale.keyframes.contains(where: { $0.value.x < 0 })
}
}
// 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)
throws
{
/// If this layer uses `complexTimeRemapping`, use the `addAnimation` codepath
/// which uses `Keyframes.manuallyInterpolatedWithTimeRemapping`.
if context.mustUseComplexTimeRemapping {
let isHiddenKeyframes = KeyframeGroup(keyframes: [
Keyframe(value: true, time: 0, isHold: true), // hidden, before `inFrame`
Keyframe(value: false, time: inFrame, isHold: true), // visible
Keyframe(value: true, time: outFrame, isHold: true), // hidden, after `outFrame`
])
try addAnimation(
for: .isHidden,
keyframes: isHiddenKeyframes.map { Hold(value: $0) },
value: { $0.value },
context: context)
}
/// Otherwise continue using the legacy codepath that doesn't support complex time remapping.
/// - TODO: We could remove this codepath in favor of always using the simpler codepath above,
/// but would have to solve https://github.com/airbnb/lottie-ios/pull/2254 for that codepath.
else {
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(try context.progressTime(for: inFrame)), 0)),
// Anything visible during the last frame should stay visible until the absolute end of the animation.
// - This matches the behavior of the main thread rendering engine.
context.simpleTimeRemapping(outFrame) == context.animation.endFrame
? NSNumber(value: Double(1.0))
: NSNumber(value: min(Double(try context.progressTime(for: outFrame)), 1)),
NSNumber(value: 1.0),
]
add(animation, timedWith: context)
}
}
}
// 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, logger: LottieLogger) {
self.mode = mode
self.logger = logger
}
// 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 {
logger.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
private let logger: LottieLogger
/// 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
}
}
// 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 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)
}
// 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
/// - This should only be used in cases where it's fundamentally not possible to
/// support animating a given property (e.g. if Core Animation itself doesn't
/// support the property).
func exactlyOneKeyframe(
context: CompatibilityTrackerProviding,
description: String,
fileID _: StaticString = #fileID,
line _: UInt = #line)
throws
-> 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.
""")
return keyframes[0].value
}
}
// Created by Cal Stephens on 1/28/22.
// Copyright © 2022 Airbnb Inc. All rights reserved.
// MARK: - Keyframes
enum Keyframes {
// MARK: Internal
/// Combines the given keyframe groups of `Keyframe<T>`s into a single keyframe group of of `Keyframe<[T]>`s
/// - If all of the `KeyframeGroup`s have the exact same animation timing, the keyframes are merged
/// - Otherwise, the keyframes are manually interpolated at each frame in the animation
static func combined<T>(
_ allGroups: [KeyframeGroup<T>],
requiresManualInterpolation: Bool = false)
-> KeyframeGroup<[T]>
where T: AnyInterpolatable
{
Keyframes.combined(
allGroups,
requiresManualInterpolation: requiresManualInterpolation,
makeCombinedResult: { untypedValues in
untypedValues.compactMap { $0 as? T }
})
}
/// Combines the given keyframe groups of `Keyframe<T>`s into a single keyframe group of of `Keyframe<[T]>`s
/// - If all of the `KeyframeGroup`s have the exact same animation timing, the keyframes are merged
/// - Otherwise, the keyframes are manually interpolated at each frame in the animation
static func combined<T1, T2, CombinedResult>(
_ k1: KeyframeGroup<T1>,
_ k2: KeyframeGroup<T2>,
requiresManualInterpolation: Bool = false,
makeCombinedResult: (T1, T2) throws -> CombinedResult)
rethrows
-> KeyframeGroup<CombinedResult>
where T1: AnyInterpolatable, T2: AnyInterpolatable
{
try Keyframes.combined(
[k1, k2],
requiresManualInterpolation: requiresManualInterpolation,
makeCombinedResult: { untypedValues in
guard
let t1 = untypedValues[0] as? T1,
let t2 = untypedValues[1] as? T2
else { return nil }
return try makeCombinedResult(t1, t2)
})
}
/// Combines the given keyframe groups of `Keyframe<T>`s into a single keyframe group of of `Keyframe<[T]>`s
/// - If all of the `KeyframeGroup`s have the exact same animation timing, the keyframes are merged
/// - Otherwise, the keyframes are manually interpolated at each frame in the animation
static func combined<T1, T2, T3, CombinedResult>(
_ k1: KeyframeGroup<T1>,
_ k2: KeyframeGroup<T2>,
_ k3: KeyframeGroup<T3>,
requiresManualInterpolation: Bool = false,
makeCombinedResult: (T1, T2, T3) -> CombinedResult)
-> KeyframeGroup<CombinedResult>
where T1: AnyInterpolatable, T2: AnyInterpolatable, T3: AnyInterpolatable
{
Keyframes.combined(
[k1, k2, k3],
requiresManualInterpolation: requiresManualInterpolation,
makeCombinedResult: { untypedValues in
guard
let t1 = untypedValues[0] as? T1,
let t2 = untypedValues[1] as? T2,
let t3 = untypedValues[2] as? T3
else { return nil }
return makeCombinedResult(t1, t2, t3)
})
}
/// Combines the given keyframe groups of `Keyframe<T>`s into a single keyframe group of of `Keyframe<[T]>`s
/// - If all of the `KeyframeGroup`s have the exact same animation timing, the keyframes are merged
/// - Otherwise, the keyframes are manually interpolated at each frame in the animation
static func combined<T1, T2, T3, T4, T5, T6, T7, CombinedResult>(
_ k1: KeyframeGroup<T1>,
_ k2: KeyframeGroup<T2>,
_ k3: KeyframeGroup<T3>,
_ k4: KeyframeGroup<T4>,
_ k5: KeyframeGroup<T5>,
_ k6: KeyframeGroup<T6>,
_ k7: KeyframeGroup<T7>,
requiresManualInterpolation: Bool = false,
makeCombinedResult: (T1, T2, T3, T4, T5, T6, T7) -> CombinedResult)
-> KeyframeGroup<CombinedResult>
where T1: AnyInterpolatable, T2: AnyInterpolatable, T3: AnyInterpolatable, T4: AnyInterpolatable,
T5: AnyInterpolatable, T6: AnyInterpolatable, T7: AnyInterpolatable
{
Keyframes.combined(
[k1, k2, k3, k4, k5, k6, k7],
requiresManualInterpolation: requiresManualInterpolation,
makeCombinedResult: { untypedValues in
guard
let t1 = untypedValues[0] as? T1,
let t2 = untypedValues[1] as? T2,
let t3 = untypedValues[2] as? T3,
let t4 = untypedValues[3] as? T4,
let t5 = untypedValues[4] as? T5,
let t6 = untypedValues[5] as? T6,
let t7 = untypedValues[6] as? T7
else { return nil }
return makeCombinedResult(t1, t2, t3, t4, t5, t6, t7)
})
}
/// Combines the given keyframe groups of `Keyframe<T>`s into a single keyframe group of of `Keyframe<[T]>`s
/// - If all of the `KeyframeGroup`s have the exact same animation timing, the keyframes are merged
/// - Otherwise, the keyframes are manually interpolated at each frame in the animation
static func combined<T1, T2, T3, T4, T5, T6, T7, T8, CombinedResult>(
_ k1: KeyframeGroup<T1>,
_ k2: KeyframeGroup<T2>,
_ k3: KeyframeGroup<T3>,
_ k4: KeyframeGroup<T4>,
_ k5: KeyframeGroup<T5>,
_ k6: KeyframeGroup<T6>,
_ k7: KeyframeGroup<T7>,
_ k8: KeyframeGroup<T8>,
requiresManualInterpolation: Bool = false,
makeCombinedResult: (T1, T2, T3, T4, T5, T6, T7, T8) -> CombinedResult)
-> KeyframeGroup<CombinedResult>
where T1: AnyInterpolatable, T2: AnyInterpolatable, T3: AnyInterpolatable, T4: AnyInterpolatable,
T5: AnyInterpolatable, T6: AnyInterpolatable, T7: AnyInterpolatable, T8: AnyInterpolatable
{
Keyframes.combined(
[k1, k2, k3, k4, k5, k6, k7, k8],
requiresManualInterpolation: requiresManualInterpolation,
makeCombinedResult: { untypedValues in
guard
let t1 = untypedValues[0] as? T1,
let t2 = untypedValues[1] as? T2,
let t3 = untypedValues[2] as? T3,
let t4 = untypedValues[3] as? T4,
let t5 = untypedValues[4] as? T5,
let t6 = untypedValues[5] as? T6,
let t7 = untypedValues[6] as? T7,
let t8 = untypedValues[7] as? T8
else { return nil }
return makeCombinedResult(t1, t2, t3, t4, t5, t6, t7, t8)
})
}
/// Combines the given keyframe groups of `Keyframe<T>`s into a single keyframe group of of `Keyframe<[T]>`s
/// - If all of the `KeyframeGroup`s have the exact same animation timing, the keyframes are merged
/// - Otherwise, the keyframes are manually interpolated at each frame in the animation
static func combined<T1, T2, T3, T4, T5, T6, T7, T8, T9, T10, CombinedResult>(
_ k1: KeyframeGroup<T1>,
_ k2: KeyframeGroup<T2>,
_ k3: KeyframeGroup<T3>,
_ k4: KeyframeGroup<T4>,
_ k5: KeyframeGroup<T5>,
_ k6: KeyframeGroup<T6>,
_ k7: KeyframeGroup<T7>,
_ k8: KeyframeGroup<T8>,
_ k9: KeyframeGroup<T9>,
_ k10: KeyframeGroup<T10>,
requiresManualInterpolation: Bool = false,
makeCombinedResult: (T1, T2, T3, T4, T5, T6, T7, T8, T9, T10) -> CombinedResult)
-> KeyframeGroup<CombinedResult>
where T1: AnyInterpolatable, T2: AnyInterpolatable, T3: AnyInterpolatable, T4: AnyInterpolatable,
T5: AnyInterpolatable, T6: AnyInterpolatable, T7: AnyInterpolatable, T8: AnyInterpolatable,
T9: AnyInterpolatable, T10: AnyInterpolatable
{
Keyframes.combined(
[k1, k2, k3, k4, k5, k6, k7, k8, k9, k10],
requiresManualInterpolation: requiresManualInterpolation,
makeCombinedResult: { untypedValues in
guard
let t1 = untypedValues[0] as? T1,
let t2 = untypedValues[1] as? T2,
let t3 = untypedValues[2] as? T3,
let t4 = untypedValues[3] as? T4,
let t5 = untypedValues[4] as? T5,
let t6 = untypedValues[5] as? T6,
let t7 = untypedValues[6] as? T7,
let t8 = untypedValues[7] as? T8,
let t9 = untypedValues[8] as? T9,
let t10 = untypedValues[9] as? T10
else { return nil }
return makeCombinedResult(t1, t2, t3, t4, t5, t6, t7, t8, t9, t10)
})
}
// MARK: Private
/// Combines the given `[KeyframeGroup]` of `Keyframe<T>`s into a single `KeyframeGroup` of `Keyframe<CombinedResult>`s
/// - If all of the `KeyframeGroup`s have the exact same animation timing, the keyframes are merged
/// - Otherwise, the keyframes are manually interpolated at each frame in the animation
///
/// `makeCombinedResult` is a closure that takes an array of keyframe values (with the exact same length as `AnyKeyframeGroup`),
/// casts them to the expected type, and combined them into the final resulting keyframe.
///
/// `requiresManualInterpolation` determines whether the keyframes must be computed using `Keyframes.manuallyInterpolated`,
/// which interpolates the value at each frame, or if the keyframes can simply be combined.
private static func combined<CombinedResult>(
_ allGroups: [AnyKeyframeGroup],
requiresManualInterpolation: Bool,
makeCombinedResult: ([Any]) throws -> CombinedResult?)
rethrows
-> KeyframeGroup<CombinedResult>
{
let untypedGroups = allGroups.map { $0.untyped }
// Animations with no timing information (e.g. with just a single keyframe)
// can be trivially combined with any other set of keyframes, so we don't need
// to check those.
let animatingKeyframes = untypedGroups.filter { $0.keyframes.count > 1 }
guard
!requiresManualInterpolation,
!allGroups.isEmpty,
animatingKeyframes.allSatisfy({ $0.hasSameTimingParameters(as: animatingKeyframes[0]) })
else {
// If the keyframes don't all share the same timing information,
// we have to interpolate the value at each individual frame
return try Keyframes.manuallyInterpolated(allGroups, makeCombinedResult: makeCombinedResult)
}
var combinedKeyframes = ContiguousArray<Keyframe<CombinedResult>>()
let baseKeyframes = (animatingKeyframes.first ?? untypedGroups[0]).keyframes
for index in baseKeyframes.indices {
let baseKeyframe = baseKeyframes[index]
let untypedValues = untypedGroups.map { $0.valueForCombinedKeyframes(at: index) }
if let combinedValue = try makeCombinedResult(untypedValues) {
combinedKeyframes.append(baseKeyframe.withValue(combinedValue))
} else {
LottieLogger.shared.assertionFailure("""
Failed to cast untyped keyframe values to expected type. This is an internal error.
""")
}
}
return KeyframeGroup(keyframes: combinedKeyframes)
}
private static func manuallyInterpolated<CombinedResult>(
_ allGroups: [AnyKeyframeGroup],
makeCombinedResult: ([Any]) throws -> CombinedResult?)
rethrows
-> KeyframeGroup<CombinedResult>
{
let untypedGroups = allGroups.map { $0.untyped }
let untypedInterpolators = allGroups.map { $0.interpolator }
let times = untypedGroups.flatMap { $0.keyframes.map { $0.time } }
let minimumTime = times.min() ?? 0
let maximumTime = times.max() ?? 0
// We disable Core Animation interpolation when using manually interpolated keyframes,
// so we don't animate between these values. To prevent the animation from being choppy
// even at low playback speed, we have to interpolate at a very high fidelity.
let animationLocalTimeRange = stride(from: minimumTime, to: maximumTime, by: 0.1)
let interpolatedKeyframes = try animationLocalTimeRange.compactMap { localTime -> Keyframe<CombinedResult>? in
let interpolatedValues = untypedInterpolators.map { interpolator in
interpolator.value(frame: AnimationFrameTime(localTime))
}
guard let combinedResult = try makeCombinedResult(interpolatedValues) else {
LottieLogger.shared.assertionFailure("""
Failed to cast untyped keyframe values to expected type. This is an internal error.
""")
return nil
}
return Keyframe(
value: combinedResult,
time: AnimationFrameTime(localTime),
// Since we already manually interpolated the keyframes, have Core Animation display
// each value as a static keyframe rather than trying to interpolate between them.
isHold: true)
}
return KeyframeGroup(keyframes: ContiguousArray(interpolatedKeyframes))
}
}
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<U>(as other: KeyframeGroup<U>) -> 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,
/// excluding `spatialInTangent` and `spatialOutTangent`.
fileprivate func hasSameTimingParameters<U>(as other: Keyframe<U>) -> Bool {
time == other.time
&& isHold == other.isHold
&& inTangent == other.inTangent
&& outTangent == other.outTangent
// We intentionally don't compare spatial in/out tangents,
// since those values are only used in very specific cases
// (animating the x/y position of a layer), which aren't ever
// combined in this way.
}
}
extension KeyframeGroup {
/// The value to use for a combined set of keyframes, for the given index
fileprivate func valueForCombinedKeyframes(at index: Int) -> T {
if keyframes.count == 1 {
return keyframes[0].value
} else {
return keyframes[index].value
}
}
}
// Created by Cal Stephens on 1/8/24.
// Copyright © 2024 Airbnb Inc. All rights reserved.
extension Keyframes {
/// Manually interpolates the given keyframes, and applies `context.complexTimeRemapping`.
/// - Since `complexTimeRemapping` is a mapping from "global time" to "local time",
/// we have to manually interpolate the keyframes at every frame in the animation.
static func manuallyInterpolatedWithTimeRemapping<T: AnyInterpolatable>(
_ keyframes: KeyframeGroup<T>,
context: LayerAnimationContext)
-> KeyframeGroup<T>
{
let minimumTime = context.animation.startFrame
let maximumTime = context.animation.endFrame
let animationLocalTimeRange = stride(from: minimumTime, to: maximumTime, by: 1.0)
let interpolator = keyframes.interpolator
// Since potentially many global times can refer to the same local time,
// we can cache and reused these local-time values.
var localTimeCache = [AnimationFrameTime: T]()
let interpolatedRemappedKeyframes = animationLocalTimeRange.compactMap { globalTime -> Keyframe<T>? in
let remappedLocalTime = context.complexTimeRemapping(globalTime)
let valueAtRemappedTime: T
if let cachedValue = localTimeCache[remappedLocalTime] {
valueAtRemappedTime = cachedValue
} else if let interpolatedValue = interpolator.value(frame: remappedLocalTime) as? T {
valueAtRemappedTime = interpolatedValue
localTimeCache[remappedLocalTime] = interpolatedValue
} else {
LottieLogger.shared.assertionFailure("""
Failed to cast untyped keyframe values to expected type. This is an internal error.
""")
return nil
}
return Keyframe(
value: valueAtRemappedTime,
time: AnimationFrameTime(globalTime))
}
return KeyframeGroup(keyframes: ContiguousArray(interpolatedRemappedKeyframes))
}
}
// 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: LottieAnimation
/// 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 logger that should be used for assertions and warnings
let logger: LottieLogger
/// Mutable state related to log events, stored on the `CoreAnimationLayer`.
let loggingState: LoggingState
/// The AnimationKeypath represented by the current layer
var currentKeypath: AnimationKeypath
/// The `AnimationKeypathTextProvider`
var textProvider: AnimationKeypathTextProvider
/// Records the given animation keypath so it can be logged or collected into a list
/// - Used for `CoreAnimationLayer.logHierarchyKeypaths()` and `allHierarchyKeypaths()`
var recordHierarchyKeypath: ((String) -> Void)?
/// A closure that remaps the given frame in the child layer's local time to a frame
/// in the animation's overall global time.
/// - This time remapping is simple and only used `preCompLayer.timeStretch` and `preCompLayer.startTime`,
/// so is a trivial function and is invertible. This allows us to invert the time remapping from
/// "global time to local time" to instead be "local time to global time".
private(set) var simpleTimeRemapping: ((_ localTime: AnimationFrameTime) -> AnimationFrameTime) = { $0 }
/// A complex time remapping closure that remaps the given frame in the animation's overall global time
/// into the child layer's local time.
/// - This time remapping is arbitrarily complex because it includes the full `preCompLayer.timeRemapping`.
/// - Since it isn't possible to invert the time remapping function, this can only be applied by converting
/// from global time to local time. This requires using `Keyframes.manuallyInterpolatedWithTimeRemapping`.
private(set) var complexTimeRemapping: ((_ globalTime: AnimationFrameTime) -> AnimationFrameTime) = { $0 }
/// Whether or not this layer is required to use the `complexTimeRemapping` via
/// the more expensive `Keyframes.manuallyInterpolatedWithTimeRemapping` codepath.
var mustUseComplexTimeRemapping = false
/// The duration of the animation
var animationDuration: AnimationFrameTime {
// Normal animation playback (like when looping) skips the last frame.
// However when the animation is paused, we need to be able to render the final frame.
// To allow this we have to extend the length of the animation by one frame.
let animationEndFrame: AnimationFrameTime
if timingConfiguration.speed == 0 {
animationEndFrame = animation.endFrame + 1
} else {
animationEndFrame = animation.endFrame
}
return Double(animationEndFrame - animation.startFrame) / animation.framerate
}
/// 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 `simpleTimeRemapping` applied to this layer.
func progressTime(for frame: AnimationFrameTime) throws -> AnimationProgressTime {
try compatibilityAssert(
!mustUseComplexTimeRemapping,
"LayerAnimationContext.time(forFrame:) does not support complex time remapping")
let animationFrameCount = animationDuration * animation.framerate
return (simpleTimeRemapping(frame) - animation.startFrame) / animationFrameCount
}
/// The real-time `TimeInterval` for the given `AnimationFrameTime` within this layer,
/// accounting for the `simpleTimeRemapping` applied to this layer.
func time(forFrame frame: AnimationFrameTime) throws -> TimeInterval {
try compatibilityAssert(
!mustUseComplexTimeRemapping,
"LayerAnimationContext.time(forFrame:) does not support complex time remapping")
return animation.time(forFrame: simpleTimeRemapping(frame))
}
/// Chains an additional time remapping closure onto the `simpleTimeRemapping` closure
func withSimpleTimeRemapping(
_ additionalSimpleTimeRemapping: @escaping (_ localTime: AnimationFrameTime) -> AnimationFrameTime)
-> LayerAnimationContext
{
var copy = self
copy.simpleTimeRemapping = { [existingSimpleTimeRemapping = simpleTimeRemapping] time in
existingSimpleTimeRemapping(additionalSimpleTimeRemapping(time))
}
return copy
}
/// Chains an additional time remapping closure onto the `complexTimeRemapping` closure.
/// - If `required` is `true`, all subsequent child layers will be required to use the expensive
/// `complexTimeRemapping` / `Keyframes.manuallyInterpolatedWithTimeRemapping` codepath.
/// - `required: true` is necessary when this time remapping is not available via `simpleTimeRemapping`.
func withComplexTimeRemapping(
required: Bool,
_ additionalComplexTimeRemapping: @escaping (_ globalTime: AnimationFrameTime) -> AnimationFrameTime)
-> LayerAnimationContext
{
var copy = self
copy.mustUseComplexTimeRemapping = copy.mustUseComplexTimeRemapping || required
copy.complexTimeRemapping = { [existingComplexTimeRemapping = complexTimeRemapping] time in
additionalComplexTimeRemapping(existingComplexTimeRemapping(time))
}
return copy
}
/// Returns a copy of this context with time remapping removed
func withoutTimeRemapping() -> LayerAnimationContext {
var copy = self
copy.simpleTimeRemapping = { $0 }
copy.complexTimeRemapping = { $0 }
copy.mustUseComplexTimeRemapping = false
return copy
}
}
// MARK: - LoggingState
/// Mutable state related to log events, stored on the `CoreAnimationLayer`.
final class LoggingState {
// MARK: Lifecycle
init() { }
// MARK: Internal
/// Whether or not the warning about unsupported After Effects expressions
/// has been logged yet for this layer.
var hasLoggedAfterEffectsExpressionsWarning = false
}
// 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 }
}
}
// 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
contentsLayer.name = "\(layerModel.name) (Content)"
}
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
/// The layer that content / sublayers should be rendered in.
/// This is the layer that transform animations are applied to.
let contentsLayer = BaseAnimationLayer()
/// 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 {
let layerContext = context.addingKeypathComponent(baseLayerModel.name)
let childContext = renderLayerContents ? layerContext : context
try setupLayerAnimations(context: layerContext)
try setupChildAnimations(context: childContext)
}
func setupLayerAnimations(context: LayerAnimationContext) throws {
let transformContext = context.addingKeypathComponent("Transform")
try contentsLayer.addTransformAnimations(for: baseLayerModel.transform, context: transformContext)
if renderLayerContents {
try contentsLayer.addOpacityAnimation(for: baseLayerModel.transform, context: transformContext)
try contentsLayer.addVisibilityAnimation(
inFrame: CGFloat(baseLayerModel.inFrame),
outFrame: CGFloat(baseLayerModel.outFrame),
context: context)
// There are two different drop shadow schemas, either using `DropShadowEffect` or `DropShadowStyle`.
// If both happen to be present, prefer the `DropShadowEffect` (which is the drop shadow schema
// supported on other platforms).
let dropShadowEffect = baseLayerModel.effects.first(where: { $0 is DropShadowEffect }) as? DropShadowModel
let dropShadowStyle = baseLayerModel.styles.first(where: { $0 is DropShadowStyle }) as? DropShadowModel
if let dropShadowModel = dropShadowEffect ?? dropShadowStyle {
try contentsLayer.addDropShadowAnimations(for: dropShadowModel, context: context)
}
}
}
func setupChildAnimations(context: LayerAnimationContext) throws {
try super.setupAnimations(context: context)
}
override func addSublayer(_ layer: CALayer) {
if layer === contentsLayer {
super.addSublayer(contentsLayer)
} else {
contentsLayer.addSublayer(layer)
}
}
// MARK: Private
private let baseLayerModel: LayerModel
private func setupSublayers() {
addSublayer(contentsLayer)
if
renderLayerContents,
let masks = baseLayerModel.masks?.filter({ $0.mode != .none }),
!masks.isEmpty
{
contentsLayer.mask = MaskCompositionLayer(masks: masks)
}
}
}
// Created by Cal Stephens on 1/11/22.
// Copyright © 2022 Airbnb Inc. All rights reserved.
import QuartzCore
extension CALayer {
// MARK: Internal
/// Sets up an `AnimationLayer` / `CALayer` hierarchy in this layer,
/// using the given list of layers.
@nonobjc
func setupLayerHierarchy(
for layers: [LayerModel],
context: LayerContext)
throws
{
// A `LottieAnimation`'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, mask) in try layersInZAxisOrder.pairedLayersAndMasks() {
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 mask,
let maskLayer = try maskLayer(for: mask.model, type: mask.matteType, context: context)
{
let maskParentTransformLayer = makeParentTransformLayer(
childLayerModel: mask.model,
childLayer: maskLayer,
name: { parentLayerModel in
"\(mask.model.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)
}
}
}
// MARK: Fileprivate
/// Creates a mask `CALayer` from the given matte layer model, using the `MatteType`
/// from the layer that is being masked.
fileprivate func maskLayer(
for matteLayerModel: LayerModel,
type: MatteType,
context: LayerContext)
throws -> CALayer?
{
switch type {
case .add:
return try matteLayerModel.makeAnimationLayer(context: context)
case .invert:
guard let maskLayer = try matteLayerModel.makeAnimationLayer(context: context) else {
return nil
}
// We can invert the mask layer by having a large solid black layer with the
// given mask layer subtracted out using the `xor` blend mode. When applied to the
// layer being masked, this creates an inverted mask where only areas _outside_
// of the mask layer are visible.
// https://developer.apple.com/documentation/coregraphics/cgblendmode/xor
// - The inverted mask is supposed to expand infinitely around the shape,
// so we use `InfiniteOpaqueAnimationLayer`
let base = InfiniteOpaqueAnimationLayer()
base.backgroundColor = .rgb(0, 0, 0)
base.addSublayer(maskLayer)
maskLayer.compositingFilter = "xor"
return base
case .none, .unknown:
return nil
}
}
}
extension Collection<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() throws
-> [(layer: LayerModel, mask: (model: LayerModel, matteType: MatteType)?)]
{
var layersAndMasks = [(layer: LayerModel, mask: (model: LayerModel, matteType: MatteType)?)]()
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()
{
layersAndMasks.append((layer: layer, mask: (model: maskLayer, matteType: matteType)))
}
else {
layersAndMasks.append((layer: layer, mask: nil))
}
}
return layersAndMasks
}
}
// 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 }
}
// Created by Laura Skelton on 5/11/17.
// Copyright © 2017 Airbnb. All rights reserved.
// MARK: - Diffable
/// A protocol that allows us to check identity and equality between items for the purposes of
/// diffing.
protocol Diffable {
/// Checks for equality between items when diffing.
///
/// - Parameters:
/// - otherDiffableItem: The other item to check equality against while diffing.
func isDiffableItemEqual(to otherDiffableItem: Diffable) -> Bool
/// The identifier to use when checking identity while diffing.
var diffIdentifier: AnyHashable { get }
}
// Created by eric_horacek on 12/15/20.
// Copyright © 2020 Airbnb Inc. All rights reserved.
/// An Epoxy model with an associated context type that's passed into callback closures.
protocol CallbackContextEpoxyModeled: EpoxyModeled {
/// A context type that's passed into callback closures.
associatedtype CallbackContext
}
Markdown is supported
0% .
You are about to add 0 people to the discussion. Proceed with caution.
先完成此消息的编辑!
想要评论请 注册