group_effect

GroupEffect Explainer

Introduction

Animation sequencing is a common technique that allows creation of complex animated effects where multiple effects (potentially on multiple targets) are animated together as a group.

There is currently a draft proposal to include some form of sequencing in Web Animations Level 2 Specification. The proposal introduces a GroupEffect which is a type of AnimationEffect that contains an ordered sequence of other animation effects a.k.a children effects.

In the explainer we are proposing a few changes to this model with the objective to introduce flexibility and customizability. Furthermore we propose adding a new sequencing model “stagger”. The altered design introduces a different hierarchy of group effect: An abstract group effect representing the grouping, and concrete sub-types that define different possible mapping of time from the parent to children.

Motivations

Grouping is a powerful mechanism that allows creation of complex tree structures by hiding the complexity inside the group. Note that for this to work well the group should hide its inner working and expose a common API. This is a valuable primitive that allows scaling animations to complex sequences and it is a common technique supported by popular animation libraries and tools.

Why adding this to web animations

With current level-1 web animations specification, to create a coordinated sequence of animations a web developer has to create multiple animations and each of them is associated with only one effect and target.

This can work but suboptimal for the following reasons.

Example

Consider this sequence where two elements are animated and then reversed. Figure 1 A possible implementation without grouping can be done this way:

// Without GroupEffect
var effect_a = new KeyframeEffect(
    target_a, [{ opacity: 0, offset: 0},
               { opacity: 1, offset: 0.5},
               { opacity: 1, offset: 1}], {
    duration: 200, iterations: 2, direction: 'alternate'});

var effect_b = new KeyframeEffect(
    target_b, { opacity: [0, 1] }, {
    duration: 200, iterations: 2, direction: 'alternate' });

var animation_a = new Animation(effect_a, document.timeline);
var animation_b = new Animation(effect_b, document.timeline);
animation_a.play();
animation_b.play();

There are several drawbacks in the example above:

With grouping the code can be described more naturally as:

// With GroupEffect the effects don't need to have exact durations.
var effect_a = new KeyframeEffect(
    target_a, { opacity: [0, 1] }, {duration: 100, fill: 'forwards' });

var effect_b = new KeyframeEffect(
    target_b, { opacity: [0, 1] }, {duration: 200});

var parallel_effect = new ParallelEffect([effect_a, effect_b],
    {iterations: 2, direction: 'alternate'});

var animation = new Animation(parallel_effect, document.timeline);
animation.play()

Deviation from existing design in Level-2

The key insight of the current design is to allow grouping of the animation effects. We believe this is the right choice but propose some changes in the hierarchy of classes.

In the current design the base class (GroupEffect) comes with a default time mapping from the group time to the child. This default choice is parallel. i.e. children have the same start time as the group. Then there is a special group effect: sequence effects which overrides the time mapping to arranges the start time of the children effects in a way that they start in turn.

We propose to separate the concepts of “group of effects” from “time mapping” thus making it more natural to later add grouping with custom time mappings. In this design the base class, GroupEffect, has no time mapping and cannot be directly constructed. Instead, concrete subclasses (SequenceEffect, ParallelEffect, StaggerEffect) have time mappings. In particular, in future AnimationWorklet can be used to allow a special WorkletGroupEffect whose time can be mapped to its children in any fashion.

Also, the existing spec only speculates two different scheduling models (i.e. parallel, sequence) and we believe stagger is a nice addition. With polyfill, a nice animation is created using StaggerEffect. See more examples of stagger in popular frameworks.

Proposed Design

Figure 2 GroupEffect: an abstract class that contains an ordered list of “child effects” but not time mapping from parent to children and not intrinsic iteration duration. There are some concrete subclasses which have a specific time mapping model. These models determine

  1. the intrinsic iteration duration of the group
  2. how group inherited time maps to a child inherited time.

Note that the basic supported types map have a simple time shift model which addresses most common use cases.

Notes:

Design Constraints

Examples

Note that the proposed GroupEffect is for Web Animation. The following examples are implemented using AnimationWorklet just to showcase how GroupEffect should be used.

An example of using GroupEffect to animate stagger effects with AnimationWorklet.

Figure 4

// Inside AnimationWorkletGlobalScope.
<script id="passthrough"  type="text/worklet">
registerAnimator("test_animator", class {
  animate(currentTime, effect) {
    let effects = effect.getChildren();
    for (var i = 0; i < effects.length; ++i)
      effects[i].localTime = currentTime;
  }
});
</script>

// In document scope.
// For simplicity, all the single keyframe effects are the same.
function SingleEffect(target) {
  return new KeyframeEffect(target,
      [
         { transform: 'scale(1.3) translateX(-400px) translateY(300px) rotate(360deg)',
           opacity: 1 }
      ], {
        duration: 500,
        fill: 'forwards'
      }
  );
}

function AnotherEffect(target) {
  return new KeyframeEffect(target,
      [
         { transform: 'scale(1) translateX(-400px) translateY(-300px) rotate(0deg)',
           opacity: 0 }
      ], {
        duration: 500,
        delay: 500,
        fill: 'forwards'
      }
  );
}

// Converts the input text to multiple effects. Each effect is defined by the
// same KeyframeEffect function.
function Effects(text) {
  var effects = [];
  var wrapper = document.getElementById('wrapper');
  text.split('').forEach(function(c) {
    var div = document.createElement('div');
    div.textContent = c;
    div.setAttribute('class', 'box');
    wrapper.appendChild(div);
    effects.push(SingleEffect(div));
  });

  var children = wrapper.children;
  for (var i = 0; i< children.length; ++i) {
    effects.push(AnotherEffect(children[i]));
  }
  return effects;
}

function runInAnimationWorklet(code) {
  return CSS.animationWorklet.addModule(
    URL.createObjectURL(new Blob([code], {type: 'text/javascript'}))
  );
}

runInAnimationWorklet(
  document.getElementById('passthrough').textContent
).then(()=>{

  var stagger_options = {
    delay: 0.2,        // time gap between two KeyframeEffects
    emanation: 0,      // index of emanative KeyframeEffect
    timing: 'linear',  // function that distributes the start time of each KeyframeEffect
  };
    
  var stagger_effect =
      new StaggerEffect(Effects('stagger effect'),
          {delay: 1000},
          stagger_options);
  var animation = new WorkletAnimation('test_animator', stagger_effect);

  animation.play();
});

An example of using GroupEffect to animate two correlated effects with AnimationWorklet.

Figure 5

// Inside AnimationWorkletGlobalScope.
registerAnimator("test_animator", class {
    constructor(options, state = { hold_time : 0 }) {
      this.hold_time = state.hold_time;
    }
    animate(currentTime, effect) { 
      let effects = effect.getChildren();
      if (currentTime <= effects[0].getTiming().duration * 0.3) {
        effects[0].localTime = currentTime;
        effects[1].localTime = 0;
        this.hold_time = effects[0].localTime;
      } else if (currentTime <= effects[0].getTiming().duration * 0.6) {
        effects[0].localTime = this.hold_time;
        effects[1].localTime = currentTime - this.hold_time;
      } else {
        effects[0].localTime = currentTime - this.hold_time;
        effects[1].localTime = currentTime - this.hold_time;
      }
    }
    state() {
      return {
        hold_time : this.hold_time
      };
    }
});

// In document scope.
let effect_a = new KeyframeEffect(
    target,
    [{ height: '100px' }, { height: '200px' }],
    { duration: 2000 }
);

let effect_b = new KeyframeEffect(
    target,
    [{width: '100px' }, {width: '200px' }],
    { duration: 2000 }
);

var exclusive_effect = new ParallelEffect([effect_a, effect_b], {fill: 'forwards'});

let animation = new WorkletAnimation('test_animator', exclusive_effect);
animation.play();

Appendix

Relevant information and use cases