You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

React Native Skia:将矩形填充波浪动画改为自定义角度扇形填充

问题描述

我希望将当前的矩形填充波浪动画修改为可自定义角度(例如40度)的扇形填充效果,示例如下:
示例扇形
当前动画效果:
当前动画
我尝试使用<Group clip={...}>实现,但出现了整个扇形抖动的问题,未能成功解决。当前代码如下:

import React from "react";
import {
  Alert,
  Dimensions,
  SafeAreaView,
  View,
  StyleSheet,
  Text as RNText,
  TouchableOpacity,
} from "react-native";

import {
  Skia,
  Canvas,
  Path,
  vec,
  useComputedValue,
  useClockValue,
  useValue,
  useTouchHandler,
  LinearGradient,
  Text,
  useFont,
  rrect,rect,
} from "@shopify/react-native-skia";

import { line, curveBasis } from "d3";

const dimens = Dimensions.get("screen");
const width = 150;
const frequency = 2;
const initialAmplitude = 5;
const verticalShiftConst = 100;
const height = 600;
const horizontalShift = (dimens.width - width) / 2;
const indicatorArray = Array.from({ length: 11 }, (_, i) => i);

export const WaveMeter = () => {
  const verticalShift = useValue(verticalShiftConst);
  const amplitude = useValue(initialAmplitude);
  const clock = useClockValue();


  const touchHandler = useTouchHandler({
    onActive: ({ y }) => {
      if (y > verticalShiftConst) {
        verticalShift.current = Math.min(height, y);
        amplitude.current = Math.max(
          0,
          (height - verticalShift.current) * 0.025
        );
      }
    },
  });

  const createWavePath = (phase = 20) => {
    let points = Array.from({ length: width + horizontalShift }, (_, index) => {
      const angle = 
        ((index - horizontalShift) / width) * (Math.PI * frequency) + phase;
      return [
        index,
        amplitude.current * Math.sin(angle) + verticalShift.current,
      ];
    });

    const shiftedPoints = points.slice(horizontalShift, 300);

    const lineGenerator = line().curve(curveBasis);
    const waveLine = lineGenerator(shiftedPoints);
    const bottomLine = `L${
      width + horizontalShift
    },${height} L${horizontalShift},${height}`;
    const extendedWavePath = `${waveLine} ${bottomLine} Z`;
    return extendedWavePath;
  };

  const animatedPath = useComputedValue(() => {
    const speedFactor = 0.5;
    const current = (clock.current / 225) % 225;
    const start = Skia.Path.MakeFromSVGString(createWavePath(current));
    const end = Skia.Path.MakeFromSVGString(createWavePath(Math.PI * current * speedFactor));
  
    return start.interpolate(end, 0.5);
  }, [clock, verticalShift]);
  

  const trianglePath = useComputedValue(() => {
    return [
      vec(horizontalShift * 2.6, verticalShift.current - 20),
      vec(horizontalShift * 2.6, verticalShift.current + 20),
      vec(horizontalShift * 2.3, verticalShift.current),
    ];
  }, [verticalShift]);


  const font = useFont( require('../../../../shared/assets/fonts/Hammersmith_One/HammersmithOne-Regular.ttf'))

  const gradientStart = useComputedValue(() => {
    return vec(0, verticalShift.current);
  }, [verticalShift]);

  const gradientEnd = useComputedValue(() => {
    return vec(0, verticalShift.current + 150);
  }, [verticalShift]);

  const getLabelYValueOffset = (position) => {
    return verticalShiftConst + 50 * position;
  };

  const getYLabelValue = (position) => {
    return `${(100 - position * 10)/10}`;
  };

  const alertValue = () => {
    const adjustedShift = 
      (verticalShiftConst - verticalShift.current) /
        (height - verticalShiftConst) +
      1;

    Alert.alert("VALUE", `Your value is: ${Math.round(adjustedShift * 10)}`);
  };


  const size = 600
  const r = size / 2;
  const padding = size / 30;
  const innerCircleSize = size - padding * 2;

  const roundedRectangle = 
  rrect(
    rect(padding, padding, innerCircleSize, innerCircleSize),
    r/2,
    r,
  );


  return (
    <SafeAreaView style={styles.container}>
      <Canvas style={styles.canvas} onTouch={touchHandler}>


        <Path path={animatedPath} style="fill">
          <LinearGradient
            start={gradientStart}
            end={gradientEnd}
            colors={['cyan', 'blue']}
          />
        </Path>


      </Canvas>
      <TouchableOpacity style={styles.buttonContainer} onPress={alertValue}>
        <RNText style={styles.buttonText}>GET VALUE</RNText>
      </TouchableOpacity>
    </SafeAreaView>
  );
};

const styles = StyleSheet.create({
  container: {
    flex: 1,
  },
  canvas: {
    flex: 1,
  },
  buttonContainer: {
    height: 60,
    borderRadius: 8,
    backgroundColor: "#FF5349",
    marginHorizontal: 50,
    marginBottom: 20,
    justifyContent: "center",
    alignItems: "center",
  },
  buttonText: {
    color: "white",
    fontSize: 20,
    fontWeight: "bold",
  },
});
解决方案

之前用Group clip出现抖动,是因为裁剪路径和波浪路径的坐标系统不匹配,加上动画插值时的精度误差导致的。下面是修复后的完整代码,支持自定义扇形角度:

import React from "react";
import {
  Alert,
  Dimensions,
  SafeAreaView,
  View,
  StyleSheet,
  Text as RNText,
  TouchableOpacity,
} from "react-native";

import {
  Skia,
  Canvas,
  Path,
  vec,
  useComputedValue,
  useClockValue,
  useValue,
  useTouchHandler,
  LinearGradient,
  Text,
  useFont,
  Group
} from "@shopify/react-native-skia";

import { line, curveBasis } from "d3";

const dimens = Dimensions.get("screen");
const frequency = 3;
const initialAmplitude = 8;
// 自定义扇形角度(单位:度)
const fanAngle = 40;
// 转换为弧度
const fanAngleRad = (fanAngle * Math.PI) / 180;
// 扇形中心和半径
const center = vec(dimens.width / 2, dimens.height / 2);
const radius = 250;
// 控制填充高度的变量
const fillRatio = useValue(0.2);

export const WaveMeter = () => {
  const amplitude = useValue(initialAmplitude);
  const clock = useClockValue();

  const touchHandler = useTouchHandler({
    onActive: ({ y }) => {
      // 根据触摸位置计算填充比例(从顶部到底部对应0到1)
      const ratio = Math.max(0, Math.min(1, (dimens.height - y) / dimens.height));
      fillRatio.current = ratio;
      amplitude.current = Math.max(2, ratio * 15);
    },
  });

  // 创建扇形裁剪路径(固定不变,避免抖动)
  const fanClipPath = useComputedValue(() => {
    const path = Skia.Path.Make();
    path.moveTo(center.x, center.y);
    // 从中心画到扇形起始点(右侧)
    path.lineTo(center.x + radius, center.y);
    // 画扇形弧
    path.arcTo(
      center.x + radius, center.y + radius,
      center.x - radius, center.y + radius,
      radius,
      false,
      true
    );
    // 闭合路径
    path.close();
    return path;
  }, []);

  const createWavePath = (phase) => {
    // 生成扇形弧上的波浪点
    const pointCount = 100;
    const points = Array.from({ length: pointCount }, (_, i) => {
      // 计算当前点在扇形上的角度(从0到fanAngleRad)
      const angle = (i / (pointCount - 1)) * fanAngleRad;
      // 计算基础半径(根据填充比例)
      const baseRadius = radius * fillRatio.current;
      // 计算波浪偏移
      const waveOffset = amplitude.current * Math.sin((angle / fanAngleRad) * Math.PI * frequency + phase);
      // 最终半径
      const finalRadius = baseRadius + waveOffset;
      // 转换为笛卡尔坐标
      const x = center.x + finalRadius * Math.cos(angle);
      const y = center.y + finalRadius * Math.sin(angle);
      return [x, y];
    });

    // 生成波浪路径并闭合到扇形中心
    const lineGenerator = line().curve(curveBasis);
    const waveLine = lineGenerator(points);
    const closePath = `L${center.x},${center.y} Z`;
    return `${waveLine} ${closePath}`;
  };

  const animatedPath = useComputedValue(() => {
    const speedFactor = 0.8;
    const currentPhase = (clock.current / 300) % (Math.PI * 2);
    const startPath = Skia.Path.MakeFromSVGString(createWavePath(currentPhase));
    const endPath = Skia.Path.MakeFromSVGString(createWavePath(currentPhase + Math.PI * speedFactor));
    return startPath.interpolate(endPath, 0.5);
  }, [clock, fillRatio, amplitude]);

  const gradientStart = useComputedValue(() => {
    return vec(center.x, center.y);
  }, []);

  const gradientEnd = useComputedValue(() => {
    return vec(center.x + radius, center.y + radius);
  }, []);

  const alertValue = () => {
    Alert.alert("VALUE", `填充比例: ${Math.round(fillRatio.current * 100)}%`);
  };

  const font = useFont(require('../../../../shared/assets/fonts/Hammersmith_One/HammersmithOne-Regular.ttf'));

  return (
    <SafeAreaView style={styles.container}>
      <Canvas style={styles.canvas} onTouch={touchHandler}>
        {/* 扇形裁剪容器,裁剪路径固定不变 */}
        <Group clip={fanClipPath}>
          {/* 填充波浪 */}
          <Path path={animatedPath} style="fill">
            <LinearGradient
              start={gradientStart}
              end={gradientEnd}
              colors={['cyan', 'blue']}
            />
          </Path>
        </Group>
        {/* 绘制扇形边框 */}
        <Path
          path={fanClipPath}
          style="stroke"
          strokeWidth={2}
          color="#fff"
        />
      </Canvas>
      <TouchableOpacity style={styles.buttonContainer} onPress={alertValue}>
        <RNText style={styles.buttonText}>GET VALUE</RNText>
      </TouchableOpacity>
    </SafeAreaView>
  );
};

const styles = StyleSheet.create({
  container: {
    flex: 1,
    backgroundColor: "#000",
  },
  canvas: {
    flex: 1,
  },
  buttonContainer: {
    height: 60,
    borderRadius: 8,
    backgroundColor: "#FF5349",
    marginHorizontal: 50,
    marginBottom: 20,
    justifyContent: "center",
    alignItems: "center",
  },
  buttonText: {
    color: "white",
    fontSize: 20,
    fontWeight: "bold",
  },
});

关键修改说明

  • 自定义扇形配置:新增fanAngle变量直接设置扇形角度,自动转换为弧度用于计算,同时固定扇形的中心和半径
  • 稳定裁剪路径:用Skia原生Path API创建固定的扇形裁剪路径,只在初始化时生成一次,避免动画中路径变化导致抖动
  • 极坐标波浪生成:把原来的水平坐标转换为扇形上的角度,计算每个点的极坐标位置,让波浪完美贴合扇形弧度
  • 触摸交互适配:调整触摸逻辑,根据触摸位置计算填充比例,控制波浪的基础高度
  • 消除抖动:裁剪路径固定,只让波浪路径做动画插值,彻底解决之前的抖动问题

内容的提问来源于stack exchange,提问作者user19020361

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.29 20:35:53