Skip to content

Commit 703ce38

Browse files
authored
refactor: Progress circle (#141)
* refactor: use circle instead of path * fix: gapDegree * fix: gapPosition * fix: gapPosition default value * chore: VIEW_BOX_SIZE variable * fix: strokeLinecap square make percent not correct * fix: percent accuracy issue when >98% or <2% Reduce half value of storkeWidth when strokeLinecap="round" * test: update snapshot * chore: upgrade devDeps * chore: improve ts type * fix: test case
1 parent 2ea3828 commit 703ce38

11 files changed

+373
-613
lines changed

docs/examples/fast-progress.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ class App extends React.Component<ProgressProps, any> {
1111
this.restart = this.restart.bind(this);
1212
}
1313

14-
private tm: number;
14+
private tm: NodeJS.Timeout;
1515

1616
componentDidMount() {
1717
this.increase();

docs/examples/simple.tsx

+20-11
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ class Example extends React.Component<ProgressProps, any> {
55
constructor(props) {
66
super(props);
77
this.state = {
8-
percent: 30,
8+
percent: 96,
99
color: '#3FC7FA',
1010
};
1111
this.changeState = this.changeState.bind(this);
@@ -23,19 +23,25 @@ class Example extends React.Component<ProgressProps, any> {
2323
};
2424

2525
changeIncrease() {
26-
let percent = this.state.percent + 10;
27-
if (percent > 100) {
28-
percent = 100;
29-
}
30-
this.setState({ percent });
26+
this.setState(({ percent }) => {
27+
if (percent > 100) {
28+
percent = 100;
29+
}
30+
return {
31+
percent: percent + 1,
32+
};
33+
});
3134
};
3235

3336
changeReduce() {
34-
let percent = this.state.percent - 10;
35-
if (percent < 0) {
36-
percent = 0;
37-
}
38-
this.setState({ percent });
37+
this.setState(({ percent }) => {
38+
if (percent < 0) {
39+
percent = 0;
40+
}
41+
return {
42+
percent: percent - 1,
43+
};
44+
});
3945
};
4046

4147
render() {
@@ -63,6 +69,9 @@ class Example extends React.Component<ProgressProps, any> {
6369
<div style={circleContainerStyle}>
6470
<Circle percent={percent} strokeWidth={6} strokeLinecap="round" strokeColor={color} />
6571
</div>
72+
<div style={circleContainerStyle}>
73+
<Circle percent={percent} strokeWidth={6} strokeLinecap="butt" strokeColor={color} />
74+
</div>
6675
<div style={circleContainerStyle}>
6776
<Circle percent={percent} strokeWidth={6} strokeLinecap="square" strokeColor={color} />
6877
</div>

package.json

+9-7
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
"lint": "eslint src/ --ext .ts,.tsx,.jsx,.js",
3434
"prettier": "prettier --write \"**/*.{ts,tsx,js,jsx,json,md}\"",
3535
"test": "father test",
36+
"tsc": "tsc --noEmit",
3637
"coverage": "father test --coverage",
3738
"now-build": "npm run docs:build"
3839
},
@@ -47,22 +48,23 @@
4748
},
4849
"devDependencies": {
4950
"@types/classnames": "^2.2.9",
50-
"@types/jest": "^26.0.0",
51-
"@types/react": "^16.9.2",
52-
"@types/react-dom": "^16.9.0",
51+
"@types/jest": "^27.5.0",
52+
"@types/react": "^18.0.9",
53+
"@types/react-dom": "^18.0.3",
5354
"@umijs/fabric": "^2.0.0",
55+
"@wojtekmaj/enzyme-adapter-react-17": "^0.6.7",
5456
"cross-env": "^7.0.0",
5557
"dumi": "^1.1.0",
5658
"enzyme": "^3.1.1",
5759
"enzyme-adapter-react-16": "^1.0.1",
5860
"enzyme-to-json": "^3.1.2",
59-
"eslint": "^7.6.0",
61+
"eslint": "^7.1.0",
6062
"father": "^2.29.6",
61-
"glob": "^7.1.6",
63+
"glob": "^8.0.1",
6264
"np": "^7.2.0",
6365
"prettier": "^2.1.1",
64-
"react": "^16.9.0",
65-
"react-dom": "^16.9.0",
66+
"react": "^17.0.2",
67+
"react-dom": "^17.0.2",
6668
"typescript": "^4.0.2"
6769
}
6870
}

src/Circle.tsx

+82-71
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import * as React from 'react';
22
import classNames from 'classnames';
33
import { useTransitionDuration, defaultProps } from './common';
4-
import type { ProgressProps, GapPositionType } from './interface';
4+
import type { ProgressProps } from './interface';
55
import useId from './hooks/useId';
66

77
function stripPercentToNumber(percent: string) {
@@ -13,56 +13,52 @@ function toArray<T>(value: T | T[]): T[] {
1313
return Array.isArray(mergedValue) ? mergedValue : [mergedValue];
1414
}
1515

16-
function getPathStyles(
16+
const VIEW_BOX_SIZE = 100;
17+
18+
const getCircleStyle = (
19+
radius: number,
1720
offset: number,
1821
percent: number,
1922
strokeColor: string | Record<string, string>,
20-
strokeWidth: number,
2123
gapDegree = 0,
22-
gapPosition: GapPositionType,
23-
) {
24-
const radius = 50 - strokeWidth / 2;
25-
let beginPositionX = 0;
26-
let beginPositionY = -radius;
27-
let endPositionX = 0;
28-
let endPositionY = -2 * radius;
29-
switch (gapPosition) {
30-
case 'left':
31-
beginPositionX = -radius;
32-
beginPositionY = 0;
33-
endPositionX = 2 * radius;
34-
endPositionY = 0;
35-
break;
36-
case 'right':
37-
beginPositionX = radius;
38-
beginPositionY = 0;
39-
endPositionX = -2 * radius;
40-
endPositionY = 0;
41-
break;
42-
case 'bottom':
43-
beginPositionY = radius;
44-
endPositionY = 2 * radius;
45-
break;
46-
default:
24+
gapPosition: ProgressProps['gapPosition'],
25+
strokeLinecap: ProgressProps['strokeLinecap'],
26+
strokeWidth,
27+
) => {
28+
const rotateDeg = gapDegree > 0 ? 90 + gapDegree / 2 : -90;
29+
const perimeter = Math.PI * 2 * radius;
30+
const perimeterWithoutGap = perimeter * ((360 - gapDegree) / 360);
31+
const offsetDeg = (offset / 100) * 360 * ((360 - gapDegree) / 360);
32+
33+
const positionDeg = {
34+
bottom: 0,
35+
top: 180,
36+
left: 90,
37+
right: -90,
38+
}[gapPosition];
39+
40+
let strokeDashoffset = ((100 - percent) / 100) * perimeterWithoutGap;
41+
// Fix percent accuracy when strokeLinecap is round
42+
// https://github.com/ant-design/ant-design/issues/35009
43+
if (strokeLinecap === 'round' && percent !== 100) {
44+
strokeDashoffset += strokeWidth / 2;
45+
// when percent is small enough (<= 1%), keep smallest value to avoid it's disapperance
46+
if (strokeDashoffset >= perimeterWithoutGap) {
47+
strokeDashoffset = perimeterWithoutGap - 0.01;
48+
}
4749
}
48-
const pathString = `M 50,50 m ${beginPositionX},${beginPositionY}
49-
a ${radius},${radius} 0 1 1 ${endPositionX},${-endPositionY}
50-
a ${radius},${radius} 0 1 1 ${-endPositionX},${endPositionY}`;
51-
const len = Math.PI * 2 * radius;
5250

53-
const pathStyle = {
51+
return {
5452
stroke: typeof strokeColor === 'string' ? strokeColor : undefined,
55-
strokeDasharray: `${(percent / 100) * (len - gapDegree)}px ${len}px`,
56-
strokeDashoffset: `-${gapDegree / 2 + (offset / 100) * (len - gapDegree)}px`,
53+
strokeDasharray: `${perimeterWithoutGap}px ${perimeter}`,
54+
strokeDashoffset,
55+
transform: `rotate(${rotateDeg + offsetDeg + positionDeg}deg)`,
56+
transformOrigin: '50% 50%',
5757
transition:
58-
'stroke-dashoffset .3s ease 0s, stroke-dasharray .3s ease 0s, stroke .3s, stroke-width .06s ease .3s, opacity .3s ease 0s', // eslint-disable-line
58+
'stroke-dashoffset .3s ease 0s, stroke-dasharray .3s ease 0s, stroke .3s, stroke-width .06s ease .3s, opacity .3s ease 0s',
59+
fillOpacity: 0,
5960
};
60-
61-
return {
62-
pathString,
63-
pathStyle,
64-
};
65-
}
61+
};
6662

6763
const Circle: React.FC<ProgressProps> = ({
6864
id,
@@ -80,16 +76,18 @@ const Circle: React.FC<ProgressProps> = ({
8076
...restProps
8177
}) => {
8278
const mergedId = useId(id);
83-
8479
const gradientId = `${mergedId}-gradient`;
80+
const radius = VIEW_BOX_SIZE / 2 - strokeWidth / 2;
8581

86-
const { pathString, pathStyle } = getPathStyles(
82+
const circleStyle = getCircleStyle(
83+
radius,
8784
0,
8885
100,
8986
trailColor,
90-
strokeWidth,
9187
gapDegree,
9288
gapPosition,
89+
strokeLinecap,
90+
strokeWidth,
9391
);
9492
const percentList = toArray(percent);
9593
const strokeColorList = toArray(strokeColor);
@@ -99,32 +97,44 @@ const Circle: React.FC<ProgressProps> = ({
9997

10098
const getStokeList = () => {
10199
let stackPtg = 0;
102-
return percentList.map((ptg, index) => {
103-
const color = strokeColorList[index] || strokeColorList[strokeColorList.length - 1];
104-
const stroke = color && typeof color === 'object' ? `url(#${gradientId})` : '';
105-
const pathStyles = getPathStyles(stackPtg, ptg, color, strokeWidth, gapDegree, gapPosition);
106-
stackPtg += ptg;
107-
return (
108-
<path
109-
key={index}
110-
className={`${prefixCls}-circle-path`}
111-
d={pathStyles.pathString}
112-
stroke={stroke}
113-
strokeLinecap={strokeLinecap}
114-
strokeWidth={strokeWidth}
115-
opacity={ptg === 0 ? 0 : 1}
116-
fillOpacity="0"
117-
style={pathStyles.pathStyle}
118-
ref={paths[index]}
119-
/>
120-
);
121-
});
100+
return percentList
101+
.map((ptg, index) => {
102+
const color = strokeColorList[index] || strokeColorList[strokeColorList.length - 1];
103+
const stroke = color && typeof color === 'object' ? `url(#${gradientId})` : undefined;
104+
const circleStyleForStack = getCircleStyle(
105+
radius,
106+
stackPtg,
107+
ptg,
108+
color,
109+
gapDegree,
110+
gapPosition,
111+
strokeLinecap,
112+
strokeWidth,
113+
);
114+
stackPtg += ptg;
115+
return (
116+
<circle
117+
key={index}
118+
className={`${prefixCls}-circle-path`}
119+
r={radius}
120+
cx={VIEW_BOX_SIZE / 2}
121+
cy={VIEW_BOX_SIZE / 2}
122+
stroke={stroke}
123+
strokeLinecap={strokeLinecap}
124+
strokeWidth={strokeWidth}
125+
opacity={ptg === 0 ? 0 : 1}
126+
style={circleStyleForStack}
127+
ref={paths[index]}
128+
/>
129+
);
130+
})
131+
.reverse();
122132
};
123133

124134
return (
125135
<svg
126136
className={classNames(`${prefixCls}-circle`, className)}
127-
viewBox="0 0 100 100"
137+
viewBox={`0 0 ${VIEW_BOX_SIZE} ${VIEW_BOX_SIZE}`}
128138
style={style}
129139
id={id}
130140
{...restProps}
@@ -140,16 +150,17 @@ const Circle: React.FC<ProgressProps> = ({
140150
</linearGradient>
141151
</defs>
142152
)}
143-
<path
153+
<circle
144154
className={`${prefixCls}-circle-trail`}
145-
d={pathString}
155+
r={radius}
156+
cx={VIEW_BOX_SIZE / 2}
157+
cy={VIEW_BOX_SIZE / 2}
146158
stroke={trailColor}
147159
strokeLinecap={strokeLinecap}
148160
strokeWidth={trailWidth || strokeWidth}
149-
fillOpacity="0"
150-
style={pathStyle}
161+
style={circleStyle}
151162
/>
152-
{getStokeList().reverse()}
163+
{getStokeList()}
153164
</svg>
154165
);
155166
};

src/Line.tsx

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import * as React from 'react';
22
import classNames from 'classnames';
33
import { useTransitionDuration, defaultProps } from './common';
4-
import { ProgressProps } from './interface';
4+
import type { ProgressProps } from './interface';
55

66
const Line: React.FC<ProgressProps> = ({
77
className,

src/common.ts

+3-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { useRef, useEffect } from 'react';
2-
import { ProgressProps } from './interface';
2+
import type { ProgressProps } from './interface';
33

44
export const defaultProps: Partial<ProgressProps> = {
55
className: '',
@@ -11,6 +11,7 @@ export const defaultProps: Partial<ProgressProps> = {
1111
style: {},
1212
trailColor: '#D9D9D9',
1313
trailWidth: 1,
14+
gapPosition: 'bottom',
1415
};
1516

1617
export const useTransitionDuration = (percentList: number[]) => {
@@ -21,7 +22,7 @@ export const useTransitionDuration = (percentList: number[]) => {
2122
const now = Date.now();
2223
let updated = false;
2324

24-
Object.keys(paths).forEach(key => {
25+
Object.keys(paths).forEach((key) => {
2526
const path = paths[key].current;
2627
if (!path) {
2728
return;

src/index.ts

+2-3
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
11
import Line from './Line';
22
import Circle from './Circle';
3-
import { ProgressProps } from './interface';
4-
5-
export { Line, Circle, ProgressProps };
63

4+
export type { ProgressProps } from './interface';
5+
export { Line, Circle };
76
export default {
87
Line,
98
Circle,

0 commit comments

Comments
 (0)