animationFrames.ts 4.97 KB
import { Observable } from '../../Observable';
import { TimestampProvider } from '../../types';
import { performanceTimestampProvider } from '../../scheduler/performanceTimestampProvider';
import { animationFrameProvider } from '../../scheduler/animationFrameProvider';

/**
 * An observable of animation frames
 *
 * Emits the amount of time elapsed since subscription and the timestamp on each animation frame.
 * Defaults to milliseconds provided to the requestAnimationFrame's callback. 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.
 *
 * ## Examples
 *
 * Tweening a div to move it on the screen
 *
 * ```ts
 * import { animationFrames, map, takeWhile, endWith } from 'rxjs';
 *
 * 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)`;
 * });
 * ```
 *
 * 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(({ elapsed }) => console.log(elapsed));
 * ```
 *
 * @param timestampProvider An object with a `now` method that provides a numeric timestamp
 */
export function animationFrames(timestampProvider?: TimestampProvider) {
  return timestampProvider ? animationFramesFactory(timestampProvider) : DEFAULT_ANIMATION_FRAMES;
}

/**
 * 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<{ timestamp: number; elapsed: number }>((subscriber) => {
    // If no timestamp provider is specified, use performance.now() - as it
    // will return timestamps 'compatible' with those passed to the run
    // callback and won't be affected by NTP adjustments, etc.
    const provider = timestampProvider || performanceTimestampProvider;

    // Capture the start time upon subscription, as the run callback can remain
    // queued for a considerable period of time and the elapsed time should
    // represent the time elapsed since subscription - not the time since the
    // first rendered animation frame.
    const start = provider.now();

    let id = 0;
    const run = () => {
      if (!subscriber.closed) {
        id = animationFrameProvider.requestAnimationFrame((timestamp: DOMHighResTimeStamp | number) => {
          id = 0;
          // Use the provider's timestamp to calculate the elapsed time. Note that
          // this means - if the caller hasn't passed a provider - that
          // performance.now() will be used instead of the timestamp that was
          // passed to the run callback. The reason for this is that the timestamp
          // passed to the callback can be earlier than the start time, as it
          // represents the time at which the browser decided it would render any
          // queued frames - and that time can be earlier the captured start time.
          const now = provider.now();
          subscriber.next({
            timestamp: timestampProvider ? now : timestamp,
            elapsed: now - start,
          });
          run();
        });
      }
    };

    run();

    return () => {
      if (id) {
        animationFrameProvider.cancelAnimationFrame(id);
      }
    };
  });
}

/**
 * In the common case, where the timestamp provided by the rAF API is used,
 * we use this shared observable to reduce overhead.
 */
const DEFAULT_ANIMATION_FRAMES = animationFramesFactory();