From 6a4cd689c02bbbc8c47b90e01dbfabbe9e5983ce Mon Sep 17 00:00:00 2001 From: Ben Lesh Date: Wed, 18 Sep 2019 08:42:06 -0500 Subject: [PATCH] feat(animationFrames): Adds an observable of animationFrames (#5021) * feat(animationFrames): Adds an observable of animationFrames - Also adds tests and test harness for requestAnimationFrame stubbing with sinon - Updates TypeScript lib to use ES2018 (so we can use `findIndex` on `Array`). * refactor: switch to a loop-per-subscription approach Now each subscription will cause a new animation frame loop to be kicked off, instead of trying to share a single animation loop. --- .../observables/dom/animationFrames-spec.ts | 13 ++ spec/helpers/test-helper.ts | 102 +++++++++++ spec/observables/dom/animationFrames-spec.ts | 166 ++++++++++++++++++ src/index.ts | 1 + .../observable/dom/animationFrames.ts | 107 +++++++++++ tsconfig.base.json | 6 +- 6 files changed, 390 insertions(+), 5 deletions(-) create mode 100644 spec-dtslint/observables/dom/animationFrames-spec.ts create mode 100644 spec/observables/dom/animationFrames-spec.ts create mode 100644 src/internal/observable/dom/animationFrames.ts diff --git a/spec-dtslint/observables/dom/animationFrames-spec.ts b/spec-dtslint/observables/dom/animationFrames-spec.ts new file mode 100644 index 0000000000..249615ee0f --- /dev/null +++ b/spec-dtslint/observables/dom/animationFrames-spec.ts @@ -0,0 +1,13 @@ +import { animationFrames } from 'rxjs'; + +it('should just be an observable of numbers', () => { + const o$ = animationFrames(); // $ExpectType Observable +}); + +it('should allow the passing of a timestampProvider', () => { + const o$ = animationFrames(performance); // $ExpectType Observable +}); + +it('should not allow the passing of an invalid timestamp provider', () => { + const o$ = animationFrames({ now() { return 'wee' } }); // $ExpectError +}); \ No newline at end of file diff --git a/spec/helpers/test-helper.ts b/spec/helpers/test-helper.ts index 3096a153ba..5043bf7d18 100644 --- a/spec/helpers/test-helper.ts +++ b/spec/helpers/test-helper.ts @@ -4,6 +4,7 @@ import { of, asyncScheduler, Observable, scheduled, ObservableInput } from 'rxjs import { root } from 'rxjs/internal/util/root'; import { observable } from 'rxjs/internal/symbol/observable'; import { iterator } from 'rxjs/internal/symbol/iterator'; +import * as sinon from 'sinon'; export function lowerCaseO(...args: Array): Observable { const o = { @@ -47,3 +48,104 @@ export const createObservableInputs = (value: T) => of( ) as Observable>; global.__root__ = root; + +let _raf: any; +let _caf: any; +let _id = 0; + +/** + * A type used to test `requestAnimationFrame` + */ +export interface RAFTestTools { + /** + * Synchronously fire the next scheduled animation frame + */ + tick(): void; + + /** + * Synchronously fire all scheduled animation frames + */ + flush(): void; + + /** + * Un-monkey-patch `requestAnimationFrame` and `cancelAnimationFrame` + */ + restore(): void; +} + +/** + * Monkey patches `requestAnimationFrame` and `cancelAnimationFrame`, returning a + * toolset to allow animation frames to be synchronously controlled. + * + * ### Usage + * ```ts + * let raf: RAFTestTools; + * + * beforeEach(() => { + * // patch requestAnimationFrame + * raf = stubRAF(); + * }); + * + * afterEach(() => { + * // unpatch + * raf.restore(); + * }); + * + * it('should fire handlers', () => { + * let test = false; + * // use requestAnimationFrame as normal + * requestAnimationFrame(() => test = true); + * // no frame has fired yet (this would be generally true anyhow) + * expect(test).to.equal(false); + * // manually fire the next animation frame + * raf.tick(); + * // frame as fired + * expect(test).to.equal(true); + * // raf is now a SinonStub that can be asserted against + * expect(requestAnimationFrame).to.have.been.calledOnce; + * }); + * ``` + */ +export function stubRAF(): RAFTestTools { + _raf = requestAnimationFrame; + _caf = cancelAnimationFrame; + + const handlers: any[] = []; + + (requestAnimationFrame as any) = sinon.stub().callsFake((handler: Function) => { + const id = _id++; + handlers.push({ id, handler }); + return id; + }); + + (cancelAnimationFrame as any) = sinon.stub().callsFake((id: number) => { + const index = handlers.findIndex(x => x.id === id); + if (index >= 0) { + handlers.splice(index, 1); + } + }); + + function tick() { + if (handlers.length > 0) { + handlers.shift().handler(); + } + } + + function flush() { + while (handlers.length > 0) { + handlers.shift().handler(); + } + } + + return { + tick, + flush, + restore() { + (requestAnimationFrame as any) = _raf; + (cancelAnimationFrame as any) = _caf; + _raf = _caf = undefined; + handlers.length = 0; + _id = 0; + } + }; +} \ No newline at end of file diff --git a/spec/observables/dom/animationFrames-spec.ts b/spec/observables/dom/animationFrames-spec.ts new file mode 100644 index 0000000000..91fce1d060 --- /dev/null +++ b/spec/observables/dom/animationFrames-spec.ts @@ -0,0 +1,166 @@ +import { expect } from 'chai'; +import { animationFrames, Subject } from 'rxjs'; +import * as sinon from 'sinon'; +import { take, takeUntil } from 'rxjs/operators'; +import { RAFTestTools, stubRAF } from 'spec/helpers/test-helper'; + +describe('animationFrame', () => { + let raf: RAFTestTools; + let DateStub: sinon.SinonStub; + let now = 1000; + + beforeEach(() => { + raf = stubRAF(); + DateStub = sinon.stub(Date, 'now').callsFake(() => { + return ++now; + }); + }); + + afterEach(() => { + raf.restore(); + DateStub.restore(); + }); + + it('should animate', function () { + const results: any[] = []; + const source$ = animationFrames(); + + const subs = source$.subscribe({ + next: ts => results.push(ts), + error: err => results.push(err), + complete: () => results.push('done'), + }); + + expect(DateStub).to.have.been.calledOnce; + + expect(results).to.deep.equal([]); + + raf.tick(); + expect(DateStub).to.have.been.calledTwice; + expect(results).to.deep.equal([1]); + + raf.tick(); + expect(DateStub).to.have.been.calledThrice; + expect(results).to.deep.equal([1, 2]); + + raf.tick(); + expect(results).to.deep.equal([1, 2, 3]); + + // Stop the animation loop + subs.unsubscribe(); + }); + + it('should use any passed timestampProvider', () => { + const results: any[] = []; + let i = 0; + const timestampProvider = { + now: sinon.stub().callsFake(() => { + return [100, 200, 210, 300][i++]; + }) + }; + + const source$ = animationFrames(timestampProvider); + + const subs = source$.subscribe({ + next: ts => results.push(ts), + error: err => results.push(err), + complete: () => results.push('done'), + }); + + expect(DateStub).not.to.have.been.called; + expect(timestampProvider.now).to.have.been.calledOnce; + expect(results).to.deep.equal([]); + + raf.tick(); + expect(DateStub).not.to.have.been.called; + expect(timestampProvider.now).to.have.been.calledTwice; + expect(results).to.deep.equal([100]); + + raf.tick(); + expect(DateStub).not.to.have.been.called; + expect(timestampProvider.now).to.have.been.calledThrice; + expect(results).to.deep.equal([100, 110]); + + raf.tick(); + expect(results).to.deep.equal([100, 110, 200]); + + // Stop the animation loop + subs.unsubscribe(); + }); + + it('should compose with take', () => { + const results: any[] = []; + const source$ = animationFrames(); + expect(requestAnimationFrame).not.to.have.been.called; + + source$.pipe( + take(2), + ).subscribe({ + next: ts => results.push(ts), + error: err => results.push(err), + complete: () => results.push('done'), + }); + + expect(DateStub).to.have.been.calledOnce; + expect(requestAnimationFrame).to.have.been.calledOnce; + + expect(results).to.deep.equal([]); + + raf.tick(); + expect(DateStub).to.have.been.calledTwice; + expect(requestAnimationFrame).to.have.been.calledTwice; + expect(results).to.deep.equal([1]); + + raf.tick(); + expect(DateStub).to.have.been.calledThrice; + // It shouldn't reschedule, because there are no more subscribers + // for the animation loop. + expect(requestAnimationFrame).to.have.been.calledTwice; + expect(results).to.deep.equal([1, 2, 'done']); + + // Since there should be no more subscribers listening on the loop + // the latest animation frame should be cancelled. + expect(cancelAnimationFrame).to.have.been.calledOnce; + }); + + it('should compose with takeUntil', () => { + const subject = new Subject(); + const results: any[] = []; + const source$ = animationFrames(); + expect(requestAnimationFrame).not.to.have.been.called; + + source$.pipe( + takeUntil(subject), + ).subscribe({ + next: ts => results.push(ts), + error: err => results.push(err), + complete: () => results.push('done'), + }); + + expect(DateStub).to.have.been.calledOnce; + expect(requestAnimationFrame).to.have.been.calledOnce; + + expect(results).to.deep.equal([]); + + raf.tick(); + expect(DateStub).to.have.been.calledTwice; + expect(requestAnimationFrame).to.have.been.calledTwice; + expect(results).to.deep.equal([1]); + + raf.tick(); + expect(DateStub).to.have.been.calledThrice; + expect(requestAnimationFrame).to.have.been.calledThrice; + expect(results).to.deep.equal([1, 2]); + expect(cancelAnimationFrame).not.to.have.been.called; + + // Complete the observable via `takeUntil`. + subject.next(); + expect(cancelAnimationFrame).to.have.been.calledOnce; + expect(results).to.deep.equal([1, 2, 'done']); + + raf.tick(); + expect(DateStub).to.have.been.calledThrice; + expect(requestAnimationFrame).to.have.been.calledThrice; + expect(results).to.deep.equal([1, 2, 'done']); + }); +}); diff --git a/src/index.ts b/src/index.ts index 624a92be5c..09f8c5df4e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,6 +4,7 @@ export { ConnectableObservable } from './internal/observable/ConnectableObservab export { GroupedObservable } from './internal/operators/groupBy'; export { Operator } from './internal/Operator'; export { observable } from './internal/symbol/observable'; +export { animationFrames } from './internal/observable/dom/animationFrames'; /* Subjects */ export { Subject } from './internal/Subject'; diff --git a/src/internal/observable/dom/animationFrames.ts b/src/internal/observable/dom/animationFrames.ts new file mode 100644 index 0000000000..63946dfeac --- /dev/null +++ b/src/internal/observable/dom/animationFrames.ts @@ -0,0 +1,107 @@ +import { Observable } from '../../Observable'; + +// TODO: move to types.ts +export interface TimestampProvider { + now(): number; +} + +/** + * An observable of animation frames + * + * Emits the the amount of time elapsed since subscription on each animation frame. Defaults to elapsed + * milliseconds. Does not end on its own. + * + * Every subscription will start a separate animation loop. Since animation frames are always scheduled + * by the browser to occur directly before a repaint, scheduling more than one animation frame synchronously + * should not be much different or have more overhead than looping over an array of events during + * a single animation frame. However, if for some reason the developer would like to ensure the + * execution of animation-related handlers are all executed during the same task by the engine, + * the `share` operator can be used. + * + * This is useful for setting up animations with RxJS. + * + * ### Example + * + * Tweening a div to move it on the screen + * + * ```ts + * import { animationFrames } from 'rxjs'; + * import { map, takeWhile, endWith } from 'rxjs/operators'; + * + * function tween(start: number, end: number, duration: number) { + * const diff = end - start; + * return animationFrames().pipe( + * // Figure out what percentage of time has passed + * map(elapsed => elapsed / duration), + * // Take the vector while less than 100% + * takeWhile(v => v < 1), + * // Finish with 100% + * endWith(1), + * // Calculate the distance traveled between start and end + * map(v => v * diff + start) + * ); + * } + * + * // Setup a div for us to move around + * const div = document.createElement('div'); + * document.body.appendChild(div); + * div.style.position = 'absolute'; + * div.style.width = '40px'; + * div.style.height = '40px'; + * div.style.backgroundColor = 'lime'; + * div.style.transform = 'translate3d(10px, 0, 0)'; + * + * tween(10, 200, 4000).subscribe(x => { + * div.style.transform = `translate3d(${x}px, 0, 0)`; + * }); + * ``` + * + * ### Example + * + * Providing a custom timestamp provider + * + * ```ts + * import { animationFrames, TimestampProvider } from 'rxjs'; + * + * // A custom timestamp provider + * let now = 0; + * const customTSProvider: TimestampProvider = { + * now() { return now++; } + * }; + * + * const source$ = animationFrames(customTSProvider); + * + * // Log increasing numbers 0...1...2... on every animation frame. + * source$.subscribe(x => console.log(x)); + * ``` + * + * @param timestampProvider An object with a `now` method that provides a numeric timestamp + */ +export function animationFrames(timestampProvider: TimestampProvider = Date) { + return timestampProvider === Date ? DEFAULT_ANIMATION_FRAMES : animationFramesFactory(timestampProvider); +} + +/** + * Does the work of creating the observable for `animationFrames`. + * @param timestampProvider The timestamp provider to use to create the observable + */ +function animationFramesFactory(timestampProvider: TimestampProvider) { + return new Observable(subscriber => { + let id: number; + const start = timestampProvider.now(); + const run = () => { + subscriber.next(timestampProvider.now() - start); + if (!subscriber.closed) { + id = requestAnimationFrame(run); + } + }; + id = requestAnimationFrame(run); + return () => cancelAnimationFrame(id); + }); +} + +/** + * In the common case, where `Date` is passed to `animationFrames` as the default, + * we use this shared observable to reduce overhead. + */ +const DEFAULT_ANIMATION_FRAMES = animationFramesFactory(Date); diff --git a/tsconfig.base.json b/tsconfig.base.json index 14e762195e..f5da547783 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -14,11 +14,7 @@ "outDir": "./.out", "lib": [ "es5", - "es2015.iterable", - "es2015.collection", - "es2015.promise", - "es2015.symbol", - "es2015.symbol.wellknown", + "es2018", "dom" ] },