稀土掘金技术社区 09月27日 09:55
SVG 动态水波纹进度条实现
index_new5.html
../../../zaker_core/zaker_tpl_static/wap/tpl_guoji1.html

 

本文介绍了如何使用 HTML、SVG 和 JavaScript 创建一个具有动态水波纹效果的进度条。核心技术在于利用 SVG 的滤镜功能,通过 `feTurbulence` 生成随机噪声图,再用 `feDisplacementMap` 将噪声图应用于圆环,从而产生波纹效果。文章还提供了完整的 HTML、CSS 和 JavaScript 代码示例,并展示了如何通过 JavaScript 控制滤镜参数,实现进度条的交互式调整,包括进度、波纹幅度和频率等。

💡 **SVG 滤镜实现水波纹效果**:文章的核心在于使用 SVG 的 `filter` 元素,特别是 `feTurbulence` 和 `feDisplacementMap` 组合。`feTurbulence` 生成动态的随机噪声,模拟水面纹理;`feDisplacementMap` 则根据此噪声图扭曲和移动圆环的像素点,从而创造出逼真的水波纹动态效果,这是实现此视觉特效的关键。

⚙️ **基础图形与进度控制**:通过 `` 标签内的 `` 元素绘制出背景圆环和进度圆环。利用 CSS 的 `stroke-dasharray` 和 `stroke-dashoffset` 属性,可以将圆环的描边长度与进度百分比关联起来,实现进度条的动态更新,使整体效果既有视觉吸引力,又能清晰展示进度信息。

🎛️ **JavaScript 交互式控制**:文章提供了 JavaScript 代码,用于监听 HTML 滑块(range input)的变化。用户可以通过拖动滑块来实时调整 SVG 滤镜中的关键参数,如 `baseFrequency`(控制波纹的精细度)和 `scale`(控制波纹的幅度)。这种交互性使得开发者和用户能够根据需求自定义波纹的视觉表现,增加了组件的灵活性和可玩性。

原创 拜无忧 2025-09-27 08:31 重庆

点击关注公众号,“技术干货” 及时达!

「绘制基础图形 (HTML/SVG)」

我们先用 <svg> 标签画出两个叠在一起的圆环(<circle>):一个作为灰色的背景,另一个作为亮黄色的进度条。

通过 CSS 的 stroke-dasharray 和 stroke-dashoffset 属性,我们可以精确地控制黄色圆环显示多少,从而实现进度条功能。

「创建 “水波纹” 滤镜 (SVG Filter)」

这是最关键的一步。我们在 SVG 中定义了一个 <filter>

滤镜内部,首先使用 feTurbulence 标签生成一张看不见的、类似云雾或大理石纹理的「随机噪声图」。这个噪声图本身就是动态变化的。

然后,使用 feDisplacementMap 标签,将这张噪声图作为一张 “置换地图”,应用到我们第一步画的圆环上。它会根据噪声图的明暗信息,去「扭曲和移动」圆环上的每一个点,于是就产生了我们看到的波纹效果。

「添加交互控制 (JavaScript)」

最后,我们用 JavaScript 监听几个 HTML 滑块(<input type="range">)的变化。

当用户拖动滑块时,JS 会实时地去修改 SVG 滤镜中的各种参数,比如 feTurbulence 的 baseFrequency(波纹的频率)和 feDisplacementMap 的 scale(波纹的幅度),让用户可以自由定制喜欢的效果。

<!DOCTYPE html>
<html lang="zh">
<head>
    <meta charset="UTF-8">
    <meta >
    <title>动态水波纹边框</title>
    <style>
        :root {
            --progress50/* 进度: 0-100 */
            --base-frequency-x0.05;
            --base-frequency-y0.05;
            --num-octaves2;
            --scale15;
            --active-color#ceff00;
            --inactive-color#333;
            --bg-color#1a1a1a;
            --text-color#ceff00;
        }

        body {
            display: flex;
            justify-content: center;
            align-items: center;
            min-height100vh;
            background-colorvar(--bg-color);
            font-family: Arial, sans-serif;
            margin0;
            flex-direction: column;
            gap40px;
        }

        .progress-container {
            width250px;
            height250px;
            position: relative;
        }

        .progress-ring {
            width100%;
            height100%;
            transformrotate(-90deg); /* 让起点在顶部 */
            filterurl(#wobble-filter); /* 应用SVG滤镜 */
        }

        .progress-ring__circle {
            fill: none;
            stroke-width20;
            transition: stroke-dashoffset 0.35s;
        }

        .progress-ring__background {
            stroke: var(--inactive-color);
        }

        .progress-ring__progress {
            stroke: var(--active-color);
            stroke-linecap: round; /* 圆角端点 */
        }

        .progress-text {
            position: absolute;
            top50%;
            left50%;
            transformtranslate(-50%, -50%);
            colorvar(--text-color);
            font-size50px;
            font-weight: bold;
        }

        .controls {
            display: flex;
            flex-direction: column;
            gap15px;
            background#2c2c2c;
            padding20px;
            border-radius8px;
            color: white;
            width300px;
        }

        .control-group {
            display: flex;
            flex-direction: column;
            gap5px;
        }

        .control-group label {
          display: flex;
          justify-content: space-between;
        }

        input[type="range"] {
            width100%;
        }
    </style>
</head>
<body>

    <div class="progress-container">
        <svg class="progress-ring" viewBox="0 0 120 120">
            <!-- 背景圆环 -->
            <circle class="progress-ring__circle progress-ring__background" r="50" cx="60" cy="60"></circle>
            <!-- 进度圆环 -->
            <circle class="progress-ring__circle progress-ring__progress" r="50" cx="60" cy="60"></circle>
        </svg>
        <div class="progress-text">50%</div>
    </div>

    <!-- SVG 滤镜定义 -->
    <svg width="0" height="0">
        <filter id="wobble-filter">
            <!-- 
                feTurbulence: 创建湍流噪声
                - baseFrequency: 噪声的基础频率,值越小,波纹越大越平缓
                - numOctaves: 噪声的倍频数,值越大,细节越多越锐利
                - type: 'fractalNoise' 或 'turbulence'
            -->
            <feTurbulence id="turbulence" type="fractalNoise" baseFrequency="0.05 0.05" numOctaves="2" result="turbulenceResult">
                <!-- 动画:让 turbulence 的基础频率动起来,模拟流动效果 -->
                <animate attribute></animate>
            </feTurbulence>

            <!-- 
                feDisplacementMap: 用一个图像(这里是上面的噪声)来置换另一个图像
                - in: 输入源,这里是 SourceGraphic,即我们的圆环
                - in2: 置换图源,这里是上面生成的噪声
                - scale: 置换的缩放因子,即波纹的幅度/强度
                - xChannelSelector / yChannelSelector: 指定使用噪声的哪个颜色通道进行置换
            -->
            <feDisplacementMap in="SourceGraphic" in2="turbulenceResult" scale="15" xChannelSelector="R" yChannelSelector="G"></feDisplacementMap>
        </filter>
    </svg>

    <div class="controls">
        <div class="control-group">
            <label for="progress">进度: <span id="progress-value">50%</span></label>
            <input type="range" id="progress" min="0" max="100" value="50">
        </div>
        <div class="control-group">
            <label for="scale">波纹幅度 (scale): <span id="scale-value">15</span></label>
            <input type="range" id="scale" min="0" max="50" value="15" step="1">
        </div>
        <div class="control-group">
            <label for="frequency">波纹频率 (baseFrequency): <span id="frequency-value">0.05</span></label>
            <input type="range" id="frequency" min="0.01" max="0.2" value="0.05" step="0.01">
        </div>
         <div class="control-group">
            <label for="octaves">波纹细节 (numOctaves): <span id="octaves-value">2</span></label>
            <input type="range" id="octaves" min="1" max="10" value="2" step="1">
        </div>
    </div>

    <script>
        const root = document.documentElement;
        const progressCircle = document.querySelector('.progress-ring__progress');
        const progressText = document.querySelector('.progress-text');
        const radius = progressCircle.r.baseVal.value;
        const circumference = 2 * Math.PI * radius;

        progressCircle.style.strokeDasharray = `${circumference} ${circumference}`;

        function setProgress(percent) {
            const offset = circumference - (percent / 100) * circumference;
            progressCircle.style.strokeDashoffset = offset;
            progressText.textContent = `${Math.round(percent)}%`;
            root.style.setProperty('--progress', percent);
        }

        // --- 控制器逻辑 ---
        const progressSlider = document.getElementById('progress');
        const scaleSlider = document.getElementById('scale');
        const frequencySlider = document.getElementById('frequency');
        const octavesSlider = document.getElementById('octaves');

        const progressValue = document.getElementById('progress-value');
        const scaleValue = document.getElementById('scale-value');
        const frequencyValue = document.getElementById('frequency-value');
        const octavesValue = document.getElementById('octaves-value');

        const turbulence = document.getElementById('turbulence');
        const displacementMap = document.querySelector('feDisplacementMap');

        progressSlider.addEventListener('input'(e) => {
            const value = e.target.value;
            setProgress(value);
            progressValue.textContent = `${value}%`;
        });

        scaleSlider.addEventListener('input'(e) => {
            const value = e.target.value;
            displacementMap.setAttribute('scale', value);
            scaleValue.textContent = value;
        });

        frequencySlider.addEventListener('input'(e) => {
            const value = e.target.value;
            turbulence.setAttribute('baseFrequency'`${value} ${value}`);
            frequencyValue.textContent = value;
        });

        octavesSlider.addEventListener('input'(e) => {
            const value = e.target.value;
            turbulence.setAttribute('numOctaves', value);
            octavesValue.textContent = value;
        });

        // 初始化
        setProgress(50);
    </script>

</body>
</html>

第二版本 - 带进度条边框宽度版本

<!DOCTYPE html>
<html lang="zh">
<head>
    <meta charset="UTF-8">
    <meta >
    <title>动态水波纹边框</title>
    <style>
        :root {
            --progress50/* 进度: 0-100 */
            --stroke-width20/* 边框宽度 */
            --base-frequency-x0.05;
            --base-frequency-y0.05;
            --num-octaves2;
            --scale15;
            --active-color#ceff00;
            --inactive-color#333;
            --bg-color#1a1a1a;
            --text-color#ceff00;
        }

        body {
            display: flex;
            justify-content: center;
            align-items: center;
            min-height100vh;
            background-colorvar(--bg-color);
            font-family: Arial, sans-serif;
            margin0;
            flex-direction: column;
            gap40px;
        }

        .progress-container {
            width250px;
            height250px;
            position: relative;
        }

        .progress-ring {
            width100%;
            height100%;
            transformrotate(-90deg); /* 让起点在顶部 */
            filterurl(#wobble-filter); /* 应用SVG滤镜 */
        }

        .progress-ring__circle {
            fill: none;
            stroke-widthvar(--stroke-width);
            transition: stroke-dashoffset 0.35s;
        }

        .progress-ring__background {
            stroke: var(--inactive-color);
        }

        .progress-ring__progress {
            stroke: var(--active-color);
            stroke-linecap: round; /* 圆角端点 */
        }

        .progress-text {
            position: absolute;
            top50%;
            left50%;
            transformtranslate(-50%, -50%);
            colorvar(--text-color);
            font-size50px;
            font-weight: bold;
        }

        .controls {
            display: flex;
            flex-direction: column;
            gap15px;
            background#2c2c2c;
            padding20px;
            border-radius8px;
            color: white;
            width300px;
        }

        .control-group {
            display: flex;
            flex-direction: column;
            gap5px;
        }

        .control-group label {
          display: flex;
          justify-content: space-between;
        }

        input[type="range"] {
            width100%;
        }
    </style>
</head>
<body>

    <div class="progress-container">
        <svg class="progress-ring" viewBox="0 0 120 120">
            <!-- 背景圆环 -->
            <circle class="progress-ring__circle progress-ring__background" r="50" cx="60" cy="60"></circle>
            <!-- 进度圆环 -->
            <circle class="progress-ring__circle progress-ring__progress" r="50" cx="60" cy="60"></circle>
        </svg>
        <div class="progress-text">50%</div>
    </div>

    <!-- SVG 滤镜定义 -->
    <svg width="0" height="0">
        <filter id="wobble-filter">
            <!-- 
                feTurbulence: 创建湍流噪声
                - baseFrequency: 噪声的基础频率,值越小,波纹越大越平缓
                - numOctaves: 噪声的倍频数,值越大,细节越多越锐利
                - type: 'fractalNoise' 或 'turbulence'
            -->
            <feTurbulence id="turbulence" type="fractalNoise" baseFrequency="0.05 0.05" numOctaves="2" result="turbulenceResult">
                <!-- 动画:让 turbulence 的基础频率动起来,模拟流动效果 -->
                <animate attribute></animate>
            </feTurbulence>

            <!-- 
                feDisplacementMap: 用一个图像(这里是上面的噪声)来置换另一个图像
                - in: 输入源,这里是 SourceGraphic,即我们的圆环
                - in2: 置换图源,这里是上面生成的噪声
                - scale: 置换的缩放因子,即波纹的幅度/强度
                - xChannelSelector / yChannelSelector: 指定使用噪声的哪个颜色通道进行置换
            -->
            <feDisplacementMap in="SourceGraphic" in2="turbulenceResult" scale="15" xChannelSelector="R" yChannelSelector="G"></feDisplacementMap>
        </filter>
    </svg>

    <div class="controls">
        <div class="control-group">
            <label for="progress">进度: <span id="progress-value">50%</span></label>
            <input type="range" id="progress" min="0" max="100" value="50">
        </div>
        <div class="control-group">
            <label for="stroke-width">边框宽度: <span id="stroke-width-value">20</span></label>
            <input type="range" id="stroke-width" min="1" max="50" value="20" step="1">
        </div>
        <div class="control-group">
            <label for="scale">波纹幅度 (scale): <span id="scale-value">15</span></label>
            <input type="range" id="scale" min="0" max="50" value="15" step="1">
        </div>
        <div class="control-group">
            <label for="frequency">波纹频率 (baseFrequency): <span id="frequency-value">0.05</span></label>
            <input type="range" id="frequency" min="0.01" max="0.2" value="0.05" step="0.01">
        </div>
         <div class="control-group">
            <label for="octaves">波纹细节 (numOctaves): <span id="octaves-value">2</span></label>
            <input type="range" id="octaves" min="1" max="10" value="2" step="1">
        </div>
    </div>

    <script>
        const root = document.documentElement;
        const progressCircle = document.querySelector('.progress-ring__progress');
        const progressText = document.querySelector('.progress-text');
        const radius = progressCircle.r.baseVal.value;
        const circumference = 2 * Math.PI * radius;

        progressCircle.style.strokeDasharray = `${circumference} ${circumference}`;

        function setProgress(percent) {
            const offset = circumference - (percent / 100) * circumference;
            progressCircle.style.strokeDashoffset = offset;
            progressText.textContent = `${Math.round(percent)}%`;
            root.style.setProperty('--progress', percent);
        }

        // --- 控制器逻辑 ---
        const progressSlider = document.getElementById('progress');
        const strokeWidthSlider = document.getElementById('stroke-width');
        const scaleSlider = document.getElementById('scale');
        const frequencySlider = document.getElementById('frequency');
        const octavesSlider = document.getElementById('octaves');

        const progressValue = document.getElementById('progress-value');
        const strokeWidthValue = document.getElementById('stroke-width-value');
        const scaleValue = document.getElementById('scale-value');
        const frequencyValue = document.getElementById('frequency-value');
        const octavesValue = document.getElementById('octaves-value');

        const turbulence = document.getElementById('turbulence');
        const displacementMap = document.querySelector('feDisplacementMap');

        progressSlider.addEventListener('input'(e) => {
            const value = e.target.value;
            setProgress(value);
            progressValue.textContent = `${value}%`;
        });

        strokeWidthSlider.addEventListener('input'(e) => {
            const value = e.target.value;
            root.style.setProperty('--stroke-width', value);
            strokeWidthValue.textContent = value;
        });

        scaleSlider.addEventListener('input'(e) => {
            const value = e.target.value;
            displacementMap.setAttribute('scale', value);
            scaleValue.textContent = value;
        });

        frequencySlider.addEventListener('input'(e) => {
            const value = e.target.value;
            turbulence.setAttribute('baseFrequency'`${value} ${value}`);
            frequencyValue.textContent = value;
        });

        octavesSlider.addEventListener('input'(e) => {
            const value = e.target.value;
            turbulence.setAttribute('numOctaves', value);
            octavesValue.textContent = value;
        });

        // 初始化
        setProgress(50);
    </script>

</body>
</html>

vue3 版本

<template>
  <div class="progress-container" :style="containerStyle">
    <svg class="progress-ring" viewBox="0 0 120 120">
      <!-- 背景圆环 -->
      <circle
        class="progress-ring__circle progress-ring__background"
        :style="{ stroke: inactiveColor }"
        :r="radius"
        cx="60"
        cy="60"
      ></circle>
      <!-- 进度圆环 -->
      <circle
        class="progress-ring__circle progress-ring__progress"
        :style="{ 
          stroke: activeColor, 
          strokeDashoffset: strokeDashoffset 
        }"
        :r="radius"
        cx="60"
        cy="60"
      ></circle>
    </svg>
    <div class="progress-text" :style="{ color: textColor }">
      {{ Math.round(progress) }}%
    </div>

    <!-- SVG 滤镜定义 (在组件内部,不会污染全局) -->
    <svg width="0" height="0" style="position: absolute">
        <filter :id="filterId">
            <feTurbulence 
              ref="turbulenceFilter"
              type="fractalNoise" 
              :baseFrequency="`${frequency} ${frequency}`" 
              :numOctaves="octaves" 
              result="turbulenceResult">
                <animate 
                  attribute 
                  dur="10s" 
                  :values="`${frequency} ${frequency};${frequency + 0.03} ${frequency - 0.03};${frequency} ${frequency};`" 
                  repeatCount="indefinite">
                </animate>
            </feTurbulence>
            <feDisplacementMap 
              ref="displacementMapFilter"
              in="SourceGraphic" 
              in2="turbulenceResult" 
              :scale="scale" 
              xChannelSelector="R" 
              yChannelSelector="G">
            </feDisplacementMap>
        </filter>
    </svg>
  </div>
</template>

<script setup>
import { computed, ref, watchEffect, onMounted } from 'vue';

// 定义组件接收的 Props
const props = defineProps({
  size: { typeNumberdefault250 },
  progress: { typeNumberdefault50validator(v) => v >= 0 && v <= 100 },
  strokeWidth: { typeNumberdefault20 },
  scale: { typeNumberdefault15 },
  frequency: { typeNumberdefault0.05 },
  octaves: { typeNumberdefault2 },
  activeColor: { typeStringdefault'#ceff00' },
  inactiveColor: { typeStringdefault'#333' },
  textColor: { typeStringdefault'#ceff00' },
});

// 生成一个唯一的 ID,避免多个组件实例之间滤镜冲突
const filterId = `wobble-filter-${Math.random().toString(36).substring(7)}`;

// --- 响应式计算 ---
const radius = 50;
const circumference = 2 * Math.PI * radius;

// 计算进度条的偏移量
const strokeDashoffset = computed(() => {
  return circumference - (props.progress / 100) * circumference;
});

// 计算容器样式
const containerStyle = computed(() => ({
  width`${props.size}px`,
  height`${props.size}px`,
}));

// --- DOM 引用 (虽然Vue会自动更新属性,但保留引用以备将来更复杂的操作) ---
const turbulenceFilter = ref(null);
const displacementMapFilter = ref(null);

onMounted(() => {
  // 可以在这里访问 DOM 元素
  // console.log(turbulenceFilter.value);
});
</script>

<style scoped>
.progress-container {
  position: relative;
  display: inline-block; /* 改为 inline-block 以适应 size prop */
}

.progress-ring {
  width100%;
  height100%;
  transformrotate(-90deg);
  /* 动态应用滤镜 */
  filterv-bind('`url(#${filterId})`');
}

.progress-ring__circle {
  fill: none;
  stroke-widthv-bind('strokeWidth');
  transition: stroke-dashoffset 0.35s ease;
  stroke-dasharray: v-bind('`${circumference} ${circumference}`');
}

.progress-ring__progress {
  stroke-linecap: round;
}

.progress-text {
  position: absolute;
  top50%;
  left50%;
  transformtranslate(-50%, -50%);
  font-sizev-bind('`${size * 0.2}px`'); /* 字体大小与容器大小关联 */
  font-weight: bold;
}
</style>

react 版本公共组件

import React, { useState, useMemo, useId } from 'react';

// --- WavyProgress Component ---
// 将 WavyProgress 组件直接定义在 App.jsx 文件中,以解决导入问题
const WavyProgress = ({
  size = 250,
  progress = 50,
  strokeWidth = 20,
  scale = 15,
  frequency = 0.05,
  octaves = 2,
  activeColor = '#ceff00',
  inactiveColor = '#333',
  textColor = '#ceff00',
}) => {
  const filterId = useId();

  const radius = 50;
  const circumference = 2 * Math.PI * radius;

  const strokeDashoffset = useMemo(() => {
    return circumference - (progress / 100) * circumference;
  }, [progress, circumference]);

  const containerStyle = useMemo(() => ({
    position: 'relative',
    width: `${size}px`,
    height: `${size}px`,
  }), [size]);

  const textStyle = useMemo(() => ({
    color: textColor,
    position: 'absolute',
    top: '50%',
    left: '50%',
    transform: 'translate(-50%, -50%)',
    fontSize: `${size * 0.2}px`,
    fontWeight: 'bold',
  }), [textColor, size]);

  const circleStyle = {
      fill: 'none',
      strokeWidth: strokeWidth,
      transition: 'stroke-dashoffset 0.35s ease',
      strokeDasharray: `${circumference} ${circumference}`,
  };

  return (
    <div style={containerStyle}>
      <svg
        class
        style={{
          width: '100%',
          height: '100%',
          transform: 'rotate(-90deg)',
          filter: `url(#${filterId})`,
        }}
        viewBox="0 0 120 120"
      >
        <circle
          class
          style={{ ...circleStylestroke: inactiveColor }}
          r={radius}
          cx="60"
          cy="60"
        />
        <circle
          class
          style={{
            ...circleStyle,
            stroke: activeColor,
            strokeDashoffset: strokeDashoffset,
            strokeLinecap: 'round',
          }}
          r={radius}
          cx="60"
          cy="60"
        />
      </svg>
      <div style={textStyle}>
        {`${Math.round(progress)}%`}
      </div>

      <svg width="0" height="0" style={{ position: 'absolute' }}>
        <filter id={filterId}>
          <feTurbulence
            type="fractalNoise"
            baseFrequency={`${frequency} ${frequency}`}
            numOctaves={octaves}
            result="turbulenceResult"
          >
            <animate
              attribute
              dur="10s"
              values={`${frequency} ${frequency};${frequency + 0.03} ${frequency - 0.03};${frequency} ${frequency};`}
              repeatCount="indefinite"
            />
          </feTurbulence>
          <feDisplacementMap
            in="SourceGraphic"
            in2="turbulenceResult"
            scale={scale}
            xChannelSelector="R"
            yChannelSelector="G"
          />
        </filter>
      </svg>
    </div>
  );
};


// --- App Component ---
// App 组件现在可以直接使用上面的 WavyProgress 组件
const App = () => {
  const [progress, setProgress] = useState(50);
  const [strokeWidth, setStrokeWidth] = useState(20);
  const [scale, setScale] = useState(15);
  const [frequency, setFrequency] = useState(0.05);
  const [octaves, setOctaves] = useState(2);

  // 将 CSS 样式直接嵌入到组件中
  const styles = `
    body {
      background-color: #1a1a1a;
      margin: 0;
      font-family: Arial, sans-serif;
    }

    #app-container {
      display: flex;
      justify-content: center;
      align-items: center;
      min-height: 100vh;
      flex-direction: column;
      gap: 40px;
    }

    .controls {
      display: flex;
      flex-direction: column;
      gap: 15px;
      background: #2c2c2c;
      padding: 20px;
      border-radius: 8px;
      color: white;
      width: 300px;
    }

    .control-group {
      display: flex;
      flex-direction: column;
      gap: 5px;
    }

    .control-group label {
      display: flex;
      justify-content: space-between;
    }

    input[type="range"] {
      width: 100%;
    }
  `;

  return (
    <>
      <style>{styles}</style>
      <div id="app-container">
        <WavyProgress
          progress={progress}
          strokeWidth={strokeWidth}
          scale={scale}
          frequency={frequency}
          octaves={octaves}
        />

        <div class>
          <div class>
            <label>进度: <span>{progress}%</span></label>
            <input
              type="range"
              value={progress}
              onChange={(e) => setProgress(Number(e.target.value))}
              min="0"
              max="100"
            />
          </div>
          <div class>
            <label>边框宽度: <span>{strokeWidth}</span></label>
            <input
              type="range"
              value={strokeWidth}
              onChange={(e) => setStrokeWidth(Number(e.target.value))}
              min="1"
              max="50"
              step="1"
            />
          </div>
          <div class>
            <label>波纹幅度 (scale): <span>{scale}</span></label>
            <input
              type="range"
              value={scale}
              onChange={(e) => setScale(Number(e.target.value))}
              min="0"
              max="50"
              step="1"
            />
          </div>
          <div class>
            <label>波纹频率 (frequency): <span>{frequency.toFixed(2)}</span></label>
            <input
              type="range"
              value={frequency}
              onChange={(e) => setFrequency(Number(e.target.value))}
              min="0.01"
              max="0.2"
              step="0.01"
            />
          </div>
          <div class>
            <label>波纹细节 (octaves): <span>{octaves}</span></label>
            <input
              type="range"
              value={octaves}
              onChange={(e) => setOctaves(Number(e.target.value))}
              min="1"
              max="10"
              step="1"
            />
          </div>
        </div>
      </div>
    </>
  );
};

export default App;


``

# canvas-版本

    <!DOCTYPE html>
    <html lang="zh-CN">
    <head>
        <meta charset="UTF-8">
        <meta >
        <title>笔刷效果环形进度条 (流动方向修正版)</title>
        <script src="https://cdn.tailwindcss.com"></script>
        <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;700&display=swap" rel="stylesheet">
        <style>
            body {
                font-family'Inter', sans-serif;
                -webkit-font-smoothing: antialiased;
                -moz-osx-font-smoothing: grayscale;
            }
            input[type="range"] {
                -webkit-appearance: none;
                appearance: none;
                width100%;
                height8px;
                background#4a5568;
                border-radius5px;
                outline: none;
                opacity0.7;
                transition: opacity .2s;
            }
            input[type="range"]:hover {
                opacity1;
            }
            input[type="range"]::-webkit-slider-thumb {
                -webkit-appearance: none;
                appearance: none;
                width20px;
                height20px;
                background#90eea8;
                cursor: pointer;
                border-radius50%;
            }
            input[type="range"]::-moz-range-thumb {
                width20px;
                height20px;
                background#90eea8;
                cursor: pointer;
                border-radius50%;
            }
        </style>
    </head>
    <body class="bg-gray-900 text-white flex flex-col lg:flex-row items-center justify-center min-h-screen p-4">

        <div class="w-full lg:w-1/2 flex items-center justify-center p-8">
            <canvas id="progressCanvas"></canvas>
        </div>

        <div class="w-full lg:w-1/3 bg-gray-800 p-6 rounded-2xl shadow-2xl space-y-5 border border-gray-700">
            <h2 class="text-2xl font-bold text-center text-green-300 mb-6">配置属性</h2>

            <div class="space-y-2">
                <div class="flex justify-between items-center">
                    <label for="percentage" class="font-medium text-gray-300">百分比</label>
                    <span id="percentageValue" class="px-2 py-1 bg-gray-700 text-green-300 rounded-md text-sm font-mono">48%</span>
                </div>
                <input type="range" id="percentage" min="0" max="100" value="48" class="w-full">
            </div>

            <div class="space-y-2">
                <div class="flex justify-between items-center">
                    <label for="lineWidth" class="font-medium text-gray-300">进度条粗细</label>
                    <span id="lineWidthValue" class="px-2 py-1 bg-gray-700 text-green-300 rounded-md text-sm font-mono">16px</span>
                </div>
                <input type="range" id="lineWidth" min="5" max="60" value="16" class="w-full">
            </div>

            <div class="space-y-2">
                <div class="flex justify-between items-center">
                    <label for="roughness" class="font-medium text-gray-300">边缘粗糙度</label>
                    <span id="roughnessValue" class="px-2 py-1 bg-gray-700 text-green-300 rounded-md text-sm font-mono">3</span>
                </div>
                <input type="range" id="roughness" min="0" max="40" value="3" class="w-full">
            </div>

            <div class="space-y-2">
                <div class="flex justify-between items-center">
                    <label for="animationSpeed" class="font-medium text-gray-300">过渡速度</label>
                    <span id="animationSpeedValue" class="px-2 py-1 bg-gray-700 text-green-300 rounded-md text-sm font-mono">7</span>
                </div>
                <input type="range" id="animationSpeed" min="1" max="100" value="7" class="w-full">
            </div>

            <div class="space-y-2">
                <div class="flex justify-between items-center">
                    <label for="flowSpeed" class="font-medium text-gray-300">流动速度</label>
                    <span id="flowSpeedValue" class="px-2 py-1 bg-gray-700 text-green-300 rounded-md text-sm font-mono">3</span>
                </div>
                <input type="range" id="flowSpeed" min="1" max="100" value="3" class="w-full">
            </div>

            <div class="grid grid-cols-1 sm:grid-cols-2 gap-4 pt-4">
                <div class="flex flex-col items-center space-y-2">
                    <label for="progressColor" class="font-medium text-gray-300">进度颜色</label>
                    <input type="color" id="progressColor" value="#ADFF2F" class="w-full h-10 p-1 bg-gray-700 rounded-md cursor-pointer border-2 border-gray-600">
                </div>
                <div class="flex flex-col items-center space-y-2">
                    <label for="baseColor" class="font-medium text-gray-300">底环颜色</label>
                    <input type="color" id="baseColor" value="#333333" class="w-full h-10 p-1 bg-gray-700 rounded-md cursor-pointer border-2 border-gray-600">
                </div>
            </div>
        </div>

        <script>
            const canvas = document.getElementById('progressCanvas');
            const ctx = canvas.getContext('2d');

            const controls = {
                percentagedocument.getElementById('percentage'),
                lineWidthdocument.getElementById('lineWidth'),
                roughnessdocument.getElementById('roughness'),
                animationSpeeddocument.getElementById('animationSpeed'),
                flowSpeeddocument.getElementById('flowSpeed'),
                progressColordocument.getElementById('progressColor'),
                baseColordocument.getElementById('baseColor'),
            };

            const valueDisplays = {
                percentagedocument.getElementById('percentageValue'),
                lineWidthdocument.getElementById('lineWidthValue'),
                roughnessdocument.getElementById('roughnessValue'),
                animationSpeeddocument.getElementById('animationSpeedValue'),
                flowSpeeddocument.getElementById('flowSpeedValue'),
            };

            let config = {
                percentage48,
                lineWidth16,
                radius100,
                roughness3,
                steps100
                animationSpeed7,
                flowSpeed3
                progressColor'#ADFF2F',
                baseColor'#333333',
            };

            let animatedPercentage = 0;
            let currentDisplacements = [];
            let targetDisplacements = [];
            let texturePhase = 0;

            const lerp = (start, end, amt) => (1 - amt) * start + amt * end;

            function generateTargetDisplacements() {
                targetDisplacements = [];
                for (let i = 0; i <= config.steps; i++) {
                    const outer = (Math.random() - 0.5) * 2;
                    const inner = (Math.random() - 0.5) * 2;
                    targetDisplacements.push({ outer, inner });
                }
            }

            function setupCanvas() {
                const dpr = window.devicePixelRatio || 1;
                const size = (config.radius + config.lineWidth + config.roughness) * 2.2;
                canvas.width = size * dpr;
                canvas.height = size * dpr;
                canvas.style.width = `${size}px`;
                canvas.style.height = `${size}px`;
                ctx.scale(dpr, dpr);
            }

            function drawRoughArc(cx, cy, radius, lineWidth, startAngle, endAngle, color, roughness, steps, displacements) {
                const innerRadius = radius - lineWidth / 2;
                const outerRadius = radius + lineWidth / 2;

                if (steps <= 0 || displacements.length === 0return;

                const angleStep = (endAngle - startAngle) / steps;

                const outerPoints = [];
                const innerPoints = [];

                for (let i = 0; i <= steps; i++) {
                    const angle = startAngle + i * angleStep;
                    const cosA = Math.cos(angle);
                    const sinA = Math.sin(angle);

                    // 根据点的实际角度和流动相位来确定使用哪个纹理数据
                    let normalizedAngle = angle % (Math.PI * 2);
                    if (normalizedAngle < 0) normalizedAngle += Math.PI * 2;
                    const indexFromAngle = Math.round((normalizedAngle / (Math.PI * 2)) * config.steps);
                    const totalDisplacements = displacements.length;
                    const displacementIndex = (indexFromAngle + Math.floor(texturePhase)) % totalDisplacements;

                    const disp = displacements[displacementIndex] || { outer0inner0 };
                    const currentOuterRadius = outerRadius + disp.outer * roughness;
                    const currentInnerRadius = innerRadius + disp.inner * roughness;

                    outerPoints.push({ x: cx + cosA * currentOuterRadius, y: cy + sinA * currentOuterRadius });
                    innerPoints.push({ x: cx + cosA * currentInnerRadius, y: cy + sinA * currentInnerRadius });
                }

                ctx.fillStyle = color;
                ctx.beginPath();
                ctx.moveTo(outerPoints[0].x, outerPoints[0].y);
                for (let i = 1; i < outerPoints.length; i++) {
                    ctx.lineTo(outerPoints[i].x, outerPoints[i].y);
                }
                ctx.lineTo(innerPoints[innerPoints.length - 1].x, innerPoints[innerPoints.length - 1].y);
                for (let i = innerPoints.length - 2; i >= 0; i--) {
                    ctx.lineTo(innerPoints[i].x, innerPoints[i].y);
                }
                ctx.closePath();
                ctx.fill();
            }

            function draw(percentageToDraw) {
                const size = (config.radius + config.lineWidth + config.roughness) * 2.2;
                const center = size / 2;

                ctx.clearRect(00, canvas.width, canvas.height);

                drawRoughArc(center, center, config.radius, config.lineWidth0Math.PI * 2, config.baseColor, config.roughness, config.steps, currentDisplacements);

                if (percentageToDraw > 0) {
                    const endAngle = (Math.PI * 2 * percentageToDraw) / 100 - Math.PI / 2;
                    const startAngle = -Math.PI / 2;
                    const progressSteps = Math.max(1Math.round(config.steps * (percentageToDraw / 100)));
                    drawRoughArc(center, center, config.radius, config.lineWidth, startAngle, endAngle, config.progressColor, config.roughness, progressSteps, currentDisplacements);
                }

                ctx.fillStyle = config.progressColor;
                ctx.font = `bold ${config.radius * 0.5}px Inter`;
                ctx.textAlign = 'center';
                ctx.textBaseline = 'middle';
                ctx.fillText(`${Math.round(percentageToDraw)}%`, center, center);
            }

            function animate() {
                requestAnimationFrame(animate);

                // 1. 百分比平滑过渡
                const targetPercentage = config.percentage;
                const easingFactor = config.animationSpeed / 1000
                const diff = targetPercentage - animatedPercentage;
                if (Math.abs(diff) > 0.01) {
                    animatedPercentage += diff * easingFactor;
                } else {
                    animatedPercentage = targetPercentage;
                }

                // 2. 边缘呼吸效果的平滑过渡
                const transitionSpeed = config.flowSpeed / 1000;
                for (let i = 0; i <= config.steps; i++) {
                    if (currentDisplacements[i] && targetDisplacements[i]) {
                        currentDisplacements[i].outer = lerp(currentDisplacements[i].outer, targetDisplacements[i].outer, transitionSpeed);
                        currentDisplacements[i].inner = lerp(currentDisplacements[i].inner, targetDisplacements[i].inner, transitionSpeed);
                    }
                }

                // --- 核心改动:纹理整体流动,改为减少相位来反向流动 ---
                texturePhase -= config.flowSpeed / 50
                // 保持texturePhase在一个合理的范围内,防止数字过大
                if (texturePhase < 0) texturePhase += config.steps;
                texturePhase %= config.steps;


                // 4. 当呼吸效果接近目标时,生成新目标
                if (currentDisplacements.length > 0 && Math.abs(currentDisplacements[0].outer - targetDisplacements[0].outer) < 0.01) {
                    generateTargetDisplacements();
                }

                // 5. 每帧执行绘制
                draw(animatedPercentage);
            }

            function updateConfigFromControls() {
                const sizeChanged = config.lineWidth !== parseFloat(controls.lineWidth.value) || 
                                    config.roughness !== parseFloat(controls.roughness.value);

                config.percentage = parseFloat(controls.percentage.value);
                config.lineWidth = parseFloat(controls.lineWidth.value);
                config.roughness = parseFloat(controls.roughness.value);
                config.animationSpeed = parseFloat(controls.animationSpeed.value);
                config.flowSpeed = parseFloat(controls.flowSpeed.value);
                config.progressColor = controls.progressColor.value;
                config.baseColor = controls.baseColor.value;

                valueDisplays.percentage.textContent = `${Math.round(config.percentage)}%`;
                valueDisplays.lineWidth.textContent = `${config.lineWidth}px`;
                valueDisplays.roughness.textContent = `${config.roughness}`;
                valueDisplays.animationSpeed.textContent = `${Math.round(config.animationSpeed)}`;
                valueDisplays.flowSpeed.textContent = `${Math.round(config.flowSpeed)}`;

                if (sizeChanged) {
                    setupCanvas();
                    // 重新设置位移数据,确保流畅
                    generateTargetDisplacements();
                    currentDisplacements = JSON.parse(JSON.stringify(targetDisplacements));
                }
            }

            for (const key in controls) {
                controls[key].addEventListener('input', updateConfigFromControls);
            }

            window.addEventListener('resize', setupCanvas);

            function initialize() {
                updateConfigFromControls();
                setupCanvas();

                generateTargetDisplacements();
                currentDisplacements = JSON.parse(JSON.stringify(targetDisplacements));

                requestAnimationFrame(animate);
            }

            initialize();
        </script>
    </body>
    </html>

# 微信小程序测试版本

    <template>
    <view class="container">

    <view class="progress-display-area">
    <rough-circular-progress
    :canvas-size="250"
    :percentage="config.percentage"
    :line-width="config.lineWidth"
    :roughness="config.roughness"
    :font-size="config.fontSize"
    progress-color="#ADFF2F"
    base-color="#444444"
    ></rough-circular-progress>
    </view>

    <view class="controls-area">
    <view class="control-item">
    <view class="control-label">
    <text>进度 (Percentage)</text>
    <text class="value-display">{{ config.percentage.toFixed(0) }}%</text>
    </view>
    <slider 
    :value="config.percentage" 
    @changing="onSliderChange('percentage', $event)"
    min="0"
    max="100"
    active-color="#ADFF2F"
    block-size="20"
    />
    </view>

    <view class="control-item">
    <view class="control-label">
    <text>线宽 (LineWidth)</text>
    <text class="value-display">{{ config.lineWidth.toFixed(1) }}</text>
    </view>
    <slider 
    :value="config.lineWidth" 
    @changing="onSliderChange('lineWidth', $event)"
    min="5"
    max="40"
    step="0.5"
    active-color="#ADFF2F"
    block-size="20"
    />
    </view>

    <view class="control-item">
    <view class="control-label">
    <text>粗糙度 (Roughness)</text>
    <text class="value-display">{{ config.roughness.toFixed(1) }}</text>
    </view>
    <slider 
    :value="config.roughness" 
    @changing="onSliderChange('roughness', $event)"
    min="0"
    max="10"
    step="0.1"
    active-color="#ADFF2F"
    block-size="20"
    />
    </view>

    <view class="control-item">
    <view class="control-label">
    <text>字号 (FontSize)</text>
    <text class="value-display">{{ config.fontSize.toFixed(0) }}</text>
    </view>
    <slider 
    :value="config.fontSize" 
    @changing="onSliderChange('fontSize', $event)"
    min="20"
    max="80"
    active-color="#ADFF2F"
    block-size="20"
    />
    </view>
    </view>

    </view>
    </template>

    <script>
    // 引入组件
    import RoughCircularProgress from '@/components/rough-circular-progress.vue';

    export default {
    // 注册组件
    components: {
    RoughCircularProgress
    },
    data() {
    return {
    // 将所有可配置参数集中管理
    config: {
    percentage48,
    lineWidth20,
    roughness4,
    fontSize50,
    }
    };
    },
    methods: {
    // 创建一个通用的滑块更新方法
    onSliderChange(key, event) {
    // 使用 key 来动态更新 config 对象中对应的属性
    this.config[key] = event.detail.value;
    }
    }
    };
    </script>

    <style scoped>
    .container {
    display: flex;
    flex-direction: column;
    align-items: center;
    min-height100vh;
    background-color#1a1a1a;
    padding20px;
    box-sizing: border-box;
    }

    .progress-display-area {
    flex-shrink0;
    display: flex;
    justify-content: center;
    align-items: center;
    width100%;
    padding40px 0;
    }

    .controls-area {
    width90%;
    max-width400px;
    }

    .control-item {
    margin-bottom25px;
    }

    .control-label {
    display: flex;
    justify-content: space-between;
    align-items: center;
    margin-bottom10px;
    color#cccccc;
    font-size15px;
    }

    .value-display {
    font-weight: bold;
    color#ffffff;
    background-color#333333;
    padding2px 8px;
    border-radius4px;
    font-family: monospace; /* 使用等宽字体让数字更好看 */
    }

    /* 覆盖 uni-app slider 的默认样式,使其更贴合主题 */
    /deep/ .uni-slider-handle-wrapper {
    height40px
    }
    </style>

将环形进度条改为直线形式,同时保留核心的 “笔刷” 和“流动” 「效果」

<template>
<view class="progress-container" :style="{ width: width + 'px', height: height + 'px' }">
<canvas 
type="2d" 
id="linearProgressCanvas" 
canvas-id="linearProgressCanvas"
:style="{ width: width + 'px', height: height + 'px' }"
></canvas>
</view>
</template>

<script>
export default {
name: "rough-linear-progress",
props: {
// 画布宽度
width: {
type: Number,
default: 300
},
// 画布高度(即进度条粗细)
height: {
type: Number,
default: 40
},
// 进度百分比 (0-100)
percentage: {
type: Number,
default: 60
},
// 边缘粗糙度/波浪幅度
roughness: {
type: Number,
default: 5
},
// 进度条颜色
progressColor: {
type: String,
default: '#ADFF2F'
},
// 背景颜色
baseColor: {
type: String,
default: '#333333'
},
// 文字大小
fontSize: {
type: Number,
default: 16
},
// 文字颜色
fontColor: {
type: String,
default: '#111111'
},
// 是否显示文字
showText: {
type: Boolean,
default: true
},
// 过渡动画速度 (值越小越快)
transitionSpeed: {
type: Number,
default: 0.07 
}
},
data() {
return {
ctx: null,
canvas: null,
animatedPercentage: 0,
animationFrameId: null,
};
},
watch: {
'$props': {
handler() {
if (!this.animationFrameId) {
this.startAnimation();
}
},
deep: true,
immediate: false 
}
},
mounted() {
this.$nextTick(() => {
this.initCanvas();
});
},
beforeDestroy() {
this.stopAnimation();
},
methods: {
initCanvas() {
const query = uni.createSelectorQuery().in(this);
query.select('#linearProgressCanvas')
.fields({ node: true, size: true })
.exec((res) => {
if (!res[0] || !res[0].node) {
console.error('无法找到Canvas节点');
return;
}

this.canvas = res[0].node;
this.ctx = this.canvas.getContext('2d');

const dpr = uni.getSystemInfoSync().pixelRatio;
this.canvas.width = this.width * dpr;
this.canvas.height = this.height * dpr;
this.ctx.scale(dpr, dpr);

this.animatedPercentage = this.percentage;
this.startAnimation();
});
},

startAnimation() {
if (this.animationFrameId) return;
this.animate();
},

stopAnimation() {
if (this.animationFrameId && this.canvas) {
this.canvas.cancelAnimationFrame(this.animationFrameId);
this.animationFrameId = null;
}
},

animate() {
this.animationFrameId = this.canvas.requestAnimationFrame(this.animate);

const targetPercentage = this.percentage;
const diff = targetPercentage - this.animatedPercentage;

if (Math.abs(diff) > 0.01) {
this.animatedPercentage += diff * this.transitionSpeed;
else {
this.animatedPercentage = targetPercentage;
}

this.draw();
},

draw() {
this.ctx.clearRect(00this.width, this.height);

// 绘制背景
this.drawRoughRect(00this.width, this.height, this.baseColor, this.roughness);

// 绘制进度条
const progressWidth = (this.width * this.animatedPercentage) / 100;
if (progressWidth > 0) {
this.drawRoughRect(00, progressWidth, this.height, this.progressColor, this.roughness);
}

// 绘制文字
if (this.showText) {
this.ctx.fillStyle = this.fontColor;
this.ctx.font = `bold ${this.fontSize}px sans-serif`;
this.ctx.textAlign = 'center';
this.ctx.textBaseline = 'middle';
this.ctx.fillText(`${Math.round(this.animatedPercentage)}%`, this.width / 2this.height / 2);
}
},

/**
* --- 核心改造函数 ---
* 绘制带粗糙边缘的矩形
*/
drawRoughRect(x, y, width, height, color, roughness) {
const points = [];
const step = 10// 每隔10px计算一个锚点

// 1. 生成上边缘的点
for (let i = 0; i <= width; i += step) {
points.push({
x: x + i,
y: y + (Math.random() - 0.5) * roughness
});
}
points.push({x: x + width, y: y + (Math.random() - 0.5) * roughness});

// 2. 生成右边缘的点
for (let i = 0; i <= height; i += step) {
points.push({
x: x + width + (Math.random() - 0.5) * roughness,
y: y + i
});
}
points.push({x: x + width + (Math.random() - 0.5) * roughness, y: y + height});

// 3. 生成下边缘的点(反向)
for (let i = width; i >= 0; i -= step) {
points.push({
x: x + i,
y: y + height + (Math.random() - 0.5) * roughness
});
}
points.push({x: x, y: y + height + (Math.random() - 0.5) * roughness});

// 4. 生成左边缘的点(反向)
for (let i = height; i >= 0; i -= step) {
points.push({
x: x + (Math.random() - 0.5) * roughness,
y: y + i
});
}
points.push({x: x + (Math.random() - 0.5) * roughness, y: y});

this.ctx.fillStyle = color;
this.ctx.beginPath();
this.ctx.moveTo(points[0].x, points[0].y);
for (let i = 1; i < points.length; i++) {
this.ctx.lineTo(points[i].x, points[i].y);
}
this.ctx.closePath();
this.ctx.fill();
}
}
}
</script>

<style scoped>
.progress-container {
display: flex;
justify-content: center;
align-items: center;
}
</style>

AI编程资讯AI Coding专区指南:https://aicoding.juejin.cn/aicoding

""~

阅读原文

跳转微信打开

Fish AI Reader

Fish AI Reader

AI辅助创作,多种专业模板,深度分析,高质量内容生成。从观点提取到深度思考,FishAI为您提供全方位的创作支持。新版本引入自定义参数,让您的创作更加个性化和精准。

FishAI

FishAI

鱼阅,AI 时代的下一个智能信息助手,助你摆脱信息焦虑

联系邮箱 441953276@qq.com

相关标签

SVG JavaScript CSS HTML 进度条 水波纹 前端开发 Web特效 Progress Bar Wavy Effect Frontend Development Web Effects
相关文章