稀土掘金技术社区

水波纹进度条,带有“水波纹”或“扭曲”效果,filter,svg

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

Image
  • 「绘制基础图形 (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><htmllang="zh"><head>    <metacharset="UTF-8">    <meta >    <title>动态水波纹边框</title>    <style>        :root {            --progress: 50; /* 进度: 0-100 */            --base-frequency-x: 0.05;            --base-frequency-y: 0.05;            --num-octaves: 2;            --scale: 15;            --active-color: #ceff00;            --inactive-color: #333;            --bg-color: #1a1a1a;            --text-color: #ceff00;        }
        body {            display: flex;            justify-content: center;            align-items: center;            min-height: 100vh;            background-color: var(--bg-color);            font-family: Arial, sans-serif;            margin: 0;            flex-direction: column;            gap: 40px;        }
        .progress-container {            width: 250px;            height: 250px;            position: relative;        }
        .progress-ring {            width: 100%;            height: 100%;            transform: rotate(-90deg); /* 让起点在顶部 */            filter: url(#wobble-filter); /* 应用SVG滤镜 */        }
        .progress-ring__circle {            fill: none;            stroke-width: 20;            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;            top: 50%;            left: 50%;            transform: translate(-50%, -50%);            color: var(--text-color);            font-size: 50px;            font-weight: bold;        }
        .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%;        }    </style></head><body>
    <divclass="progress-container">        <svgclass="progress-ring"viewBox="0 0 120 120">            <!-- 背景圆环 -->            <circleclass="progress-ring__circle progress-ring__background"r="50"cx="60"cy="60"></circle>            <!-- 进度圆环 -->            <circleclass="progress-ring__circle progress-ring__progress"r="50"cx="60"cy="60"></circle>        </svg>        <divclass="progress-text">50%</div>    </div>
    <!-- SVG 滤镜定义 -->    <svgwidth="0"height="0">        <filterid="wobble-filter">            <!--                 feTurbulence: 创建湍流噪声                - baseFrequency: 噪声的基础频率,值越小,波纹越大越平缓                - numOctaves: 噪声的倍频数,值越大,细节越多越锐利                - type: 'fractalNoise' 或 'turbulence'            -->            <feTurbulenceid="turbulence"type="fractalNoise"baseFrequency="0.05 0.05"numOctaves="2"result="turbulenceResult">                <!-- 动画:让 turbulence 的基础频率动起来,模拟流动效果 -->                <animateattribute></animate>            </feTurbulence>
            <!--                 feDisplacementMap: 用一个图像(这里是上面的噪声)来置换另一个图像                - in: 输入源,这里是 SourceGraphic,即我们的圆环                - in2: 置换图源,这里是上面生成的噪声                - scale: 置换的缩放因子,即波纹的幅度/强度                - xChannelSelector / yChannelSelector: 指定使用噪声的哪个颜色通道进行置换            -->            <feDisplacementMapin="SourceGraphic"in2="turbulenceResult"scale="15"xChannelSelector="R"yChannelSelector="G"></feDisplacementMap>        </filter>    </svg>
    <divclass="controls">        <divclass="control-group">            <labelfor="progress">进度: <spanid="progress-value">50%</span></label>            <inputtype="range"id="progress"min="0"max="100"value="50">        </div>        <divclass="control-group">            <labelfor="scale">波纹幅度 (scale): <spanid="scale-value">15</span></label>            <inputtype="range"id="scale"min="0"max="50"value="15"step="1">        </div>        <divclass="control-group">            <labelfor="frequency">波纹频率 (baseFrequency): <spanid="frequency-value">0.05</span></label>            <inputtype="range"id="frequency"min="0.01"max="0.2"value="0.05"step="0.01">        </div>         <divclass="control-group">            <labelfor="octaves">波纹细节 (numOctaves): <spanid="octaves-value">2</span></label>            <inputtype="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>

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

Image
<!DOCTYPE html><htmllang="zh"><head>    <metacharset="UTF-8">    <meta >    <title>动态水波纹边框</title>    <style>        :root {            --progress: 50; /* 进度: 0-100 */            --stroke-width: 20; /* 边框宽度 */            --base-frequency-x: 0.05;            --base-frequency-y: 0.05;            --num-octaves: 2;            --scale: 15;            --active-color: #ceff00;            --inactive-color: #333;            --bg-color: #1a1a1a;            --text-color: #ceff00;        }
        body {            display: flex;            justify-content: center;            align-items: center;            min-height: 100vh;            background-color: var(--bg-color);            font-family: Arial, sans-serif;            margin: 0;            flex-direction: column;            gap: 40px;        }
        .progress-container {            width: 250px;            height: 250px;            position: relative;        }
        .progress-ring {            width: 100%;            height: 100%;            transform: rotate(-90deg); /* 让起点在顶部 */            filter: url(#wobble-filter); /* 应用SVG滤镜 */        }
        .progress-ring__circle {            fill: none;            stroke-width: var(--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;            top: 50%;            left: 50%;            transform: translate(-50%, -50%);            color: var(--text-color);            font-size: 50px;            font-weight: bold;        }
        .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%;        }    </style></head><body>
    <divclass="progress-container">        <svgclass="progress-ring"viewBox="0 0 120 120">            <!-- 背景圆环 -->            <circleclass="progress-ring__circle progress-ring__background"r="50"cx="60"cy="60"></circle>            <!-- 进度圆环 -->            <circleclass="progress-ring__circle progress-ring__progress"r="50"cx="60"cy="60"></circle>        </svg>        <divclass="progress-text">50%</div>    </div>
    <!-- SVG 滤镜定义 -->    <svgwidth="0"height="0">        <filterid="wobble-filter">            <!--                 feTurbulence: 创建湍流噪声                - baseFrequency: 噪声的基础频率,值越小,波纹越大越平缓                - numOctaves: 噪声的倍频数,值越大,细节越多越锐利                - type: 'fractalNoise' 或 'turbulence'            -->            <feTurbulenceid="turbulence"type="fractalNoise"baseFrequency="0.05 0.05"numOctaves="2"result="turbulenceResult">                <!-- 动画:让 turbulence 的基础频率动起来,模拟流动效果 -->                <animateattribute></animate>            </feTurbulence>
            <!--                 feDisplacementMap: 用一个图像(这里是上面的噪声)来置换另一个图像                - in: 输入源,这里是 SourceGraphic,即我们的圆环                - in2: 置换图源,这里是上面生成的噪声                - scale: 置换的缩放因子,即波纹的幅度/强度                - xChannelSelector / yChannelSelector: 指定使用噪声的哪个颜色通道进行置换            -->            <feDisplacementMapin="SourceGraphic"in2="turbulenceResult"scale="15"xChannelSelector="R"yChannelSelector="G"></feDisplacementMap>        </filter>    </svg>
    <divclass="controls">        <divclass="control-group">            <labelfor="progress">进度: <spanid="progress-value">50%</span></label>            <inputtype="range"id="progress"min="0"max="100"value="50">        </div>        <divclass="control-group">            <labelfor="stroke-width">边框宽度: <spanid="stroke-width-value">20</span></label>            <inputtype="range"id="stroke-width"min="1"max="50"value="20"step="1">        </div>        <divclass="control-group">            <labelfor="scale">波纹幅度 (scale): <spanid="scale-value">15</span></label>            <inputtype="range"id="scale"min="0"max="50"value="15"step="1">        </div>        <divclass="control-group">            <labelfor="frequency">波纹频率 (baseFrequency): <spanid="frequency-value">0.05</span></label>            <inputtype="range"id="frequency"min="0.01"max="0.2"value="0.05"step="0.01">        </div>         <divclass="control-group">            <labelfor="octaves">波纹细节 (numOctaves): <spanid="octaves-value">2</span></label>            <inputtype="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>  <divclass="progress-container":style="containerStyle">    <svgclass="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>    <divclass="progress-text":style="{ color: textColor }">      {{ Math.round(progress) }}%    </div>
    <!-- SVG 滤镜定义 (在组件内部,不会污染全局) -->    <svgwidth="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>
<scriptsetup>import { computed, ref, watchEffect, onMounted } from 'vue';
// 定义组件接收的 Propsconst props = defineProps({  size: { type: Number, default: 250 },  progress: { type: Number, default: 50, validator: (v) => v >= 0 && v <= 100 },  strokeWidth: { type: Number, default: 20 },  scale: { type: Number, default: 15 },  frequency: { type: Number, default: 0.05 },  octaves: { type: Number, default: 2 },  activeColor: { type: String, default: '#ceff00' },  inactiveColor: { type: String, default: '#333' },  textColor: { type: String, default: '#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>
<stylescoped>.progress-container {  position: relative;  display: inline-block; /* 改为 inline-block 以适应 size prop */}
.progress-ring {  width: 100%;  height: 100%;  transform: rotate(-90deg);  /* 动态应用滤镜 */  filter: v-bind('`url(#${filterId})`');}
.progress-ring__circle {  fill: none;  stroke-width: v-bind('strokeWidth');  transition: stroke-dashoffset 0.35s ease;  stroke-dasharray: v-bind('`${circumference} ${circumference}`');}
.progress-ring__progress {  stroke-linecap: round;}
.progress-text {  position: absolute;  top: 50%;  left: 50%;  transform: translate(-50%, -50%);  font-size: v-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 (    <divstyle={containerStyle}>      <svg        class        style={{          width: '100%',          height: '100%',          transform: 'rotate(-90deg)',          filter: `url(#${filterId})`,        }}        viewBox="0 0 120 120"      >        <circle          class          style={{ ...circleStyle, stroke: inactiveColor }}          r={radius}          cx="60"          cy="60"        />        <circle          class          style={{            ...circleStyle,            stroke: activeColor,            strokeDashoffset: strokeDashoffset,            strokeLinecap: 'round',          }}          r={radius}          cx="60"          cy="60"        />      </svg>      <divstyle={textStyle}>        {`${Math.round(progress)}%`}      </div>
      <svgwidth="0"height="0"style={{position: 'absolute' }}>        <filterid={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>      <divid="app-container">        <WavyProgress          progress={progress}          strokeWidth={strokeWidth}          scale={scale}          frequency={frequency}          octaves={octaves}        />
        <divclass>          <divclass>            <label>进度: <span>{progress}%</span></label>            <input              type="range"              value={progress}              onChange={(e) => setProgress(Number(e.target.value))}              min="0"              max="100"            />          </div>          <divclass>            <label>边框宽度: <span>{strokeWidth}</span></label>            <input              type="range"              value={strokeWidth}              onChange={(e) => setStrokeWidth(Number(e.target.value))}              min="1"              max="50"              step="1"            />          </div>          <divclass>            <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>          <divclass>            <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>          <divclass>            <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>    <htmllang="zh-CN">    <head>        <metacharset="UTF-8">        <meta >        <title>笔刷效果环形进度条 (流动方向修正版)</title>        <scriptsrc="https://cdn.tailwindcss.com"></script>        <linkhref="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;                width: 100%;                height: 8px;                background: #4a5568;                border-radius: 5px;                outline: none;                opacity: 0.7;                transition: opacity .2s;            }            input[type="range"]:hover {                opacity: 1;            }            input[type="range"]::-webkit-slider-thumb {                -webkit-appearance: none;                appearance: none;                width: 20px;                height: 20px;                background: #90eea8;                cursor: pointer;                border-radius: 50%;            }            input[type="range"]::-moz-range-thumb {                width: 20px;                height: 20px;                background: #90eea8;                cursor: pointer;                border-radius: 50%;            }        </style>    </head>    <bodyclass="bg-gray-900 text-white flex flex-col lg:flex-row items-center justify-center min-h-screen p-4">
        <divclass="w-full lg:w-1/2 flex items-center justify-center p-8">            <canvasid="progressCanvas"></canvas>        </div>
        <divclass="w-full lg:w-1/3 bg-gray-800 p-6 rounded-2xl shadow-2xl space-y-5 border border-gray-700">            <h2class="text-2xl font-bold text-center text-green-300 mb-6">配置属性</h2>
            <divclass="space-y-2">                <divclass="flex justify-between items-center">                    <labelfor="percentage"class="font-medium text-gray-300">百分比</label>                    <spanid="percentageValue"class="px-2 py-1 bg-gray-700 text-green-300 rounded-md text-sm font-mono">48%</span>                </div>                <inputtype="range"id="percentage"min="0"max="100"value="48"class="w-full">            </div>
            <divclass="space-y-2">                <divclass="flex justify-between items-center">                    <labelfor="lineWidth"class="font-medium text-gray-300">进度条粗细</label>                    <spanid="lineWidthValue"class="px-2 py-1 bg-gray-700 text-green-300 rounded-md text-sm font-mono">16px</span>                </div>                <inputtype="range"id="lineWidth"min="5"max="60"value="16"class="w-full">            </div>
            <divclass="space-y-2">                <divclass="flex justify-between items-center">                    <labelfor="roughness"class="font-medium text-gray-300">边缘粗糙度</label>                    <spanid="roughnessValue"class="px-2 py-1 bg-gray-700 text-green-300 rounded-md text-sm font-mono">3</span>                </div>                <inputtype="range"id="roughness"min="0"max="40"value="3"class="w-full">            </div>
            <divclass="space-y-2">                <divclass="flex justify-between items-center">                    <labelfor="animationSpeed"class="font-medium text-gray-300">过渡速度</label>                    <spanid="animationSpeedValue"class="px-2 py-1 bg-gray-700 text-green-300 rounded-md text-sm font-mono">7</span>                </div>                <inputtype="range"id="animationSpeed"min="1"max="100"value="7"class="w-full">            </div>
            <divclass="space-y-2">                <divclass="flex justify-between items-center">                    <labelfor="flowSpeed"class="font-medium text-gray-300">流动速度</label>                    <spanid="flowSpeedValue"class="px-2 py-1 bg-gray-700 text-green-300 rounded-md text-sm font-mono">3</span>                </div>                <inputtype="range"id="flowSpeed"min="1"max="100"value="3"class="w-full">            </div>
            <divclass="grid grid-cols-1 sm:grid-cols-2 gap-4 pt-4">                <divclass="flex flex-col items-center space-y-2">                    <labelfor="progressColor"class="font-medium text-gray-300">进度颜色</label>                    <inputtype="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>                <divclass="flex flex-col items-center space-y-2">                    <labelfor="baseColor"class="font-medium text-gray-300">底环颜色</label>                    <inputtype="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 = {                percentage: document.getElementById('percentage'),                lineWidth: document.getElementById('lineWidth'),                roughness: document.getElementById('roughness'),                animationSpeed: document.getElementById('animationSpeed'),                flowSpeed: document.getElementById('flowSpeed'),                progressColor: document.getElementById('progressColor'),                baseColor: document.getElementById('baseColor'),            };
            const valueDisplays = {                percentage: document.getElementById('percentageValue'),                lineWidth: document.getElementById('lineWidthValue'),                roughness: document.getElementById('roughnessValue'),                animationSpeed: document.getElementById('animationSpeedValue'),                flowSpeed: document.getElementById('flowSpeedValue'),            };
            let config = {                percentage: 48,                lineWidth: 16,                radius: 100,                roughness: 3,                steps: 100,                 animationSpeed: 7,                flowSpeed: 3,                 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 === 0) return;
                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] || { outer: 0, inner: 0 };                    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(0, 0, canvas.width, canvas.height);
                drawRoughArc(center, center, config.radius, config.lineWidth, 0, Math.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(1, Math.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>    <viewclass="container">
    <viewclass="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>
    <viewclass="controls-area">    <viewclass="control-item">    <viewclass="control-label">    <text>进度 (Percentage)</text>    <textclass="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>
    <viewclass="control-item">    <viewclass="control-label">    <text>线宽 (LineWidth)</text>    <textclass="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>
    <viewclass="control-item">    <viewclass="control-label">    <text>粗糙度 (Roughness)</text>    <textclass="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>
    <viewclass="control-item">    <viewclass="control-label">    <text>字号 (FontSize)</text>    <textclass="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: {    percentage: 48,    lineWidth: 20,    roughness: 4,    fontSize: 50,    }    };    },    methods: {    // 创建一个通用的滑块更新方法    onSliderChange(key, event) {    // 使用 key 来动态更新 config 对象中对应的属性    this.config[key] = event.detail.value;    }    }    };    </script>
    <stylescoped>    .container {    display: flex;    flex-direction: column;    align-items: center;    min-height: 100vh;    background-color: #1a1a1a;    padding: 20px;    box-sizing: border-box;    }
    .progress-display-area {    flex-shrink: 0;    display: flex;    justify-content: center;    align-items: center;    width: 100%;    padding: 40px 0;    }
    .controls-area {    width: 90%;    max-width: 400px;    }
    .control-item {    margin-bottom: 25px;    }
    .control-label {    display: flex;    justify-content: space-between;    align-items: center;    margin-bottom: 10px;    color: #cccccc;    font-size: 15px;    }
    .value-display {    font-weight: bold;    color: #ffffff;    background-color: #333333;    padding: 2px 8px;    border-radius: 4px;    font-family: monospace; /* 使用等宽字体让数字更好看 */    }
    /* 覆盖 uni-app slider 的默认样式,使其更贴合主题 */    /deep/ .uni-slider-handle-wrapper {    height: 40px;     }    </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(0, 0, this.width, this.height);
// 绘制背景this.drawRoughRect(0, 0, this.width, this.height, this.baseColor, this.roughness);
// 绘制进度条const progressWidth = (this.width * this.animatedPercentage) / 100;if (progressWidth > 0) {this.drawRoughRect(0, 0, 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 / 2, this.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

图片
点击"阅读原文"了解详情~