canjs / can-animate

animation view helpers
MIT License
4 stars 1 forks source link

Support for multiple animations on a single element #3

Closed mickmcgrath13 closed 7 years ago

mickmcgrath13 commented 8 years ago

I'd like to see some way of specifying multiple types of custom animations per element. I've had the following idea for a while, but I haven't put any thought into it for several months. I thought I'd drop a proposal to get a conversation started:

/* Problem: 
    * Consider a simple input modal which has 
    * a message, a text input, a cancel button, and a confirm button.
    *  ------------------------------------------
    * |             Type a number                |
    * |          ---------------------           |
    * |         |                     |          |
    * |          ---------------------           |
    * |                                          |
    * |                   [ Cancel ] [ Confirm ] |
    *  ------------------------------------------
    *
    * The user wants the following for the modal: 
    * Scale in (custom animation) when inserted
    * Fade out when removed
    * Shake when there is an error (scope.attr('hasError', true))
*/

/* Proposal:  use can-animate-options */

var scope = new can.Map({
    "hasError": false,

    modalAnimateOptions: {

        "duration": 2000,

        "whens":{ // <= this name sucks (bindings?)
            "inserted": {
                //'run' footprint needs work:  object vs multiple params
                //object:
                //  run: ({ 
                //     scope:Map,       //the element's scope
                //     el:element,   //the element (jquery wrapped?)
                //     fn:function      //call when finished
                // }){}
                //
                // multiple params:
                //  run: (scope, el, fn, options){}
                "run": function(opts){
                    //run custom scale in animation
                    // $(opts.el).animate({ 
                    //     scale: 0,
                    //     duration:0
                    // },function(){
                    //  $(opts.el).animate({
                    //     scale: 1,
                    //     duration: opts.duration //how to get the nearest specification of duration?
                    //  },function(){
                    //      call opts.fn when done animating
                    //      opts.fn();
                    //      //an alternative to opts.fn could be to provide a promise to resolve
                    //      //opts.fn.resolve()
                    //  })
                    // })

                    //additional thoughts:
                    // can we use can-zone for JS animations?
                },
                "before": function(){
                    //can-animate will call this before 'run'
                },
                "after": function(){
                    //can-animate will call this when run is complete
                }
            },

            "removed": {
                "run": "fade-out" //this would run with a duration of 2000 (set in root of animate options)
            },
            /* possible alternative:
            "removed": "fade-out"
            */

            "hasError": { //not inserted or removed => scope property to bind to
                "run": function(opts){
                    //run custom shake method
                    //run opts.fn when done
                },
                "duration": 1500 //can override the duration from root of animate options
            }
        }

    }
});

$('#content').html(can.view('#demo-html', scope));

/*
<some-component can-animate-options="modalAnimateOptions"></some-component>
*/
mickmcgrath13 commented 8 years ago

Here's a branch implementing the above request: https://github.com/canjs/can-animate/compare/advanced-options?expand=1

Things to note about advanced options (also [rather poorly] documented here)

Use of options

Specify an options object on the scope.

<div can-animation-options="{options}"></div>

options object

{
  duration: 1000, //the global duration for this set of animations
  bindings: {
    "inserted": "fade-in",
    "removed": "fade-out",

    //function - called when prop1 changes
    //  call js animation from within this function
    "prop1": function(props){}, 

    //object - run, before, and after props
    "prop2": {
      //function - main animation function - called when prop2 changes
      //  call js animation from within this function
      "run": function(props), 

      //function - can-animate calls this before "run"
      "before": function(props){}, 

      //function - can-animate calls this when the animation inside of "run" is done
      "after": function(props){} 
    },

    //plugins like these not yet supported
    "prop3": "custom-animation",

    //run 
    "prop4": {
      "run": "custom-animation", //plugins like these not yet supported
      "after": function(props){}
    },

    //specify css properties only (not yet supported)
    prop5: {
      //set up the element
      before:{
        "opacity":0,
        "top":"-100px",
        "z-index":"1000000000"
      },
      //animate to this
      run:{
        "opacity":1,
        "top":"0px"
      },
      //finalize the properties
      after:{
        "z-index":""
      }
    }
  }
}

Examples

Fade In on inserted:

var scope = new can.Map({
  animateOptions: {
    "bindings":{
        "inserted": {
          "run": "fade-in"
        }
    }
  }
});

Fade Out on removed:

var scope = new can.Map({
  animateOptions: {
    "bindings":{
        "removed": {
          "run": "fade-out"
        }
    }
  }
});

Custom animation on inserted:

var scope = new can.Map({
  animateOptions: {
    "bindings":{
      "inserted": function(opts){
          $(opts.el)
          .css({
              opacity:0,
              top:"-100px"
          })
          .animate({
              "opacity":1,
              "top":"0px"
          }, 1000, function(){
              console.log("inserted done");
          });
      }
    }
  }
});

Custom animation on inserted with callbacks:

var scope = new can.Map({
  animateOptions: {
    "bindings":{
      "inserted": {
        "run": function(opts){
          console.log("inserted - running");
          $(opts.el).hide().fadeIn(opts.options.duration, function(){
              console.log("inserted - animation done");
          });
        },
        "before": function(){
            //can-animate will call this before 'run'
            console.log("inserted - before");
        },
        "after": function(){
            //can-animate will call this when run is complete
            console.log("inserted - after");
        }
      },
    }
  }
});

In addition to the inserted and removed events, animations can be attached to any property on the scope. Here's a function that will run when hasError changes.

"hasError": function(opts){
  if(opts.context.attr("hasError")){
    console.log("run animation");
    $(opts.el).fadeOut(200, function(){
        $(opts.el).fadeIn(1000, function(){
            console.log("hasError animation done");
        });
    });
  }else{
    console.log("don't run animation");
  }
}

before and after methods work here, too:

"hasError": {
  "run": function(opts){
    if(opts.context.attr("hasError")){
      $(opts.el).fadeOut(opts.options.duration, function(){
        $(opts.el).fadeIn(opts.options.duration, function(){
          console.log("hasError animation done");
        });
      });
    }else{
      console.log("don't run animation");
    }
  },
  before: function(opts){
    if(!opts.context.attr("hasError")){
      return false; //return false to cancel the run method
    }
    //animation is good to go, set up some things
    $(opts.el).css({
      //starting styles
    })
  },
  after: function(){
    console.log("hasError after", arguments);
    //animation is good to go, set up some things
    $(opts.el).css({
      //final styles
    })
  },
  "duration": 1500 //can override the duration from root of animate options
}

Not yet supported

Strings & Custom animation identifiers

Fade In on inserted:

var scope = new can.Map({
  animateOptions: {
    "bindings":{
      "inserted": "fade-in"
    }
  }
});

Fade Out on removed:

var scope = new can.Map({
  animateOptions: {
    "bindings":{
      "removed": "fade-out"
    }
  }
});

Custom animation when property is true:

var scope = new can.Map({
  animateOptions: {
    "bindings":{
      "hasError": "shake"
    }
  }
});

CSS objects

Specify css object instead of function for before, after, and run

var scope = new can.Map({
  animateOptions: {
    "bindings":{
      "isActive": {
        //set up the element
        before:{
          "position":"relative",
          "left":"0px",
          "cursor": "wait"
        },
        //animate to this
        run:{
          "left":"10px"
        },
        //finalize the properties
        after:{
          "cursor":""
        }
      }
    }
  }
});
BigAB commented 8 years ago

First: though binding is better than whens, if you still don't like bindings(multiple meanings across js and can), how about hooks?

Second: I far prefer this to current can-animate attributes, perhaps instead of can-animate-options="{whatev}" it could just be can-animate="{whatev}"?

Third: I think it was mentioned before but you may need a cancelled binding (hook?) and perhaps some way to cancel animations that don't have that binding (like a default cancel) for when the occasion calls to interrupt an animation.

I don't know enough about the animation space to know if it's a robust solution, but it seems easy to reason about and covers most things I could think of out of the box, so I like this idea/branch.

mickmcgrath13 commented 8 years ago

Thanks @BigAB. I've made some changes (including changing bindings to hooks and changing can-animate-options to just can-animate).

Still to come:

Shortcut attrs

I do think i'd still like to support the following in the future:

Animate object structure

Ideally, all of these will be supported: (for can-animate="animateOptions")

provide a string value

animateOptions: "fade"

no duration provided (set default somewhere)

animateOptions: { 
  hooks: {
    "inserted": "fade" 
  }
}

Create custom animation strings ('register' them somwehere/somehow..?)

animateOptions: { 
  hooks: {
    "inserted": "myCustomAnimationBehavior" 
  }
}

CSS only

animateOptions: { 
  "hooks": {
    "inserted": {
      "opacity":1
    } 
  }
}

CSS as hook

animateOptions: {
  "hooks":{
    "inserted": {
      //set up the element
      before:{
        "position":"relative",
        "left":"0px",
        "cursor": "wait"
      },
      //animate to this
      run:{
        "left":"10px"
      },
      //finalize the properties
      after:{
        "cursor":""
      }
    }
  }
}

Cancel Animation

Provide some way of cancelling animations

Tests & Docs

mickmcgrath13 commented 7 years ago

Updated proposal here: https://github.com/canjs/can-animate/blob/advanced-options/README.md

Feedback welcome

justinbmeyer commented 7 years ago

Instead of using the attribute name for arguments why not use the attribute value, especially since can-stache has a lot of functionality already built to parse and convert to values?

<my-component can-animate="duration: 100, on: something" />

Or immediately available:

<my-component can-animate="duration=100 on=something type=fade" />
mickmcgrath13 commented 7 years ago

I think that works for a single animation, but what if I want a different animation for each of several different property changes or even different animations for different values of a single property? With the attribute name method, it can easily be done:

<my-component can-animate-myProp!="some-animation-for-truthy" can-animate-!myProp="some-animation-for-falsey" />

With the examples you've given, I can't clearly see how to accomplish that.

Taking the original example problem at the start of this issue:

/* Problem: 
    * Consider a simple input modal which has 
    * a message, a text input, a cancel button, and a confirm button.
    *  ------------------------------------------
    * |             Type a number                |
    * |          ---------------------           |
    * |         |                     |          |
    * |          ---------------------           |
    * |                                          |
    * |                   [ Cancel ] [ Confirm ] |
    *  ------------------------------------------
    *
    * The user wants the following for the modal: 
    * Scale in (custom animation) when inserted
    * Fade out when removed
    * Shake when there is an error (scope.attr('hasError', true))
*/

With the proposal I gave, I could do:

<number-input-modal can-animate="animateOptions" can-animate-$inserted="scale-in" can-animate-$removed="fade-out" can-animate-hasError!="shake" />

How would I do that with the attribute value? The first thing that comes to mind would be something like:

<number-input-modal can-animate="opts=animateOptions $inserted=scale-in $removed=fade-out hasError!=shake" />

in which case I'd actually prefer to just do:

<number-input-modal can-animate="animateOptions" />

..and just define it all in that object like this.

mickmcgrath13 commented 7 years ago

New concept based on offline discussions:

Provide a left-hand-side scope property to bind to, and simply call a method for the animation.

Example:

<can-import from="can-animate" {^value}="animate" />

  <my-component
    {child-prop}="scopeProp"
    (. scopeEvent)="animate.someAnimationMethod"
    (.. parentScopeEvent)="animate.someParentAnimateMethod"
    {. scopeProp}="animate.someScopeAnimationMethod"
    ($inserted)="animate.fade scopeUserProp.patience"
    ($onInsertedAnimationFinished)="someScopeMethod(%event)"
    />

Example explained:

Unhandled scenarios

Animations aren't just functions

Animations are a set of before, run, and after methods. How would we take advantage of that given the following syntax:

<child
  (. hasError)="animate.pulseRed"
/>

if pulseRed is something like this:

"pulseRed": {
  before: function(opts){
    //set up some css properties on opts.$el
  },
  run: function(opts){
    //pulse opts.$el's background-color to red, then to white
  },
  after: function(opts){
    //reset opts.$el's css props to their original values
  }
}

..we could do something like this?

  (. hasError)="animate.run('pulseRed')"

DomEvent - vm method and animation

What would happen if we wanted to call a viewmodel method and an animation when a dom event happens? For example:

<child
  ($click)="animate.pulseGreen"
  ($click)="someVmMethodLikeLogClickToServer()"
/>

..maybe semicolons are necessary?

<child
  ($click)="animate.pulseGreen; someVmMethodLikeLogClickToServer()"
/>

Animation event binding

How best to bind to events triggered by one of the used animations?

Does adding () on rhs change the parameters?

Are the parameters given to pulseRed different for this:

  (. hasError)="animate.pulseRed"

..than for this:

  (. hasError)="animate.pulseRed()"

Dynamic scope property bindings

What if we want to bind to a scope prop's property but the scope property itself is dynamic? We'd need to re-bind to the scope property when it changes. For example:

<child
  (../dynamicScopeProperty prop)="animate.someAnimation"
/>

How to run an animation simply when a property changes?

if (. hasError)="animate.pulseRed" runs pulseRed when hasError is true, how would we run an animation when, for example, searchTerm's value changes?

<child
  (. searchTerm)="animate.wiggle"
/>

..what if we assume that the right-hand-side always runs on change, and then can-animate provides some helper methods to specify whether to run on truthy or falsey?

(. scopeProp)="animate.onTrue(animate.fade)"

..but does that mean that we always have to pass %element and/or %event ?

(. scopeProp)="animate.onTrue(%element, %event, animate.fade)"

Syntax is different depending on whether we're animating on a dom element or a component

Suppose someone sets up an animation on an input event:

  <input
    {child-prop}="scopeProp"
    (. scopeProp)="animate.fade"
     (.. noLongerHasAnError)="animate.something" />

...then later on, they create a custom input component. ..they would need to change the scopes

  <custom-input
    {child-prop}="scopeProp"
    (.. scopeProp)="animate.fade"
     (../.. noLongerHasAnError)="animate.something" />

General thoughts

Potentially difficult for designers

IMO, the stache-based syntax is much less designer friendly. Doing this:

  (. hasError)="animate.pulseRed"

means that designers need to have an understanding of scope (., .., etc) as well as "what happens if I add (), etc. ..as opposed to simply define your animations in a file, import it with stache, and use the stache imported object on the element

Move forward with what I have, then re-architect into various modules

I would prefer to move forward with what I have (can-animate="animateOptions") and then work on solving some of the above issues and better integrating into things like can-stache-bindings and such (maybe then move from can-animate to can-stache-animate?)

mickmcgrath13 commented 7 years ago

This has resulted in https://www.npmjs.com/package/can-stache-animate.

This issue can be closed