├── .babelrc ├── .eslintrc ├── .gitignore ├── LICENSE ├── README.md ├── package.json ├── rollup.config.js └── src └── ReactAnimationOrchestrator.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-env", 4 | "@babel/preset-react", 5 | ], 6 | "plugins": ["@babel/plugin-external-helpers"] 7 | } -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | "parserOptions": { 4 | "sourceType": "module", 5 | "allowImportExportEverywhere": true 6 | }, 7 | "extends": [ 8 | "airbnb", 9 | "prettier", 10 | "prettier/flowtype", 11 | "prettier/react" 12 | ], 13 | "env": { 14 | "browser": true, 15 | "node": true 16 | }, 17 | "rules": { 18 | "no-underscore-dangle": "off", 19 | "default-case": "off", 20 | "no-param-reassign": "off", 21 | "react/destructuring-assignment": "off", 22 | "react/prop-types": "off", 23 | "no-plusplus": "off", 24 | "arrow-parens": [ 25 | "off" 26 | ], 27 | "compat/compat": "error", 28 | "consistent-return": "off", 29 | "comma-dangle": "off", 30 | "flowtype/boolean-style": [ 31 | "error", 32 | "boolean" 33 | ], 34 | "flowtype/define-flow-type": "warn", 35 | "flowtype/delimiter-dangle": [ 36 | "error", 37 | "never" 38 | ], 39 | "flowtype/generic-spacing": [ 40 | "error", 41 | "never" 42 | ], 43 | "flowtype/no-primitive-constructor-types": "error", 44 | "flowtype/no-weak-types": "warn", 45 | "flowtype/object-type-delimiter": [ 46 | "error", 47 | "comma" 48 | ], 49 | "flowtype/require-parameter-type": "off", 50 | "flowtype/require-return-type": "off", 51 | "flowtype/require-valid-file-annotation": "off", 52 | "flowtype/semi": [ 53 | "error", 54 | "always" 55 | ], 56 | "flowtype/space-after-type-colon": [ 57 | "error", 58 | "always" 59 | ], 60 | "flowtype/space-before-generic-bracket": [ 61 | "error", 62 | "never" 63 | ], 64 | "flowtype/space-before-type-colon": [ 65 | "error", 66 | "never" 67 | ], 68 | "flowtype/union-intersection-spacing": [ 69 | "error", 70 | "always" 71 | ], 72 | "flowtype/use-flow-type": "error", 73 | "flowtype/valid-syntax": "error", 74 | "generator-star-spacing": "off", 75 | "import/no-unresolved": "error", 76 | "import/no-extraneous-dependencies": "off", 77 | "jsx-a11y/anchor-is-valid": "off", 78 | "no-console": "off", 79 | "no-use-before-define": "off", 80 | "no-multi-assign": "off", 81 | "promise/param-names": "error", 82 | "promise/always-return": "error", 83 | "promise/catch-or-return": "error", 84 | "promise/no-native": "off", 85 | "react/sort-comp": [ 86 | "error", 87 | { 88 | "order": [ 89 | "type-annotations", 90 | "static-methods", 91 | "lifecycle", 92 | "everything-else", 93 | "render" 94 | ] 95 | } 96 | ], 97 | "react/jsx-no-bind": "off", 98 | "react/jsx-filename-extension": [ 99 | "error", 100 | { 101 | "extensions": [ 102 | ".js", 103 | ".jsx" 104 | ] 105 | } 106 | ], 107 | "react/prefer-stateless-function": "off", 108 | "jsx-a11y/mouse-events-have-key-events": "off" 109 | }, 110 | "plugins": [ 111 | "flowtype", 112 | "import", 113 | "promise", 114 | "compat", 115 | "react" 116 | ], 117 | "settings": { 118 | "import/resolver": { 119 | "webpack": { 120 | "config": "webpack.config.eslint.js" 121 | } 122 | } 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist/* 3 | .idea -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | This software is licensed under the Apache 2 license, quoted below. 2 | 3 | Copyright 2016 Eko 4 | 5 | Licensed under the Apache License, Version 2.0 (the "License"); you may not 6 | use this file except in compliance with the License. You may obtain a copy of 7 | the License at 8 | 9 | http://www.apache.org/licenses/LICENSE-2.0 10 | 11 | Unless required by applicable law or agreed to in writing, software 12 | distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 13 | WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 14 | License for the specific language governing permissions and limitations under 15 | the License. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | React Animation Orchestrator Logo 2 | 3 | ## React Animation Orchestrator 4 | 5 | #### A react-based library for managing complex animations 6 | 7 | ![react-fancy-multi-select](https://user-images.githubusercontent.com/46129036/55287638-5235ff80-53b4-11e9-8a78-09523622aadf.gif) 8 | 9 | --- 10 | 11 | See Opher's talk from YGLF 2019: 12 | #### [Managing animations (sanely) in (insanely) complex apps](https://www.youtube.com/watch?v=NqClaDilv90) 13 | [![Managing animations (sanely) in (insanely) complex apps](https://user-images.githubusercontent.com/3951311/56507838-478e0680-652b-11e9-92b5-72a02c6d0b1b.png)](https://www.youtube.com/watch?v=NqClaDilv90) 14 | 15 | --- 16 | 17 | `state = (oldState, action) => newState` 18 | 19 | but 20 | 21 | `animation = (time) => frame` 22 | 23 | --- 24 | 25 | React Animation Orchestrator is a library that solves the problem of discrepancy between the fact that state changes are nearly instantaneous but animations, by definition, take time to complete. 26 | 27 | It provides *higher order components* to manage multiple, complex animations in an app that contains a lot of state changes that affect its animations,oftentimes even while other animations are running 28 | 29 | Based on the timeline feature of the incredible [GSAP](https://greensock.com/gsap) animation library it offers: 30 | 31 | * Different modes of resolving conflicting animations (queueing or fast-forwarding animations) 32 | * Support for Static/Dynamic Animations 33 | * Triggering animations from a state change or from user-initiated events (mouse click, scroll etc) 34 | * Taking care of annoying edge cases, like how components shouldn't be removed from the DOM until they've performed their requested animations. 35 | 36 | See a live demo [here!](https://codesandbox.io/s/github/ekolabs/react-fancy-multi-select) 37 | 38 | --- 39 | 40 | ## Overview 41 | 42 | React Animation Orchestrator is used in two major steps: Defining animations and Describing scenarios that decide when to run these animations. 43 | 44 | 1. First, a user [decorates](#attachAnimation) a React component to become an `AnimatedComponent`. An `AnimatedComponent` can use its `registerAnimation` function to register animation specific to the component domain, to be later used within a scenario. 45 | 46 | 2. An `AnimatedComponent` is configured with a set of [Scenarios](#ScenarioConfiguration) with the [`attachAnimation`](#attachAnimation) function, to act as a *"controller"* of sorts to its domain-specific animations. Once the props of an `AnimatedComponent` change, all [triggers](#TriggerConfiguration) in all of its scenarios are evaluated. If one of the triggers is evaluated to be triggered, the animations associated with the scenario the trigger belongs to are added to a timeline. 47 | 48 | Woah that was a mouthful - in practice this should be much clearer: 49 | 50 | ## Usage example 51 | 52 | ```javascript 53 | npm install @ekolabs/react-animation-orchestrator 54 | ``` 55 | 56 | Defining and registering animation for a component: 57 | 58 | ```js 59 | import React from "react"; 60 | import { TimelineMax } from 'gsap'; 61 | import { attachAnimation } from "@ekolabs/react-animation-orchestrator"; 62 | 63 | // an example of an animation generator function 64 | const lookAtMeAnimation = (ref, options) => { 65 | let tl = new TimelineMax(); 66 | let myEl = ref.current; 67 | 68 | tl.to(myEl, 0.5, { 69 | scale: 1.5, 70 | rotating: '45deg', 71 | transformOrigin: 'center', 72 | opacity: 0.7 73 | }) 74 | .to(myEl, 0.2, { 75 | scale: 1, 76 | rotating: '0deg', 77 | transformOrigin: 'center', 78 | opacity: 1 79 | }); 80 | 81 | return tl; 82 | }; 83 | 84 | class FancyComponent extends React.Component { 85 | constructor(props){ 86 | super(props); 87 | this.ref = React.createRef(); 88 | this.props.registerAnimation('lookAtMe', lookAtMeAnimation, this.ref); 89 | } 90 | 91 | render(){ 92 | return ( 93 |
Attention-grabbing element
94 | ) 95 | } 96 | } 97 | 98 | export default attachAnimation(FancyComponent); 99 | ``` 100 | 101 | Configuring scenarios to trigger animations 102 | 103 | ```js 104 | import React from "react"; 105 | import { attachAnimation } from "@ekolabs/react-animation-orchestrator"; 106 | import FancyComponent from "./FancyComponent"; 107 | 108 | class PageComponent extends React.Component { 109 | 110 | render(){ 111 | return ( 112 |
113 | 114 | 115 | 116 |
117 | ) 118 | } 119 | } 120 | 121 | export default attachAnimation(PageComponent, [ 122 | // when the grabAttention prop changes from false to true, 123 | // we want to queue the lookAtMe animation 124 | { 125 | id: 'someChange', 126 | trigger: { 127 | select: props => props.grabAttention, 128 | value: false, 129 | nextValue: true 130 | }, 131 | animations: 'lookAtMe' 132 | } 133 | ]); 134 | 135 | ``` 136 | 137 | ## API 138 | 139 | 140 | 141 | **registerAnimation(animationId, animationGeneratorFunction, elementReference)** 142 | 143 | Registers a new animation for an AnimatedComponent. This method is a available in the props of an attached component. 144 | 145 | | Parameter| Type | Value | 146 | | ------------- |----- |--------| 147 | | animationId | string | A unique id for this animation 148 | | animationGeneratorFunction| [AnimationGenrator](#AnimationGenerator) | The animation generator function for this reference 149 | | elementReference | [React ref](https://reactjs.org/docs/refs-and-the-dom.html#creating-refs) | A React reference for the DOM object being animated 150 | 151 | ```js 152 | // example 153 | class FancyComponent extends React.Component { 154 | constructor(props){ 155 | super(props); 156 | this.ref = React.createRef(); 157 | this.props.registerAnimation('lookAtMe', lookAtMeAnimation, this.ref); 158 | } 159 | 160 | render(){ 161 | return ( 162 |
Attention-grabbing element
163 | ) 164 | } 165 | } 166 | ``` 167 | 168 | 169 | 170 | **attachAnimation(WrappedComponent, scenariosConfig)** 171 | 172 | Creates a higher-order component `AnimatedComponent` based on the supplied component, along with its scenario configurations. 173 | 174 | | Parameter| Type | Value | 175 | | ------------- |----- |--------| 176 | WrappedComponent | React.Component | A react component class (not an instance) | 177 | | scenariosConfig| [Scenario Configuration](#ScenarioConfiguration) | An array of scenario configurations to be managed by this component (optional)| 178 | 179 | **addAnimation(animations, timelineOrTimelineId, options)** 180 | 181 | Manually add animations to a timeline 182 | 183 | | Parameter| Type | Value | 184 | | ------------- |----- |--------| 185 | animations | An array of [AnimationConfiguration](#AnimationConfiguration) | The animations to run 186 | | timelineOrTimelineId | A timeline id or a timeline instance | The timeline to add the animations to. If a timeline with such id does not exist, a new timeline will be created. 187 | options | object | The options will be passed as the second parameter of the [animation generator function](#AnimationGenerator). 188 | 189 | **triggerScenario(scenarioId)** 190 | 191 | Manually triggers the scenario's animations 192 | 193 | | Parameter| Type | Value | 194 | | ------------- |----- |--------| 195 | scenarioId | string | The id of the scenario to trigger. 196 | 197 | **setGlobalOptions(options)** 198 | 199 | Sets global options for React Animation Orchestrator. 200 | Options are: 201 | 202 | ```js 203 | // example 204 | { 205 | onScenarioTriggered: (matchedScenario) => {}, 206 | onScenarioStart: (refs, scenarioConfig, triggerConfig) => {}, 207 | onScenarioComplete: (refs, scenarioConfig, triggerConfig) => {} 208 | } 209 | 210 | ``` 211 | 212 | ## Configuration 213 | 214 | ### Scenario 215 | 216 | A scenario describes a set of animations to be added to a timeline once a certain [trigger](#TriggerConfiguration) has been met. 217 | 218 | | Property| Type | Value | 219 | | --- | --- | --- | 220 | | id | string | The scenario ID | 221 | | trigger| [TriggerConfiguration](#TriggerConfiguration) \| array of TriggerConfiguration | Triggers associated with this scenario. 222 | | timeline | string | The id of the animation where the animations will be inserted to. If not specified the default master timeline is used. 223 | | animations | [AnimationConfiguration](#AnimationConfiguration) | Describes which animations will be added once a trigger is met 224 | | interrupt | boolean | If true, all other animations currently present in the timeline will complete immediately before adding this scenario's animations. 225 | 226 | ### Trigger Configuration 227 | 228 | A trigger describes a certain change in props that if evaluated to be true will ultimately result in addition of animations to a timeline 229 | 230 | Can either be an object or a function 231 | 232 | ***As an object*** 233 | 234 | When testing the trigger, the `select` function will be executed with the props as its parameter once on the previous props and once on the next (changed) props. If the result of the previous props selection is equal to `value` and the next props selection is equal to `nextValue` the trigger is considered, well, triggered. 235 | 236 | ```js 237 | // example 238 | { 239 | select: props => props.varToCheck, 240 | value: 'oldValue', 241 | nextValue: 'newValue' 242 | } 243 | ``` 244 | 245 | ***As a function*** 246 | 247 | This allows more custom logic of props comparison to determine how to evaluate the trigger. 248 | 249 | ```js 250 | // example 251 | (triggerComponent, prevProps, nextProps) => { 252 | nextProps.VarToCheck === prevProps.varToCheck + 1; 253 | } 254 | ``` 255 | 256 | Trigger function Parameters: 257 | 258 | | Property| Type | Value | 259 | | --- | --- | --- | 260 | | triggerComponent | React.Component instance | The instance of the attached component. 261 | | prevProps| object | The props before the change. 262 | | nextProps | object | The props after the change. 263 | 264 | ### Animation 265 | 266 | An animation is a configuration which is ultimately resolved into a GSAP sub-timeline using an Animation Generator function, and then added to a Animation Orchestrator timeline. 267 | 268 | Can either be a string, an object, a function or an array containing these types for multiple animations. 269 | 270 | ***As a string*** 271 | 272 | ```'fadeIn'``` 273 | 274 | ***As an Object*** 275 | 276 | ```js 277 | // example 278 | { 279 | animation: 'fadeIn', 280 | position: 'withPrev', 281 | immediate: false, 282 | onComplete: () => { console.log(); } 283 | } 284 | ``` 285 | 286 | | Property| Type | Value | 287 | | --- | --- | --- | 288 | | animation | string | The animation ID. | 289 | | timeline | string | The timeline id to insert the animation into. If not specified the default master timeline is used. 290 | | position | string \| number \| 'withPrev' | Where in the timeline to place the animation. Maps to GSAP position paramater, See [documentation](https://greensock.com/docs/TimelineMax/add). Also accepts a special `withPrev` value that places this animation at the start time of the previous animation in the timeline. 291 | | immediate | boolean | If true, animation will complete instantly (duration ~0). 292 | onStart | function | A callback that will fire when the animation starts. See [callback function signature](#AnimationCallback). 293 | onComplete| function | A callback that will fire when the animation ends. See [callback function signature](#AnimationCallback). 294 | animationOptions | object | This object will be passed to the animation generator as a second parameter. Useful for passing data to dynamic animations. 295 | 296 | ***As a function*** 297 | 298 | The animation configuration function will be evaluated when a trigger condition is met. This is useful for dynamic animations. 299 | Must return an object or a string (as described above) 300 | 301 | ```js 302 | // example 303 | animatedComponentInstance => { 304 | animation: 'fadeIn', 305 | position: '+=2', 306 | animationsOptions: { 307 | myVar: 12 308 | } 309 | } 310 | ``` 311 | 312 | ***As an array of animations*** 313 | 314 | ```['fadeIn', 'expand']``` 315 | 316 | Usage: 317 | 318 | ```js 319 | import { attachAnimation } from "@ekolabs/react-animation-orchestrator"; 320 | 321 | attachAnimation(WrappedComponent, [ 322 | { 323 | id: 'myAnimation1' 324 | trigger: ..., 325 | animations: ... 326 | }, 327 | { 328 | id: 'myAnimation2', 329 | trigger: ..., 330 | animations: ... 331 | }, 332 | ... 333 | ]); 334 | ``` 335 | 336 | ### Animation Callback Function 337 | 338 | The function that gets executed once when `onStart` or `onComplete` is defined for an animation. 339 | 340 | | Property| Type | Value | 341 | | --- | --- | --- | 342 | | refs.animatedComponent | AnimatedComponent | A reference to the component being animated 343 | | refs.triggerComponent | AnimatedComponent | A reference to the component that triggered the scenario (will be `null` if animation was triggered manually via `addAnimation`) 344 | | refs.tween | Tween | A reference to GSAP tween object 345 | 346 | ```js 347 | // example 348 | (refs) => {} 349 | ``` 350 | 351 | ### Animation Generator Function 352 | 353 | A function that generates a sub-timeline that describes the animation. 354 | Returns a [GSAP timeline](https://greensock.com/docs/TimelineMax). 355 | 356 | | Property| Type | Value | 357 | | --- | --- | --- | 358 | | ref | [React ref](https://reactjs.org/docs/refs-and-the-dom.html#creating-refs) | The element reference passed in [`registerAnimation`](#registerAnimation) | 359 | | options | object | The value of animationOptions as configured in an [AnimationConfiguration](#AnimationConfiguration) (optional) 360 | 361 | ```js 362 | // example 363 | (ref, options) => { 364 | let tl = new TimelineMax(); 365 | let myEl = ref.current; 366 | 367 | tl.to(myEl, 0.5, { 368 | scale: 1.5, 369 | rotating: '45deg', 370 | transformOrigin: 'center', 371 | opacity: 0.7 372 | }); 373 | 374 | return tl; 375 | }; 376 | 377 | ``` 378 | 379 | ## Contributing 380 | 381 | When contributing to this repository, please first discuss the change you wish to make via issue, email, or any other method before submitting a PR. 382 | 383 | ## Authors 384 | 385 | * **Opher Vishnia** - [Opherv](https://github.com/Opherv) 386 | 387 | ## License 388 | 389 | This project is licensed under the MIT License - see the [LICENSE.md](LICENSE.md) file for details 390 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@ekolabs/react-animation-orchestrator", 3 | "version": "1.0.0", 4 | "description": "A react-based library for managing complex animations", 5 | "main": "dist/ReactAnimationOrchestrator.js", 6 | "scripts": { 7 | "build": "rollup -c" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/EkoLabs/react-animation-orchestrator.git" 12 | }, 13 | "keywords": [ 14 | "react", 15 | "animation", 16 | "greensock", 17 | "gsap" 18 | ], 19 | "license": "Apache-2.0", 20 | "author": { 21 | "name": "Eko labs", 22 | "url": "https://developer.helloeko.com", 23 | "email": "dev@helloeko.com" 24 | }, 25 | "contributors": [ 26 | { 27 | "name": "Opher Vishnia", 28 | "email": "opherv@gmail.com", 29 | "url": "http://opherv.com" 30 | } 31 | ], 32 | "bugs": { 33 | "url": "https://github.com/ekolabs/react-animation-orchestrator/issues" 34 | }, 35 | "homepage": "https://github.com/ekolabs/react-animation-orchestrator#readme", 36 | "devDependencies": { 37 | "@babel/core": "^7.4.0", 38 | "@babel/plugin-external-helpers": "^7.2.0", 39 | "@babel/preset-env": "^7.4.2", 40 | "@babel/preset-react": "^7.0.0", 41 | "eslint": "^5.2.0", 42 | "eslint-config-airbnb": "^17.0.0", 43 | "eslint-config-prettier": "^2.9.0", 44 | "eslint-formatter-pretty": "^1.3.0", 45 | "eslint-import-resolver-webpack": "^0.10.1", 46 | "eslint-plugin-compat": "^2.5.1", 47 | "eslint-plugin-flowtype": "^2.50.0", 48 | "eslint-plugin-import": "^2.13.0", 49 | "eslint-plugin-jest": "^21.18.0", 50 | "eslint-plugin-jsx-a11y": "6.1.1", 51 | "eslint-plugin-promise": "^3.8.0", 52 | "eslint-plugin-react": "^7.10.0", 53 | "rollup": "^1.7.4", 54 | "rollup-plugin-babel": "^4.3.2", 55 | "rollup-plugin-commonjs": "^9.2.2", 56 | "rollup-plugin-node-resolve": "^4.0.1" 57 | }, 58 | "semistandard": { 59 | "ignore": [ 60 | "examples/build.js", 61 | "dist/**" 62 | ] 63 | }, 64 | "dependencies": { 65 | "gsap": "^2.1.2" 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | // node-resolve will resolve all the node dependencies 2 | import resolve from 'rollup-plugin-node-resolve'; 3 | import commonjs from 'rollup-plugin-commonjs'; 4 | import babel from 'rollup-plugin-babel'; 5 | 6 | export default { 7 | input: 'src/ReactAnimationOrchestrator.js', 8 | output: { 9 | file: 'dist/ReactAnimationOrchestrator.js', 10 | format: 'es' 11 | }, 12 | // All the used libs needs to be here 13 | external: [ 14 | 'react', 15 | 'react-proptypes', 16 | 'gsap' 17 | ], 18 | plugins: [ 19 | resolve(), 20 | babel({ 21 | exclude: 'node_modules/**' 22 | }), 23 | commonjs() 24 | ] 25 | } -------------------------------------------------------------------------------- /src/ReactAnimationOrchestrator.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { TimelineMax } from 'gsap'; 3 | 4 | let globalOptions = {}; 5 | 6 | // map between timeline id and timeline instance. create default timeline 'master' 7 | const timelines = { 8 | master: new TimelineMax({ 9 | autoRemoveChildren: true 10 | }) 11 | }; 12 | 13 | // maps between mounted components and their instances. used for debugging 14 | const components = {}; 15 | const scenarios = {}; 16 | 17 | let scenarioIdCounter = 0; 18 | let componentIdCounter = 0; 19 | let animationIdCounter = 0; 20 | 21 | function attachAnimation(WrappedComponent, scenariosConfig = []){ 22 | 23 | scenariosConfig.forEach(scenarioConfig => { 24 | // add ids to scenario configs with no id specified 25 | if ('id' in scenarioConfig === false){ 26 | scenarioConfig.id = `scenario_${++scenarioIdCounter}`; 27 | } 28 | 29 | // generate timeline instance if it doesn't exist yet 30 | if (scenarioConfig.timeline && !timelines[scenarioConfig.timeline]){ 31 | timelines[scenarioConfig.timeline] = new TimelineMax({ 32 | autoRemoveChildren: true 33 | }); 34 | } 35 | 36 | // save the scenario to the global scenarios 37 | if (scenarioConfig.id in scenarios){ 38 | error(`scenario with id ${scenarioConfig.id} already exists. It will be overwritten!`) 39 | } else { 40 | scenarios[scenarioConfig.id] = scenarioConfig; 41 | } 42 | }); 43 | 44 | return class AnimatedComponent extends React.Component { 45 | 46 | /** 47 | * @typedef AnimationConfig 48 | * @type {string | object | function)} 49 | * 50 | * @example string 51 | * 'fadeIn' 52 | * 53 | * @example Array of strings 54 | * ['fadeIn', 'expand'] 55 | * 56 | * @example object 57 | * { 58 | * animation: 'fadeIn', 59 | * position: 'withPrev', 60 | * immediate: false, 61 | * onComplete: ()=>{} 62 | * } 63 | * 64 | * @example function 65 | * A function that receives the context the AnimatedComponent running the animation and returns an AnimationConfig in object format 66 | * animatedComponentInstance => { 67 | * animation: 'fadeIn', 68 | * position: '+=2', 69 | * } 70 | */ 71 | 72 | constructor(props){ 73 | super(props); 74 | // if no displayName is specified, use 'AnimationComponent'; 75 | const componentDisplayName = WrappedComponent.displayName?WrappedComponent.displayName:`AnimatedComponent`; 76 | this.animationComponentId = `${componentDisplayName}_${++componentIdCounter}`; 77 | components[this.animationComponentId] = { 78 | animations: {}, 79 | runningAnimations: {}, 80 | pendingAnimations: {}, 81 | evaluatingAnimations: false, 82 | removeCandidate: false, 83 | instance: this 84 | }; 85 | 86 | this.wrappedComponentRef = React.createRef(); 87 | } 88 | 89 | // decide whether to animate this element 90 | // don't worry I know what I'm doing 91 | UNSAFE_componentWillUpdate(nextProps, nextState){ 92 | // we're only interested in scenarios that have triggers 93 | const filteredScenarios = scenariosConfig.filter(scenariosConfig => 'trigger' in scenariosConfig); 94 | let matchedScenario; 95 | 96 | // if multiple scenarios are defined, we only run the first one reached 97 | filteredScenarios.some( scenarioConfig => { 98 | const scenarioTest = this.testScenario(scenarioConfig, this.props, nextProps); 99 | if (scenarioTest.result){ 100 | matchedScenario = scenarioTest; 101 | return true; 102 | } 103 | return false 104 | 105 | 106 | }); 107 | 108 | 109 | if (matchedScenario){ 110 | // save this until componentDidUpdate 111 | this.matchedScenario = matchedScenario 112 | } 113 | 114 | components[this.animationComponentId].evaluatingAnimations = true; 115 | // when switching from shouldShow=true to false, we need to wait until all components finished 116 | // evaluating which animations are running in real-time so that the component does not get removed 117 | // from the DOM prematurely 118 | if (this.props.shouldShow && !nextProps.shouldShow){ 119 | components[this.animationComponentId].removeCandidate = true; 120 | } 121 | } 122 | 123 | componentDidUpdate(prevProps, prevState){ 124 | const {matchedScenario} = this; 125 | if (matchedScenario){ 126 | delete this.matchedScenario; 127 | 128 | if (matchedScenario.firedTriggerConfig){ 129 | if (globalOptions.onScenarioTriggered) { 130 | globalOptions.onScenarioTriggered(matchedScenario.scenarioConfig); 131 | } 132 | this.addScenarioAnimations(matchedScenario.scenarioConfig, prevProps, matchedScenario.firedTriggerConfig); 133 | } 134 | } 135 | 136 | components[this.animationComponentId].evaluatingAnimations = false; 137 | components[this.animationComponentId].removeCandidate = false 138 | } 139 | 140 | testScenario(scenarioConfig, prevProps, nextProps){ 141 | // ensure we have triggers as an array 142 | const triggerArray = Array.isArray(scenarioConfig.trigger) ? scenarioConfig.trigger : [scenarioConfig.trigger]; 143 | // find the first trigger that fires (no need to find further triggers - because the scenario is either triggered or not) 144 | const firedTriggerConfig = triggerArray.find(triggerConfig => this.testTrigger(triggerConfig, prevProps, nextProps) ); 145 | 146 | return { 147 | result: firedTriggerConfig !== undefined, 148 | scenarioConfig, 149 | firedTriggerConfig 150 | } 151 | } 152 | 153 | testTrigger(triggerConfig, prevProps, nextProps){ 154 | if (typeof triggerConfig === 'object'){ 155 | return triggerConfig.select(prevProps) === triggerConfig.value && 156 | triggerConfig.select(nextProps) === triggerConfig.nextValue; 157 | } 158 | if (typeof triggerConfig === 'function'){ 159 | return triggerConfig(this.wrappedComponentRef.current, prevProps, nextProps); 160 | } 161 | } 162 | 163 | addScenarioAnimations(scenarioConfig, prevProps, triggerConfig){ 164 | const timeline = timelines[scenarioConfig.timeline || 'master']; 165 | if (scenarioConfig.interrupt){ 166 | timeline.progress(1, false); 167 | } 168 | 169 | // scenarios that are triggered can resolve functions to return animation config in runtime 170 | const scenarioConfigDraft = {...scenarioConfig}; 171 | if (typeof scenarioConfigDraft.animations === 'function'){ 172 | scenarioConfigDraft.animations = scenarioConfig.animations(this.wrappedComponentRef.current, prevProps) 173 | } 174 | 175 | addScenarioAnimations(scenarioConfigDraft , { 176 | thisContext: this, 177 | triggerConfig 178 | }); 179 | } 180 | 181 | /** 182 | * 183 | * @param {AnimationConfig | AnimationConfig[])} animations 184 | * @param timelineOrTimelineId 185 | */ 186 | addAnimation(animations, timelineOrTimelineId, options = {}) { 187 | const componentData = components[this.animationComponentId]; 188 | 189 | // for convenience we assume we have an array of AnimationConfigs 190 | const animationConfigArray = Array.isArray(animations) ? animations : [animations]; 191 | animationConfigArray.forEach((rawAnimationConfig, index) => { 192 | const animationConfig = transformAnimationConfig(rawAnimationConfig, this); 193 | const animationData = componentData.animations[animationConfig.animation]; 194 | if (!animationData) { 195 | error(`No such animation "${animationConfig.animation}" for component "${this.animationComponentId}". Perhaps you meant to call the global addAnimation?`) 196 | return; 197 | } 198 | const animationTlToAdd = animationData.generatorFunc(animationData.elementRef, animationConfig.animationOptions); 199 | 200 | // figure out which timeline 201 | let timeline; 202 | if (typeof timelineOrTimelineId === 'undefined' || timelineOrTimelineId === null){ 203 | timeline = timelines.master; 204 | } else if (typeof timelineOrTimelineId === 'string'){ 205 | timeline = timelines[timelineOrTimelineId]; 206 | } else if (typeof timelineOrTimelineId === 'object'){ 207 | timeline = timelineOrTimelineId 208 | } 209 | 210 | // actual animation start will happen in future cycles, for now we want to mark this component as animating 211 | components[this.animationComponentId].pendingAnimations[animationConfig.id] = true; 212 | 213 | function attachCallbackToTl(callbackType, callbackFunction, params){ 214 | const references = { 215 | animationComponent: componentData.instance.wrappedComponentRef.current 216 | }; 217 | 218 | if (options.thisContext){ 219 | references.triggerComponent = options.thisContext.wrappedComponentRef.current 220 | } 221 | 222 | function gsapCallback(tweenRef){ 223 | // save the tweenref, as GSAP only passes it via string {self} string replacement 224 | references.tween = tweenRef; 225 | let callbackArgs = [references]; 226 | if (params) { 227 | callbackArgs = [...callbackArgs, ...params]; 228 | } 229 | 230 | callbackFunction.apply(callbackArgs); 231 | } 232 | 233 | animationTlToAdd.eventCallback(callbackType, gsapCallback, ['{self}']); 234 | } 235 | 236 | attachCallbackToTl("onStart", (...args) => { 237 | components[this.animationComponentId].runningAnimations[animationConfig.id] = true; 238 | delete components[this.animationComponentId].pendingAnimations[animationConfig.id]; 239 | 240 | if (animationConfig.onStart){ 241 | animationConfig.onStart(...args); 242 | } 243 | 244 | if (options.firstAnimationInScenario){ 245 | if (options.scenarioConfig && globalOptions.onScenarioStart) { 246 | globalOptions.onScenarioStart.apply([...args, options.scenarioConfig, options.triggerConfig]); 247 | } 248 | } 249 | 250 | }); 251 | 252 | attachCallbackToTl("onComplete", (...args)=>{ 253 | delete components[this.animationComponentId].runningAnimations[animationConfig.id]; 254 | 255 | if (animationConfig.onComplete){ 256 | animationConfig.onComplete(...args); 257 | } 258 | 259 | // force the react component to re-render, in case we need to remove it from the DOM 260 | this.forceUpdate(); 261 | 262 | if (options.lastAnimationInScenario){ 263 | if (options.scenarioConfig && globalOptions.onScenarioComplete) { 264 | globalOptions.onScenarioComplete.apply([...args, options.scenarioConfig, options.triggerConfig]); 265 | } 266 | } 267 | }); 268 | 269 | 270 | // "immediate" in config makes the timeline animation instantaneous 271 | if (animationConfig.immediate){ 272 | animationTlToAdd.duration(0.001); 273 | } 274 | 275 | // special case for starting an animation with the previous one 276 | if (animationConfig.position === 'withPrev'){ 277 | let newPosition = 0; 278 | const timelineChildren = timeline.getChildren(false); 279 | if (timelineChildren.length > 0) { 280 | const previousTimeline = timelineChildren[timelineChildren.length - 1]; 281 | newPosition = previousTimeline.startTime(); 282 | } 283 | animationConfig.position = newPosition; 284 | } 285 | timeline.add(animationTlToAdd, animationConfig.position); 286 | }) 287 | } 288 | 289 | registerAnimation(animationId, generatorFunc, elementRef) { 290 | const componentAnimations = components[this.animationComponentId].animations; 291 | 292 | if (animationId in componentAnimations){ 293 | error(`animationId ${animationId} already registered`); 294 | } 295 | else{ 296 | componentAnimations[animationId] = { 297 | generatorFunc, 298 | elementRef, 299 | }; 300 | 301 | 302 | } 303 | } 304 | 305 | getScenarios(){ 306 | return scenariosConfig; 307 | } 308 | 309 | render(){ 310 | const augmentedProps = { 311 | ...this.props, 312 | registerAnimation: this.registerAnimation.bind(this), 313 | addAnimation: this.addAnimation.bind(this) 314 | }; 315 | 316 | const component = components[this.animationComponentId]; 317 | const shouldShowInProps = 'shouldShow' in this.props; 318 | // if shouldShow is specified, then component will be displayed as long as it has a running animation 319 | const hasRunningAnimations = Object.values(component.runningAnimations).length>0; 320 | const hasPendingAnimations = Object.values(component.pendingAnimations).length>0; 321 | const isRemoveCandidate = component.removeCandidate; 322 | const isSomeComponentEvaluatingAnimations = Object.values(components).some(component=> component.evaluatingAnimations); 323 | 324 | if (shouldShowInProps === false || 325 | (shouldShowInProps && 326 | (this.props.shouldShow === true || 327 | hasRunningAnimations || 328 | hasPendingAnimations || 329 | (isRemoveCandidate && isSomeComponentEvaluatingAnimations) 330 | ))){ 331 | return ; 332 | } 333 | return null; 334 | 335 | } 336 | }; 337 | } 338 | 339 | /** 340 | * 341 | * @param {animationConfig | animationConfig[] } animationConfig 342 | * @param timelineOrTimelineId 343 | * @param {objects} options 344 | */ 345 | function addAnimation(animations, timelineOrTimelineId, options = {}) { 346 | // for convenience we assume we have an array of AnimationConfigs 347 | const animationConfigArray = Array.isArray(animations) ? animations : [animations]; 348 | 349 | animationConfigArray.forEach((rawAnimationConfig, animationIndex) => { 350 | const animationConfig = transformAnimationConfig(rawAnimationConfig, options.thisContext); 351 | // search for components with this animationId 352 | const componentsWithAnimation = Object.values(components).filter( 353 | componentData => animationConfig.animation in componentData.animations 354 | ); 355 | 356 | if (componentsWithAnimation.length > 0){ 357 | componentsWithAnimation.forEach((componentData, componentIndex) => { 358 | options = { 359 | ...options, 360 | // the first animation on the first component 361 | firstAnimationInScenario: animationIndex === 0 && componentIndex === 0, 362 | // the last animation on the last component 363 | lastAnimationInScenario: animationIndex === (animationConfigArray.length -1) && componentIndex === (componentsWithAnimation.length - 1) 364 | }; 365 | componentData.instance.addAnimation(animationConfig, timelineOrTimelineId, options) 366 | }) 367 | } else{ 368 | error(`no components with such animation ${animationConfig.animation}. Did you forget to call registerAnimation()?`) 369 | } 370 | }); 371 | 372 | } 373 | 374 | function addScenarioAnimations(scenarioConfig, options = {}){ 375 | const scenarioConfigDraft = {...scenarioConfig}; 376 | 377 | // use master timeline in scenario configs with no timeline specified 378 | if ('timeline' in scenarioConfigDraft === false){ 379 | scenarioConfigDraft.timeline = 'master'; 380 | } 381 | 382 | // make sure 'animations' is an array 383 | let animations; 384 | if (Array.isArray(scenarioConfigDraft.animations)){ 385 | animations = scenarioConfigDraft.animations; 386 | } else if (typeof scenarioConfigDraft.animations === 'string'){ 387 | animations = [scenarioConfigDraft.animations]; 388 | } 389 | 390 | addAnimation(animations, scenarioConfigDraft.timeline, { 391 | ...options, 392 | scenarioConfig: scenarioConfigDraft 393 | }); 394 | } 395 | 396 | function triggerScenario(scenarioId){ 397 | if (scenarios[scenarioId]){ 398 | addScenarioAnimations(scenarios[scenarioId]) 399 | } else { 400 | error(`can't trigger scenario ${scenarioId}, no such scenario defined`); 401 | } 402 | } 403 | 404 | function setGlobalOptions(options){ 405 | globalOptions = options; 406 | } 407 | 408 | /** 409 | * Private functions 410 | */ 411 | 412 | // gets an animationID, a function or a config and transforms it to a proper to feed to the run animation function 413 | function transformAnimationConfig(inputAnimationConfig, thisContext){ 414 | // avoid double transformation 415 | if (inputAnimationConfig.__transformed) { 416 | return inputAnimationConfig 417 | }; 418 | 419 | let animationConfig; 420 | if (typeof inputAnimationConfig === 'string'){ 421 | animationConfig = { 422 | animation: inputAnimationConfig 423 | } 424 | } 425 | else if (typeof inputAnimationConfig === 'function') { 426 | if (thisContext && thisContext.animationComponentId){ 427 | const funcResult = inputAnimationConfig.call(thisContext.wrappedComponentRef.current); 428 | if (typeof funcResult === 'string'){ 429 | animationConfig = { 430 | animation: funcResult 431 | } 432 | 433 | } else if (funcResult === null || typeof funcResult === 'undefined'){ 434 | error('animation config function returned null or undefined'); 435 | } else { 436 | // function returned object 437 | animationConfig = funcResult; 438 | } 439 | } else { 440 | error(`Can't evaluate animation config without a "this" context`); 441 | } 442 | } 443 | else{ 444 | animationConfig = inputAnimationConfig; 445 | } 446 | 447 | // defaults 448 | animationConfig = 449 | { 450 | id : ++animationIdCounter, 451 | position: "+=0", 452 | ...animationConfig, 453 | __transformed: true 454 | }; 455 | 456 | return animationConfig; 457 | } 458 | 459 | function error(message){ 460 | console.error(`AnimationOrchestrator: ${message}`); 461 | } 462 | 463 | 464 | export { 465 | attachAnimation, 466 | addAnimation, 467 | triggerScenario, 468 | setGlobalOptions 469 | } --------------------------------------------------------------------------------