繪製基礎圖形 (HTML/SVG)
<svg>
標籤畫出兩個疊在一起的圓環(<circle>
):一個作為灰色的背景,另一個作為亮黃色的進度條。stroke-dasharray
和 stroke-dashoffset
屬性,我們可以精確地控制黃色圓環顯示多少,從而實現進度條功能。創建“水波紋”濾鏡 (SVG Filter)
<filter>
。feTurbulence
標籤生成一張看不見的、類似雲霧或大理石紋理的隨機噪聲圖。這個噪聲圖本身就是動態變化的。feDisplacementMap
標籤,將這張噪聲圖作為一張“置換地圖”,應用到我們第一步畫的圓環上。它會根據噪聲圖的明暗信息,去扭曲和移動圓環上的每一個點,於是就產生了我們看到的波紋效果。添加交互控制 (JavaScript)
<input type="range">
)的變化。feTurbulence
的 baseFrequency
(波紋的頻率)和 feDisplacementMap
的 scale
(波紋的幅度),讓使用者可以自由定制喜歡的效果。<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
class="progress-ring__circle progress-ring__progress"
:style="{
stroke: activeColor,
strokeDashoffset: strokeDashoffset
}"
:r="radius"
cx="60"
cy="60"
/>
</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"
/>
</feTurbulence>
<feDisplacementMap
ref="displacementMapFilter"
in="SourceGraphic"
in2="turbulenceResult"
:scale="scale"
xChannelSelector="R"
yChannelSelector="G"
/>
</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;
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}>
<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,
}}
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;
---
原文出處:https://juejin.cn/post/7546261151232786458