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

文章 2小时前 juejinhot
1 0

  • 绘制基础图形 (HTML/SVG)

    • 我们先用 <svg> 标签画出两个叠在一起的圆环(<circle>):一个作为灰色的背景,另一个作为亮黄色的进度条。
    • 通过 CSSstroke-dasharraystroke-dashoffset 属性,我们可以精确地控制黄色圆环显示多少,从而实现进度条功能。
  • 创建“水波纹”滤镜 (SVG Filter)

    • 这是最关键的一步。我们在 SVG 中定义了一个 <filter>
    • 滤镜内部,首先使用 feTurbulence 标签生成一张看不见的、类似云雾或大理石纹理的随机噪声图。这个噪声图本身就是动态变化的。
    • 然后,使用 feDisplacementMap 标签,将这张噪声图作为一张“置换地图”,应用到我们第一步画的圆环上。它会根据噪声图的明暗信息,去扭曲和移动圆环上的每一个点,于是就产生了我们看到的波纹效果。
  • 添加交互控制 (JavaScript)

    • 最后,我们用 JavaScript 监听几个 HTML 滑块(<input type="range">)的变化。
    • 当用户拖动滑块时,JS 会实时地去修改 SVG 滤镜中的各种参数,比如 feTurbulencebaseFrequency(波纹的频率)和 feDisplacementMapscale(波纹的幅度),让用户可以自由定制喜欢的效果。
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<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 attributeName="baseFrequency" dur="10s" values="0.05 0.05;0.08 0.02;0.05 0.05;" repeatCount="indefinite"></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 name="viewport" content="width=device-width, initial-scale=1.0">
<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 attributeName="baseFrequency" dur="10s" values="0.05 0.05;0.08 0.02;0.05 0.05;" repeatCount="indefinite"></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 
attributeName="baseFrequency" 
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: { 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>

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
className="progress-ring"
style={{
width: '100%',
height: '100%',
transform: 'rotate(-90deg)',
filter: `url(#${filterId})`,
}}
viewBox="0 0 120 120"
>
<circle
className="progress-ring__background"
style={{ ...circleStyle, stroke: inactiveColor }}
r={radius}
cx="60"
cy="60"
/>
<circle
className="progress-ring__progress"
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
attributeName="baseFrequency"
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 className="controls">
<div className="control-group">
<label>进度: <span>{progress}%</span></label>
<input
type="range"
value={progress}
onChange={(e) => setProgress(Number(e.target.value))}
min="0"
max="100"
/>
</div>
<div className="control-group">
<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 className="control-group">
<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 className="control-group">
<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 className="control-group">
<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 name="viewport" content="width=device-width, initial-scale=1.0">
<title>笔刷效果环形进度条 (流动方向修正版)</title>
<script src="https://cdn.tailwindcss.com"></script>
<link class="external" rel="nofollow" target="_blank" href="https://hao.panziye.com/go/?url=aHR0cHM6Ly9mb250cy5nb29nbGVhcGlzLmNvbS9jc3MyP2ZhbWlseT1JbnRlcjp3Z2h0QDQwMDs1MDA7NzAwJmFtcDtkaXNwbGF5PXN3YXA=" 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' }">
<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>

暂无评论

您必须登录才能参与评论!
立即登录
暂无评论...