/** * Animated.tsx * * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the MIT license. * * Implements animated components for web version of ReactXP. */ // tslint:disable:function-name import * as React from 'react'; import * as ReactDOM from 'react-dom'; import AppConfig from '../common/AppConfig'; import Easing from '../common/Easing'; import * as RX from '../common/Interfaces'; import { executeTransition, TransitionSpec } from './animated/executeTransition'; import RXImage from './Image'; import * as _ from './utils/lodashMini'; import Styles from './Styles'; import RXText from './Text'; import RXTextInput from './TextInput'; import RXView from './View'; // Animated Css Property Units - check /common/Types for the list of available // css animated properties const animatedPropUnits: { [key: string]: string } = { // AnimatedFlexboxStyleRules height: 'px', width: 'px', left: 'px', right: 'px', top: 'px', bottom: 'px', // AnimatedTransformStyleRules perspective: '', rotate: 'deg', rotateX: 'deg', rotateY: 'deg', rotateZ: 'deg', scale: '', scaleX: '', scaleY: '', scaleZ: '', translateX: 'px', translateY: 'px', skewX: '', skewY: '', // AnimatedViewAndImageCommonStyleRules backgroundColor: '', opacity: '', borderRadius: 'px', // AnimatedTextStyleRules color: '', fontSize: 'px' }; // Every Animation subclass should extend this. export abstract class Animation { _id: number | undefined; // Starts the animation abstract start(onEnd?: RX.Types.Animated.EndCallback): void; // Stops the animation abstract stop(): void; } // Interface for a component that wants to know when the value // of an Animated.Value changes or is about to be animated. interface ValueListener { setValue(valueObject: Value, newValue: number | string): void; startTransition(valueObject: Value, from: number | string, toValue: number | string, duration: number, easing: string, delay: number, onEnd: RX.Types.Animated.EndCallback): void; stopTransition(valueObject: Value): number | string | undefined; } // The animated value object export class Value extends RX.Types.AnimatedValue { protected _value: number | string; private _listeners: ValueListener[]; // Initializes the object with the defaults and assigns the id for the animated value. constructor(value: number) { super(value); this._value = value; this._listeners = []; } // Gets the current animated value (this gets updates after animation concludes) _getInputValue(): number | string { return this._value; } _getOutputValue(): number | string { return this._getInterpolatedValue(this._value); } _getInterpolatedValue(inputVal: number | string): number | string { return inputVal; } _isInterpolated(): boolean { return false; } interpolate(config: RX.Types.Animated.InterpolationConfigType) { return new InterpolatedValue(config, this); } // Updates a value in this animated reference. setValue(value: number | string): void { if (value === undefined) { throw new Error('An invalid value was passed into setvalue in the animated value api'); } // If value the same, do nothing. if (value === this._value) { return; } this._value = value; // Notify subscribers about the new value. _.each(this._listeners, listener => listener.setValue(this, value)); } // Add listener for when the value gets updated. _addListener(listenerToAdd: ValueListener): void { if (this._listeners.indexOf(listenerToAdd) < 0) { this._listeners.push(listenerToAdd); } } // Remove a specific listner. _removeListener(listenerToRemove: ValueListener): void { this._listeners = _.filter(this._listeners, listener => listener !== listenerToRemove); } // Remove all listeners. _removeAllListeners(): void { this._listeners = []; } // Start a specific animation. _startTransition(toValue: number | string, duration: number, easing: string, delay: number, onEnd: RX.Types.Animated.EndCallback): void { // If there are no listeners, the app probably has a bug where it's // starting an animation before the associated element is mounted. // Complete the animation immediately by updating to the end value // and caling the onEnd callback. if (this._listeners.length === 0) { this._updateFinalValue(toValue); if (onEnd) { onEnd({ finished: false }); } return; } // Only call onEnd once for a series of listeners. let onEndCalled = false; const onEndWrapper = (result: RX.Types.Animated.EndResult) => { if (onEndCalled) { return; } onEndCalled = true; onEnd(result); }; _.each(this._listeners, listener => { listener.startTransition(this, this._getOutputValue(), this._getInterpolatedValue(toValue), duration, easing, delay, onEndWrapper); }); } // Stop animation. _stopTransition() { _.each(this._listeners, listener => { const updatedValue = listener.stopTransition(this); if (updatedValue !== undefined) { this._updateFinalValue(updatedValue); } }); } // After an animation is stopped or completed, updates // the final value. _updateFinalValue(value: number | string) { this.setValue(value); } } export class InterpolatedValue extends Value { private _interpolationConfig: { [key: number]: string | number } | undefined; constructor(private _config: RX.Types.Animated.InterpolationConfigType, rootValue: Value) { super(rootValue._getOutputValue() as number); if (!this._config || !this._config.inputRange || !this._config.outputRange || this._config.inputRange.length < 2 || this._config.outputRange.length < 2 || this._config.inputRange.length !== this._config.outputRange.length) { throw new Error('The interpolation config is invalid. Input and output arrays must be same length.'); } const newInterpolationConfig: { [key: number]: string | number } = {}; _.each(this._config.inputRange, (key, index) => { newInterpolationConfig[key] = this._config.outputRange[index]; }); this._interpolationConfig = newInterpolationConfig; rootValue._addListener({ setValue: (valueObject: Value, newValue: number | string) => { this.setValue(valueObject._getOutputValue()); }, startTransition: (valueObject: Value, from: number | string, toValue: number | string, duration: number, easing: string, delay: number, onEnd: RX.Types.Animated.EndCallback) => { this._startTransition(toValue, duration, easing, delay, onEnd); }, stopTransition: (valueObject: Value) => { this._stopTransition(); return undefined; } }); } _startTransition(toValue: number | string, duration: number, easing: string, delay: number, onEnd: RX.Types.Animated.EndCallback): void { // This API doesn't currently support more than two elements in the // interpolation array. Supporting this in the web would require the // use of JS-driven animations or keyframes, both of which are prohibitively // expensive from a performance and responsiveness perspective. if (this._config.inputRange.length !== 2) { if (AppConfig.isDevelopmentMode()) { console.log('Web implementation of interpolate API currently supports only two interpolation values.'); } } super._startTransition(toValue, duration, easing, delay, onEnd); } _convertValueToNumeric(inputVal: number | string): number { if (_.isNumber(inputVal)) { return inputVal; } return parseInt(inputVal, 10); } _addUnitsToNumericValue(value: number, templateValue: number | string): number | string { if (_.isNumber(templateValue)) { return value; } // Does the template contain any of the common units? const templateString = templateValue; const commonUnits = ['deg', 'grad', 'rad']; for (const unit of commonUnits) { if (templateString.indexOf(unit) > 0) { return value.toString() + unit; } } return value; } _getInterpolatedValue(inputVal: number | string): number | string { if (!this._interpolationConfig) { throw new Error('There is no interpolation config but one is required'); } const numericInputValue = this._convertValueToNumeric(inputVal); const outputValues = this._config.outputRange.map(value => { return this._convertValueToNumeric(value); }); if (this._interpolationConfig[numericInputValue]) { return this._interpolationConfig[numericInputValue]; } if (inputVal < this._config.inputRange[0]) { return outputValues[0]; } for (let i = 1; i < this._config.inputRange.length; i++) { if (inputVal < this._config.inputRange[i]) { const ratio = (numericInputValue - this._config.inputRange[i - 1]) / (this._config.inputRange[i] - this._config.inputRange[i - 1]); return this._addUnitsToNumericValue( (this._config.outputRange as number[])[i] * ratio + (this._config.outputRange as number[])[i - 1] * (1 - ratio), inputVal); } } return this._addUnitsToNumericValue( outputValues[this._config.inputRange.length - 1], inputVal); } _isInterpolated(): boolean { return true; } } export let timing: RX.Types.Animated.TimingFunction = function( value: Value, config: RX.Types.Animated.TimingAnimationConfig): RX.Types.Animated.CompositeAnimation { if (!value || !config) { throw new Error('Timing animation requires value and config'); } let stopLooping = false; return { start: function(onEnd?: RX.Types.Animated.EndCallback): void { const animate = () => { if (config.loop) { value.setValue(config.loop.restartFrom); } const easing: RX.Types.Animated.EasingFunction = config.easing || Easing.Default(); const duration = config.duration !== undefined ? config.duration : 500; const delay = config.delay || 0; value._startTransition(config.toValue, duration, easing.cssName, delay, result => { // Restart the loop? if (config.loop && !stopLooping) { animate(); } else { value._updateFinalValue(config.toValue); } if (onEnd) { onEnd(result); } }); }; // Trigger animation loop animate(); }, stop: function(): void { stopLooping = true; value._stopTransition(); } }; }; export let sequence: RX.Types.Animated.SequenceFunction = function( animations: Array): RX.Types.Animated.CompositeAnimation { if (!animations) { throw new Error('Sequence animation requires a list of animations'); } let hasBeenStopped = false; let doneCount = 0; const result = { start: function(onEnd?: RX.Types.Animated.EndCallback) { if (!animations || animations.length === 0) { throw new Error('No animations were passed to the animated sequence API'); } const executeNext = () => { doneCount++; const isFinished = doneCount === animations.length; if (hasBeenStopped || isFinished) { doneCount = 0; hasBeenStopped = false; if (onEnd) { onEnd({ finished: isFinished }); } return; } animations[doneCount].start(executeNext); }; animations[doneCount].start(executeNext); }, stop: function() { if (doneCount < animations.length) { doneCount = 0; hasBeenStopped = true; animations[doneCount].stop(); } } }; return result; }; export let parallel: RX.Types.Animated.ParallelFunction = function( animations: Array): RX.Types.Animated.CompositeAnimation { if (!animations) { throw new Error('Parallel animation requires a list of animations'); } // Variable to make sure we only call stop() at most once let hasBeenStopped = false; let doneCount = 0; const result = { start: function(onEnd?: RX.Types.Animated.EndCallback) { if (!animations || animations.length === 0) { throw new Error('No animations were passed to the animated parallel API'); } // Walk through animations and start all as soon as possible. animations.forEach((animation, id) => { animation.start(animationResult => { doneCount++; const isFinished = doneCount === animations.length; if (hasBeenStopped || isFinished) { doneCount = 0; hasBeenStopped = false; if (onEnd) { onEnd({ finished: isFinished }); } return; } }); }); }, stop: function(): void { doneCount = 0; hasBeenStopped = true; animations.forEach(animation => { animation.stop(); }); } }; return result; }; interface ExtendedTransition extends TransitionSpec { onEnd?: RX.Types.Animated.EndCallback; toValue?: number | string; } interface AnimatedAttribute { valueObject: Value; activeTransition?: ExtendedTransition; } type AnimatedValueMap = { [transform: string]: AnimatedAttribute }; // Function for creating wrapper AnimatedComponent around passed in component function createAnimatedComponent, C>(Component: any): any { class AnimatedComponentGenerated extends React.Component implements RX.AnimatedComponent, ValueListener { private _mountedComponent: any = null; private _propsWithoutStyle: any; // Gets initialized via _updateStypes private _processedStyle!: { [attribute: string]: string}; private _animatedAttributes: AnimatedValueMap; // Gets initialized via _updateStypes private _staticTransforms!: { [transform: string]: string }; private _animatedTransforms: AnimatedValueMap; constructor(props: PropsType) { super(props); this._animatedAttributes = {}; this._animatedTransforms = {}; this._updateStyles(props); } setNativeProps(props: PropsType) { if (AppConfig.isDevelopmentMode()) { console.error('setNativeProps not supported on web'); } } UNSAFE_componentWillReceiveProps(props: RX.Types.CommonStyledProps, C>) { this._updateStyles(props); } setValue(valueObject: Value, newValue: number | string): void { // We should never get here if the component isn't mounted, // but we'll add this additional protection. if (!this._mountedComponent) { return; } const attrib = this._findAnimatedAttributeByValue(this._animatedAttributes, valueObject); if (attrib) { const domNode = this._getDomNode(); if (domNode) { const cssValue = this._generateCssAttributeValue(attrib, valueObject._getOutputValue()); (domNode.style as any)[attrib] = cssValue; } return; } const transform = this._findAnimatedAttributeByValue(this._animatedTransforms, valueObject); if (transform) { const domNode = this._getDomNode(); if (domNode) { domNode.style.transform = this._generateCssTransformList(true); } } } startTransition(valueObject: Value, fromValue: number | string, toValue: number | string, duration: number, easing: string, delay: number, onEnd: RX.Types.Animated.EndCallback): void { // We should never get here if the component isn't mounted, // but we'll add this additional protection. if (!this._mountedComponent) { return; } let updateTransition = false; const attrib = this._findAnimatedAttributeByValue(this._animatedAttributes, valueObject); if (attrib) { if (this._animatedAttributes[attrib].activeTransition) { if (AppConfig.isDevelopmentMode()) { console.error('Animation started while animation was already pending'); } } this._animatedAttributes[attrib].activeTransition = { property: Styles.convertJsToCssStyle(attrib), from: this._generateCssAttributeValue(attrib, fromValue), to: this._generateCssAttributeValue(attrib, toValue), duration, timing: easing, delay, toValue, onEnd }; updateTransition = true; } const transform = this._findAnimatedAttributeByValue(this._animatedTransforms, valueObject); if (transform) { if (this._animatedTransforms[transform].activeTransition) { if (AppConfig.isDevelopmentMode()) { console.error('Animation started while animation was already pending'); } } this._animatedTransforms[transform].activeTransition = { property: transform, from: fromValue, to: toValue, duration, timing: easing, delay, toValue, onEnd }; updateTransition = true; } if (updateTransition) { this._updateTransition(); } } // Stops a pending transition, returning the value at the current time. stopTransition(valueObject: Value): number | string | undefined { // We should never get here if the component isn't mounted, // but we'll add this additional protection. if (!this._mountedComponent) { return; } let partialValue: number | string | undefined; let stoppedTransition: ExtendedTransition | undefined; let updateTransition = false; const attrib = this._findAnimatedAttributeByValue(this._animatedAttributes, valueObject); if (attrib) { const activeTransition = this._animatedAttributes[attrib].activeTransition; if (activeTransition) { partialValue = activeTransition.toValue; // We don't currently support updating to an intermediate // value for interpolated values because this would involve // mapping the interpolated value in reverse. Instead, we'll // simply update it to the "toValue". if (!valueObject._isInterpolated()) { const domNode = this._getDomNode(); if (domNode) { const computedStyle = window.getComputedStyle(domNode, undefined); if (computedStyle && (computedStyle as any)[attrib]) { partialValue = (computedStyle as any)[attrib]; } } } stoppedTransition = this._animatedAttributes[attrib].activeTransition; delete this._animatedAttributes[attrib].activeTransition; updateTransition = true; } } else { const transform = this._findAnimatedAttributeByValue(this._animatedTransforms, valueObject); if (transform) { const activeTransition = this._animatedTransforms[transform].activeTransition; if (activeTransition) { // We don't currently support updating to an intermediate value // for transform values. This is because getComputedStyle // returns a transform matrix for 'transform'. To implement this, we'd // need to convert the matrix back to a rotation, scale, etc. partialValue = activeTransition.toValue; stoppedTransition = this._animatedTransforms[transform].activeTransition; delete this._animatedTransforms[transform].activeTransition; updateTransition = true; } } } if (stoppedTransition && stoppedTransition.onEnd) { stoppedTransition.onEnd({ finished: false }); } if (updateTransition) { this._updateTransition(); } return partialValue; } private _getDomNode(): HTMLElement | null { return ReactDOM.findDOMNode(this._mountedComponent) as HTMLElement | null; } // Looks for the specified value object in the specified map. Returns // the key for the map (i.e. the attribute name) if found. private _findAnimatedAttributeByValue(map: AnimatedValueMap, valueObj: Value): string | undefined { const keys = _.keys(map); const index = _.findIndex(keys, key => map[key].valueObject === valueObj); return index >= 0 ? keys[index] : undefined; } // Updates the "transform" CSS attribute for the element to reflect all // active transitions. private _updateTransition() { // We should never get here if the component isn't mounted, // but we'll add this additional protection. if (!this._mountedComponent) { return; } const activeTransitions: TransitionSpec[] = []; _.each(this._animatedAttributes, attrib => { if (attrib.activeTransition) { activeTransitions.push(attrib.activeTransition); } }); // If there are any transform transitions, we need to combine // these into a single transition. That means we can't specify // different durations, delays or easing functions for each. That's // an unfortunate limitation of CSS. const keys = _.keys(this._animatedTransforms); const index = _.findIndex(keys, key => !!this._animatedTransforms[key].activeTransition); if (index >= 0) { const transformTransition = this._animatedTransforms[keys[index]].activeTransition!; activeTransitions.push({ property: 'transform', from: this._generateCssTransformList(false), to: this._generateCssTransformList(true), duration: transformTransition.duration, timing: transformTransition.timing, delay: transformTransition.delay }); } if (activeTransitions.length > 0) { const domNode = this._getDomNode(); if (domNode) { executeTransition(domNode, activeTransitions, () => { // Clear all of the active transitions and invoke the onEnd callbacks. const completeTransitions: ExtendedTransition[] = []; _.each(this._animatedAttributes, attrib => { if (attrib.activeTransition) { completeTransitions.push(attrib.activeTransition); delete attrib.activeTransition; } }); _.each(this._animatedTransforms, transform => { if (transform.activeTransition) { completeTransitions.push(transform.activeTransition); delete transform.activeTransition; } }); _.each(completeTransitions, transition => { if (transition.onEnd) { transition.onEnd({ finished: true }); } }); }); } } } // Generates the CSS value for the specified attribute given // an animated value object. private _generateCssAttributeValue(attrib: string, newValue: number | string): string { // If the value is a raw number, append the default units. // If it's a string, we assume the caller has specified the units. if (typeof newValue === 'number') { newValue = newValue + animatedPropUnits[attrib]; } return newValue; } private _generateCssTransformValue(transform: string, newValue: number | string): string { // If the value is a raw number, append the default units. // If it's a string, we assume the caller has specified the units. if (typeof newValue === 'number') { newValue = newValue + animatedPropUnits[transform]; } return newValue; } // Regenerates the list of transforms, combining all static and animated transforms. private _generateCssTransformList(useActiveValues: boolean): string { const transformList: string[] = []; _.each(this._staticTransforms, (value, transform) => { transformList.push(transform + '(' + value + ')'); }); _.each(this._animatedTransforms, (value, transform) => { const newValue = useActiveValues && value.activeTransition ? value.activeTransition.to : value.valueObject._getOutputValue(); transformList.push(transform + '(' + this._generateCssTransformValue(transform, newValue) + ')'); }); return transformList.join(' '); } // Typing of `any` on StyleRuleSet isn't desirable, but there's not accurate typings that can be used to represent // our merging of web/RX styles here here private _updateStyles(props: RX.Types.CommonStyledProps, C>) { this._propsWithoutStyle = _.omit(props, 'style'); const rawStyles = Styles.combine(props.style || {}); this._processedStyle = {}; const newAnimatedAttributes: { [transform: string]: Value } = {}; for (const attrib in rawStyles) { // Handle transforms separately. if (attrib === 'staticTransforms' || attrib === 'animatedTransforms') { continue; } // Is this a dynamic (animated) value? if (rawStyles[attrib] instanceof Value) { const valueObj = rawStyles[attrib] as Value; this._processedStyle[attrib] = this._generateCssAttributeValue(attrib, valueObj._getOutputValue()); newAnimatedAttributes[attrib] = valueObj; } else { // Copy the static style value. this._processedStyle[attrib] = rawStyles[attrib]; } } // Handle transforms, which require special processing because they need to // be combined into a single 'transform' CSS attribute. this._staticTransforms = rawStyles.staticTransforms || {}; const newAnimatedTransforms: { [transform: string]: Value } = rawStyles.animatedTransforms || {}; // Update this._animatedAttributes and this._animatedTransforms so they match // the updated style. // Remove any previous animated attributes that are no longer present // or associated with different value objects. _.each(this._animatedAttributes, (value, attrib) => { if (!newAnimatedAttributes[attrib] || newAnimatedAttributes[attrib] !== value.valueObject) { if (value.activeTransition) { if (AppConfig.isDevelopmentMode()) { console.error('Animated style attribute removed while the animation was active'); } } value.valueObject._removeListener(this); delete this._animatedAttributes[attrib]; } }); // Add new animated attributes. _.each(newAnimatedAttributes, (value, attrib) => { if (!this._animatedAttributes[attrib]) { this._animatedAttributes[attrib] = { valueObject: value }; if (this._mountedComponent) { value._addListener(this); } } }); // Remove any previous animated transforms that are no longer present // or associated with different value objects. _.each(this._animatedTransforms, (value, transform) => { if (!newAnimatedTransforms[transform] || newAnimatedTransforms[transform] !== value.valueObject) { if (value.activeTransition) { if (AppConfig.isDevelopmentMode()) { console.warn('Should not remove an animated transform attribute while the animation is active'); } } value.valueObject._removeListener(this); delete this._animatedTransforms[transform]; } }); // Add new animated transforms. _.each(newAnimatedTransforms, (value, transform) => { if (!this._animatedTransforms[transform]) { this._animatedTransforms[transform] = { valueObject: value }; if (this._mountedComponent) { value._addListener(this); } } }); // Update the transform attribute in this._processedStyle. const transformList = this._generateCssTransformList(true); if (transformList) { this._processedStyle.transform = transformList; } } componentDidMount() { _.each(this._animatedAttributes, value => { value.valueObject._addListener(this); }); _.each(this._animatedTransforms, value => { value.valueObject._addListener(this); }); } componentWillUnmount() { _.each(this._animatedAttributes, value => { value.valueObject._removeListener(this); }); this._animatedAttributes = {}; _.each(this._animatedTransforms, value => { value.valueObject._removeListener(this); }); this._animatedTransforms = {}; } focus() { if (this._mountedComponent && this._mountedComponent.focus) { this._mountedComponent.focus(); } } requestFocus() { if (this._mountedComponent && this._mountedComponent.requestFocus) { this._mountedComponent.requestFocus(); } } blur() { if (this._mountedComponent && this._mountedComponent.blur) { this._mountedComponent.blur(); } } setFocusRestricted(restricted: boolean) { if (this._mountedComponent && this._mountedComponent.setFocusRestricted) { this._mountedComponent.setFocusRestricted(restricted); } } setFocusLimited(limited: boolean) { if (this._mountedComponent && this._mountedComponent.setFocusLimited) { this._mountedComponent.setFocusLimited(limited); } } render() { return ( { this.props.children } ); } protected _onMount = (component: any) => { this._mountedComponent = component; } // Update the component's display name for easy debugging in react devtools extension static displayName = `Animated.${Component.displayName || Component.name || 'Component'}`; } return AnimatedComponentGenerated; } export let Image = createAnimatedComponent(RXImage) as typeof RX.AnimatedImage; export let Text = createAnimatedComponent(RXText) as typeof RX.AnimatedText; export let TextInput = createAnimatedComponent(RXTextInput) as typeof RX.AnimatedTextInput; export let View = createAnimatedComponent(RXView) as typeof RX.AnimatedView; export type Image = RX.AnimatedImage; export type Text = RX.AnimatedText; export type TextInput = RX.AnimatedTextInput; export type View = RX.AnimatedView; export let createValue: (initialValue: number) => Value = function(initialValue: number) { return new Value(initialValue); }; export let interpolate: (value: Value, inputRange: number[], outputRange: string[]) => Value = function(value: Value, inputRange: number[], outputRange: string[]) { return value.interpolate({ inputRange: inputRange, outputRange: outputRange }); }; export { Easing };