让物体沿着特定曲线路径执行动画,react-native-svg+react-native-reanimated

让物体沿着特定路径执行位移动画,使用了react-native-svg + react-native-reanimated+ svg-path-properties,
效果如下:


WechatIMG175.jpg

代码如下:

import React, { useEffect, useImperativeHandle, useRef, useState } from 'react';
import { View } from 'react-native';
import { UIButton } from '../../components';
import Animated, {
  cancelAnimation, Easing, Extrapolation, interpolate,
  runOnUI,
  useAnimatedProps,
  useAnimatedStyle, useSharedValue, withDecay, withDelay, withRepeat,
  withSequence, withSpring, withTiming
} from "react-native-reanimated";
import pTd from '../../utils/PxTdt';
import Svg, { Path, Circle, G, Image } from 'react-native-svg';
import { svgPathProperties } from "svg-path-properties";


/**
 * 贝壳飘上的组件
 */
export default function ShellAnimatView() {
  const [showShell, setShowShell] = useState(false);
  // const [targetY1, setTargetY1] = useState(0);
  // const [targetY2, setTargetY2] = useState(0);
  const [list, setList] = useState([]);
  const listRefList = [];

  useEffect(() => {
    const newList = Array(10).fill().map((item, index) => {
      return { num: index, delayMills: index * 150 }
    })
    setList(newList);

  }, []);

  const parentW = pTd(375);
  const parentH = pTd(300);

  return <View style={{ backgroundColor: '#eee', width: parentW, height: parentH, marginVertical: 50 }}>
    <View style={{
      width: 30,
      height: 30,
      backgroundColor: '#f0f',
      position: 'absolute',
      top: 0,
      left: (parentW - 30) / 2
    }}/>

    <View style={{
      position: 'absolute',
      bottom: 0,
      left: (parentW - 30) / 2,
    }} >
      <UIButton style={{
        backgroundColor: 'blue',
        width: 30,
        height: 30,
      }} onPress={() => {
        if (showShell) {
          setShowShell(false);
        } else {
          // const y3 = targetY1 - targetY2 - 0;
          setShowShell(true);
          list.map((item, index) => {
            listRefList[index].startGo();
          })
        }
      }} />
    </View>

    <View style={{
      opacity: showShell ? 1 : 0,
      position: 'absolute',
    }} pointerEvents={'box-none'}>
      {list.map((item, index) => {
        return <AnimatedItemView key={index}
          ref={ref => listRefList[index] = ref}
          delayMills={item.delayMills}
          parentW={parentW}
          parentH={parentH} />
      })}
    </View>

  </View>
}

//https://docs.swmansion.com/react-native-reanimated/docs/fundamentals/animating-styles-and-props
const AnimatedImage = Animated.createAnimatedComponent(Image);

const AnimatedItemView = React.forwardRef((props, ref) => {
  const { delayMills, parentW, parentH } = props;
  const progress = useSharedValue(0);
  const position = useSharedValue({ x: parentW / 2, y: parentH });

  const pathString = `M ${parentW / 2} ${parentH} Q 0 ${parentH / 2}, ${parentW / 2} 0`;
  // const pathStr = `M ${parentW / 2} ${parentH} L 0 ${parentH / 2} L ${parentW / 2} 0`;

  const progressList = [0, 0.2, 0.4, 0.6, 0.8, 1];
  const imgWidth = 100;
  const imgHeight = 100;

  const animateStyle = useAnimatedStyle(() => {
    return {
      opacity: interpolate(
        progress.value,
        progressList,
        [1, 1, 1, 0.8, 0.6, 0.4],
        Extrapolation.CLAMP
      ),
    }
  })

  const getPositon = (p) => {
    const properties = new svgPathProperties(pathString);
    const pathLength = properties.getTotalLength();
    position.value = properties.getPointAtLength(pathLength * p);
    // console.log("====== getPositon ===p:" + p, " position:" + JSON.stringify(position.value))
  }
  const animatedProps = useAnimatedProps(() => {
    //调用js模块的代码
    runOnJS(getPositon)(progress.value);

    return {
      x: position.value.x,
      y: position.value.y,
      width: interpolate(
        progress.value,
        [0, 1],
        [imgWidth * 1.5, imgWidth * 0.6],
        Extrapolation.EXTEND
      ),
      height: interpolate(
        progress.value,
        [0, 1],
        [imgHeight * 1.5, imgHeight * 0.6],
        Extrapolation.EXTEND
      )
    }
  });

  useImperativeHandle(ref, () => ({
    startGo: (callBack) => {
      progress.value = withDelay(delayMills, withTiming(1, {
        duration: 800,
        easing: Easing.linear,
      }, () => {
        runOnJS(callBack)()
      }));
    }
  }));

  useEffect(() => {
    return () => {
      cancelAnimation(progress);
    }
  }, [])

  return (<Animated.View style={[{
    width: parentW,
    height: parentH,
    position: 'absolute',
    top: 0
  }, animateStyle]}>
    <Svg width={parentW} height={parentH}>
      <Path
        ref={ref}
        id={'lineAB'}
        // d={'M 5 40 q 45 60 85 0 t 100 20'}
        // d="M 20 30 L 180 120"
        // d="M 10 10 H 190 V 40 Z"//画水平线到 (190 10) 这个点, Z是闭合
        d={pathString}
        // stroke={"#0f0"}
        strokeWidth={'6'}
        fill={'none'}
        strokeLinecap={'round'} />
      {/* <Path d={pathStr} stroke="#888" stroke-dasharray="5" fill="none" /> */}
      <AnimatedImage
        href={require('@assets/icons/m_activity/rp.png')}
        animatedProps={animatedProps}
      />
    </Svg>
  </Animated.View>
  );
})

需要添加这个库svg-path-properties ,用于获取路线中坐标值。代码里有引用到其他文件,自己手动修改下就好了。
但是上述使用runOnJS回调到JS线程,会比较慢,所以下面有个不使用react-native-reanimated库,而直接用react-native的Animated组件开发,代码如下:

import React, { useEffect, useImperativeHandle, useRef, useState } from 'react';
import { Animated, Easing, View } from 'react-native';
import pTd from '../../../utils/PxTdt';
import Svg, { Path, Circle, G, Image } from 'react-native-svg';
import { svgPathProperties } from "svg-path-properties";
import { Colors } from '../../../constants';
import { ScreenUtils } from '../../../utils';
import appModel from '../../../models/AppModel';


/**
 * 金币飘上的组件
 */
export const ShellAnimatView = React.forwardRef((props, ref) => {
  const { callBack } = props
  const [showShell, setShowShell] = useState(true);
  const [list, setList] = useState(Array(10).fill().map((item, index) => {
    return { num: index, delayMills: index * 200 }
  }));
  const listRefList = useRef([]);

  const parentW = pTd(375);
  const parentH = pTd(419);//这是线的高度
  const canvasHeight = pTd(419 + 50);//这是画布的高度

  const timeId = useRef(null);
  const maxInitCount = useRef(0);

  //倒计时
  const startTimeOut = () => {
    timeId.current && clearTimeout(timeId.current);
    const t = list.length * 200 + 600 + 500;
    timeId.current = setTimeout(() => {
      if (showShell) {
        setShowShell(false);
        callBack && callBack();
      }
    }, t);
  }

  const startItemGo = () => {
    maxInitCount.current = maxInitCount.current + 1;
    list.map((item, index) => {
      const refList = listRefList.current;
      refList[index]?.startGo(index, (result) => {
        if (index == 1 && result.finished) {// 正常动画后,开启倒计时关闭
          startTimeOut();
        }
        if (index == (list.length - 1)) {//运行到最后一个动画
          if (result.finished) {//是否已经完成
            if (showShell) {
              setShowShell(false);
              callBack && callBack();
              timeId.current && clearTimeout(timeId.current);
            }
          } else if (maxInitCount.current < 3) {// 没有正常完成动画,需要循环
            startItemGo();
          } else {
            startTimeOut();
          }
        }
      });
    })
  }


  //开启贝壳动画
  const startShell = () => {
    startItemGo();
  }

  useEffect(() => {
    startShell();
    return () => {
      timeId.current && clearTimeout(timeId.current);
    }
  }, []);


  return <View style={{
    backgroundColor: Colors.transparent,
    width: parentW,
    height: canvasHeight,
    bottom: pTd(12)
  }}>
    <View style={{
      opacity: showShell ? 1 : 0,
      position: 'absolute',
      top: 0,
    }} pointerEvents={'box-none'}>
      {list.map((item, index) => {
        return <AnimatedItemView key={index}
          ref={ref => {
            const list = listRefList.current;
            list[index] = ref;
          }}
          delayMills={item.delayMills}
          parentW={parentW}
          parentH={parentH}
          canvasHeight={canvasHeight}
          index={index} />
      })}
    </View>
  </View>
})

//https://docs.swmansion.com/react-native-reanimated/docs/fundamentals/animating-styles-and-props
const AnimatedImage = Animated.createAnimatedComponent(Image);

class AnimatedItemView extends React.Component {

  xNum = 0;
  yNum = 0;
  imgWidth = 100;
  imgHeight = 100;
  progress = new Animated.Value(0.31);
  opacityValue = new Animated.Value(0);

  pathString = ``;
  pathStr = ``;
  progressList = [0, 0.31, 0.5, 0.6, 0.8, 1];
  progressList2 = Array(10).fill().map((item, index) => {
    return 0.1 * index
  });
  animateCompos = null;
  xList = [];
  yList = [];

  constructor(props) {
    super(props);

    const { parentW, parentH } = props;
    this.xNum = parentW / 2
    this.yNum = parentH
    this.progress = new Animated.Value(0.31);
    this.pathString = `M ${parentW / 2 + 20} ${parentH} Q -20 ${parentH / 2}, ${ScreenUtils.width / 2 - 50} 0`;
    this.pathStr = `M ${parentW / 2} ${parentH} L 0 ${parentH / 2} L ${parentW / 2} 0`;


    this.progressList2.map((item, index) => {
      const properties = new svgPathProperties(this.pathString);
      const pathLength = properties.getTotalLength();
      const newPosition = properties.getPointAtLength(pathLength * item);
      this.xList.push(newPosition.x);
      this.yList.push(newPosition.y);
    })

    this.progressList = [0, 0.31, 0.5, 0.6, 0.8, 1];
    this.progressList2 = Array(10).fill().map((item, index) => {
      return 0.1 * index
    });

  }

  startGo = (index, callBack) => {
    const { delayMills } = this.props;
    this.animateCompos = Animated.sequence([
      Animated.timing(this.opacityValue, {
        toValue: 1,
        delay: delayMills,
        duration: 10,
        easing: Easing.linear,
        useNativeDriver: true
      }),
      Animated.timing(this.progress, {
        toValue: 1,
        duration: 450,
        easing: Easing.linear,
        useNativeDriver: true
      })
    ]).start((result) => {
      console.log("===== index:" + index + "  " + JSON.stringify(result))
      callBack && callBack(result);
    });
  }

  startShow = () => {
    this.setState({ opacity: 1 });
  }

  componentWillUnmount() {
    this.animateCompos && this.animateCompos.stop();
  }

  render() {
    const { parentW, parentH, canvasHeight } = this.props;
    return (<Animated.View style={[{
      width: parentW,
      height: canvasHeight,
      position: 'absolute',
      top: 0,
      backgroundColor: Colors.transparent,
      opacity: this.opacityValue,
    }]} >
      <Svg width={parentW} height={canvasHeight}>
        <Path
          id={'lineAB'}
          // d={'M 5 40 q 45 60 85 0 t 100 20'}
          // d="M 20 30 L 180 120"
          // d="M 10 10 H 190 V 40 Z"//画水平线到 (190 10) 这个点, Z是闭合
          d={this.pathString}
          // stroke={"#0f0"}
          strokeWidth={'6'}
          fill={'none'}
          strokeLinecap={'round'} />
        {/* <Path d={this.pathStr} stroke="#888" stroke-dasharray="5" fill="none" /> */}
        <AnimatedImage
          href={appModel.country == 'ID' ? require('@assets/icons/m_activity/rp.png') : require('@assets/icons/m_activity/rm.png')}
          x={this.progress.interpolate({
            inputRange: this.progressList2,
            outputRange: this.xList,
            extrapolate: 'clamp',
          })}
          y={this.progress.interpolate({
            inputRange: this.progressList2,
            outputRange: this.yList,
            extrapolate: 'clamp',
          })}
          width={this.progress.interpolate({
            inputRange: [0, 1],
            outputRange: [this.imgWidth * 1.5, this.imgWidth * 0.6],
            extrapolate: 'clamp',
          })}
          height={this.progress.interpolate({
            inputRange: [0, 1],
            outputRange: [this.imgHeight * 1.5, this.imgHeight * 0.6],
            extrapolate: 'clamp',
          })}
          opacity={this.progress.interpolate({
            inputRange: this.progressList,
            outputRange: [1, 1, 1, 0.8, 0.6, 0.4],
            extrapolate: 'clamp',
          })}
        />
      </Svg>
    </Animated.View>
    );
  }

}

// const AnimatedItemView = React.forwardRef((props, ref) => {
//   const { delayMills, parentW, parentH } = props;
//   const xNum = parentW / 2
//   const yNum = parentH
//   const imgWidth = 100;
//   const imgHeight = 100;
//   const progress = new Animated.Value(0.31);

//   const pathString = `M ${parentW / 2 + 20} ${parentH} Q -20 ${parentH / 2}, ${ScreenUtils.width / 2 - 50} 0`;
//   const pathStr = `M ${parentW / 2} ${parentH} L 0 ${parentH / 2} L ${parentW / 2} 0`;

//   // const [xList, setXList] = useState([xNum, xNum, xNum, xNum, xNum, xNum]);
//   // const [yList, setYList] = useState([yNum, yNum, yNum, yNum, yNum, yNum]);
//   const progressList = [0, 0.31, 0.5, 0.6, 0.8, 1];
//   const progressList2 = Array(10).fill().map((item, index) => {
//     return 0.1 * index
//   });
//   let animateCompos = null;

//   useImperativeHandle(ref, () => ({
//     startGo: (index, callBack) => {
//       console.log("======item index ====" + index)
//       progress.setValue(0.31);
//       animateCompos = Animated.timing(progress, {
//         toValue: 1,
//         delay: delayMills,
//         duration: 1000,
//         easing: Easing.linear,
//         useNativeDriver: false
//       }).start((result) => {
//         // console.log("===== index:" + index + "  " + JSON.stringify(result) + '  progress:' + progress.value)
//         callBack && callBack(result);
//         // if (index == 9 && result.finished) {
//         //   callBack();
//         // }
//       });
//     },
//   }));

//   let xL = [];
//   let yL = []
//   progressList2.map((item, index) => {
//     const properties = new svgPathProperties(pathString);
//     const pathLength = properties.getTotalLength();
//     const newPosition = properties.getPointAtLength(pathLength * item);
//     xL.push(newPosition.x);
//     yL.push(newPosition.y);
//   })

//   const [xList, setXList] = useState(xL);
//   const [yList, setYList] = useState(yL);

//   useEffect(() => {
//     return () => {
//       animateCompos && animateCompos.stop();
//     }
//   }, [])

//   return (<Animated.View style={[{
//     width: parentW,
//     height: parentH,
//     position: 'absolute',
//     top: 0,
//     backgroundColor: Colors.transparent,
//     opacity: progress.interpolate({
//       inputRange: [0, 0.31, 0.32, 1],
//       outputRange: [0, 0, 1, 1],
//       extrapolate: 'clamp',
//     })
//   }]}
//   // onLayout={({ nativeEvent }) => {
//   //   console.log("==== nativeEvent ===index:" + props.index, nativeEvent.layout)
//   // }}
//   >
//     <Svg width={parentW} height={parentH}>
//       <Path
//         ref={ref}
//         id={'lineAB'}
//         // d={'M 5 40 q 45 60 85 0 t 100 20'}
//         // d="M 20 30 L 180 120"
//         // d="M 10 10 H 190 V 40 Z"//画水平线到 (190 10) 这个点, Z是闭合
//         d={pathString}
//         // stroke={"#0f0"}
//         strokeWidth={'6'}
//         fill={'none'}
//         strokeLinecap={'round'} />
//       {/* <Path d={pathStr} stroke="#888" stroke-dasharray="5" fill="none" /> */}
//       <AnimatedImage
//         href={appModel.country == 'ID' ? require('@assets/icons/m_activity/rp.png') : require('@assets/icons/m_activity/rm.png')}
//         x={progress.interpolate({
//           inputRange: progressList2,
//           outputRange: xList,
//           extrapolate: 'clamp',
//         })}
//         y={progress.interpolate({
//           inputRange: progressList2,
//           outputRange: yList,
//           extrapolate: 'clamp',
//         })}
//         width={progress.interpolate({
//           inputRange: [0, 1],
//           outputRange: [imgWidth * 1.5, imgWidth * 0.6],
//           extrapolate: 'clamp',
//         })}
//         height={progress.interpolate({
//           inputRange: [0, 1],
//           outputRange: [imgHeight * 1.5, imgHeight * 0.6],
//           extrapolate: 'clamp',
//         })}
//         opacity={progress.interpolate({
//           inputRange: progressList,
//           outputRange: [1, 1, 1, 0.8, 0.6, 0.4],
//           extrapolate: 'clamp',
//         })}
//       />
//     </Svg>
//   </Animated.View>
//   );
// })

参考网址有:
画贝塞尔曲线:https://juejin.cn/post/7018952717343129607
路径中添加图片:https://stackoverflow.com/questions/78072730/animate-an-image-or-a-view-along-the-curved-path-created-using-path-imported-f
给svg的组件添加动画属性:
https://docs.swmansion.com/react-native-reanimated/docs/fundamentals/animating-styles-and-props

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

推荐阅读更多精彩内容