Skip to content

Commit

Permalink
feat: ✨ implement vant-weapp circle component (#2)
Browse files Browse the repository at this point in the history
* feat: ✨ implement circle component

* style: 🎨 drop unnecessary blank line

* fix: 🐛 format value as integer number, avoid infinite paint
  • Loading branch information
huang-xiao-jian committed Jul 10, 2020
1 parent eb62ac2 commit 863f5f7
Show file tree
Hide file tree
Showing 7 changed files with 352 additions and 1 deletion.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ $ yarn build

### 展示组件

- [ ] Circle
- [x] Circle
- [x] Collapse
- [x] Divider
- [x] NoticeBar
Expand Down
15 changes: 15 additions & 0 deletions packages/Circle/Circle.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
.van-circle {
position: relative;
display: inline-block;
text-align: center;
}
.van-circle__text {
position: absolute;
top: 50%;
left: 0;
width: 100%;
transform: translateY(-50%);
color: #323233;
color: var(--circle-text-color, #323233);
font-size: 16px;
}
196 changes: 196 additions & 0 deletions packages/Circle/Circle.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
// packages
import React, {
FunctionComponent,
CSSProperties,
useMemo,
useRef,
useEffect,
} from 'react';
import clsx from 'clsx';
import { useNativeEffect } from 'remax';
import { View, Canvas, CoverView } from 'remax/wechat';
// internal
import uuid from '../tools/uuid';
import Deferred from '../tools/Deferred';
import { Switch, Case } from '../tools/Switch';
import withDefaultProps from '../tools/with-default-props-advance';
import { calcStrokeStyle, format, calcAngleRange } from './actions';
import './Circle.css';

// 默认值填充属性
interface NeutralCircleProps {
size: number;
color: string | Record<string, string>;
layerColor: string;
speed: number;
strokeWidth: number;
clockwise: boolean;
}

interface ExogenousCircleProps {
// 容器类名,用以覆盖内部
className?: string;
// 必须属性
value: number;
// 填充色
fill?: string;
// 文本
text: string;
}

type CircleProps = NeutralCircleProps & ExogenousCircleProps;

// scope
const DefaultCircleProps: NeutralCircleProps = {
size: 100,
color: '#1989fa',
layerColor: '#fff',
speed: 50,
strokeWidth: 4,
clockwise: true,
};

const Circle: FunctionComponent<CircleProps> = (props) => {
const {
className,
size,
color,
text,
children,
clockwise,
layerColor,
speed,
fill,
strokeWidth,
value: _value, // 限制范围
} = props;
const id = useMemo(() => uuid(), []);
// limit range 0 ~ 100
const value = format(_value);
const classnames = {
container: clsx(className, 'van-circle'),
};
const stylesheets: Record<string, CSSProperties> = {
canvas: {
width: `${size}px`,
height: `${size}px`,
},
};

const $context$ = useRef(Deferred<CanvasRenderingContext2D>());
const $point$ = useRef(0);
const $pristine$ = useRef(true);
const draw = (
context: CanvasRenderingContext2D,
strokeStyle: string | CanvasGradient,
beginAngle: number,
endAngle: number
): void => {
const position = size / 2;
const radius = position - strokeWidth / 2;

// context.setStrokeStyle(strokeStyle);
context.strokeStyle = strokeStyle;
// context.setLineWidth(strokeWidth);
context.lineWidth = strokeWidth;
// context.setLineCap(lineCap);
context.lineCap = 'round';

context.beginPath();
context.arc(position, position, radius, beginAngle, endAngle, !clockwise);
context.stroke();

if (fill) {
context.fillStyle = fill;
context.fill();
}
};

const paint = (point: number) => {
$context$.current.promise.then((context) => {
// 清理现场
context.clearRect(0, 0, size, size);

// render layer circle
draw(context, layerColor, 0, 2 * Math.PI);

// render holver circle
const [beginAngle, endAngle] = calcAngleRange(point, clockwise);
const strokeStyle = calcStrokeStyle(context, color, size);

draw(context, strokeStyle, beginAngle, endAngle);
});
};

// resolve canvas context only once
useNativeEffect(() => {
const { pixelRatio: dpr } = wx.getSystemInfoSync();

wx.createSelectorQuery()
.select(`#${id}`)
.node()
.exec((res) => {
const canvas = res[0].node;
const context = canvas.getContext('2d') as CanvasRenderingContext2D;

// pre-setup
canvas.width = size * dpr;
canvas.height = size * dpr;

context.scale(dpr, dpr);

// callback ctx
$context$.current.resolve(context);
});
}, []);

/* eslint-disable react-hooks/exhaustive-deps */
// 初次绘制无动效
useEffect(() => {
paint(value);

// 状态标记
$pristine$.current = false;
// 内敛值更新
$point$.current = value;
}, []);

// 后续绘制处理动效
// 仅支持 value 响应,其他属性变更不支持
useEffect(() => {
const interval = setInterval(() => {
if (value !== $point$.current) {
const { current } = $point$;
const next = current < value ? current + 1 : current - 1;
// 更新内部 step
$point$.current = next;

paint(next);
} else {
clearInterval(interval);
}
}, 1000 / speed);

return () => {
clearInterval(interval);
};
}, [value]);

return (
<View className={classnames.container}>
<Canvas type="2d" id={id} style={stylesheets.canvas} />
<Switch>
<Case in={!text}>
<View className="van-circle__text">{children}</View>
</Case>
<Case default>
<CoverView className="van-circle__text">{text}</CoverView>
</Case>
</Switch>
</View>
);
};

export default withDefaultProps<ExogenousCircleProps, NeutralCircleProps>(
DefaultCircleProps
)(Circle);
41 changes: 41 additions & 0 deletions packages/Circle/actions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/**
* @description - split canvas render logical
* @author - huang.jian <[email protected]>
*/

export type CircleColor = string | Record<string, string>;

export const format = (rate: number): number =>
Math.round(Math.min(Math.max(rate, 0), 100));

export const calcStrokeStyle = (
context: CanvasRenderingContext2D,
color: CircleColor,
size: number
): string | CanvasGradient => {
if (typeof color === 'string') {
return color;
}

const LinearColor = context.createLinearGradient(size, 0, 0, 0);

Object.keys(color)
.sort((a, b) => parseFloat(a) - parseFloat(b))
.map((key) => LinearColor.addColorStop(parseFloat(key) / 100, color[key]));

return LinearColor;
};

export const calcAngleRange = (
point: number,
clockwise: boolean
): [number, number] => {
const round = 2 * Math.PI;
const beginAngle = -Math.PI / 2;
const progress = round * (point / 100);
const endAngle = clockwise
? beginAngle + progress
: 3 * Math.PI - (beginAngle + progress);

return [beginAngle, endAngle];
};
4 changes: 4 additions & 0 deletions packages/Circle/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
/**
* @description - nothing but export component
*/
export { default } from './Circle';
23 changes: 23 additions & 0 deletions packages/tools/Deferred.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/**
* @description - deferred promise
* @author - huang.jian <[email protected]>
*/

interface DeferreMedium<T> {
promise: Promise<T>;
resolve: (value?: T | PromiseLike<T>) => void;
reject: (reason?: unknown) => void;
}

function Deferred<T>(): DeferreMedium<T> {
const medium: Partial<DeferreMedium<T>> = {};

medium.promise = new Promise((resolve, reject) => {
medium.resolve = resolve;
medium.reject = reject;
});

return medium as DeferreMedium<T>;
}

export default Deferred;
72 changes: 72 additions & 0 deletions storyboard/pages/circle/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
// packages
import React, { useState } from 'react';
import { View, Text } from 'remax/wechat';

// internal
import Circle from '../../../packages/Circle';
import Button from '../../../packages/Button';

export default () => {
const [value, setValue] = useState(40);
const onClickMinus = () => {
setValue((prev) => Math.max(0, prev - 5));
};
const onClickPlus = () => {
setValue((prev) => Math.min(100, prev + 5));
};
const text = `${value}%`;

return (
<View className="demo-block">
<Text className="demo-block__title">基础用法</Text>
<View className="demo-block__content">
<Circle value={value} text={text} />
</View>
<Text className="demo-block__title">宽度定制</Text>
<View className="demo-block__content">
<Circle value={value} text={text} strokeWidth={6} />
</View>
<Text className="demo-block__title">颜色定制</Text>
<View className="demo-block__content">
<Circle value={value} text={text} layerColor="#eee" color="#ee0a24" />
</View>
<Text className="demo-block__title">渐变色</Text>
<View className="demo-block__content">
<Circle
value={value}
text={text}
color={{
'0%': '#ffd01e',
'100%': '#ee0a24',
}}
/>
</View>
<Text className="demo-block__title">逆时针</Text>
<View className="demo-block__content">
<Circle value={value} text={text} color="#07c160" clockwise={false} />
</View>
<Text className="demo-block__title">大小定制</Text>
<View className="demo-block__content">
<Circle value={value} text={text} size={120} />
</View>
<View className="demo-block__content">
<Button
type="info"
block
onClick={onClickPlus}
style={{ marginTop: '20px' }}
>
增加
</Button>
<Button
type="primary"
block
onClick={onClickMinus}
style={{ marginTop: '10px' }}
>
减少
</Button>
</View>
</View>
);
};

0 comments on commit 863f5f7

Please sign in to comment.