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

[Compose] Breaking Change: Major Compose API Refactor #1793

Closed
wants to merge 32 commits into from

Conversation

gpeal
Copy link
Collaborator

@gpeal gpeal commented Apr 18, 2021

This PR is a major update to the Lottie Compose APIs.
Notable changes:

  • LottieAnimationState no longer exists.
  • LottieAnimation composable now just takes a composition, progress as a Float, and base properties such as ImageAssetDelegate, mergePaths, etc.
  • Animating the composition can be done either with a manual animation, gesture, etc or via the new animateLottieComposition() function.
  • LottieAnimation contains overloads that make it easier to just pass in a LottieCompositionSpec or animation parameters and under the hood, it will wrap lottieComposition() and animateLottieComposition().
  • Animations can be chained together using a new lottieTransition() function which lets you set up animations for different states. It also makes it trivial to ensure that the current state plays to completion before starting the next one which used to be very challenging.

The next set of APIs after this PR will be dynamic properties.

Manual Progress

// Base LottieAnimation usage
val compositionResult = rememberLottieComposition(LottieCompositionSpec.RawRes(R.raw.heart))
LottieAnimation(
    compositionResult(),
    progress = 0.5f,
    imageAssetDelegate = ...
    enableMergePaths = ...
)
// LottieCompositionResult implements State<LottieComposition?> so it can be used like this.
val composition by rememberLottieComposition(LottieCompositionSpec.RawRes(R.raw.heart))
LottieAnimation(
    composition,
    progress = 0.5f,
)

Animating Progress

// Driving an animation has been split out into animateLottieComposition(...)
// custom animation using Lottie's animator
var progress by animateLottieComposition(
    iterations = LottieConstants.IterateForever,
)
LottieAnimation(
    LottieCompositionSpec.RawRes(R.raw.heart),
    progress = progress,
)
// There is an overloaded version of LottieAnimation that wraps fetching the composition,
// calling animateLottieComposition(), and the LottieAnimation composable itself.
// This will auto-play one time
LottieAnimation(LottieCompositionSpec.RawRes(R.raw.heart))
// This will auto-play and repeat forever
LottieAnimation(
    LottieCompositionSpec.RawRes(R.raw.heart),
    iterations = LottieConstants.IterateForever,
)
// This will play one time whenever isPlaying becomes true
val isPlaying by remember { mutableStateOf(false) }
LottieAnimation(
    LottieCompositionSpec.RawRes(R.raw.heart),
    isPlaying = isPlaying,
)
// custom animation using Lottie's animator
var progress by animateLottieComposition(
    iterations = LottieConstants.IterateForever,
)
// progress will be animated automatically but we can also manually set it and it'll jump to that progress.
LaunchedEffect(Unit) {
    while (isActive) {
        progress /= 2f
        delay(1000)
    }
}

LottieAnimation(
    LottieCompositionSpec.RawRes(R.raw.heart),
    progress = progress,
)

Retries

There is now a simple way to retry animations that fail to load.

var backoffMs = 2000L
val composition by lottieComposition(
    LottieCompositionSpec.Url("https://...")
    onRetry = { retryCount, _ ->
        // This will do 6 exponential backoff retries.
        delay(backoffMs)
        backoffMs *= 2
        retryCount <= 5
    },
)

Transitions

This also includes the ability to create transitions where different states map to different compositions or clip specs within an animation:

var state by remember { mutableStateOf(0) }
val compositionResult = rememberLottieComposition(LottieCompositionSpec.RawRes(R.raw.loading))

// This will loop from [0,0.5] as long as the state is 0. When the state transitions to 1,
// it will finish animating to 0.5 and then animate from [0.5,1] one time.
val progress by lottieTransition(state) { progress ->
    val composition = compositionResult.await()
    when (state) {
        0 -> {
            while (isActive) {
                animateLottieComposition(
                    composition,
                    progress,
                    clipSpec = LottieAnimationClipSpec.MinAndMaxProgress(0f, 0.5f),
                    cancellationBehavior = LottieCancellationBehavior.AtEnd,
                )
            }
        }
        1 -> {
            animateLottieComposition(
                composition,
                progress,
                clipSpec = LottieAnimationClipSpec.MinAndMaxProgress(0.5f, 1f),
                cancellationBehavior = LottieCancellationBehavior.AtEnd,
            )
        }
    }
}

Column(
    horizontalAlignment = Alignment.CenterHorizontally,
) {
    LottieAnimation(
        compositionResult(),
        progress,
    )
    TextButton(
        onClick = { state = (state + 1) % 2 },
    ) {
        Text(state.toString())
    }
}

@gpeal gpeal force-pushed the gpeal/compose-min-max-frame branch 2 times, most recently from 7c2967e to 1387e8a Compare May 17, 2021 04:43
@gpeal gpeal force-pushed the gpeal/compose-min-max-frame branch from 1387e8a to d671309 Compare May 17, 2021 05:04
@gpeal gpeal changed the title [Compose] Added the ability to clip animation starting and ending times. [Compose] Breaking Change: Major Compose API Refactory May 17, 2021
@gpeal gpeal changed the title [Compose] Breaking Change: Major Compose API Refactory [Compose] Breaking Change: Major Compose API Refactor May 17, 2021
gpeal added a commit that referenced this pull request May 20, 2021
Fixes #1808

Separated the upgrade from #1793 so the larger PR can be more carefully reviewed.

After this lands, sample-compose will be broken until mavericks-compose for beta07 is published but getting the lottie artifact updated is more important right now.
@gpeal gpeal force-pushed the gpeal/compose-min-max-frame branch from 48fb2aa to 8f7429a Compare May 24, 2021 00:37
*/
suspend fun animateLottieComposition(
composition: LottieComposition?,
progress: MutableState<Float>,
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

@lelandrichardson is it okay for this to take MutableState<Float> or should it be a MutableStateFlow? The semantics of this API works nicely with MutableState but it is rare to see a third party library use MutableState in a public API.

Copy link
Contributor

Choose a reason for hiding this comment

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

If Leland hasn't gotten back to you yet maybe we can ping him somewhere else. I think using a MutableState here works well and is more Compose-agnostic than a StateFlow and also a little easier to understand if you're just starting out with Compose; there are more pitfalls around StateFlow.

Choose a reason for hiding this comment

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

This MutableState seems to have two sources of truth. I'm guessing the intention here is to support seeking while playing. Since when progress is mutated outside of this suspend fun, it effectively resets the initialProgress of the animation, I wonder if it would more clear to have separate concepts for initialProgress vs. animatedProgress.

BTW, what's the recommended way seek with gesture?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

@doris4lt you are correct. It does, in fact, have 2 sources of truth so you can adjust the progress while it animates. When you do that, it snaps to that progress within the current repeatCount. This is how we achieve playing + dragging the seek bar in the sample app. Does this seem like a reasonable approach to do that?

Choose a reason for hiding this comment

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

The multiple sources of truth may be error-prone, as it's hard to track down a bug when something goes wrong. At the same time, it's limiting - using MutableState here only allows devs to change progress. If they were to imperatively control other behaviors of the animation, it needs to be done at an entirely separate place. I can imagine scenarios where when changing the progress with a gesture, devs may prefer the animation to not auto-play until finger release.

I would create a set of imperative suspend fun APIs for various controls that would affect animation's lifecycle, such as changing the starting progress of a run, stopping the animation, snapping to a progress without running (i.e. seek only), etc. That way devs could control the animation in one LaunchedEffect.

For the multiple sources of truth, I would recommend writing the current progress of the animation to a different animation state object, along with other animation related states (e.g. whether it's playing, iteration #, etc). They could read only. I assume the reason for writing the animation progress back to the MutableState is so that devs could inspect the current progress of the animation and make decisions accordingly. Though the progress alone may not be sufficient for such a decision. Hence the recommendation for a separate state object altogether. :-)

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

@doris4lt I think you raise a lot of valid points. However, I can't seem to get an imperative corutines API to come together in a way I'm happy with. Here are 3 branches in which I tried some different approaches:
https://github.com/airbnb/lottie-android/commits/gpeal/compose-min-max-frame-imperative-api
https://github.com/airbnb/lottie-android/commits/gpeal/compose-min-max-frame-imperative-suspend-api-2
https://github.com/airbnb/lottie-android/commits/gpeal/compose-min-max-frame-imperative-api-3

I'd love another set of eyes or ideas for an API that works. In general, the dual-ownership aspect of these animations made most of these APIs really tricky/confusing.

Do you think there are any major issues with these APIs as they are? If not, I'd be open to adding additional animation APIs in the future now that the base LottieAnimation composable just takes a raw progress float.

@airbnb airbnb deleted a comment from LottieSnapshotBot May 30, 2021
@airbnb airbnb deleted a comment from LottieSnapshotBot May 30, 2021
@airbnb airbnb deleted a comment from LottieSnapshotBot May 30, 2021
@airbnb airbnb deleted a comment from LottieSnapshotBot May 30, 2021
@airbnb airbnb deleted a comment from LottieSnapshotBot May 30, 2021
@airbnb airbnb deleted a comment from LottieSnapshotBot May 30, 2021
@airbnb airbnb deleted a comment from LottieSnapshotBot May 30, 2021
@airbnb airbnb deleted a comment from LottieSnapshotBot May 30, 2021
@airbnb airbnb deleted a comment from LottieSnapshotBot May 30, 2021
@LottieSnapshotBot
Copy link

Snapshot Tests
28: Report Diff

@LottieSnapshotBot
Copy link

Snapshot Tests
28: Report Diff

@LottieSnapshotBot
Copy link

Snapshot Tests
28: Report Diff

Copy link
Contributor

@jossiwolf jossiwolf left a comment

Choose a reason for hiding this comment

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

So I finally had a chance to look through this, sorry for taking so long!

Awesome! It took me a bit to get used to the new API surface, but looking at the samples it makes a lot of sense and is easy to use. I haven't gotten around to trying a snapshot of this in my side project yet but I'm planning to do that this week.

Compose-wise, I didn't see anything major! Amazing PR :)

* Numbers between 0 and 1 will slow it down. Numbers less than 0 will play it backwards.
* @param repeatCount The number of times the animation should repeat before stopping. It must be
* a positive number. [Integer.MAX_VALUE] can be used to repeat forever.
* @param onRepeat An optional callback to be notified every time the animation repeats. Return whether
Copy link
Contributor

Choose a reason for hiding this comment

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

Should onRepeat return a Boolean to indicate whether the animation should continue to repeat?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Good catch. I original had this functionality but removed it because the suspend API was more useful for its original purpose. Do you think it's worth it to keep it around? It'll have the side effect of awkwardly dangling true at the end of 99% of the onRepeat lambdas.

*/
suspend fun animateLottieComposition(
composition: LottieComposition?,
progress: MutableState<Float>,
Copy link
Contributor

Choose a reason for hiding this comment

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

If Leland hasn't gotten back to you yet maybe we can ping him somewhere else. I think using a MutableState here works well and is more Compose-agnostic than a StateFlow and also a little easier to understand if you're just starting out with Compose; there are more pitfalls around StateFlow.

): Float {
val progress = remember { mutableStateOf(0f) }
val states = remember { MutableStateFlow(state) }
states.value = state
Copy link
Contributor

Choose a reason for hiding this comment

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

SideEffect?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

@jossiwolf For what, exactly?

* val compositionResult = lottieComposition(LottieCompositionSpec.RawRes(R.raw.your_animation))
* val progress = lottieTransition(state) { progress ->
* val composition = compositionResult.await()
* when (state) {
Copy link
Contributor

Choose a reason for hiding this comment

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

I'm not quite sure I understand this example😅 Where is the state mutated?

Should the animate lambda return a LottieComposition?

Copy link
Contributor

Choose a reason for hiding this comment

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

Upon second look, I got now that this is using the mutable state APIs. Just from reading this sample code, it wasn't quite clear to me how it worked. What do you think about returning a LottieComposition from animate instead?

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

@jossiwolf Not sure I follow. animateLottieComposition here actually returns Unit. It suspends during the animation and updates the progress MutableState passed in.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

@jossiwolf It is a little bit complicated but there is a transition example further down this PR that might clarify things.

@Composable
fun animateLottieComposition(
composition: LottieComposition?,
isPlaying: Boolean = true,

Choose a reason for hiding this comment

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

Have you considered playMode vs. seekMode here, to avoid having isPlaying = true after animation is finished? :-)

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

@doris4lt what would that look like, exactly?

Choose a reason for hiding this comment

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

I seem to have misunderstood what isPlaying is for. Sorry about the confusion... Upon reading the impl below, it is for pausing/resuming the animation.

This really isn't a big deal. But I'd gravitate towards: mode: PlayMode = Play, where enum class PlayMode { Play, Paused } over isPlaying = true, since isPlaying implies the animation is ongoing and unfinished. Alternatively, you could consider isPaused, isPaused = false doesn't imply the animation is finished or otherwise.

@headsvk
Copy link
Contributor

headsvk commented Jun 11, 2021

Looking forward to this overhaul, good job guys!

@LottieSnapshotBot
Copy link

Snapshot Tests
28: Report Diff

@LottieSnapshotBot
Copy link

Snapshot Tests
28: Report Diff

@gpeal
Copy link
Collaborator Author

gpeal commented Jun 28, 2021

Closing in favor of #1827

@gpeal gpeal closed this Jun 28, 2021
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.

5 participants