原创 拜无忧 2025-09-27 08:31 重庆
点击关注公众号,“技术干货” 及时达!
「绘制基础图形 (HTML/SVG)」
我们先用 <svg> 标签画出两个叠在一起的圆环(<circle>):一个作为灰色的背景,另一个作为亮黄色的进度条。通过 CSS 的 stroke-dasharray 和 stroke-dashoffset 属性,我们可以精确地控制黄色圆环显示多少,从而实现进度条功能。「创建 “水波纹” 滤镜 (SVG Filter)」 
💡 **SVG 滤镜实现水波纹效果**:文章的核心在于使用 SVG 的 `filter` 元素,特别是 `feTurbulence` 和 `feDisplacementMap` 组合。`feTurbulence` 生成动态的随机噪声,模拟水面纹理;`feDisplacementMap` 则根据此噪声图扭曲和移动圆环的像素点,从而创造出逼真的水波纹动态效果,这是实现此视觉特效的关键。
⚙️ **基础图形与进度控制**:通过 `
🎛️ **JavaScript 交互式控制**:文章提供了 JavaScript 代码,用于监听 HTML 滑块(range input)的变化。用户可以通过拖动滑块来实时调整 SVG 滤镜中的关键参数,如 `baseFrequency`(控制波纹的精细度)和 `scale`(控制波纹的幅度)。这种交互性使得开发者和用户能够根据需求自定义波纹的视觉表现,增加了组件的灵活性和可玩性。
原创 拜无忧 2025-09-27 08:31 重庆
<svg> 标签画出两个叠在一起的圆环(<circle>):一个作为灰色的背景,另一个作为亮黄色的进度条。通过 CSS 的 stroke-dasharray 和 stroke-dashoffset 属性,我们可以精确地控制黄色圆环显示多少,从而实现进度条功能。「创建 “水波纹” 滤镜 (SVG Filter)」<filter>。滤镜内部,首先使用 「feTurbulence」 标签生成一张看不见的、类似云雾或大理石纹理的「随机噪声图」。这个噪声图本身就是动态变化的。然后,使用 「feDisplacementMap」 标签,将这张噪声图作为一张 “置换地图”,应用到我们第一步画的圆环上。它会根据噪声图的明暗信息,去「扭曲和移动」圆环上的每一个点,于是就产生了我们看到的波纹效果。「添加交互控制 (JavaScript)」<input type="range">)的变化。当用户拖动滑块时,JS 会实时地去修改 SVG 滤镜中的各种参数,比如 feTurbulence 的 baseFrequency(波纹的频率)和 feDisplacementMap 的 scale(波纹的幅度),让用户可以自由定制喜欢的效果。第二版本 - 带进度条边框宽度版本<html lang="zh"><head><meta charset="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><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>
vue3 版本<html lang="zh"><head><meta charset="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><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>
react 版本公共组件<template><div class="progress-container" :style="containerStyle"><svg class="progress-ring" viewBox="0 0 120 120"><!-- 背景圆环 --><circleclass="progress-ring__circle progress-ring__background":style="{ stroke: inactiveColor }":r="radius"cx="60"cy="60"></circle><!-- 进度圆环 --><circleclass="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"><feTurbulenceref="turbulenceFilter"type="fractalNoise":baseFrequency="`${frequency} ${frequency}`":numOctaves="octaves"result="turbulenceResult"><animateattributedur="10s":values="`${frequency} ${frequency};${frequency + 0.03} ${frequency - 0.03};${frequency} ${frequency};`"repeatCount="indefinite"></animate></feTurbulence><feDisplacementMapref="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';// 定义组件接收的 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><style scoped>.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>
将环形进度条改为直线形式,同时保留核心的 “笔刷” 和“流动” 「效果」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}><svgclassstyle={{width: '100%',height: '100%',transform: 'rotate(-90deg)',filter: `url(#${filterId})`,}}viewBox="0 0 120 120"><circleclassstyle={{ ...circleStyle, stroke: inactiveColor }}r={radius}cx="60"cy="60"/><circleclassstyle={{...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}><feTurbulencetype="fractalNoise"baseFrequency={`${frequency} ${frequency}`}numOctaves={octaves}result="turbulenceResult"><animateattributedur="10s"values={`${frequency} ${frequency};${frequency + 0.03} ${frequency - 0.03};${frequency} ${frequency};`}repeatCount="indefinite"/></feTurbulence><feDisplacementMapin="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"><WavyProgressprogress={progress}strokeWidth={strokeWidth}scale={scale}frequency={frequency}octaves={octaves}/><div class><div class><label>进度: <span>{progress}%</span></label><inputtype="range"value={progress}onChange={(e) => setProgress(Number(e.target.value))}min="0"max="100"/></div><div class><label>边框宽度: <span>{strokeWidth}</span></label><inputtype="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><inputtype="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><inputtype="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><inputtype="range"value={octaves}onChange={(e) => setOctaves(Number(e.target.value))}min="1"max="10"step="1"/></div></div></div></>);};export default App;``# canvas-版本<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;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><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 = {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><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: {percentage: 48,lineWidth: 20,roughness: 4,fontSize: 50,}};},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-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' }"><canvastype="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
点击"阅读原文"了解详情~
AI辅助创作,多种专业模板,深度分析,高质量内容生成。从观点提取到深度思考,FishAI为您提供全方位的创作支持。新版本引入自定义参数,让您的创作更加个性化和精准。
鱼阅,AI 时代的下一个智能信息助手,助你摆脱信息焦虑