Canvas 画水球
东北小麦客 2023-06-11 WebGLCanvasrequestAnimationFrame
# 基础
参考资料
# 一. HTML版本
# 案例
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>canvas实现水球加载动画</title>
<style>
body {
display: flex;
flex-flow: column wrap;
justify-content: center;
align-items: center;
}
#c {
margin-top: 20px;
}
input[type=range]::before {
content: attr(min);
color: #000;
padding-right: 5px;
}
input[type=range]::after {
content: attr(max);
color: #000;
padding-left: 5px;
}
</style>
</head>
<body>
<canvas id="c">当前浏览器不支持canvas 请升级!</canvas>
<input type="range" name="range" min="0" max="100" step="1">
<script>
canvas = document.getElementById("c");
ctx = canvas.getContext("2d");
oRange = document.getElementsByName("range")[0];
M = Math;
Sin = M.sin;
Cos = M.cos;
Sqrt = M.sqrt;
Pow = M.pow;
PI = M.PI;
Round = M.round;
oW = canvas.width = 300;
oH = canvas.height = 300;
// 线宽
lineWidth = 2
// 大半径
r = (oW / 2);
cR = r - 8 * lineWidth;
ctx.beginPath();
ctx.lineWidth = lineWidth;
// 水波动画初始参数
axisLength = 2 * r - 16 * lineWidth; // Sin 图形长度
unit = axisLength / 8; // 波浪宽
range = .2 // 浪幅
nowrange = range;
xoffset = 8 * lineWidth; // x 轴偏移量
data = ~~(oRange.value) / 100; // 数据量
sp = 0; // 周期偏移量
nowdata = 0;
waveupsp = 0.002; // 水波上涨速度
// 圆动画初始参数
arcStack = []; // 圆栈
bR = r - 8 * lineWidth;
soffset = -(PI / 2); // 圆动画起始位置
circleLock = true; // 起始动画锁
// 获取圆动画轨迹点集
for (var i = soffset; i < soffset + 2 * PI; i += 1 / (8 * PI)) {
arcStack.push([
r + bR * Cos(i),
r + bR * Sin(i)
])
}
cStartPoint = arcStack.shift(); // 圆起始点
ctx.strokeStyle = "#1c86d1";
ctx.moveTo(cStartPoint[0], cStartPoint[1])
render(); // 开始渲染
function drawSine() {
ctx.beginPath();
ctx.save();
var Stack = []; // 记录起始点和终点坐标
for (var i = xoffset; i <= xoffset + axisLength; i += 20 / axisLength) {
var x = sp + (xoffset + i) / unit;
var y = Sin(x) * nowrange;
var dx = i;
var dy = 2 * cR * (1 - nowdata) + (r - cR) - (unit * y);
ctx.lineTo(dx, dy);
Stack.push([dx, dy])
}
// 获取初始点和结束点
var startP = Stack[0]
var endP = Stack[Stack.length - 1]
ctx.lineTo(xoffset + axisLength, oW);
ctx.lineTo(xoffset, oW);
ctx.lineTo(startP[0], startP[1])
ctx.fillStyle = "#1c86d1";
ctx.fill()
ctx.restore();
}
function drawText() {
ctx.globalCompositeOperation = 'source-over';
var size = 0.4 * cR;
ctx.font = 'bold ' + size + 'px Microsoft Yahei';
txt = (nowdata.toFixed(2) * 100).toFixed(0) + '%';
var fonty = r + size / 2;
var fontx = r - size * 0.8;
ctx.fillStyle = "rgba(06, 85, 128, 0.8)";
ctx.fillText(txt, fontx, fonty)
}
function render() {
ctx.clearRect(0, 0, oW, oH);
if (circleLock) {
if (arcStack.length) {
var temp = arcStack.shift();
ctx.lineTo(temp[0], temp[1])
ctx.stroke();
} else {
circleLock = false;
ctx.lineTo(cStartPoint[0], cStartPoint[1])
ctx.stroke();
arcStack = null;
ctx.globalCompositeOperation = 'destination-over';
ctx.beginPath();
ctx.lineWidth = lineWidth;
ctx.arc(r, r, bR, 0, 2 * PI, 1);
ctx.beginPath();
ctx.save();
ctx.arc(r, r, r - 16 * lineWidth, 0, 2 * PI, 1);
ctx.restore()
ctx.clip();
ctx.fillStyle = "#1c86d1";
// 初始拖拽控件
oRange.addEventListener("change", function () {
data = ~~(oRange.value) / 100;
console.log("data=" + data)
}, 0);
}
} else {
// 开始水波动画
// 控制波幅
if (data >= 0.85) {
if (nowrange > range / 4) {
var t = range * 0.01;
nowrange -= t;
}
} else if (data <= 0.1) {
if (nowrange < range * 1.5) {
var t = range * 0.01;
nowrange += t;
}
} else {
if (nowrange <= range) {
var t = range * 0.01;
nowrange += t;
}
if (nowrange >= range) {
var t = range * 0.01;
nowrange -= t;
}
}
if ((data - nowdata) > 0) {
nowdata += waveupsp;
}
if ((data - nowdata) < 0) {
nowdata -= waveupsp
}
sp += 0.07;
drawSine();
drawText();
}
requestAnimationFrame(render)
}
</script>
</body>
</html>
# 简易版
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>起始、终止角度同一高度的环形</title>
<style>
* {
padding: 0;
margin: 0;
}
body {
background: #E9EFF6;
}
.ring-wrap {
position: relative;
width: 400px;
height: 400px;
}
.tips {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
</style>
</head>
<body>
<div class="ring-wrap">
<canvas id="myCanvas" width="400" height="400"></canvas>
<div class="tips"></div>
</div>
<script>
/**
* 循环动画:requestAnimationFrame 是一种递归调用的方式来实现动画循环。你需要确保在每一帧中都重新调用requestAnimationFrame来保持动画的流畅性和连贯性
* 1.时间计算 2. requestAnimationFrame 减帧
*
*
*/
/** 减帧
let frameCount = 0;
const frameRateThreshold = 2; // 帧率阈值
function animate() {
// 增加计数器
frameCount++;
if (frameCount % frameRateThreshold === 0) {
// 绘制操作
// ...
// 清空计数器
frameCount = 0;
}
// 继续下一帧动画
requestAnimationFrame(animate);
}
*/
let curRate = 0;
let endRate = null;
let preRate = null; // 上一次的值
let aniId = null;
let step = 1;
var ctx = null;
const radius = 400;
const waveColors = ['#FF863D', '#FF9507'];
const waveShadow = '#F2C486';
const oRingbBorderColor = 'rgb(255,255,255)'; // 外圈的边框颜色
const oRingShadow = 'rgba(0,23,100, 0.2)'; // 外圈的阴影颜色
const tipRingGradientColors = ['rgba(255, 255, 255, 1)', 'rgba(233, 239, 246, 1)']; // 中间占位园的颜色
function generSphereByRadius(x, y, r, borderColor = oRingbBorderColor) {
if (!ctx) {
return;
}
ctx.save();
ctx.lineWidth = 1;
ctx.strokeStyle = borderColor;
// 添加阴影
ctx.shadowOffsetY = 4;
ctx.shadowOffsetX = 2;
ctx.shadowBlur = 15;
ctx.shadowColor = oRingShadow;
ctx.beginPath();
ctx.arc(x, y, r, 0, 2 * Math.PI);
ctx.stroke();
ctx.restore();
}
function genGradient(colorList) {
if (!ctx) {
return null;
}
// ctx.createRadialGradient(x1, y1, r1, x2, y2, r2)
const v = radius / 2;
const gradient = ctx.createRadialGradient(v, v, v, v, 0, v);
gradient.addColorStop(0, colorList[0]);
gradient.addColorStop(1, colorList[1]);
return gradient;
}
function drawWave(x, y, r, process = 50) {
if (!ctx) {
return;
}
ctx.beginPath();
ctx.save();
const deg = Math.PI / 180;
const tag = 50;
const curRate = 90 * ((tag - process) / tag); // 当前进度所占一半的角度
// console.log('curRate', curRate);
ctx.arc(x, y, r, curRate * deg, (180 - curRate) * deg);
ctx.fillStyle = genGradient(waveColors);
if (process === endRate) {
ctx.shadowOffsetY = 5;
ctx.shadowBlur = 25;
ctx.shadowColor = waveShadow;
}
ctx.fill();
ctx.restore();
}
function drawWhiteFullCircle(x, y, r, gradient = null) {
if (!ctx) {
return;
}
ctx.beginPath();
ctx.arc(x, y, r, 0, 2 * Math.PI);
ctx.fillStyle = gradient || genGradient(tipRingGradientColors);
ctx.fill();
}
function clearCanvasStore() {
ctx.clearRect(0, 0, radius, radius); // 解决无法清除已绘制的图形
basicOpt();
}
function execDraw() {
clearCanvasStore();
curRate += step;
const v = radius / 2;
drawWave(v, v, v - 20, curRate);
drawWhiteFullCircle(v, v, v - 40);
}
function increase() {
if (curRate >= endRate) {
cancelAni();
return;
}
execDraw();
aniId = requestAnimationFrame(increase);
}
function minus() {
// console.log('endRate', curRate, endRate);
if (curRate <= endRate) {
cancelAni();
return;
}
execDraw();
aniId = requestAnimationFrame(minus);
}
function cancelAni() {
if (aniId) {
cancelAnimationFrame(aniId);
preRate = endRate;
// ctx.globalCompositeOperation = 'source-over';
}
}
function wrapRender() {
curRate = preRate || 0;
if (step < 0) {
minus();
} else {
increase();
}
}
function basicOpt() {
const v = radius / 2;
generSphereByRadius(v, v, v - 5); // 外环
drawWhiteFullCircle(v, v, v - 20, genGradient(['#E9EFF6', '#F9FAFC']));
}
// 获取操作前的上下文
function createCtx() {
const canvas = document.getElementById('myCanvas');
canvas.width = radius;
canvas.height = radius;
ctx = canvas.getContext('2d');
}
/**
*
* @param rate 当前的进度
*/
function init(rate) {
if (+rate === preRate) {
return;
}
endRate = +rate;
createCtx();
if (+rate > preRate) {
step = 1;
} else {
step = -1;
}
wrapRender();
}
init(50);
</script>
</body>
</html>
# 二. vue版本
<template>
<div class="ring-wrap">
<canvas ref="cIns">当前浏览器不支持canvas 请升级!</canvas>
<div class="tips"><slot name="tip"></slot></div>
<slot></slot>
</div>
</template>
<script setup lang="ts">
import { onMounted, shallowRef } from 'vue';
const oRingbBorderColor = 'rgb(255,255,255)'; // 外圈的边框颜色
const oRingShadow = 'rgba(0,23,100, 0.5)'; // 外圈的阴影颜色
const tipRingGradientColors = ['rgba(255, 255, 255, 1)', 'rgba(233, 239, 246, 1)']; // 中间占位园的颜色
const props = withDefaults(defineProps<{ size: number; waveColors: string[]; waveShadow: string }>(), {
size: 180, // 尺寸
waveColors: () => ['#06a5e9', '#89bede'], // 进度环的颜色
waveShadow: 'rgba(131, 206, 237, 0.8)', // 进度环的阴影
});
const cIns = shallowRef(null);
let curRate = 0;
let endRate = null;
let preRate = null; // 上一次的值
let aniId = null;
let step = 1;
let ctx = null;
function generSphereByRadius(x, y, r, borderColor = oRingbBorderColor) {
if (!ctx) {
return;
}
ctx.save();
ctx.lineWidth = 1;
ctx.strokeStyle = borderColor;
// 添加阴影
ctx.shadowOffsetY = 2;
// ctx.shadowOffsetX = 2;
ctx.shadowBlur = 8;
ctx.shadowColor = oRingShadow;
ctx.beginPath();
ctx.arc(x, y, r, 0, 2 * Math.PI);
ctx.stroke();
ctx.restore();
}
function genGradient(colorList) {
if (!ctx) {
return null;
}
// ctx.createRadialGradient(x1, y1, r1, x2, y2, r2)
const v = props.size / 2;
const gradient = ctx.createRadialGradient(v, v, v, v, 0, v);
gradient.addColorStop(0, colorList[0]);
gradient.addColorStop(1, colorList[1]);
return gradient;
}
function drawWave(x, y, r, process = 50) {
if (!ctx) {
return;
}
ctx.save();
ctx.beginPath();
const degUnit = Math.PI / 180;
const tag = 50;
const startDeg = 90 * ((tag - process) / tag); // 当前进度所占一半的角度
ctx.arc(x, y, r, startDeg * degUnit, (180 - startDeg) * degUnit);
ctx.fillStyle = genGradient(props.waveColors);
if (process >= endRate) {
ctx.shadowOffsetY = 5;
ctx.shadowBlur = 8;
ctx.shadowColor = props.waveShadow;
}
ctx.fill();
ctx.restore();
}
function drawWhiteFullCircle(x, y, r, gradient = null) {
if (!ctx) {
return;
}
ctx.beginPath();
ctx.arc(x, y, r, 0, 2 * Math.PI);
ctx.fillStyle = gradient || genGradient(tipRingGradientColors);
ctx.fill();
}
function clearCanvasStore() {
ctx.clearRect(0, 0, props.size, props.size);
basicOpt();
}
function execDraw() {
clearCanvasStore(); // 清除上一次的记录
curRate += step;
const v = props.size / 2;
drawWave(v, v, v - 15, curRate);
drawWhiteFullCircle(v, v, v - 35);
}
function cancelAni() {
if (aniId) {
cancelAnimationFrame(aniId);
preRate = endRate;
// ctx.globalCompositeOperation = 'source-over';
}
}
function increase() {
if (curRate >= endRate) {
cancelAni();
return;
}
execDraw();
aniId = requestAnimationFrame(increase);
}
function minus() {
if (curRate <= endRate) {
cancelAni();
return;
}
execDraw();
aniId = requestAnimationFrame(minus);
}
function wrapRender() {
curRate = preRate || 0;
if (step < 0) {
minus();
} else {
increase();
}
}
function basicOpt() {
const v = props.size / 2;
generSphereByRadius(v, v, v - 5); // 外环
drawWhiteFullCircle(v, v, v - 15, genGradient(['#E9EFF6', '#F9FAFC']));
}
// 获取操作前的上下文
function createCtx() {
const canvas = cIns.value;
canvas.width = props.size;
canvas.height = props.size;
ctx = canvas.getContext('2d');
}
onMounted(() => {
createCtx();
});
/**
*
* @param oRate 当前的进度
*/
async function init(oRate) {
const rate = +oRate;
if (rate === preRate) {
return;
}
if (rate === 0) {
preRate = 0;
clearCanvasStore();
return;
}
endRate = rate;
if (rate > preRate) {
step = 1;
} else {
step = -1;
}
wrapRender();
}
defineExpose({
init,
});
</script>
<style lang="less" scoped>
.ring-wrap {
position: relative;
width: 100%;
height: 100%;
}
.tips {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: #07296f;
font-size: 24px;
font-weight: bold;
}
</style>