一、组件概述
FlowLikeView 是一个用于实现点赞粒子动画效果的 React Native 组件。核心功能为:用户点击底部触发按钮时,多个点赞图标(支持本地/网络图片)会沿随机贝塞尔曲线向上飘动,伴随透明度渐变消失的动画效果。适用于社交应用、短视频等需要点赞反馈的场景。
二、核心特性
- 粒子动画:点赞图标沿三阶贝塞尔曲线运动,路径随机生成
- 参数可配:支持自定义动画时长、图标大小、容器尺寸等
- 交互反馈:触发按钮带有缩放动画,增强点击感知
- 性能优化:使用
Animated原生驱动动画,减少 JS 线程压力 - 灵活扩展:支持自定义触发按钮样式
三、Props 说明
| Prop 名称 | 类型 | 是否必传 | 默认值 | 说明 |
|---|---|---|---|---|
style | StyleProp<ViewStyle> | 否 | {} | 容器整体样式 |
likeAssets | Array<Source | ImageRequireSource> | undefined | 是 | - | 点赞图标资源数组(支持本地图片资源或网络图片 URI) |
animContainerBottom | number | 否 | 0 | 动画容器底部边距(用于调整与页面底部的距离) |
animContainerWidth | number | 是 | - | 动画容器宽度(需与实际布局宽度一致,用于计算贝塞尔曲线控制点) |
animContainerHeight | number | 是 | - | 动画容器高度(需与实际布局高度一致,用于计算贝塞尔曲线控制点) |
duration | number | 否 | 1600 | 单次动画时长(单位:ms,控制图标从起点到终点的总时间) |
picSize | number | 否 | 30 | 点赞图标的尺寸(宽高均为该值,单位:dp) |
onLikeTrigger | () => void | 否 | - | 触发点赞的回调函数(图标开始动画时调用) |
renderLikeButton | () => React.ReactNode | 否 | - | 自定义触发按钮的渲染函数(若不传则使用默认的 “+” 按钮) |
四、核心方法(内部使用)
组件内部通过 useCallback 封装了以下核心方法,用于控制动画流程:
1. startAnim
- 功能:启动点赞动画(生成新图标并开始运动)
- 触发条件:调用
triggerLike时自动触发 - 实现细节:
- 使用
Animated.timing创建线性动画,通过Animated.loop实现循环计数 - 监听动画值变化,动态更新所有图标的
progress(进度)、opacity(透明度)和位置(通过贝塞尔曲线计算)
- 使用
2. stopAnim
- 功能:停止当前所有动画
- 触发条件:当没有活跃的点赞图标(
likes为空)时自动调用 - 实现细节:移除动画监听、停止动画并重置状态
3. triggerLike
- 功能:触发点赞操作(外部调用的核心入口)
- 触发条件:用户点击底部触发按钮时调用
- 实现细节:
- 校验
likeAssets非空后调用startAnim - 生成随机贝塞尔曲线控制点(
p1、p2)和起止位置(startPoint、endPoint) - 创建新的
LikeItem对象并添加到likes状态中
- 校验
五、使用示例
import React from 'react';
import { View, StyleSheet } from 'react-native';
import FlowLikeView from './FlowLikeView';
const App = () => {
// 点赞图标资源(本地或网络)
const likeAssets = [
require('./assets/like1.png'),
require('./assets/like2.png'),
{ uri: 'https://example.com/like3.png' },
];
return (
<View style={styles.container}>
{/* 其他页面内容 */}
{/* 点赞动画组件 */}
<FlowLikeView
animContainerWidth={375} // 假设页面宽度为 375dp
animContainerHeight={600} // 动画区域高度(根据实际布局调整)
likeAssets={likeAssets}
picSize={40} // 图标尺寸 40dp
animContainerBottom={50} // 底部边距 50dp(避免被底部导航遮挡)
duration={2000} // 动画时长 2000ms
onLikeTrigger={() => {
console.log('用户触发了点赞!');
}}
// 自定义触发按钮(可选)
renderLikeButton={() => (
<View style={styles.customButton}>
<Text style={styles.customButtonText}>点赞</Text>
</View>
)}
/>
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#f0f0f0',
},
customButton: {
width: 60,
height: 60,
borderRadius: 30,
backgroundColor: '#ff4757',
alignItems: 'center',
justifyContent: 'center',
},
customButtonText: {
color: 'white',
fontSize: 18,
fontWeight: 'bold',
},
});
export default App;
六、注意事项
- 必传参数:
likeAssets、animContainerWidth、animContainerHeight必须传入有效值,否则无法正常显示动画。 - 布局测量:组件通过
measureInWindow测量容器实际尺寸,需确保父容器布局稳定(避免动态变化导致尺寸测量失败)。 - 性能优化:
- 若需要大量图标同时动画(如 10 个以上),建议降低
picSize或减少likeAssets数量,避免内存溢出。 - 动画使用
useNativeDriver: true,但部分样式(如top、left)依赖 JS 计算,复杂场景下可考虑改用Animated.ValueXY优化。
- 若需要大量图标同时动画(如 10 个以上),建议降低
- 自定义按钮:通过
renderLikeButton自定义按钮时,需自行处理点击事件(无需额外绑定onPress,组件已透传)。 - 动画中断:若需要在页面切换时停止动画,可调用组件实例的
stopAnim方法(需通过ref获取)。
七、源码
import React, {useCallback, useEffect, useRef, useState} from 'react';
import {
Animated,
Easing,
Image,
StyleProp,
StyleSheet,
Text,
TouchableOpacity,
View,
ViewStyle,
} from 'react-native';
import {ImageSourcePropType} from 'react-native/Libraries/Image/Image';
type LikeItem = {
id: number;
image: ImageSourcePropType | string;
left: number;
top: number;
progress: number;
opacity: number;
startPoint: {x: number; y: number};
endPoint: {x: number; y: number};
p1: {x: number; y: number};
p2: {x: number; y: number};
};
interface FlowLikeViewProps {
style?: StyleProp<ViewStyle>;
/** 点赞图标资源数组(本地或网络) */
likeAssets: Array<ImageSourcePropType> | undefined;
/** 底部边距(默认0) */
animContainerBottom?: number;
/** 动画容器宽度 */
animContainerWidth: number;
/** 动画容器高度 */
animContainerHeight: number;
/** 动画时长(默认1600ms) */
duration?: number;
/** 触发点赞的回调(可选) */
onLikeTrigger?: () => void;
/** 点赞图标大小(30) */
picSize?: number;
/** 自定义点赞按钮 */
renderLikeButton?: () => React.ReactNode;
}
const FlowLikeView: React.FC<FlowLikeViewProps> = ({
style,
likeAssets,
animContainerBottom = 0,
animContainerWidth,
animContainerHeight,
duration = 1600,
picSize = 30,
onLikeTrigger,
renderLikeButton,
}) => {
// 点赞图标数组
const [likes, setLikes] = useState<LikeItem[]>([]);
// 容器引用
const containerRef = useRef<View>(null);
// 是否正在播放
const isAnimating = useRef(false);
// 布局高度
const [rootHeight, setRootHeight] = useState(0);
// 布局宽度
const [rootWidth, setRootWidth] = useState(0);
// 动画
const animatedValue = useRef<Animated.Value>(new Animated.Value(0));
// 上一帧动画的值
const lastValue = useRef(0);
// 按钮缩放
const [btnScale, setBtnScale] = useState(1);
// 点赞按钮是否播放
let isBtnStart = useRef(false);
// 用于计算按钮缩放值的自变量
const curScaleX = useRef(0);
// 缩放值每帧增量
const scaleOff = 0.2;
// 新添加的点赞图标
let newLikeList = useRef<LikeItem[]>([]);
// 点赞图标每帧增量
const interval = 0.03;
/**
* 计算容器高度
*/
const handleContainerLayout = useCallback(() => {
if (containerRef.current) {
containerRef.current.measureInWindow((x, y, width, height) => {
setRootHeight(height);
setRootWidth(width);
});
}
}, []);
/**
* 计算贝塞尔曲线控制点
*/
const generateControlPoints = useCallback(
(value: number) => {
return {
x: rootWidth / 2 - Math.random() * 100,
y: (Math.random() * rootHeight) / value,
};
},
[rootWidth, rootHeight],
);
/**
* 贝塞尔曲线坐标计算(三阶)
*/
const calculateBezierPoint = useCallback(
(
t: number,
start: {x: number; y: number},
end: {x: number; y: number},
p1: {x: number; y: number},
p2: {x: number; y: number},
): {x: number; y: number} => {
const u = 1 - t;
const tt = t * t;
const uu = u * u;
const uuu = uu * u;
const ttt = tt * t;
return {
x: uuu * start.x + 3 * uu * t * p1.x + 3 * u * tt * p2.x + ttt * end.x,
y: uuu * start.y + 3 * uu * t * p1.y + 3 * u * tt * p2.y + ttt * end.y,
};
},
[],
);
/**
* 传入自变量:0-1,返回因变量:1-0.8-1
* @param x 输入变量
* @returns 返回值
*/
function sineCurve(x: number): number {
const clampedX = Math.max(0, Math.min(1, x)); // 限制输入范围
return 1 - 0.2 * Math.sin(Math.PI * clampedX);
}
/**
* 开始点赞动画
*/
const startAnim = useCallback(() => {
if (isAnimating.current) return;
isAnimating.current = true;
animatedValue.current.removeAllListeners();
animatedValue.current.addListener(value => {
// 按钮缩放
if (isBtnStart.current) {
curScaleX.current += scaleOff;
const r = curScaleX.current;
if (r >= 1) {
curScaleX.current = 0;
isBtnStart.current = false;
}
setBtnScale(sineCurve(r));
}
// 点赞特效
setLikes(prev => {
const temp: LikeItem[] = [];
// 旧的点赞图标数组
let prev2: LikeItem[] = prev;
// 新的点赞图标数组
prev2.push(...newLikeList.current);
// 清空新的点赞图标数组
newLikeList.current = [];
// 如果输了大于 7,取后面 7 个
if (prev2.length > 7) {
prev2 = prev2.slice(-7);
}
prev2.forEach((item, index) => {
// 进度0-1
item.progress += interval;
// 不透明度1-0
item.opacity = 1 - item.progress;
// 利用贝塞尔公式计算点赞图标位置
const currentPos = calculateBezierPoint(
item.progress,
item.startPoint,
item.endPoint,
item.p1,
item.p2,
);
item.top = currentPos.y;
item.left = currentPos.x;
if (item.progress < 1) {
temp.push(item);
}
});
return [...temp];
});
});
const anim = Animated.timing(animatedValue.current, {
toValue: 100,
duration: duration * 100,
easing: Easing.linear,
useNativeDriver: true, // 尽可能使用原生驱动
});
Animated.loop(Animated.sequence([anim])).start();
console.log('startAnim');
}, [isAnimating, calculateBezierPoint, duration]);
/**
* 停止点赞动画
*/
const stopAnim = useCallback(() => {
lastValue.current = 0;
animatedValue.current.removeAllListeners();
animatedValue.current.stopAnimation();
isAnimating.current = false;
console.log('stopAnim');
}, []);
/**
* 监听点赞图标数组变化,如果数组为空,则停止动画
*/
useEffect(() => {
if (likes.length === 0) {
stopAnim();
animatedValue.current.removeAllListeners();
}
}, [likes, stopAnim]);
/**
* 触发点赞
*/
const triggerLike = useCallback(() => {
if (!likeAssets || likeAssets.length === 0) return;
isBtnStart.current = true;
startAnim();
// 控制点
const p1 = generateControlPoints(1);
const p2 = generateControlPoints(2);
// 定义精确的起始和结束位置
const startPoint = {
x: rootWidth / 2 - picSize / 2,
y: rootHeight - animContainerBottom - picSize, // 从底部下方开始
};
const endPoint = {
x: rootWidth / 2 + (Math.random() > 0.5 ? 1 : -1) * 100,
y: 0, // 到顶部外消失
};
const newLike: LikeItem = {
id: Date.now(),
image: likeAssets[Math.floor(Math.random() * likeAssets.length)],
left: 0,
top: 0,
progress: 0,
opacity: 0,
startPoint: startPoint,
endPoint: endPoint,
p1: p1,
p2: p2,
};
newLikeList.current.push(newLike);
onLikeTrigger?.();
}, [
likeAssets,
onLikeTrigger,
startAnim,
animContainerBottom,
picSize,
rootWidth,
rootHeight,
generateControlPoints,
]);
return (
<View
style={[
styles.fullScreenContainer,
{width: animContainerWidth || 'auto'},
style,
]}>
<View
ref={containerRef}
style={[
styles.animationLayer,
{
height: animContainerHeight || 'auto',
width: animContainerWidth || 'auto',
},
]}
onLayout={handleContainerLayout}>
{likes.map((like, index) => (
<Animated.View
key={like.id}
style={[
styles.likeItem,
{
width: picSize,
height: picSize,
top: like.top,
left: like.left,
opacity: like.opacity,
},
]}>
{typeof like.image === 'string' ? (
<Image
source={{uri: like.image}}
style={styles.likeIcon}
resizeMode="contain"
/>
) : (
<Image
source={like.image}
style={styles.likeIcon}
resizeMode="contain"
/>
)}
</Animated.View>
))}
</View>
{/* 底部触发按钮 */}
<TouchableOpacity
style={[styles.triggerButton, {transform: [{scale: btnScale}]}]}
onPress={triggerLike}
activeOpacity={1}>
{renderLikeButton ? (
renderLikeButton()
) : (
<View style={styles.triggerIcon}>
<Text style={styles.triggerIconText}>+</Text>
</View>
)}
</TouchableOpacity>
</View>
);
};
const styles = StyleSheet.create({
fullScreenContainer: {
display: 'flex',
flexDirection: 'column',
width: 'auto',
},
animationLayer: {},
likeItem: {
position: 'absolute',
zIndex: 1000,
},
likeIcon: {
width: '100%',
height: '100%',
},
triggerButton: {
alignItems: 'center',
},
triggerIcon: {
width: 50,
height: 50,
alignItems: 'center',
justifyContent: 'center',
},
triggerIconText: {
color: 'white',
},
});
export default FlowLikeView;
2426

被折叠的 条评论
为什么被折叠?



