[React Ocean 组件库] 实现 Meterial UI 交互波浪动画 - Button 组件
Button 效果展示
为什么叫 Ocean
笔者始终觉得,我们对大自然有一种与生俱来的亲和力。
Ocean - 海洋
Volcano - 火山
Forest - 森林
Violet - 紫罗兰
Bay - 海湾
Willow - 柳木
为什么要搭建 React Ocean 组件库
笔者在校做一个学习系统,Antd v4 在学习系统当中的表现,不足以支撑起学习系统对样式的定制化。所以有了 React Ocean, React Ocean 是一个为学习系统开发的组件库。考试倒计时,题目单选题,多选题,填空题………
Button 需求
- 支持 Button Group 可以在一处地方,一起配置多个按钮,项目中确实有按钮组的需求。
<ButtonGroup types={['primary', 'outline', 'danger']} widths={[200, 100, 300]}>
<Button>主要按钮</Button>
<Button>轮廓按钮</Button>
<Button>危险按钮</Button>
</ButtonGroup>
<ButtonGroup type={'primary'} width={100}>
<Button>主要按钮</Button>
<Button>轮廓按钮</Button>
<Button>文本按钮</Button>
</ButtonGroup>
- 和 Meterial UI 一样生动的波浪动画,支持修改波浪颜色。
<Button type="text" animationColor={'#2B3467'}>
文本按钮
</Button>
- 默认宽高比 Antd 大一些,颜色会比 Antd 深一些 支持默认渐变风格按钮,默认风格为圆角风格。
- Button 宽高,提供 API 使其高度可配置,同时支持 small large medium这种低配置度的 API
<Button type="primary" size={"large"} width={300} height={200}>
主要按钮
</Button>
- 支持 Loading
波浪动画实现
核心思路:
1.获取点击的位置
const rect = element?.getBoundingClientRect();
const { clientX, clientY } = event;
rippleX = Math.round(clientX - rect.left);
rippleY = Math.round(clientY - rect.top);
- 计算波浪大小
const sizeX = Math.max(Math.abs(element.clientWidth - rippleX), rippleX) * 2 + 2;
const sizeY = Math.max(Math.abs(element.clientHeight - rippleY), rippleY) * 2 + 2;
rippleSize = Math.sqrt(sizeX ** 2 + sizeY ** 2);
startCommit({ rippleSize, rippleX, rippleY });
- 点击画波浪
setRipples((oldRipples) => {
return [
...oldRipples,
<Ripple
key={Math.random() * 100}
rippleSize={rippleSize}
rippleX={rippleX}
rippleY={rippleY}
type={props.type}
animationColor={props.animationColor}
/>,
];
});
},
- 波浪动画之后清除波浪
const useTimer = () => {
let timer: any = null;
useEffect(() => {
if (start) {
timer = setTimeout(() => {
setRipples([]);
}, 1500);
}
return () => timer && clearTimeout(timer);
});
};
实现 Button Group
核心思路:通过 React.Children.forEach
遍历定制配置每一个 Button
const ButtonGroup = (props: ButtonGroup) => {
const { types } = props;
const children: React.FunctionComponentElement<any>[] = [];
React.Children.forEach(props.children, (buttonItem: any, index) => {
const child = React.cloneElement(<Button />, {
...buttonItem.props,
type: types && types[index],
});
children.push(child);
});
return <ButtonGroupContext.Provider value={props}>{children}</ButtonGroupContext.Provider>;
};
定制配置项
核心思路:通过 reduce
方法,按照优先级 props > buttonGroup > defalt
的 优先级大小来计算。
const generateOwnState = (key: ButtonPropsTupleType) => {
const ownState: BaseButtonProps = key.reduce<any>((pre, cur) => {
pre[cur] =
props[cur as keyof BaseButtonProps] ||
buttonGroupContext![cur as keyof BaseButtonProps] ||
defaultPropsValue(cur);
return pre;
}, {});
for (const key in ownState) {
if (!ownState[key as keyof BaseButtonProps]) delete ownState[key as keyof BaseButtonProps];
}
return ownState;
};
亟待改进
ButtonGroup
通过遍历时,不是Button
组件时 不会配置该组件,如果有内层嵌套的Button
组件,应该递归找到,并且按照顺序配置。- 支持波浪的律动动画,即多次连续点击触发,波浪律动。
- 给
ButtonGroup
更多的可配置所有按钮的配置项。 - 增加组件快照测试,
e2e
测试。 - 完善
Button
事件。 - 完善
CSS
变量
其余效果展示
代码实现
const ButtonBase = React.forwardRef(function (props: ButtonProps, ref) {
const { children } = props;
const rippleRef = useRef<any>(null);
const buttonGroupContext = useContext(ButtonGroupContext);
const defaultPropsValue = (propsKey: keyof BaseButtonProps | string) => {
let propsValue = undefined;
propsValue = propsKey === 'size' ? 'medium' : undefined;
propsValue = propsKey === 'type' ? 'text' : undefined;
return propsValue;
};
const generateOwnState = (key: ButtonPropsTupleType) => {
const ownState: BaseButtonProps = key.reduce<any>((pre, cur) => {
pre[cur] =
props[cur as keyof BaseButtonProps] ||
buttonGroupContext![cur as keyof BaseButtonProps] ||
defaultPropsValue(cur);
return pre;
}, {});
for (const key in ownState) {
if (!ownState[key as keyof BaseButtonProps]) delete ownState[key as keyof BaseButtonProps];
}
return ownState;
};
const ownState = generateOwnState([
'size',
'type',
'style',
'width',
'height',
'animationColor',
'loading',
]);
const type = ownState['type'];
const animationColor = ownState['animationColor'];
const loading = ownState['loading'];
function useHandleRipper(action: 'stopRipple' | 'startRipple', eventCallback: any) {
return (event: any) => {
if (eventCallback) eventCallback(event);
if (rippleRef.current) {
rippleRef.current[action](event);
}
};
}
const handleOnMouseDown = useHandleRipper('startRipple', props.onMouseDown);
return (
<ButtonBaseStyle ownState={ownState}>
<button onMouseDown={handleOnMouseDown}>
{loading ? <ButtonLoading type={type} /> : ''}
{children}
<TouchRipple ref={rippleRef} type={type} animationColor={animationColor}></TouchRipple>
</button>
</ButtonBaseStyle>
);
});
export default ButtonBase;
const ButtonLoading = styled.span<{ type: ButtonType }>`
width: 17px;
height: 17px;
margin-right: 10px;
border: 2px solid white;
border-color: ${(props) => {
let borderColor = 'white';
if (props.type === 'danger' || props.type === 'outline' || props.type === 'text') {
borderColor = GlobalColor.OceanPrimaryColor;
}
return `transparent ${borderColor} ${borderColor} transparent`;
}};
display: inline-block;
border-radius: 50%;
cursor: alias;
-webkit-animation: 1s button-loading infinite linear;
animation: 1s button-loading infinite linear;
z-index: 4;
pointer-events: none;
@keyframes button-loading {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
`;
const ButtonBaseStyle = styled.div.attrs<{ ownState: BaseButtonProps }>((props) => ({
type: props.ownState.type,
size: props.ownState.size,
isText: props.ownState.type === 'text' || props.ownState.type === 'outline',
}))<{
ownState: BaseButtonProps;
type?: ButtonType;
size?: SizeType;
isText?: boolean;
}>`
button {
position: relative;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
width: ${(props) => {
let width = props.ownState.width;
if (typeof width === 'number') {
width = width + 'px';
}
return width ? width : '100%';
}};
height: ${(props) => {
let height = props.ownState.height;
if (typeof height === 'number') {
height = height + 'px';
}
return height ? height : '43px';
}};
padding: ${(props) => {
let p1 = 5;
let p2 = 15;
if (props.ownState.size === 'small') {
p1 = 0;
p2 = 5;
}
if (props.ownState.size === 'large') {
p1 = 10;
p2 = 30;
}
return `${p1}px ${p2}px`;
}};
overflow: hidden;
color: ${(props) => {
let color = '#fff';
color = props.isText ? GlobalColor.OceanPrimaryColor : color;
color = props.type === 'danger' ? 'rgb(211, 47, 47)' : color;
return color;
}};
font-weight: 530;
font-size: 0.875rem;
letter-spacing: 0.02857em;
background-color: ${(props) => {
let color = '#fff';
color = props.type === 'primary' ? GlobalColor.OceanPrimaryColor : color;
return color;
}};
background-image: ${(props) =>
props.type === 'gradual' ? 'linear-gradient(140deg, #6cc7ff 0%, #5a33ff 100%)' : undefined};
border: none;
border: ${(props) => {
let border = 'none';
border = props.type === 'outline' ? `1px solid rgba(25, 118, 210, 0.5)` : border;
border = props.type === 'danger' ? '1px solid rgba(211, 47, 47, 0.5)' : border;
return border;
}};
border-radius: 6px;
cursor: pointer;
transition: background-color 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms,
box-shadow 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms,
border-color 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms,
color 250ms cubic-bezier(0.4, 0, 0.2, 1) 0ms;
&:hover {
${(props) =>
props.type === 'primary'
? {
backgroundColor: 'rgb(21, 101, 192)',
boxShadow:
'rgb(0 0 0 / 20%) 0px 2px 4px -1px, rgb(0 0 0 / 14%) 0px 4px 5px 0px,rgb(0 0 0 / 12%) 0px 1px 10px 0px ',
}
: undefined};
${(props) =>
props.isText
? {
backgroundColor: 'rgba(25, 118, 210, 0.04)',
}
: undefined};
${(props) =>
props.type === 'danger'
? {
background: 'rgba(211, 47, 47, 0.04)',
}
: undefined};
${(props) =>
props.type === 'gradual'
? {
background: 'linear-gradient(140deg, #89d9ff 0%, #6c4aff 100%)',
}
: undefined};
}
}
${(props) => ({ ...props.ownState.style })};
`;
转载自:https://juejin.cn/post/7180909902872150075