Skip to content

Commit

Permalink
Add support for rendering drop shadows (airbnb#2142)
Browse files Browse the repository at this point in the history
  • Loading branch information
calda authored and Igor Moroz committed May 22, 2024
1 parent b81d72b commit 72132b3
Show file tree
Hide file tree
Showing 66 changed files with 880 additions and 29 deletions.
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)

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

0 comments on commit 72132b3

Please sign in to comment.