Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions Lottie.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

183 changes: 183 additions & 0 deletions Sources/Private/CoreAnimation/Animations/DropShadowAnimation.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
// 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
// `DropShadowStyle.size` is approximately double as large
// as the visually-equivalent `cornerRadius` value
LottieVector1D(sizeValue.cgFloatValue / 2)
}
}
}

// MARK: - DropShadowEffect + DropShadowModel

extension DropShadowEffect: DropShadowModel {
var _color: KeyframeGroup<LottieColor>? { color?.value }

var _distance: KeyframeGroup<LottieVector1D>? {
distance?.value?.map { distanceValue in
// `DropShadowEffect.distance` doesn't seem to map cleanly to
// `CALayer.shadowOffset` (e.g. with a simple multiplier).
// Instead, this uses a custom quadratic regression eyeballed
// to match the expected appearance of the start / end of the
// `issue_1169_shadow_effect_animated.json` sample animation:
// - `distance=5` roughly corresponds to an offset value of 4
// - `distance=10` roughly corresponds to an offset value of 5
// This could probably be improved with more examples.
let x = distanceValue.cgFloatValue
let cornerRadiusMapping = (-0.06 * pow(x, 2)) + (1.1 * x)

return LottieVector1D(cornerRadiusMapping)
}
}

var _radius: KeyframeGroup<LottieVector1D>? {
softness?.value?.map { softnessValue in
// `DropShadowEffect.softness` doesn't seem to map cleanly to
// `CALayer.cornerRadius` (e.g. with a simple multiplier).
// Instead, this uses a custom quadratic regression eyeballed
// to match the expected appearance of the start / end of the
// `issue_1169_shadow_effect_animated.json` sample animation:
// - `softness=10` roughly corresponds to `cornerRadius=2.5`
// - `softness=50` roughly corresponds to `cornerRadius=6.25`
// This could probably be improved with more examples.
let x = softnessValue.cgFloatValue
let cornerRadiusMapping = (-0.003 * pow(x, 2)) + (0.281 * x)
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Worked with Remington to get this to match the preview in After Effects. For example:

Start, iOS End, iOS
Screenshot 2023-08-15 at 3 22 13 PM Screenshot 2023-08-15 at 3 22 16 PM
Start, After Effects End, After Effects
Screenshot 2023-08-15 at 11 01 30 AM Screenshot 2023-08-15 at 11 01 36 AM

This was the only mapping I could find that we thought looked right in both of these cases


return LottieVector1D(cornerRadiusMapping)
}
}

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)
}

}
38 changes: 38 additions & 0 deletions Sources/Private/CoreAnimation/Animations/LayerProperty.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,16 @@ struct LayerProperty<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?,
Expand Down Expand Up @@ -159,6 +169,34 @@ extension LayerProperty {
},
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),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why does this and shadowRadius have non-zero default values?

Copy link
Member Author

@calda calda Aug 22, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the default value of the CALayer.shadowOffset property (documentation):

The default value of this property is (0.0, -3.0).

Knowing this value lets us apply certain optimizations in the code that generates the CAKeyframes. (e.g. if the Lottie animation just sets the property to the default value, we can completely omit the keyframes and reduce the overhead of computing the keyframes)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ah great, this might be a good comment to add in code here so others aren't confused in the future!

customizableProperty: nil /* currently unsupported */ )
}
}

// MARK: CAShapeLayer properties
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import QuartzCore
// MARK: - TransformModel

/// This protocol mirrors the interface of `Transform`,
/// but it also implemented by `ShapeTransform` to allow
/// 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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,15 @@ class BaseCompositionLayer: BaseAnimationLayer {
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)
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,4 +41,30 @@ extension KeyedDecodingContainer {
}
return list
}

/// Decode a heterogeneous list of objects for a given family if the given key is present.
/// - Parameters:
/// - heterogeneousType: The decodable type of the list.
/// - family: The ClassFamily enum for the type family.
/// - key: The CodingKey to look up the list in the current container.
/// - Returns: The resulting list of heterogeneousType elements.
func decodeIfPresent<T: Decodable, U: ClassFamily>(_: [T].Type, ofFamily family: U.Type, forKey key: K) throws -> [T]? {
var container: UnkeyedDecodingContainer
do {
container = try nestedUnkeyedContainer(forKey: key)
} catch {
return nil
}

var list = [T]()
var tmpContainer = container
while !container.isAtEnd {
let typeContainer = try container.nestedContainer(keyedBy: Discriminator.self)
let family: U = try typeContainer.decode(U.self, forKey: U.discriminator)
if let type = family.getType() as? T.Type {
list.append(try tmpContainer.decode(type))
}
}
return list
}
}
1 change: 0 additions & 1 deletion Sources/Private/Model/Keyframes/KeyframeGroup.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ import Foundation
/// Keyframe data is wrapped in a dictionary { "k" : KeyframeData }.
/// The keyframe data can either be an array of keyframes or, if no animation is present, the raw value.
/// This helper object is needed to properly decode the json.

final class KeyframeGroup<T> {

// MARK: Lifecycle
Expand Down
45 changes: 45 additions & 0 deletions Sources/Private/Model/LayerEffects/DropShadowEffect.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// Created by Cal Stephens on 8/14/23.
// Copyright © 2023 Airbnb Inc. All rights reserved.

import Foundation

final class DropShadowEffect: LayerEffect {

// MARK: Lifecycle

required init(from decoder: Decoder) throws {
try super.init(from: decoder)
}

required init(dictionary: [String: Any]) throws {
try super.init(dictionary: dictionary)
}

// MARK: Internal

/// The color of the drop shadow
var color: ColorEffectValue? {
value(named: "Shadow Color")
}

/// Opacity between 0 and 255
var opacity: Vector1DEffectValue? {
value(named: "Opacity")
}

/// The direction / angle of the drop shadow, in degrees
var direction: Vector1DEffectValue? {
value(named: "Direction")
}

/// The distance of the drop shadow
var distance: Vector1DEffectValue? {
value(named: "Distance")
}

/// The softness of the drop shadow
var softness: Vector1DEffectValue? {
value(named: "Softness")
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// Created by Cal Stephens on 8/14/23.
// Copyright © 2023 Airbnb Inc. All rights reserved.

import Foundation

final class ColorEffectValue: EffectValue {

// MARK: Lifecycle

required init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
value = try? container.decode(KeyframeGroup<LottieColor>.self, forKey: .value)
try super.init(from: decoder)
}

required init(dictionary: [String: Any]) throws {
let valueDictionary: [String: Any] = try dictionary.value(for: CodingKeys.value)
value = try KeyframeGroup<LottieColor>(dictionary: valueDictionary)
try super.init(dictionary: dictionary)
}

// MARK: Internal

/// The value of the color
let value: KeyframeGroup<LottieColor>?

override func encode(to encoder: Encoder) throws {
try super.encode(to: encoder)
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(value, forKey: .value)
}

// MARK: Private

private enum CodingKeys: String, CodingKey {
case value = "v"
}
}
Loading