Progress Spinner: Replace SVG+CSS with Canvas

This commit is contained in:
Alexander Zinchuk 2023-08-16 15:27:28 +02:00
parent b6b2ed0379
commit f46c563d6d
2 changed files with 87 additions and 50 deletions

View File

@ -50,38 +50,17 @@
width: 3.25rem;
height: 3.25rem;
svg {
width: 3rem;
height: 3rem;
&_canvas {
margin: 0.125rem;
}
circle {
stroke-width: 3px;
}
}
&.transparent {
background-color: transparent !important;
}
svg {
&_canvas {
display: block;
transform: rotate(-90deg);
transform-origin: 50% 50%;
animation: 4s linear 0s infinite ProgressSpinnerAnimation;
}
circle {
transition: stroke-dashoffset 0.5s;
}
}
@keyframes ProgressSpinnerAnimation {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
background-color: transparent !important;
}
}

View File

@ -1,16 +1,24 @@
import React, { memo } from '../../lib/teact/teact';
import type { FC } from '../../lib/teact/teact';
import React, { memo, useEffect, useRef } from '../../lib/teact/teact';
import { DPR } from '../../util/windowEnvironment';
import buildClassName from '../../util/buildClassName';
import './ProgressSpinner.scss';
import { animate, timingFunctions } from '../../util/animation';
import { requestMutation } from '../../lib/fasterdom/fasterdom';
import { useStateRef } from '../../hooks/useStateRef';
const RADIUSES = {
s: 22, m: 25, l: 28, xl: 20,
const SIZES = {
s: 42, m: 48, l: 54, xl: 52,
};
const STROKE_WIDTH = 2;
const STROKE_WIDTH = 2 * DPR;
const STROKE_WIDTH_XL = 3 * DPR;
const PADDING = 2 * DPR;
const MIN_PROGRESS = 0.05;
const MAX_PROGRESS = 1;
const GROW_DURATION = 600; // 0.6 s
const ROTATE_DURATION = 2000; // 2 s
const ProgressSpinner: FC<{
progress?: number;
@ -27,11 +35,47 @@ const ProgressSpinner: FC<{
noCross,
onClick,
}) => {
const radius = RADIUSES[size];
const circleRadius = radius - STROKE_WIDTH * 2;
const borderRadius = radius - 1;
const circumference = circleRadius * 2 * Math.PI;
const strokeDashOffset = circumference - Math.min(Math.max(MIN_PROGRESS, progress), MAX_PROGRESS) * circumference;
// eslint-disable-next-line no-null/no-null
const canvasRef = useRef<HTMLCanvasElement>(null);
const width = SIZES[size];
const progressRef = useStateRef(progress);
useEffect(() => {
let isFirst = true;
let growFrom = MIN_PROGRESS;
let growStartedAt: number | undefined;
let prevProgress: number | undefined;
animate(() => {
if (!canvasRef.current) {
return false;
}
if (progressRef.current !== prevProgress) {
growFrom = Math.min(Math.max(MIN_PROGRESS, prevProgress || 0), MAX_PROGRESS);
growStartedAt = Date.now();
prevProgress = progressRef.current;
}
const targetProgress = Math.min(Math.max(MIN_PROGRESS, progressRef.current), MAX_PROGRESS);
const t = Math.min(1, (Date.now() - growStartedAt!) / GROW_DURATION);
const animationFactor = timingFunctions.easeOutQuad(t);
const currentProgress = growFrom + (targetProgress - growFrom) * animationFactor;
drawSpinnerArc(
canvasRef.current,
width * DPR,
size === 'xl' ? STROKE_WIDTH_XL : STROKE_WIDTH,
'white',
currentProgress,
isFirst,
);
isFirst = false;
return currentProgress < 1;
}, requestMutation);
}, [progressRef, size, width]);
const className = buildClassName(
`ProgressSpinner size-${size}`,
@ -45,25 +89,39 @@ const ProgressSpinner: FC<{
className={className}
onClick={onClick}
>
<svg
viewBox={`0 0 ${borderRadius * 2} ${borderRadius * 2}`}
height={borderRadius * 2}
width={borderRadius * 2}
>
<circle
stroke="white"
fill="transparent"
stroke-width={STROKE_WIDTH}
stroke-dasharray={`${circumference} ${circumference}`}
stroke-dashoffset={strokeDashOffset}
stroke-linecap="round"
r={circleRadius}
cx={borderRadius}
cy={borderRadius}
/>
</svg>
<canvas ref={canvasRef} className="ProgressSpinner_canvas" style={`width: ${width}; height: ${width}px;`} />
</div>
);
};
function drawSpinnerArc(
canvas: HTMLCanvasElement,
size: number,
strokeWidth: number,
color: string,
progress: number,
shouldInit = false,
) {
const centerCoordinate = size / 2;
const radius = (size - strokeWidth) / 2 - PADDING;
const rotationOffset = (Date.now() % ROTATE_DURATION) / ROTATE_DURATION;
const startAngle = (2 * Math.PI) * rotationOffset;
const endAngle = startAngle + (2 * Math.PI) * progress;
const ctx = canvas.getContext('2d')!;
if (shouldInit) {
canvas.width = size;
canvas.height = size;
ctx.lineCap = 'round';
ctx.strokeStyle = color;
ctx.lineWidth = strokeWidth;
}
ctx.clearRect(0, 0, size, size);
ctx.beginPath();
ctx.arc(centerCoordinate, centerCoordinate, radius, startAngle, endAngle);
ctx.stroke();
}
export default memo(ProgressSpinner);