w3c / csswg-drafts

CSS Working Group Editor Drafts
https://drafts.csswg.org/
Other
4.5k stars 666 forks source link

[web-animations-2] Add `Animation.started` and `Animation.playing` promises #5871

Open kevinbrewster opened 3 years ago

kevinbrewster commented 3 years ago

Problem

  1. If you manually construct an Animation to be played at a later time, there's no way to know when it's actually playing.
  2. If the animation has a delay, there's no way to know when the animation effect actually starts.
let animation = new Animation(new KeyframeEffect(element, { opacity: 1 }, { delay: 1000, duration: 2000 }));

animation.ready.then(_ => {
    console.log("animation is ready"); // this is logged immediately
});
window.setTimeout(_ => {
    animation.play()
}, 1500);

// When will animation play?
// When will the delay period end?

Proposal

Introduce two new promises on Animation:

  1. Animation.playing: returns a Promise which resolves once the animation has started playing.
  2. Animation.started: returns a Promise which resolves once the delay period is over and the effect has started
let animation = new Animation(new KeyframeEffect(element, { opacity: 1 }, { delay: 1000, duration: 2000 }));

animation.ready.then(_ => {
    console.log("animation is ready"); // @ time = 0
});
animation.playing.then(_ => {
    console.log("animation is playing"); // @ time = 1500
});
animation.started.then(_ => {
    console.log("animation effect started"); // @ time = 2500
});
animation.finished.then(_ => {
    console.log("animation effect started"); // @ time = 4500
});

window.setTimeout(_ => {
    animation.play()
}, 1500);

Alternatives

  1. Animation.played instead of Animation.playing for consistency in tense with Animation.finished
  2. Animation.effectStarted instead of Animation.started for clarification
birtles commented 3 years ago

Thanks for filing this issue. I wonder if you could elaborate on the use cases for this?

I'm not sure that Promises are the right fit for this use case. The Writing Promise-Using Specifications document gives some guidance about when to use Promises vs events.

In this case a start event can happen many times because:

So it would seem like events would be a better fit for this.

Regarding the third point above, it might also make sense to specify that such events only apply to the delay defined on the root effect, but then I believe authors will also want events on child effects and I don't know how we can fix that without introducing performance issues.

I believe we've seen similar proposals before so it might be worth searching for them and seeing what the concerns were then.

kevinbrewster commented 3 years ago

Related Proposals

  1. [web-animations-2] animation (or effect) start and iteration events #4461
  2. [scroll-animations] Should animation events fire every time active range is left / reentered? #4324

Motivation & Use Cases

Often, an author needs to synchronize arbitrary code with a web animation. For example, during a few key points in an animation lifecycle, one may need to manipulate the DOM, or initiate network requests, or conditionally trigger other animations, among other things. These key lifecycle points are:

  1. When the animation is finished
  2. When the animation is played
  3. When an animation effect begins (i.e. after duration has expired)
  4. When an animation effect iterates

There is precedent for the usefulness of such triggers in the existence of:

CSS Animation Events:

Authors can already be informed when an animation is finished (case #1 above) via the Animation.finished promise but it's currently impossible to be informed about cases 2, 3, and 4

Promise vs Event

The unhandled cases 2, 3, and 4 from above can be separated into two groups:

Animation

"2. When the animation is played" could be handled in one of two ways:

I think there is an argument to be made that Animation.played is analogous to Animation.ready and, as such, it makes sense to use a Promise. I suppose this would fall under 2.3. More general state transitions in the Writing Promise-Using Specifications document.

Side note: even after reading the spec a few times and experimenting with the Animation.ready promise, I'm still unclear on it's use-case or why a Promise was used versus and event. Perhaps an exploration of that reasoning could help guide a decision for an Animation.played Promise.

AnimationEffect

In issue 4461, You wrote:

If we were to add iteration events they would most naturally become a property of the effects...Initially these events were not added because of performance concerns...since then we've taken a different approach to the requirements for dispatching events for CSS animations that limits events to 1~2 per animation frame. I think that would address some of the performance concerns we previously had

I agree and therefore propose that "3. When an animation effect begins" and "4. When an animation effect iterates" be handled by the following events:

AnimationEffectEvent

An uneducated guess of what AnimationEffectEvent could look like:

interface AnimationEffectEvent : Event {
  readonly attribute CSSOMString animationEffectName;
  readonly attribute double elapsedTime;
};

This would require adding a new name attribute to AnimationEffect:

interface AnimationEffect {
    attribute CSSOMString name; /// <--- new
    EffectTiming          getTiming();
    ComputedEffectTiming  getComputedTiming();
    undefined             updateTiming(optional OptionalEffectTiming timing = {});
};

Next Steps

Would it be helpful if I created separate proposals for AnimationEffectEvent and/or Animation.played ?

birtles commented 3 years ago

Related Proposals

  1. [web-animations-2] animation (or effect) start and iteration events #4461

  2. [scroll-animations] Should animation events fire every time active range is left / reentered? #4324

Thank you! That looks right.

Motivation & Use Cases

Often, an author needs to synchronize arbitrary code with a web animation. For example, during a few key points in an animation lifecycle, one may need to manipulate the DOM, or initiate network requests, or conditionally trigger other animations, among other things.

Thank you. I was hoping there might be more specific use cases but the precedent of CSS animation events is compelling enough that I think this makes sense.

"2. When the animation is played" could be handled in one of two ways:

  • Animation.played Promise --or--
  • animationplay event

I think there is an argument to be made that Animation.played is analogous to Animation.ready and, as such, it makes sense to use a Promise. I suppose this would fall under 2.3. More general state transitions in the Writing Promise-Using Specifications document.

Side note: even after reading the spec a few times and experimenting with the Animation.ready promise, I'm still unclear on it's use-case or why a Promise was used versus and event. Perhaps an exploration of that reasoning could help guide a decision for an Animation.played Promise.

The Animation.ready promise fits into the "One-and-done operations" category, i.e. asynchronous operations. Calling play() and pause() triggers an asynchronous operation to setup the playback / pausing by synchronizing with the state of animations running on other processes/threads. The ready Promise resolves when that async operation has completed.

Initially the expectation was that user agents might take different amounts of time to setup animations but in practice all user agents resolve the promise on the next animation frame unless the animation is already playing or unless it is associated with an inactive timeline.

As for use cases, it lets you know when it is safe to read the startTime (in the casing of playing) or currentTime (in the case of pausing), so that, for example, you can synchronize other animations with the newly played/paused animation.

I'm still not clear on the use cases for the played promise, however. When do you ever have an animation that you don't initially play() on, where the caller of play() is sufficiently distant from the observer of played state that you can't call it directly, and where events would not be ergonomical?

As I understand it, the examples for "More general state transitions" in that document refer to async operations that differ from this case. Also it seems like the warnings about over-using Promises apply to this case, at least as I understand it.

AnimationEffect

In issue #4461, You wrote:

If we were to add iteration events they would most naturally become a property of the effects...Initially these events were not added because of performance concerns...since then we've taken a different approach to the requirements for dispatching events for CSS animations that limits events to 1~2 per animation frame. I think that would address some of the performance concerns we previously had

Thanks, that's great. (I've moved this discussion to web-animations-2 in keeping with the discussion there.)

Yes, it does sound like we could resolve the performance issues I had in mind.

An uneducated guess of what AnimationEffectEvent could look like: [...]

That all seems reasonable.

This would require adding a new name attribute to AnimationEffect:

I wonder if that would be necessary if we make the target of the event the AnimationEffect itself? (Since only KeyframeEffect's have a (pseudo-)element target and even that is optional.)

Next Steps

Would it be helpful if I created separate proposals for AnimationEffectEvent and/or Animation.played ?

I'm personally still unclear about Animation.played but I can see AnimationEffectEvent as being worth specifying if you have time to work on it!

kevinbrewster commented 3 years ago

EDIT: I cleaned up a few things since yesterday

Thanks for taking the time to explain all that!

I'll create a separate proposal for AnimationEffectEvent next week and use this page to further discuss the potential merits of an Animation.played promise.

The Animation.ready promise fits into the "One-and-done operations" category, i.e. asynchronous operations.

Hmm. I interpreted this category as explicitly referring to methods that "return a promise", not properties, but perhaps that's a distinction without a difference.

Anyway, I think I found a better way to articulate what I want.

Better Explanation of Animation.played Promise

I want to be informed when these conditions happen (and I'm hoping both are exactly equivalent):

In other words, I see three state categories:

    `played`  promise resolved
      | |
       V    

           |  ---- playing -----  |  
[idle]     | [running] / [paused] | [finished]
       | -------------------  |
       |    [effect]          |
       |      [effect]        |
       |             [effect] |

I want to know when the animation has entered this playing state category. This would be a one-time event (i.e. would not happen with every play() or pause().

By the way, I'm not sure played is even the best name for this, as it implies it's directly related to the play() method. Perhaps you can suggest a better name?

The only advantage of a promise over an event is so authors can still be informed if animation is auto-played. In other words, if there was a play event instead of a promise, you could never be notified when using element.animate().

Consider these three examples where the console should log "played" each time.

let animation = new Animation(...);
animation.played.then( console.log("played") );
animation.play();
let animation = new Animation(...)
animation.play();
animation.played.then( console.log("played") )
let animation = element.animate()
animation.played.then( console.log("played") )

Obviously the third example is practically pointless, but I'm imagining a situation where animations are dynamically created, sometimes being played immediately and sometimes not, and we want a consistent way to be notified.

Next consider this example of a trivial "conditional sequence". In other words, when one animation finishes it may or may not trigger another animation. Imagine there is some timeline UI shown on the page for users to visualize animations on the timeline. Whenever an animation enters the playing state category, I want to append a DIV to the timeline UI element to represent that animation.

There's certainly work-arounds (e.g. lifting animation1.play() to a separate function that can handle the extra DOM work as well as trigger the animation) but these work-arounds quickly become cumbersome as the code grows in complexity.

let box = document.querySelector("#box");

let animation1 = new Animation(new KeyframeEffect(box, 
   [{ opacity: 1 }, { opacity: 0 }], 
   { duration: 300, delay: 1000 }
))
animation1.played.then(_ => {
   console.log("animation1 played")
   // todo: add some kind of visual element to the timelineUI
});
animation1.finished.then(_ => {
   console.log("animation1 finished") 
});

let animation2 = new Animation(new KeyframeEffect(box, 
   [{ transform: "translateX(0)" }, { transform: "translateX(100px)" }], 
   { duration: 600, delay: 100 }
))
animation2.played.then(_ => {
   console.log("animation2 played")
   // todo: add some kind of visual element to the timelineUI
});
animation2.finished.then(_ => {
   console.log("animation2 finished") 
});

let animation3 = new Animation(new KeyframeEffect(box, 
   [{ transform: "rotate(0)" }, { transform: "rotate(90deg)" }], 
   { duration: 800, delay: 400 }
))
animation3.played.then(_ => {
   console.log("animation3 played");
   // todo: add some kind of visual element to the timelineUI
});
animation3.finished.then(_ => {
   console.log("animation3 finished")

   if(Math.random() > 0.5) {
      animation1.play();
   } else {
      animation2.play();
   } 
});

// kick-off the first animation
animation3.play();

For a more concrete example, I'm currently creating a dynamic slideshow using animations. I'm creating all of my animations ahead of time and effectively doing a bunch of SequenceAnimations and GroupAnimatons but sometimes more complex (i.e. with conditions / different pathways for the sequence part). The spacebar and arrow keys trigger certain animation groups/sequences, and the exact sequence is not predetermined ahead of time.

It would greatly simplify my life to have custom code run whenever an animation is first played, so I can adjust the DOM as needed (or even just logging for debug purposes). Sometimes the actual effects have a significant delay so I don't want to synchronize with the effect - I'm strictly interested in when play() is actually called).

From a theoretical standpoint, the API allows Animation instances to be created ahead of time and remain in an idle state indefinitely. It stands to reason that the API should either auto-play every animation, or provide a mechanism to signal the transition from idle to running / paused.

Alternative

Alternatively, you could alter the spec to add a third condition for an animation to be considered ready:

An animation is ready at the first moment where all of the following conditions are true:

  • the user agent has completed any setup required to begin the playback of the animation’s associated effect including rendering the first frame of any keyframe effect.
  • the animation is associated with a timeline that is not inactive.
  • the animation is NOT in an idle state [i.e. play() or pause() has been called]. // << NEW

That would mean that the current ready promise would initially be unresolved.

In the below code, I think it's intuitive that the animation should not be considered "ready" until play() or pause() has been called and the animation is out of the idle state.

let animation = new Animation(new KeyframeEffect(box, 
   [{ opacity: 1 }, { opacity: 0 }], 
   { duration: 300, delay: 1000 }
))
animation.ready.then(_ => {
   console.log("ready"); // should NOT be called
});
birtles commented 3 years ago

Thank you for taking the time to explain this use case. I really appreciate it. I've read it through and I think I understand the scenario.

Firstly, I think you'd want to define that such a promise is replaced when cancel() is called. That method causes the currentTime to become null so I think that matches the usage you outlined.

That said, I'm still not sure that this pattern is common enough to warrant adding to the platform. Like you said, you could accomplish your particular case by wrapping the play() / pause() / animate() methods to add the desired behavior.

For the timeline UI case, I think it might be better to pursue a more generic animation mutation observer API. That's what Firefox uses internally for its animation DevTools and I'm told Chrome has something similar so if we could standardize the behavior of this interface we could potentially expose it to Web content. It's more work to specify, but likely to be useful to more applications.

Of course, if you can convince other parties that the played Promise is generally useful, I'm happy to see it added.

kevinbrewster commented 3 years ago

Thanks for your response.

What are your thoughts on my "Alternative" section on my last comment?

i.e. what is the rationale for having the current ready promise be initially resolved? If the current ready promise was initially un-resolved, this would solve my problem as well.

birtles commented 3 years ago

What are your thoughts on my "Alternative" section on my last comment?

i.e. what is the rationale for having the current ready promise be initially resolved? If the current ready promise was initially un-resolved, this would solve my problem as well.

The ready promise represents the state of an asynchronous operation. When it resolves, it tells you that it is safe to read the currentTime (for pausing) and startTime (for playing) values. Having it be unresolved initially breaks that model. There is no operation pending and it is safe to read currentTime / startTime so I don't think it should be unresolved at that point. I'd also be a little concerned about changing that behavior now that it is shipping in all browsers.

kevinbrewster commented 3 years ago

Right. I guess I'm still struggling to understand how a resolved promise can represent both the completion of an asynchronous operation and also represent no asynchronous operation having been run at all. Perhaps I should have said: current ready promise should initially be null, as no asynchronous operation has been run, so there is no state to represent.