Skip to content

Commit

Permalink
Add support for opacity text animators (airbnb#2436)
Browse files Browse the repository at this point in the history
  • Loading branch information
calda committed Jun 20, 2024
1 parent f5ee923 commit 0ed725e
Show file tree
Hide file tree
Showing 44 changed files with 241 additions and 16 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,10 @@ final class TextCompositionLayer: CompositionLayer {
let tracking = (CGFloat(text.fontSize) * (rootNode?.textOutputNode.tracking ?? CGFloat(text.tracking))) / 1000.0
let matrix = rootNode?.textOutputNode.xform ?? CATransform3DIdentity
let ctFont = fontProvider.fontFor(family: text.fontFamily, size: CGFloat(text.fontSize))
let start = rootNode?.textOutputNode.start.flatMap { Int($0) }
let end = rootNode?.textOutputNode.end.flatMap { Int($0) }
let selectedRangeOpacity = rootNode?.textOutputNode.selectedRangeOpacity
let textRangeUnit = rootNode?.textAnimatorProperties.textRangeUnit

// Set all of the text layer options
textLayer.text = textString
Expand All @@ -143,6 +147,12 @@ final class TextCompositionLayer: CompositionLayer {
textLayer.lineHeight = CGFloat(text.lineHeight)
textLayer.tracking = tracking

// Configure the text animators
textLayer.start = start
textLayer.end = end
textLayer.textRangeUnit = textRangeUnit
textLayer.selectedRangeOpacity = selectedRangeOpacity

if let fillColor = rootNode?.textOutputNode.fillColor {
textLayer.fillColor = fillColor
} else if let fillColor = text.fillColorData?.cgColorValue {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ final class CoreTextRenderLayer: CALayer {
}
}

public var alignment: NSTextAlignment = .left {
public var alignment = NSTextAlignment.left {
didSet {
needsContentUpdate = true
setNeedsLayout()
Expand Down Expand Up @@ -102,6 +102,40 @@ final class CoreTextRenderLayer: CALayer {
}
}

public var start: Int? {
didSet {
needsContentUpdate = true
setNeedsLayout()
setNeedsDisplay()
}
}

public var end: Int? {
didSet {
needsContentUpdate = true
setNeedsLayout()
setNeedsDisplay()
}
}

/// The type of unit to use when computing the `start` / `end` range within the text string
public var textRangeUnit: TextRangeUnit? {
didSet {
needsContentUpdate = true
setNeedsLayout()
setNeedsDisplay()
}
}

/// The opacity to apply to the range between `start` and `end`
public var selectedRangeOpacity: CGFloat? {
didSet {
needsContentUpdate = true
setNeedsLayout()
setNeedsDisplay()
}
}

public func sizeToFit() {
updateTextContent()
bounds = drawingRect
Expand Down Expand Up @@ -176,8 +210,8 @@ final class CoreTextRenderLayer: CALayer {

// MARK: Private

private var drawingRect: CGRect = .zero
private var drawingAnchor: CGPoint = .zero
private var drawingRect = CGRect.zero
private var drawingAnchor = CGPoint.zero
private var fillFrameSetter: CTFramesetter?
private var attributedString: NSAttributedString?
private var strokeFrameSetter: CTFramesetter?
Expand Down Expand Up @@ -259,7 +293,58 @@ final class CoreTextRenderLayer: CALayer {
attributes[NSAttributedString.Key.foregroundColor] = fillColor
}

let attrString = NSAttributedString(string: text, attributes: attributes)
let attrString = NSMutableAttributedString(string: text, attributes: attributes)

// Apply the text animator within between the `start` and `end` indices
if let selectedRangeOpacity {
// The start and end of a text animator refer to the portions of the text
// where that animator is applies. In the schema these can be represented
// in absolute index value, or as percentages relative to the dynamic string length.
var startIndex: Int
var endIndex: Int

switch textRangeUnit ?? .percentage {
case .index:
startIndex = start ?? 0
endIndex = end ?? text.count

case .percentage:
let startPercentage = Double(start ?? 0) / 100
let endPercentage = Double(end ?? 100) / 100

startIndex = Int(round(Double(attrString.length) * startPercentage))
endIndex = Int(round(Double(attrString.length) * endPercentage))
}

// Carefully cap the indices, since passing invalid indices
// to `NSAttributedString` will crash the app.
startIndex = startIndex.clamp(0, attrString.length)
endIndex = endIndex.clamp(0, attrString.length)

// Make sure the end index actually comes after the start index
if endIndex < startIndex {
swap(&startIndex, &endIndex)
}

// Apply the `selectedRangeOpacity` to the current `fillColor` if provided
let textRangeColor: CGColor
if let fillColor {
if let (r, g, b) = fillColor.rgb {
textRangeColor = .rgba(r, g, b, selectedRangeOpacity)
} else {
LottieLogger.shared.warn("Could not convert color \(fillColor) to RGB values.")
textRangeColor = .rgba(0, 0, 0, selectedRangeOpacity)
}
} else {
textRangeColor = .rgba(0, 0, 0, selectedRangeOpacity)
}

attrString.addAttribute(
NSAttributedString.Key.foregroundColor,
value: textRangeColor,
range: NSRange(location: startIndex, length: endIndex - startIndex))
}

attributedString = attrString

if fillColor != nil {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ final class TextAnimatorNodeProperties: NodePropertyMap, KeypathSearchable {

init(textAnimator: TextAnimator) {
keypathName = textAnimator.name
textRangeUnit = textAnimator.textRangeUnit
var properties = [String : AnyNodeProperty]()

if let keyframeGroup = textAnimator.anchor {
Expand Down Expand Up @@ -109,6 +110,27 @@ final class TextAnimatorNodeProperties: NodePropertyMap, KeypathSearchable {
tracking = nil
}

if let startKeyframes = textAnimator.start {
start = NodeProperty(provider: KeyframeInterpolator(keyframes: startKeyframes.keyframes))
properties["Start"] = start
} else {
start = nil
}

if let endKeyframes = textAnimator.end {
end = NodeProperty(provider: KeyframeInterpolator(keyframes: endKeyframes.keyframes))
properties["End"] = end
} else {
end = nil
}

if let selectedRangeOpacityKeyframes = textAnimator.opacity {
selectedRangeOpacity = NodeProperty(provider: KeyframeInterpolator(keyframes: selectedRangeOpacityKeyframes.keyframes))
properties["SelectedRangeOpacity"] = selectedRangeOpacity
} else {
selectedRangeOpacity = nil
}

keypathProperties = properties

self.properties = Array(keypathProperties.values)
Expand All @@ -131,6 +153,10 @@ final class TextAnimatorNodeProperties: NodePropertyMap, KeypathSearchable {
let fillColor: NodeProperty<LottieColor>?
let strokeWidth: NodeProperty<LottieVector1D>?
let tracking: NodeProperty<LottieVector1D>?
let start: NodeProperty<LottieVector1D>?
let end: NodeProperty<LottieVector1D>?
let selectedRangeOpacity: NodeProperty<LottieVector1D>?
let textRangeUnit: TextRangeUnit?

let keypathProperties: [String: AnyNodeProperty]
let properties: [AnyNodeProperty]
Expand Down Expand Up @@ -223,6 +249,33 @@ final class TextOutputNode: NodeOutput {
}
}

var start: CGFloat? {
get {
_start
}
set {
_start = newValue
}
}

var end: CGFloat? {
get {
_end
}
set {
_end = newValue
}
}

var selectedRangeOpacity: CGFloat? {
get {
_selectedRangeOpacity
}
set {
_selectedRangeOpacity = newValue
}
}

func hasOutputUpdates(_: CGFloat) -> Bool {
// TODO Fix This
true
Expand All @@ -236,6 +289,9 @@ final class TextOutputNode: NodeOutput {
fileprivate var _fillColor: CGColor?
fileprivate var _tracking: CGFloat?
fileprivate var _strokeWidth: CGFloat?
fileprivate var _start: CGFloat?
fileprivate var _end: CGFloat?
fileprivate var _selectedRangeOpacity: CGFloat?
}

// MARK: - TextAnimatorNode
Expand Down Expand Up @@ -278,10 +334,13 @@ class TextAnimatorNode: AnimatorNode {

func rebuildOutputs(frame _: CGFloat) {
textOutputNode.xform = textAnimatorProperties.caTransform
textOutputNode.opacity = (textAnimatorProperties.opacity?.value.cgFloatValue ?? 100) * 0.01
textOutputNode.opacity = 1.0
textOutputNode.strokeColor = textAnimatorProperties.strokeColor?.value.cgColorValue
textOutputNode.fillColor = textAnimatorProperties.fillColor?.value.cgColorValue
textOutputNode.tracking = textAnimatorProperties.tracking?.value.cgFloatValue ?? 1
textOutputNode.strokeWidth = textAnimatorProperties.strokeWidth?.value.cgFloatValue ?? 0
textOutputNode.start = textAnimatorProperties.start?.value.cgFloatValue
textOutputNode.end = textAnimatorProperties.end?.value.cgFloatValue
textOutputNode.selectedRangeOpacity = (textAnimatorProperties.opacity?.value.cgFloatValue).flatMap { $0 * 0.01 }
}
}
48 changes: 47 additions & 1 deletion Sources/Private/Model/Text/TextAnimator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,23 @@
// Created by Brandon Withrow on 1/9/19.
//

// MARK: - TextRangeUnit

enum TextRangeUnit: Int, RawRepresentable, Codable {
case percentage = 1
case index = 2
}

// MARK: - TextAnimator

final class TextAnimator: Codable, DictionaryInitializable {

// MARK: Lifecycle

required init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: TextAnimator.CodingKeys.self)
name = try container.decodeIfPresent(String.self, forKey: .name) ?? ""

let animatorContainer = try container.nestedContainer(keyedBy: TextAnimatorKeys.self, forKey: .textAnimator)
fillColor = try animatorContainer.decodeIfPresent(KeyframeGroup<LottieColor>.self, forKey: .fillColor)
strokeColor = try animatorContainer.decodeIfPresent(KeyframeGroup<LottieColor>.self, forKey: .strokeColor)
Expand All @@ -32,10 +42,16 @@ final class TextAnimator: Codable, DictionaryInitializable {
rotationZ = nil
}
opacity = try animatorContainer.decodeIfPresent(KeyframeGroup<LottieVector1D>.self, forKey: .opacity)

let selectorContainer = try? container.nestedContainer(keyedBy: TextSelectorKeys.self, forKey: .textSelector)
start = try? selectorContainer?.decodeIfPresent(KeyframeGroup<LottieVector1D>.self, forKey: .start)
end = try? selectorContainer?.decodeIfPresent(KeyframeGroup<LottieVector1D>.self, forKey: .end)
textRangeUnit = try? selectorContainer?.decodeIfPresent(TextRangeUnit.self, forKey: .textRangeUnits)
}

init(dictionary: [String: Any]) throws {
name = (try? dictionary.value(for: CodingKeys.name)) ?? ""

let animatorDictionary: [String: Any] = try dictionary.value(for: CodingKeys.textAnimator)
if let fillColorDictionary = animatorDictionary[TextAnimatorKeys.fillColor.rawValue] as? [String: Any] {
fillColor = try? KeyframeGroup<LottieColor>(dictionary: fillColorDictionary)
Expand Down Expand Up @@ -107,6 +123,26 @@ final class TextAnimator: Codable, DictionaryInitializable {
} else {
opacity = nil
}

let selectorDictionary: [String: Any] = try dictionary.value(for: CodingKeys.textSelector)

if let startDictionary = selectorDictionary[TextSelectorKeys.start.rawValue] as? [String: Any] {
start = try KeyframeGroup<LottieVector1D>(dictionary: startDictionary)
} else {
start = nil
}

if let endDictionary = selectorDictionary[TextSelectorKeys.end.rawValue] as? [String: Any] {
end = try KeyframeGroup<LottieVector1D>(dictionary: endDictionary)
} else {
end = nil
}

if let textRangeUnitValue = selectorDictionary[TextSelectorKeys.textRangeUnits.rawValue] as? Int {
textRangeUnit = TextRangeUnit(rawValue: textRangeUnitValue)
} else {
textRangeUnit = nil
}
}

// MARK: Internal
Expand Down Expand Up @@ -152,6 +188,15 @@ final class TextAnimator: Codable, DictionaryInitializable {
/// Tracking
let tracking: KeyframeGroup<LottieVector1D>?

/// Start
let start: KeyframeGroup<LottieVector1D>?

/// End
let end: KeyframeGroup<LottieVector1D>?

/// The type of unit used by the start/end ranges
let textRangeUnit: TextRangeUnit?

func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
var animatorContainer = container.nestedContainer(keyedBy: TextAnimatorKeys.self, forKey: .textAnimator)
Expand All @@ -164,7 +209,7 @@ final class TextAnimator: Codable, DictionaryInitializable {
// MARK: Private

private enum CodingKeys: String, CodingKey {
// case textSelector = "s" TODO
case textSelector = "s"
case textAnimator = "a"
case name = "nm"
}
Expand All @@ -173,6 +218,7 @@ final class TextAnimator: Codable, DictionaryInitializable {
case start = "s"
case end = "e"
case offset = "o"
case textRangeUnits = "r"
}

private enum TextAnimatorKeys: String, CodingKey {
Expand Down
18 changes: 18 additions & 0 deletions Sources/Private/Utility/Extensions/CGColor+RGB.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,23 @@
import QuartzCore

extension CGColor {
/// Retrieves the red, green, and blue color values from this `CGColor`
var rgb: (red: CGFloat, green: CGFloat, blue: CGFloat)? {
guard let components else { return nil }

switch numberOfComponents {
case 2:
return (red: components[0], green: components[0], blue: components[0])

case 3, 4:
return (red: components[0], green: components[1], blue: components[2])

default:
// Unsupported conversion
return nil
}
}

/// Initializes a `CGColor` using the given `RGB` values
static func rgb(_ red: CGFloat, _ green: CGFloat, _ blue: CGFloat) -> CGColor {
rgba(red, green, blue, 1.0)
Expand All @@ -22,4 +39,5 @@ extension CGColor {
colorSpace: LottieConfiguration.shared.colorSpace,
components: [red, green, blue, alpha])!
}

}
Loading

0 comments on commit 0ed725e

Please sign in to comment.