-
-
Notifications
You must be signed in to change notification settings - Fork 3.8k
Add support for rendering drop shadows #2142
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| 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) | ||
| } | ||
|
|
||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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?, | ||
|
|
@@ -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), | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. why does this and
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is the default value of the
Knowing this value lets us apply certain optimizations in the code that generates the
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
|
|
||
| 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" | ||
| } | ||
| } |
There was a problem hiding this comment.
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:
This was the only mapping I could find that we thought looked right in both of these cases