diff --git a/README.md b/README.md index e8a1eb8..31b04f7 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ $ yarn build ### 展示组件 -- [ ] Circle +- [x] Circle - [x] Collapse - [x] Divider - [x] NoticeBar diff --git a/packages/Circle/Circle.css b/packages/Circle/Circle.css new file mode 100644 index 0000000..765bd58 --- /dev/null +++ b/packages/Circle/Circle.css @@ -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; +} diff --git a/packages/Circle/Circle.tsx b/packages/Circle/Circle.tsx new file mode 100644 index 0000000..19f6af0 --- /dev/null +++ b/packages/Circle/Circle.tsx @@ -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; + 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 = (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 = { + canvas: { + width: `${size}px`, + height: `${size}px`, + }, + }; + + const $context$ = useRef(Deferred()); + 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 ( + + + + + {children} + + + {text} + + + + ); +}; + +export default withDefaultProps( + DefaultCircleProps +)(Circle); diff --git a/packages/Circle/actions.ts b/packages/Circle/actions.ts new file mode 100644 index 0000000..b0d6263 --- /dev/null +++ b/packages/Circle/actions.ts @@ -0,0 +1,41 @@ +/** + * @description - split canvas render logical + * @author - huang.jian + */ + +export type CircleColor = string | Record; + +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]; +}; diff --git a/packages/Circle/index.ts b/packages/Circle/index.ts new file mode 100644 index 0000000..5951ee2 --- /dev/null +++ b/packages/Circle/index.ts @@ -0,0 +1,4 @@ +/** + * @description - nothing but export component + */ +export { default } from './Circle'; diff --git a/packages/tools/Deferred.ts b/packages/tools/Deferred.ts new file mode 100644 index 0000000..2513289 --- /dev/null +++ b/packages/tools/Deferred.ts @@ -0,0 +1,23 @@ +/** + * @description - deferred promise + * @author - huang.jian + */ + +interface DeferreMedium { + promise: Promise; + resolve: (value?: T | PromiseLike) => void; + reject: (reason?: unknown) => void; +} + +function Deferred(): DeferreMedium { + const medium: Partial> = {}; + + medium.promise = new Promise((resolve, reject) => { + medium.resolve = resolve; + medium.reject = reject; + }); + + return medium as DeferreMedium; +} + +export default Deferred; diff --git a/storyboard/pages/circle/index.tsx b/storyboard/pages/circle/index.tsx new file mode 100644 index 0000000..facaf3e --- /dev/null +++ b/storyboard/pages/circle/index.tsx @@ -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 ( + + 基础用法 + + + + 宽度定制 + + + + 颜色定制 + + + + 渐变色 + + + + 逆时针 + + + + 大小定制 + + + + + + + + + ); +};