让物体沿着特定路径执行位移动画,使用了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