├── .babelrc
├── .coveralls.yml
├── .eslintignore
├── .eslintrc.js
├── .gitignore
├── .npmignore
├── .nvmrc
├── .travis.yml
├── README.md
├── package-lock.json
├── package.json
├── src
└── AnimationFrameComponent.jsx
└── test
├── AnimationFrameComponentTests.jsx
├── init.js
└── mocha.opts
/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": ["react", "env"]
3 | }
4 |
--------------------------------------------------------------------------------
/.coveralls.yml:
--------------------------------------------------------------------------------
1 | service_name: travis-ci
--------------------------------------------------------------------------------
/.eslintignore:
--------------------------------------------------------------------------------
1 | *.transpiled.js
--------------------------------------------------------------------------------
/.eslintrc.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | "globals": {
3 | "expect": true,
4 | "describe": true,
5 | "it": true,
6 | "beforeEach": true,
7 | "afterEach": true,
8 | "createDom": true,
9 | "destroyDom": true,
10 | "mockRaf": true
11 | },
12 |
13 | "env": {
14 | "es6": true,
15 | "commonjs": true,
16 | "browser": true
17 | },
18 |
19 | "extends": "eslint:recommended",
20 |
21 | "parserOptions": {
22 | "ecmaFeatures": {
23 | "jsx": true
24 | }
25 | },
26 |
27 | "plugins": ["react"],
28 |
29 | "rules": {
30 |
31 | "indent": [
32 | "error",
33 | 4
34 | ],
35 |
36 | "linebreak-style": [
37 | "error",
38 | "unix"
39 | ],
40 |
41 | "quotes": [
42 | "error",
43 | "single"
44 | ],
45 |
46 | "semi": [
47 | "error",
48 | "always"
49 | ],
50 |
51 | "no-var": "error",
52 | "react/jsx-uses-vars": 2
53 | }
54 | };
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | dist
2 | *.transpiled.js
3 | node_modules
4 | coverage
5 | .nyc_output
--------------------------------------------------------------------------------
/.npmignore:
--------------------------------------------------------------------------------
1 | src
2 | test
3 | .babelrc
4 | .eslintignore
5 | .eslintrc.js
6 | .nvmrc
7 | README.md
--------------------------------------------------------------------------------
/.nvmrc:
--------------------------------------------------------------------------------
1 | v8.6.0
2 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: node_js
2 | node_js: 8
3 |
4 | install: "npm i"
5 | script: "npm run test-with-coverage"
6 |
7 | deploy:
8 | provider: npm
9 | email: "james@jamesswright.co.uk"
10 | api_key:
11 | secure: "IgDd7RMfLEysVzuJmPeHuiyg3V3nK9eb7rfdXdYbgSgkfFuUPck0VjUMB1vv9/7dnDolvEdJ3xGCuEbW2wX/5hq16x0k+WTfICrXy8uy2E8dhZsfYMeOqbvjNe/mahuJ4IfcM8gDptf8/oKGbUi2eeji2v1d726Hbd+R5cLu8vEVaJFxqH7io5HgZdwP2+n7r4CSpWjulz+XmamjKSH5FQ61GOBZNos/72Ef4T63KTbV9dYpIXpdACPryhv7U0BClxfIKewcFuaU707xbn5UanRpsD7nP5GMnhL+Jxu2UxVDiZ6oEvQbYLxen9gaKMbuvYd+xybztVIm4RRuxnZGBmJQ88o2CrXRSKdA368JFOwkZssmlXkTx5iutfP5qnT6MQ6WzX3z+o7sYLDEFwhV9aHwssfPhR/CKIMaRo2ZsSh+QsPUsedW8TNtpvxdhtecZv5JxwR8wQ9Ri/k5cTavvfwlhr0B+9e3H3E+tZ/V0KxloLLyL933z28RzAFywjwxuyhK/3BQuPdl2r5XD2EcqXHcuV4r/K0rPn033bqZ0vesqcI8NuYrfW//fr7WjT6Ugsd+G97TJwylOQduXJcbLTcChFwq7fyyxWUrV43ONCH5O5XRGcWiuwvLISwsOGuXwaMb+6jGcwm7KB3kyZaS2g3q4/5tK+TKGRVzjonMThA="
12 |
13 | on:
14 | tags: true
15 |
16 | after_success: npm run coverage
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # React Animation Frame
2 |
3 | [](https://travis-ci.org/jamesseanwright/react-animation-frame) [](https://coveralls.io/github/jamesseanwright/react-animation-frame?branch=master)
4 | [](https://www.npmjs.com/package/react-animation-frame)
5 |
6 |
7 | A React higher-order component for invoking component repeating logic using [`requestAnimationFrame`](https://developer.mozilla.org/en-US/docs/Web/API/window/requestAnimationFrame). It works in both the [browser](https://github.com/jamesseanwright/react-animation-frame-example) and [React Native](https://github.com/jamesseanwright/react-animation-frame-native-example).
8 |
9 | The module follows the CommonJS format, so it is compatible with Browserify and Webpack. It also supports ES5 and beyond.
10 |
11 | ## Motivation
12 |
13 | Take a look at the [example project](https://github.com/jamesseanwright/react-animation-frame-example); this contains a `Timer` component that should animate a progress bar until the configured end time is surpassed:
14 |
15 | ```js
16 | 'use strict';
17 |
18 | const React = require('react');
19 | const ReactAnimationFrame = require('react-animation-frame');
20 |
21 | class Timer extends React.Component {
22 | onAnimationFrame(time) {
23 | const progress = Math.round(time / this.props.durationMs * 100);
24 | this.bar.style.width = `${progress}%`;
25 |
26 | if (progress === 100) {
27 | this.props.endAnimation();
28 | }
29 | }
30 |
31 | render() {
32 | return (
33 |
34 |
{this.props.message}
35 |
this.bar = node}>
36 |
37 | );
38 | }
39 | }
40 |
41 | module.exports = ReactAnimationFrame(Timer);
42 | ```
43 |
44 | The `onAnimationFrame` method will be called on each repaint, via `requestAnimationFrame`, by the higher-order `ReactAnimationFrame` component. Once the progress of our animation reaches 100%, we can kill the underlying loop using the `endAnimation` method passed to the `props` of the wrapped component.
45 |
46 | The loop can also be throttled by passing a second parameter to `ReactAnimationFrame`, which represents the number of milliseconds that should elapse between invocations of `onAnimationFrame`:
47 |
48 | ```js
49 | module.exports = ReactAnimationFrame(Timer, 100);
50 | ```
51 |
52 | ## Installation
53 |
54 | `npm i --save react-animation-frame`
55 |
56 |
57 | ## API
58 |
59 | ### `ReactAnimationFrame(Component[, throttleMs])`
60 |
61 | Wraps `Component` and starts a `requestAnimationFrame` loop. `throttleMs` if specified, will throttle invocations of `onAnimationFrame` by any number of milliseconds.
62 |
63 |
64 | ### Inside a wrapped component
65 |
66 | #### `onAnimationFrame(timestamp, lastTimestamp)`
67 |
68 | Called on each iteration of the underlying `requestAnimationFrame` loop, or if the elapsed throttle time has been surpassed. `timestamp` is the same `DOMHighResTimeStamp` with which `requestAnimationFrame`'s callback is invoked.
69 |
70 | The previous timestamp is also passed, which can be useful for calculating deltas. For n calls, `lastTimestamp` will be the value of `timestamp` for call n - 1.
71 |
72 |
73 | #### `this.props.endAnimation()`
74 |
75 | Cancels the current animation frame and ends the loop.
76 |
77 | #### `this.props.startAnimation()`
78 |
79 | Function to restart the animation after it was ended by `endAnimation`.
80 |
81 |
82 | ### Local development
83 |
84 | Run `npm i` to install the dependencies.
85 |
86 | * `npm run build` - transpile the library
87 | * `npm test` - lints the code and runs the tests
88 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "react-animation-frame",
3 | "version": "1.0.2",
4 | "description": "A React higher-order component for managing recurring animation frames",
5 | "repository": "https://github.com/jamesseanwright/react-animation-frame",
6 | "main": "dist/AnimationFrameComponent.js",
7 | "scripts": {
8 | "build": "babel --out-dir dist src",
9 | "coverage": "nyc report --reporter=text-lcov | coveralls",
10 | "prepare": "npm run build",
11 | "pretest": "eslint --ext js --ext jsx src test",
12 | "test": "mocha",
13 | "test-with-coverage": "npm run pretest && nyc --extension .jsx mocha"
14 | },
15 | "author": "James Wright ",
16 | "license": "ISC",
17 | "keywords": [
18 | "react",
19 | "requestAnimationFrame",
20 | "higher-order component",
21 | "hoc"
22 | ],
23 | "devDependencies": {
24 | "babel-cli": "6.23.0",
25 | "babel-preset-env": "1.6.1",
26 | "babel-preset-react": "6.23.0",
27 | "chai": "3.5.0",
28 | "coveralls": "2.12.0",
29 | "enzyme": "3.1.0",
30 | "enzyme-adapter-react-16": "1.6.0",
31 | "eslint": "3.17.1",
32 | "eslint-plugin-react": "7.4.0",
33 | "jsdom": "9.11.0",
34 | "mocha": "3.2.0",
35 | "mock-raf": "1.0.0",
36 | "nyc": "11.2.1",
37 | "react": "16.6.1",
38 | "react-dom": "16.6.1",
39 | "react-test-renderer": "16.0.0",
40 | "sinon": "1.17.7"
41 | },
42 | "peerDependencies": {
43 | "react": "~16 || ~15"
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/src/AnimationFrameComponent.jsx:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const React = require('react');
4 |
5 | module.exports = function AnimationFrameComponent(InnerComponent, throttleMs) {
6 | return class AnimatedComponent extends React.Component {
7 | constructor() {
8 | super();
9 |
10 | this.loop = this.loop.bind(this);
11 | this.endAnimation = this.endAnimation.bind(this);
12 | this.startAnimation = this.startAnimation.bind(this);
13 |
14 | this.isActive = true;
15 | this.rafId = 0;
16 | this.lastInvocationMs = 0;
17 | }
18 |
19 | loop(time) {
20 | const { lastInvocationMs, isActive } = this;
21 | const isAnimatable = !!(this.innerComponent && this.innerComponent.onAnimationFrame);
22 |
23 | // Latter const is defensive check for React Native unmount (issues/#3)
24 | if (!isActive || !isAnimatable) return;
25 |
26 | const hasTimeElapsed = !throttleMs || time - lastInvocationMs >= throttleMs;
27 |
28 | if (hasTimeElapsed) {
29 | this.lastInvocationMs = time;
30 | this.innerComponent.onAnimationFrame(time, lastInvocationMs);
31 | }
32 |
33 | this.rafId = requestAnimationFrame(this.loop);
34 | }
35 |
36 | endAnimation() {
37 | cancelAnimationFrame(this.rafId);
38 |
39 | this.isActive = false;
40 | }
41 |
42 | startAnimation() {
43 | if (!this.isActive) {
44 | this.isActive = true;
45 | this.rafId = requestAnimationFrame(this.loop);
46 | }
47 | }
48 |
49 | componentDidMount() {
50 | if (!this.innerComponent.onAnimationFrame) {
51 | throw new Error('The component passed to AnimationFrameComponent does not implement onAnimationFrame');
52 | }
53 |
54 | this.rafId = requestAnimationFrame(this.loop);
55 | }
56 |
57 | componentWillUnmount() {
58 | this.endAnimation();
59 | }
60 |
61 | render() {
62 | return (
63 | this.innerComponent = node}
64 | endAnimation={this.endAnimation}
65 | startAnimation={this.startAnimation}
66 | {...this.props} />
67 | );
68 | }
69 | };
70 | };
71 |
--------------------------------------------------------------------------------
/test/AnimationFrameComponentTests.jsx:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | const enzyme = require('enzyme');
4 | const React = require('react');
5 | const { expect } = require('chai');
6 | const sinon = require('sinon');
7 |
8 | const AnimationFrameComponent = require('../src/AnimationFrameComponent');
9 |
10 | class InnerComponent extends React.Component {
11 | onAnimationFrame() {}
12 |
13 | render() {
14 | return Foo
;
15 | }
16 | }
17 |
18 | class NonAnimatable extends React.Component {
19 | render() {
20 | return Can't be animated!
;
21 | }
22 | }
23 |
24 | describe('the RequestAnimationFrame HOC', function () {
25 | let mockComponent;
26 |
27 | beforeEach(function () {
28 | mockComponent = sinon.mock(InnerComponent.prototype);
29 | createDom();
30 | });
31 |
32 | afterEach(function () {
33 | mockComponent.restore();
34 | destroyDom();
35 | });
36 |
37 | it('should throw an error if the inner component doesn`t implement onAnimationFrame', function () {
38 | const WrappedComponent = AnimationFrameComponent(NonAnimatable);
39 |
40 | expect(() => enzyme.mount()).to.throw(
41 | 'The component passed to AnimationFrameComponent does not implement onAnimationFrame'
42 | );
43 | });
44 |
45 | it('should pass all properties to the wrapped component', function () {
46 | const WrappedComponent = AnimationFrameComponent(InnerComponent);
47 |
48 | const renderedComponent = enzyme.mount(
49 |
50 | );
51 |
52 | const innerComponent = renderedComponent.find(InnerComponent);
53 |
54 | expect(renderedComponent.prop('foo')).to.deep.equal(innerComponent.prop('foo'));
55 | expect(renderedComponent.prop('baz')).to.deep.equal(innerComponent.prop('baz'));
56 | });
57 |
58 | it('should call onAnimationFrame on each frame', function () {
59 | mockComponent.expects('onAnimationFrame')
60 | .thrice()
61 | .withArgs(sinon.match.number);
62 |
63 | const WrappedComponent = AnimationFrameComponent(InnerComponent);
64 |
65 | enzyme.mount();
66 | mockRaf.step({ count: 3 });
67 |
68 | mockComponent.verify();
69 | });
70 |
71 | it('should pass the current and previous times to onAnimationFrame', function () {
72 | mockComponent.expects('onAnimationFrame')
73 | .withArgs(16.666666666666668, 0)
74 | .onFirstCall();
75 |
76 | mockComponent.expects('onAnimationFrame')
77 | .withArgs(33.333333333333336, 16.666666666666668)
78 | .onSecondCall();
79 |
80 | const WrappedComponent = AnimationFrameComponent(InnerComponent);
81 |
82 | enzyme.mount();
83 | mockRaf.step({ count: 2 });
84 |
85 | mockComponent.verify();
86 | });
87 |
88 | it('should stop looping when the endAnimation method is invoked', function () {
89 | mockComponent.expects('onAnimationFrame')
90 | .once()
91 | .withArgs(sinon.match.number);
92 |
93 | const WrappedComponent = AnimationFrameComponent(InnerComponent);
94 | const renderedComponent = enzyme.mount();
95 | const innerComponent = renderedComponent.find(InnerComponent);
96 |
97 | mockRaf.step({ count: 1 });
98 |
99 | innerComponent.prop('endAnimation')();
100 |
101 | mockRaf.step({ count: 3 });
102 |
103 | mockComponent.verify();
104 | });
105 |
106 | it('should restart looping when the startAnimation method is invoked', function () {
107 | mockComponent.expects('onAnimationFrame')
108 | .twice()
109 | .withArgs(sinon.match.number);
110 |
111 | const WrappedComponent = AnimationFrameComponent(InnerComponent);
112 | const renderedComponent = enzyme.mount();
113 | const innerComponent = renderedComponent.find(InnerComponent);
114 |
115 | mockRaf.step({ count: 1 });
116 |
117 | innerComponent.prop('endAnimation')();
118 |
119 | mockRaf.step({ count: 3 });
120 |
121 | innerComponent.prop('startAnimation')();
122 |
123 | mockRaf.step({ count: 1 });
124 |
125 | mockComponent.verify();
126 | });
127 |
128 | it('should throttle the invocation of the callback if specified', function (done) {
129 | this.timeout(4000);
130 |
131 | const rafIntervalMs = 16; // fixing rAF interval for predictable testing
132 | const throttleMs = 1000;
133 | const invocationCount = 3;
134 | const stepCount = Math.ceil(throttleMs / rafIntervalMs) * invocationCount;
135 |
136 | mockComponent.expects('onAnimationFrame')
137 | .exactly(invocationCount)
138 | .withArgs(sinon.match.number);
139 |
140 | const WrappedComponent = AnimationFrameComponent(InnerComponent, throttleMs);
141 |
142 | enzyme.mount();
143 |
144 | mockRaf.step({ count: stepCount, time: rafIntervalMs });
145 |
146 | /* While this is generally a bad practice,
147 | * fake timers can't be used as the component
148 | * is tracking elapsed time between each rAF
149 | * loop invocation. */
150 | setTimeout(() => {
151 | mockComponent.verify();
152 | done();
153 | }, throttleMs * invocationCount + 5);
154 | });
155 |
156 | describe('React Native unmount bug resolution', function () {
157 | it('should not call onAnimationFrame when the child unmounts', function () {
158 | mockComponent.expects('onAnimationFrame')
159 | .never();
160 |
161 | const WrappedComponent = AnimationFrameComponent(InnerComponent);
162 | const renderedComponent = enzyme.mount();
163 |
164 | renderedComponent.instance().innerComponent = null;
165 | mockRaf.step({ count: 2 });
166 |
167 | mockComponent.verify();
168 | });
169 |
170 | it('should not call onAnimationFrame it becomes unavailable', function () {
171 | mockComponent.expects('onAnimationFrame')
172 | .never();
173 |
174 | const WrappedComponent = AnimationFrameComponent(InnerComponent);
175 | const renderedComponent = enzyme.mount();
176 |
177 | renderedComponent.instance().innerComponent.onAnimationFrame = null;
178 | mockRaf.step({ count: 2 });
179 |
180 | mockComponent.verify();
181 | });
182 | });
183 | });
184 |
--------------------------------------------------------------------------------
/test/init.js:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | require('babel-register');
4 | const { jsdom } = require('jsdom');
5 | const createMockRaf = require('mock-raf');
6 | const enzyme = require('enzyme');
7 | const Adapter = require('enzyme-adapter-react-16');
8 | const adapter = new Adapter();
9 |
10 | enzyme.configure({ adapter });
11 |
12 | global.createDom = function createDom() {
13 | const mockRaf = createMockRaf();
14 |
15 | const document = jsdom(`
16 |
17 |
18 |
19 |
20 | `);
21 |
22 | global.document = document;
23 | global.window = document.defaultView;
24 | global.mockRaf = mockRaf;
25 | global.requestAnimationFrame = mockRaf.raf;
26 | global.cancelAnimationFrame = () => {};
27 | };
28 |
29 | global.destroyDom = function destroyDom() {
30 | global.window.close();
31 | delete global.window;
32 | delete global.document;
33 | };
34 |
--------------------------------------------------------------------------------
/test/mocha.opts:
--------------------------------------------------------------------------------
1 | test/*.jsx
2 | --require test/init
--------------------------------------------------------------------------------