UNPKG

react-native-magic-move

Version:

Create magical move transitions between scenes in react-native

575 lines (548 loc) 17.2 kB
import React, { Component } from "react"; import { StyleSheet, Animated, Text, Easing, Platform } from "react-native"; import PropTypes from "prop-types"; import defaultTransition from "./transitions/move"; import MagicMoveClone from "./clone"; const defaultEasingFn = Easing.inOut(Easing.ease); function resolveValue(value, def, other = 0) { if (value !== undefined) return value; return def || other; } function contentTypeFromString(str) { switch (str) { case "children": return MagicMoveClone.ContentType.CHILDREN; case "snapshot": return MagicMoveClone.ContentType.SNAPSHOT; case "rawImage": return MagicMoveClone.ContentType.RAWIMAGE; default: throw new Error( `[MagicMove] Invalid nativeContentType specified: ${str}` ); } } /** * 1. Hide to component * 2. Get layout to and from component * 3. Render MagicMove component * 4. Hide from component * 5. Animate... * 6. Show to component * 7. Remove MagicMove component */ class MagicMoveAnimation extends Component { static propTypes = { source: PropTypes.object.isRequired, target: PropTypes.object.isRequired, onCompleted: PropTypes.func.isRequired }; state = { animValue: new Animated.Value(0), sourceLayout: undefined, targetLayout: undefined }; _useDelayedTargetHide = false; /** * Upon creating, hide the "target" component as quickly * as possible. */ constructor(props) { super(props); this._useDelayedTargetHide = props.target.props.useNativeClone && Platform.OS === "ios" && (props.target.props.ComponentType !== "image" || this.getTransition() !== defaultTransition); if (!this._useDelayedTargetHide) { if (this.isDebug) { //eslint-disable-next-line console.debug(`[MagicMove] Hiding target ${props.target.debugName}`); } props.target.setHidden(true); } } get isDebug() { return this.props.target.isDebug; } getTransition() { return this.props.target.props.transition || defaultTransition; } shouldComponentUpdate(nextProps, nextState) { // Optimize render to only execute when both the source & target layout // have been obtained. return nextState.sourceLayout && nextState.targetLayout && (!this.state.sourceLayout || !this.state.targetLayout) ? true : false; } /** * Renders a visual placeholder of the source * component, so it's possible to see what the * start position/shape/size is of the source. */ renderDebugSourcePlaceholder() { if (!this.isDebug) return; const { sourceLayout } = this.state; if (!sourceLayout) return; const { source } = this.props; const style = StyleSheet.flatten([source.props.style]); return ( <Animated.View key={`${source.id}.debugFrom`} style={{ position: "absolute", width: sourceLayout.width, height: sourceLayout.height, left: sourceLayout.x, top: sourceLayout.y, backgroundColor: "rgba(0, 0, 255, 0.1)", borderColor: "royalblue", borderWidth: 1, borderStyle: "dashed", borderTopRightRadius: resolveValue( style.borderTopRightRadius, style.borderRadius ), borderTopLeftRadius: resolveValue( style.borderTopLeftRadius, style.borderRadius ), borderBottomLeftRadius: resolveValue( style.borderBottomLeftRadius, style.borderRadius ), borderBottomRightRadius: resolveValue( style.borderBottomRightRadius, style.borderRadius ), opacity: 0.8, justifyContent: "center" }} > <Text style={{ color: "royalblue", textAlign: "center" }}>From</Text> </Animated.View> ); } /** * Renders a visual placeholder of the target * component, so it's possible to see what the * start position/shape/size is of the target. */ renderDebugTargetPlaceholder() { if (!this.isDebug) return; const { targetLayout } = this.state; if (!targetLayout) return; const { target } = this.props; const style = StyleSheet.flatten([target.props.style]); return ( <Animated.View key={`${target.id}.debugTo`} style={{ position: "absolute", width: targetLayout.width, height: targetLayout.height, left: targetLayout.x, top: targetLayout.y, backgroundColor: "rgba(0, 255, 0, 0.1)", borderColor: "green", borderWidth: 1, borderStyle: "dashed", borderTopRightRadius: resolveValue( style.borderTopRightRadius, style.borderRadius ), borderTopLeftRadius: resolveValue( style.borderTopLeftRadius, style.borderRadius ), borderBottomLeftRadius: resolveValue( style.borderBottomLeftRadius, style.borderRadius ), borderBottomRightRadius: resolveValue( style.borderBottomRightRadius, style.borderRadius ), opacity: 0.8, justifyContent: "center" }} > <Text style={{ color: "green", textAlign: "center" }}>To</Text> </Animated.View> ); } /** * Renders the initial clones of the source and * target components. The clones are also responsible * for measuring the layout of the source/target components * and supplying that to this class, so it can start the * transition animation with it. */ renderInitialClones() { const { source, target } = this.props; const transition = this.getTransition(); const nativeContentTypeString = (transition.defaultProps ? transition.defaultProps.nativeContentType : undefined) || "children"; const nativeContentType = contentTypeFromString(nativeContentTypeString); return [ <MagicMoveClone key="target0" component={target} mmContext={target.props.mmContext} options={ MagicMoveClone.Option.INITIAL | MagicMoveClone.Option.TARGET | (this.isDebug ? MagicMoveClone.Option.DEBUG : 0) } nativeContentType={nativeContentType} onLayout={this.onLayoutTargetClone} > {target.props.children} </MagicMoveClone>, <MagicMoveClone key="source0" component={source} mmContext={source.props.mmContext} options={ MagicMoveClone.Option.INITIAL | MagicMoveClone.Option.VISIBLE | (this.isDebug ? MagicMoveClone.Option.DEBUG : 0) } nativeContentType={nativeContentType} onShow={this.onShowSourceClone} > {source.props.children} </MagicMoveClone> ]; } /** * Called when the initial source component has calculated * its layout and it's clone has becomes visible. * It then immediately hides the original source component, * so it doesn't appear to move away due to a stack-transition. */ onShowSourceClone = layout => { const { source } = this.props; if (this.isDebug) { //eslint-disable-next-line console.debug(`[MagicMove] Hiding source ${source.debugName}`); } source.setHidden(true); this.setState({ sourceLayout: layout }); }; /** * Called whenever the layout of the target component * has been calculated. It then stores this layout * animation, and in case the layout of the source component * has also been obtained, then starts the transition * animation between the two. */ onLayoutTargetClone = layout => { if (this._useDelayedTargetHide) { if (this.isDebug) { //eslint-disable-next-line console.debug( `[MagicMove] Hiding target ${this.props.target.debugName}` ); } this.props.target.setHidden(true); } this.setState({ targetLayout: layout }); }; /** * Helper interpolation function, which provides * a shorthand notation to be used when writing * transition functions. */ interpolate = (from, to, clamp) => { if (to === from) return to; if (clamp) { return this.state.animValue.interpolate({ inputRange: [-0.1, 0, 1, 1.1], outputRange: [from, from, to, to] }); } else { return this.state.animValue.interpolate({ inputRange: [0, 1], outputRange: [from, to] }); } }; /** * Renders a single animation clone onto the screen. */ _renderAnimationClone = (clone, index = 0) => { const { style, component, isTarget, contentStyle } = clone; const nativeContentType = contentTypeFromString(clone.nativeContentType); const key = `${isTarget ? "target" : "source"}${index + ""}`; /*console.log( `_renderAnimationClone: ${ component.id }, isTarget: ${isTarget}, nativeContentType: ${clone.nativeContentType}` );*/ return ( <MagicMoveClone key={key} component={component} mmContext={component.props.mmContext} options={ MagicMoveClone.Option.VISIBLE | (isTarget ? MagicMoveClone.Option.TARGET : 0) | (this.isDebug ? MagicMoveClone.Option.DEBUG : 0) } style={{ ...style }} contentStyle={contentStyle} nativeContentType={nativeContentType} // blurRadius={blurRadius} > {component.props.children} </MagicMoveClone> ); }; /** * Renders the animated clones using the configured * transition function. */ renderAnimationClones() { const { source, target } = this.props; const { sourceLayout, targetLayout, animValue } = this.state; const sourceStyle = StyleSheet.flatten([source.props.style]); const targetStyle = StyleSheet.flatten([target.props.style]); const transition = this.getTransition(); let nativeContentType = (transition.defaultProps ? transition.defaultProps.nativeContentType : undefined) || "children"; // When a child is being animated, then disable native optimisations // as these use a snapshot which also includes that child. // Instead, that child should not be visible in this clone, // otherwise it would be drawn twice. if ( target.props.useNativeClone && nativeContentType === "snapshot" && source.props.mmContext.administration.isAnimatingChildOf(source) ) { if (this.isDebug) { // eslint-disable-next-line console.debug( `[MagicMove] Disabling native image snapshot for ${ source.debugName }, because its child is also being animated` ); } nativeContentType = "children"; } const from = { isTarget: false, component: source, width: sourceLayout.width, height: sourceLayout.height, imageWidth: sourceLayout.imageWidth, imageHeight: sourceLayout.imageHeight, blurRadius: 0, start: { x: sourceLayout.x, y: sourceLayout.y, scaleX: sourceLayout.scaleX, scaleY: sourceLayout.scaleY, opacity: sourceStyle.opacity !== undefined ? sourceStyle.opacity : 1 }, end: { x: targetLayout.x - (sourceLayout.width - targetLayout.width) / 2, y: targetLayout.y - (sourceLayout.height - targetLayout.height) / 2, scaleX: ((targetLayout.width * targetLayout.scaleX) / (sourceLayout.width * sourceLayout.scaleX)) * sourceLayout.scaleX, scaleY: ((targetLayout.height * targetLayout.scaleY) / (sourceLayout.height * sourceLayout.scaleY)) * sourceLayout.scaleY, opacity: targetStyle.opacity !== undefined ? targetStyle.opacity : 1 }, props: { ...source.props }, style: { ...sourceStyle, position: "absolute", width: sourceLayout.width, height: sourceLayout.height, left: 0, top: 0, transform: [ { translateX: sourceLayout.x }, { translateY: sourceLayout.y }, { scaleX: sourceLayout.scaleX }, { scaleY: sourceLayout.scaleY } ], margin: 0, marginTop: 0, marginBottom: 0, marginLeft: 0, marginRight: 0 }, contentStyle: undefined, nativeContentType }; const to = { isTarget: true, component: target, width: targetLayout.width, height: targetLayout.height, imageWidth: targetLayout.imageWidth, imageHeight: targetLayout.imageHeight, blurRadius: 0, start: { x: sourceLayout.x - (targetLayout.width - sourceLayout.width) / 2, y: sourceLayout.y - (targetLayout.height - sourceLayout.height) / 2, scaleX: ((sourceLayout.width * sourceLayout.scaleX) / (targetLayout.width * targetLayout.scaleX)) * targetLayout.scaleX, scaleY: ((sourceLayout.height * sourceLayout.scaleY) / (targetLayout.height * targetLayout.scaleY)) * targetLayout.scaleY, opacity: sourceStyle.opacity !== undefined ? sourceStyle.opacity : 1 }, end: { x: targetLayout.x, y: targetLayout.y, scaleX: targetLayout.scaleX, scaleY: targetLayout.scaleY, opacity: targetStyle.opacity !== undefined ? targetStyle.opacity : 1 }, props: { ...target.props }, style: { ...targetStyle, position: "absolute", width: targetLayout.width, height: targetLayout.height, left: 0, top: 0, transform: [ { translateX: targetLayout.x }, { translateY: targetLayout.y }, { scaleX: targetLayout.scaleX }, { scaleY: targetLayout.scaleY } ], margin: 0, marginTop: 0, marginBottom: 0, marginLeft: 0, marginRight: 0 }, contentStyle: undefined, nativeContentType }; return transition({ from, to, animValue, render: this._renderAnimationClone, interpolate: this.interpolate }); } /** * Main render function. */ render() { const { sourceLayout, targetLayout } = this.state; return ( <React.Fragment> {this.renderDebugSourcePlaceholder()} {this.renderDebugTargetPlaceholder()} {sourceLayout && targetLayout ? this.renderAnimationClones() : this.renderInitialClones()} </React.Fragment> ); } /** * */ componentDidUpdate() { const { animValue, targetLayout, sourceLayout } = this.state; // // 5. Animate... // if (sourceLayout && targetLayout && !this._isAnimationStarted) { const { source, target, onCompleted } = this.props; this._isAnimationStarted = true; const targetProps = target.props; const { duration, easing, delay } = targetProps; const transition = this.getTransition(); if (this.isDebug) { // eslint-disable-next-line console.debug(`[MagicMove] Animating ${target.debugName}...`); } // Determine whether the native driver should be used const toND = targetProps.useNativeDriver; const fromND = source.props.useNativeDriver; let useNativeDriver = toND === false || fromND === false ? false : toND === undefined && fromND === undefined ? undefined : true; if (useNativeDriver === undefined) { useNativeDriver = transition.defaultProps ? transition.defaultProps.useNativeDriver : false; } Animated.timing(animValue, { toValue: 1, duration: this.isDebug ? 8000 : resolveValue( duration, transition.defaultProps ? transition.defaultProps.duration : undefined, 400 ), delay: resolveValue( delay, transition.defaultProps ? transition.defaultProps.delay : undefined, 0 ), easing: resolveValue( easing, transition.defaultProps ? transition.defaultProps.easing : undefined, defaultEasingFn ), useNativeDriver }).start(() => { if (this.isDebug) { // eslint-disable-next-line console.debug(`[MagicMove] Animating ${target.debugName}... DONE`); } if (this.isDebug) { //eslint-disable-next-line console.debug(`[MagicMove] Showing target ${target.debugName}`); } target.setHidden(false); if (!source.props.keepHidden) { if (this.isDebug) { //eslint-disable-next-line console.debug(`[MagicMove] Showing source ${source.debugName}`); } source.setHidden(false); } onCompleted(); }); } } } export default MagicMoveAnimation;