[React Ocean 组件库] 实现 Meterial UI 交互波浪动画 - Button 组件

8,900 阅读3分钟

Button 效果展示

动画.gif

为什么叫 Ocean

笔者始终觉得,我们对大自然有一种与生俱来的亲和力。

Ocean - 海洋

Volcano - 火山

Forest - 森林

Violet - 紫罗兰

Bay - 海湾

Willow - 柳木

为什么要搭建 React Ocean 组件库

笔者在校做一个学习系统,Antd v4 在学习系统当中的表现,不足以支撑起学习系统对样式的定制化。所以有了 React OceanReact Ocean 是一个为学习系统开发的组件库。考试倒计时,题目单选题,多选题,填空题………

Button 需求

  1. 支持 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>
  1. 和 Meterial UI 一样生动的波浪动画,支持修改波浪颜色。
<Button type="text" animationColor={'#2B3467'}>
        文本按钮
</Button>
  1. 默认宽高比 Antd 大一些,颜色会比 Antd 深一些 支持默认渐变风格按钮,默认风格为圆角风格。
  2. Button 宽高,提供 API 使其高度可配置,同时支持 small large medium这种低配置度的 API
<Button type="primary" size={"large"} width={300} height={200}>
        主要按钮
</Button>
  1. 支持 Loading

波浪动画实现

核心思路:

1.获取点击的位置

const rect = element?.getBoundingClientRect();
const { clientX, clientY } = event;
rippleX = Math.round(clientX - rect.left);
rippleY = Math.round(clientY - rect.top);
  1. 计算波浪大小
  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 });
  1. 点击画波浪
      setRipples((oldRipples) => {
        return [
          ...oldRipples,
          <Ripple
            key={Math.random() * 100}
            rippleSize={rippleSize}
            rippleX={rippleX}
            rippleY={rippleY}
            type={props.type}
            animationColor={props.animationColor}
          />,
        ];
      });
    },
  1. 波浪动画之后清除波浪
  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;
  };

亟待改进

  1. ButtonGroup 通过遍历时,不是 Button 组件时 不会配置该组件,如果有内层嵌套的 Button 组件,应该递归找到,并且按照顺序配置。
  2. 支持波浪的律动动画,即多次连续点击触发,波浪律动。
  3. ButtonGroup 更多的可配置所有按钮的配置项。
  4. 增加组件快照测试,e2e 测试。
  5. 完善 Button 事件。
  6. 完善 CSS 变量

其余效果展示

image.png

image.png

image.png

image.png

image.png

代码实现

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 })};
`;