likes
comments
collection
share

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

作者站长头像
站长
· 阅读数 16

Button 效果展示

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

为什么叫 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 变量

其余效果展示

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

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

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

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

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

代码实现

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