【React Native】点赞特效动画组件FlowLikeView

一、组件概述

FlowLikeView 是一个用于实现点赞粒子动画效果的 React Native 组件。核心功能为:用户点击底部触发按钮时,多个点赞图标(支持本地/网络图片)会沿随机贝塞尔曲线向上飘动,伴随透明度渐变消失的动画效果。适用于社交应用、短视频等需要点赞反馈的场景。


二、核心特性

  • 粒子动画:点赞图标沿三阶贝塞尔曲线运动,路径随机生成
  • 参数可配:支持自定义动画时长、图标大小、容器尺寸等
  • 交互反馈:触发按钮带有缩放动画,增强点击感知
  • 性能优化:使用 Animated 原生驱动动画,减少 JS 线程压力
  • 灵活扩展:支持自定义触发按钮样式

三、Props 说明

Prop 名称类型是否必传默认值说明
styleStyleProp<ViewStyle>{}容器整体样式
likeAssetsArray<Source | ImageRequireSource> | undefined-点赞图标资源数组(支持本地图片资源或网络图片 URI)
animContainerBottomnumber0动画容器底部边距(用于调整与页面底部的距离)
animContainerWidthnumber-动画容器宽度(需与实际布局宽度一致,用于计算贝塞尔曲线控制点)
animContainerHeightnumber-动画容器高度(需与实际布局高度一致,用于计算贝塞尔曲线控制点)
durationnumber1600单次动画时长(单位:ms,控制图标从起点到终点的总时间)
picSizenumber30点赞图标的尺寸(宽高均为该值,单位:dp)
onLikeTrigger() => void-触发点赞的回调函数(图标开始动画时调用)
renderLikeButton() => React.ReactNode-自定义触发按钮的渲染函数(若不传则使用默认的 “+” 按钮)

四、核心方法(内部使用)

组件内部通过 useCallback 封装了以下核心方法,用于控制动画流程:

1. startAnim

  • 功能:启动点赞动画(生成新图标并开始运动)
  • 触发条件:调用 triggerLike 时自动触发
  • 实现细节
    • 使用 Animated.timing 创建线性动画,通过 Animated.loop 实现循环计数
    • 监听动画值变化,动态更新所有图标的 progress(进度)、opacity(透明度)和位置(通过贝塞尔曲线计算)

2. stopAnim

  • 功能:停止当前所有动画
  • 触发条件:当没有活跃的点赞图标(likes 为空)时自动调用
  • 实现细节:移除动画监听、停止动画并重置状态

3. triggerLike

  • 功能:触发点赞操作(外部调用的核心入口)
  • 触发条件:用户点击底部触发按钮时调用
  • 实现细节
    • 校验 likeAssets 非空后调用 startAnim
    • 生成随机贝塞尔曲线控制点(p1p2)和起止位置(startPointendPoint
    • 创建新的 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;

六、注意事项

  1. 必传参数likeAssetsanimContainerWidthanimContainerHeight 必须传入有效值,否则无法正常显示动画。
  2. 布局测量:组件通过 measureInWindow 测量容器实际尺寸,需确保父容器布局稳定(避免动态变化导致尺寸测量失败)。
  3. 性能优化
    • 若需要大量图标同时动画(如 10 个以上),建议降低 picSize 或减少 likeAssets 数量,避免内存溢出。
    • 动画使用 useNativeDriver: true,但部分样式(如 topleft)依赖 JS 计算,复杂场景下可考虑改用 Animated.ValueXY 优化。
  4. 自定义按钮:通过 renderLikeButton 自定义按钮时,需自行处理点击事件(无需额外绑定 onPress,组件已透传)。
  5. 动画中断:若需要在页面切换时停止动画,可调用组件实例的 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;
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值