Skip to content
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

Add support to TextProviders for CoreAnimation #1723

Merged
merged 22 commits into from
Aug 19, 2022

Conversation

lostinstaches
Copy link
Contributor

@lostinstaches lostinstaches commented Aug 19, 2022

What 🙋🏼

This PR is supposed to provide support for text providers, so it would be possible to pass text to animations running on the CoreAnimation engine. 🚀

This is my first attempt of doing the PR in this library and an open source project in general. So please bear with me a bit! 🤠
I have four questions/concerns that should be answered before this PR can be valid! (All questions are also presented in the code as comments).

Concerns 😬

  1. Which keypath should we pass in the setupAnimations method?
  2. Where do we get the text layer source text from?
  3. How do we init LayerContextAnimations inside configureRenderLayer to call the self.setupAnimations method properly?
  4. In HardcodedTextProvider, what do we want to return?

Related issue 🤖

Fixes #1722 ☝🏼

/// The `AnimationTextProvider` that `TextLayer`'s use to retrieve texts,
/// that they should use to render their text context
var textProvider: AnimationTextProvider {
didSet { reloadTexts() }
Copy link
Member

Choose a reason for hiding this comment

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

reloadFonts() / reloadTexts() doesn't cause setupAnimations(context:) to be called again. Instead I think we need to call rebuildCurrentAnimation(), like we do in setValueProvider(_:keypath:) below.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Got you! Thanks!
Fixed in b542f66

try super.setupAnimations(context: context)

// Question 1: Which keypath to pass?
_ = try context.currentKeypath.keys.map { key in
Copy link
Member

Choose a reason for hiding this comment

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

We should pass the entire keypath here, e.g. context.currentKeypath.fullPath


// Question 1: Which keypath to pass?
_ = try context.currentKeypath.keys.map { key in
// Question 2: Where do we get the text layer text?
Copy link
Member

Choose a reason for hiding this comment

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

try textLayerModel.text.exactlyOneKeyframe(context: context, description: "text layer text").text is text value displayed in the text layer.

Due to limitations of Core Animation text rendering, this text layer only supports displaying a static text value over the entire animation. That's why we extract a single string from the keyframes using exaxtlyOneKeyframe().

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Updated the method in 5055f10 🟡

Due to limitations of Core Animation text rendering, this text layer only supports displaying a static text value over the entire animation. That's why we extract a single string from the keyframes using exaxtlyOneKeyframe().

Do you mean that text won't be able to be animated in the end anyway, or that just the text can't be changed during the animation? Like we can't start animation with text "Hell.." and finish it with "Hello!" 🤔

_ = try context.currentKeypath.keys.map { key in
// Question 2: Where do we get the text layer text?
let customText = context.textProvider.textFor(keypathName: key, sourceText: "text layer text")
if customText.isEmpty {
Copy link
Member

Choose a reason for hiding this comment

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

I don't think we need this isEmpty check -- the Main Thread rendering engine doesn't have this check (

let textString = textProvider.textFor(keypathName: keypathName, sourceText: text.text)
) and it seems like a valid use case to hide a piece of text by returning an empty string.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Updated the method in 5055f10 🟡

Okay, I see. But then I just don't understand how do we actually decide when to set this:
renderLayer.text = customText

and when do this:

renderLayer.text = try textLayerModel.text.exactlyOneKeyframe(context: context, description: "text layer text").text? 🤔

Copy link
Member

@calda calda Aug 19, 2022

Choose a reason for hiding this comment

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

we always just do renderLayer.text = customText 😄 https://github.com/airbnb/lottie-ios/pull/1723/files#r950221577

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Okay, then it is this way already in the code! 🎉

@@ -48,6 +64,9 @@ final class TextLayer: BaseCompositionLayer {
// - We may be able to support animating `fillColor` by getting clever with layer blend modes
// or masks (e.g. use `CoreTextRenderLayer` to draw black glyphs, and then fill them in
// using a `CAShapeLayer`).

// Question 3: Should we somehow initialize LayerContextAnimations at this stage
Copy link
Member

Choose a reason for hiding this comment

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

I think we can remove the renderLayer.text = text.text call from this method -- it only needs to be done once, and setupAnimations is fine.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Right! Fixed in 01307aa

@@ -22,6 +22,9 @@ struct SnapshotConfiguration {
/// A custom `AnimationImageProvider` to use when rendering this animation
var customImageProvider: AnimationImageProvider?

/// A custom `AnimationTextProvider` to use when rendering this animatino
var customTextProvider: AnimationTextProvider?
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
var customTextProvider: AnimationTextProvider?
var customTextProvider: AnimationTextProvider?

btw our readme has instructions on how you can run our code formatter (which will be necessary for CI to pass): https://github.com/airbnb/lottie-ios#contributing

Copy link
Contributor Author

Choose a reason for hiding this comment

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

31f3bf9

Copy link
Contributor Author

Choose a reason for hiding this comment

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

By the way, I am experiencing some troubles with the ruby version and therefore can't run the linter.
So all the previous problems I found manually and fixed. But now I don't see any problem, and it still fails...

Copy link
Member

Choose a reason for hiding this comment

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

All good, I'll run it locally and push

Copy link
Member

Choose a reason for hiding this comment

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

Ruby can be a nightmare 🫠

@@ -70,6 +73,9 @@ extension SnapshotConfiguration {
// Test cases for `AnimatedImageProvider`
"Nonanimating/_dog": .customImageProvider(HardcodedImageProvider(imageName: "Samples/Images/dog.png")),

// Test cases for `AnimatedTextProvider`
"": .customTextProvider(HardcodedTextProvider()),
Copy link
Member

Choose a reason for hiding this comment

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

This should be:

Suggested change
"": .customTextProvider(HardcodedTextProvider()),
"Issues/issue_1722": .customTextProvider(HardcodedTextProvider()),

Copy link
Contributor Author

Choose a reason for hiding this comment

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

a053713

Comment on lines 13 to 21
class HardcodedTextProvider: AnimationTextProvider {
// Question 4: What should we actually return here?
func textFor(keypathName: String, sourceText: String) -> String {
#if os(iOS)
return "text layer text"
#else
return nil
#endif
}
Copy link
Member

Choose a reason for hiding this comment

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

I think this should be:

Suggested change
class HardcodedTextProvider: AnimationTextProvider {
// Question 4: What should we actually return here?
func textFor(keypathName: String, sourceText: String) -> String {
#if os(iOS)
return "text layer text"
#else
return nil
#endif
}
class HardcodedTextProvider: AnimationTextProvider {
init(text: String) {
self.text = text
}
func textFor(keypathName _: String, sourceText _: String) -> String {
text
}
private let text: String
}

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Alrighty! a053713

Copy link
Member

@calda calda left a comment

Choose a reason for hiding this comment

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

Looking good!

Comment on lines 102 to 103
// We need to rebuild the current animation after registering a value provider,
// since any existing `CAAnimation`s could now be out of date.
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
// We need to rebuild the current animation after registering a value provider,
// since any existing `CAAnimation`s could now be out of date.
// We need to rebuild the current animation after updating the text provider,
// since this is used in `TextLayer.setupAnimations(context:)`

Copy link
Contributor Author

Choose a reason for hiding this comment

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

7a71e9a

@@ -26,10 +26,11 @@ final class TextLayer: BaseCompositionLayer {
override func setupAnimations(context: LayerAnimationContext) throws {
try super.setupAnimations(context: context)

// Question 1: Which keypath to pass?
_ = try context.currentKeypath.keys.map { key in
Copy link
Member

Choose a reason for hiding this comment

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

I don't think we need this context.currentKeypath.keys.map -- we should be able to just remove it and just have the code on the inside

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Got rid of it in c8a84c2

// Question 2.1: Should we get from somewhere source text? What if the user has custom name?
let customText = context.textProvider.textFor(keypathName: context.currentKeypath.fullPath, sourceText: "text layer text")
// Question 2.2: If we don't have this .isEmpty check, then how do we decide when to set our custom text, and when to
// set the result of the `exactlyOneKeyFrame()`?
Copy link
Member

Choose a reason for hiding this comment

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

If you look at the behavior of the main thread rendering engine, it just unconditionally sets renderLayer.text to the result of textProvider.textFor(keypathName:sourceText:). Since that method doesn't return an optional, it looks like expected usage is to just return sourceText if you don't want to customize the text for that specific keypath. That's how DefaultTextProvider works, for example.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Updated the method in c8a84c2 🟡

Then we just set it like this? 🤔

// Question 2: Where do we get the text layer text?
let customText = context.textProvider.textFor(keypathName: key, sourceText: "text layer text")
// Question 2.1: Should we get from somewhere source text? What if the user has custom name?
let customText = context.textProvider.textFor(keypathName: context.currentKeypath.fullPath, sourceText: "text layer text")
Copy link
Member

Choose a reason for hiding this comment

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

sourceText is just the text value specified in the animation json for this layer -- so, try textLayerModel.text.exactlyOneKeyframe(context: context, description: "text layer text").text

Copy link
Contributor Author

Choose a reason for hiding this comment

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

But what if the JSON will have a different name for this text layer?
Would there be a way to pass some specific value to configure animation for such behavior?

@@ -70,6 +73,9 @@ extension SnapshotConfiguration {
// Test cases for `AnimatedImageProvider`
"Nonanimating/_dog": .customImageProvider(HardcodedImageProvider(imageName: "Samples/Images/dog.png")),

// Test cases for `AnimatedTextProvider`
"Issues/issue_1722": .customTextProvider(HardcodedTextProvider(text: "Bounce-bounce")),
Copy link
Member

Choose a reason for hiding this comment

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

When you're done with the code changes you can generate the snapshot images for this by running the unit test target on an iPhone 8 simulator (or by running bundle exec rake test:package on the command line).

Copy link
Member

Choose a reason for hiding this comment

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

fyi I think the snapshot tests are sensitive to specific Xcode versions -- right now I think they run correctly on Xcode 13.4.1 but not on Xcode 14. If you get a lot of spurious failures when you run the tests let me know and I can pull you branch and run them for you.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Hm unfortunately it always return this message during the test run:
No reference was found on disk. Automatically recorded snapshot:

Copy link
Contributor Author

Choose a reason for hiding this comment

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

If you get a lot of spurious failures when you run the tests let me know and I can pull you branch and run them for you.

Yeah, Cal, that would be helpful, thanks a bunch! 🙏🏼

Copy link
Contributor Author

Choose a reason for hiding this comment

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

By the way the name of the text layer in the provided animation is Animation text layer 🙏🏼

Copy link
Member

Choose a reason for hiding this comment

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

Hm unfortunately it always return this message during the test run:
No reference was found on disk. Automatically recorded snapshot:

This is expected the first time you run the tests after you add a new sample JSON file. If you run the tests again this goes away, since now it compares the new snapshot with the previously-recorded one.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Hm true. However, the snapshots anyway don't contain the text. Could it be because the text layer has a different name?

Copy link
Member

Choose a reason for hiding this comment

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

I think this was because we had to call textRenderLayer.sizeToFit() after updating textRenderLayer.text.

@@ -22,6 +22,9 @@ struct SnapshotConfiguration {
/// A custom `AnimationImageProvider` to use when rendering this animation
var customImageProvider: AnimationImageProvider?

/// A custom `AnimationTextProvider` to use when rendering this animatino
var customTextProvider: AnimationTextProvider?
Copy link
Member

Choose a reason for hiding this comment

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

Make sure to actually apply this text provider to the AnimationView in SnapshotConfiguration.makeAnimationView: https://github.com/airbnb/lottie-ios/blob/master/Tests/SnapshotTests.swift#L255

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Cool to know, thanks!
Done in c7a21ce

@@ -23,6 +23,13 @@ final class TextLayer: BaseCompositionLayer {
fatalError("init(coder:) has not been implemented")
}

override func setupAnimations(context: LayerAnimationContext) throws {
try super.setupAnimations(context: context)
let customText = context.textProvider.textFor(keypathName: context.currentKeypath.fullPath, sourceText: "text layer text")
Copy link
Contributor

Choose a reason for hiding this comment

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

Would it be possible to pass in the actual source text like here:

let textString = textProvider.textFor(keypathName: keypathName, sourceText: text.text)

Suggested change
let customText = context.textProvider.textFor(keypathName: context.currentKeypath.fullPath, sourceText: "text layer text")
let sourceText = try textLayerModel.text.exactlyOneKeyframe(context: context, description: "text layer text")
let customText = context.textProvider.textFor(keypathName: context.currentKeypath.fullPath, sourceText: sourceText )

I think the textLayerModel.text.exactlyOneKeyframe code (currently on line 51) can be removed from configureRenderLayer.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Sure, I think it's possible.
Done in 8cb4ccd

I think the textLayerModel.text.exactlyOneKeyframe code (currently on line 51) can be removed from configureRenderLayer.

But I am not sure about that. Since we use the text later on in the configureRenderLayer(:)

    renderLayer.alignment = text.justification.textAlignment
    renderLayer.lineHeight = CGFloat(text.lineHeight)
    renderLayer.tracking = (CGFloat(text.fontSize) * CGFloat(text.tracking)) / 1000

    renderLayer.fillColor = text.fillColorData?.cgColorValue
    renderLayer.strokeColor = text.strokeColorData?.cgColorValue
    renderLayer.strokeWidth = CGFloat(text.strokeWidth ?? 0)
    renderLayer.strokeOnTop = text.strokeOverFill ?? false

    renderLayer.preferredSize = text.textFrameSize?.sizeValue

Copy link
Contributor

Choose a reason for hiding this comment

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

Ah, haven't expanded far enough downwards, nevermind.

@lostinstaches lostinstaches requested review from calda and campersau and removed request for calda and campersau August 19, 2022 14:43
Comment on lines 5 to 8
import QuartzCore
#if os(iOS)
import UIKit
#endif
Copy link
Member

Choose a reason for hiding this comment

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

Are these necessary?

Suggested change
import QuartzCore
#if os(iOS)
import UIKit
#endif

Copy link
Contributor Author

Choose a reason for hiding this comment

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

8bbf3c9

Copy link
Member

@calda calda left a comment

Choose a reason for hiding this comment

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

Nice work, I pushed some commits to make the tests pass etc

@calda calda enabled auto-merge (squash) August 19, 2022 15:43
@lostinstaches
Copy link
Contributor Author

Thanks a lot @calda! Superb experience to try out some code here together!
If everything works out with CI and it would be possible to merge the PR, do we know when the updated version could be released, so it would be possible to use the core engine for text animations? 👀

@calda
Copy link
Member

calda commented Aug 19, 2022

Thanks for the contribution!

I turned on auto-merge so this PR will merge automatically as soon as CI finishes, assuming the checks are all green.

We just released 3.4.2 so I don't plan on releasing a new version immediately. We'd probably release 3.4.3 in a month or so. In the mean time your project could point directly to master / the commit that includes this change.

@calda calda merged commit 91f46bf into airbnb:master Aug 19, 2022
calda added a commit that referenced this pull request Nov 28, 2022
calda added a commit that referenced this pull request Dec 1, 2022
MoroziOS pushed a commit to MoroziOS/tmg-lottie-ios that referenced this pull request May 22, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Core Animation rendering engine doesn't support text providers
3 participants