React Popup

1,388 阅读3分钟

前言

在移动端我们经常会用到一些反馈类型的组件,比如Toast,ActionSheet,Dialog, Modal 等组件。这些组件基本都是基于 Popup组件实现的。
目前开源的React的移动端组件库除了ant-mobile其他的比较少,不像 VueVant,Vux,cube-ui,mand-mobile等。所以移动端基于React技术栈的很多东西都需要自己去实现,接下来我们就自己去实现一个简单的Popup组件。

效果

popup.gif

Portal

Portal 可以将组件渲染到组件树之外的地方,就像乾坤大挪移一样可以移花接木、移穴换位,一般是直接将组件渲染到 document.body下面

// Portal.js
// eslint-disable-next-line no-unused-vars
import React, { memo } from 'react';
import { createPortal } from 'react-dom';

/**
 * Portal
 */
const Portal = memo(props => {
  const { children, getContainer = () => document.body } = props;
  const container = typeof getContainer === 'function' ? getContainer() : getContainer;

  if (container) {
    return createPortal(children, container);
  }
  return children;
});

export default Portal;

Popup

我们先分析下 Popup 需要哪些东西。从展现形式上看就两个,一个遮罩,一个容器。所以 html 结构比较简单

<div className="popup">
  <div className="popup__mask"></div>
  <div className="popup__content">{children}</div>
</div>
  1. 遮罩主要就是淡入淡出的效果,点击遮罩隐藏容器
  2. 然后容器的位置主要有5种,top,left,right,bottom,center,并有不同的动画效果,动画效果我们可以用react-transition-group这个库来实现,和Vue的内置动画api类似
  3. 可以先把每个位置的样式写出来,动画后面在写,动画主要有四步
    1. 进入前: xxx-enter
    2. 进入后: xxx-enter-active
    3. 出去前: xxx-exit
    4. 出去后: xxx-exit-active
// Popup.js
import React, { memo, useEffect, useState } from 'react';
import { CSSTransition } from 'react-transition-group';
import clsx from 'classnames';
import Portal from './Portal';

import './style.less';

/**
 * Popup
 */
const Popup = memo(props => {
  const {
    visible,
    children,
    position = 'bottom',
    maskClassName,
    contentClassName,
    getContainer,
    onMaskClick,
    onEnter,
    onExited,
  } = props;
  const defaultPosition = position || 'bottom';

  const maskClass = clsx('popup__mask', maskClassName);
  const contentClass = clsx(`popup__content popup__content--${defaultPosition}`, contentClassName);

  const [curVisible, setCurVisible] = useState(visible);

  useEffect(() => {
    setCurVisible(visible);
  }, [visible]);

  return (
    <Portal getContainer={getContainer}>
      <CSSTransition
        in={curVisible}
        timeout={300}
        classNames={defaultPosition}
        unmountOnExit
        onEnter={onEnter}
        onExited={onExited}
      >
        <div className="popup">
          <div className={maskClass} onClick={onMaskClick}></div>
          <div className={contentClass}>{children}</div>
        </div>
      </CSSTransition>
    </Portal>
  );
});

export default Popup;

样式

  1. 遮罩样式比较简单,rgba 实现半透明,动画主要是 opcity0 - 1之间过渡
  2. 内容样式先分别实现对应的位置,再分别实现对应的出入动画
// style.less
:global {
  .popup {
    &__mask {
      position: fixed;
      top: 0;
      left: 0;
      right: 0;
      bottom: 0;
      background-color: rgba(0, 0, 0, 0.5);
    }

    &__content {
      background: #fff;
      position: fixed;

      &--top {
        top: 0;
        left: 0;
        right: 0;
      }

      &--bottom {
        bottom: 0;
        left: 0;
        right: 0;
      }

      &--left {
        left: 0;
        top: 0;
        bottom: 0;
      }

      &--right {
        right: 0;
        top: 0;
        bottom: 0;
      }

      &--center {
        top: 50%;
        left: 50%;
        transform: translate(-50%, -50%);
      }
    }
  }

  .top,
  .bottom,
  .center,
  .left,
  .right {
    &-enter {
      .popup__mask {
        opacity: 0;
      }

      &-active {
        .popup__mask {
          opacity: 1;
          transition: 0.3s all ease;
        }

        .popup__content {
          transition: 0.3s all ease;
        }
      }
    }

    &-exit {
      .popup__mask {
        opacity: 1;
      }

      &-active {
        .popup__mask {
          opacity: 0;
          transition: 0.3s all ease;
        }
        .popup__content {
          transition: 0.3s all ease;
        }
      }
    }
  }

  .bottom {
    &-enter {
      .popup__content {
        transform: translate(0, 100%);
      }

      &-active {
        .popup__content {
          transform: translate(0, 0);
        }
      }
    }

    &-exit {
      .popup__content {
        transform: translate(0, 0);
      }

      &-active {
        .popup__content {
          transform: translate(0, 100%);
        }
      }
    }
  }

  .top {
    &-enter {
      .popup__content {
        transform: translate(0, -100%);
      }

      &-active {
        .popup__content {
          transform: translate(0, 0);
        }
      }
    }

    &-exit {
      .popup__content {
        transform: translate(0, 0);
      }

      &-active {
        .popup__content {
          transform: translate(0, -100%);
        }
      }
    }
  }

  .left {
    &-enter {
      .popup__content {
        transform: translate(-100%, 0);
      }

      &-active {
        .popup__content {
          transform: translate(0, 0);
        }
      }
    }

    &-exit {
      .popup__content {
        transform: translate(0, 0);
      }

      &-active {
        .popup__content {
          transform: translate(-100%, 0);
        }
      }
    }
  }

  .right {
    &-enter {
      .popup__content {
        transform: translate(100%, 0);
      }

      &-active {
        .popup__content {
          transform: translate(0, 0);
        }
      }
    }

    &-exit {
      .popup__content {
        transform: translate(0, 0);
      }

      &-active {
        .popup__content {
          transform: translate(100%, 0);
        }
      }
    }
  }

  .center {
    &-enter {
      .popup__content {
        opacity: 0;
        transform: translate(-50%, -50%) scale(0.7);
      }

      &-active {
        .popup__content {
          opacity: 1;
          transform: translate(-50%, -50%) scale(1);
        }
      }
    }

    &-exit {
      .popup__content {
        opacity: 1;
        transform: translate(-50%, -50%) scale(1);
      }

      &-active {
        .popup__content {
          opacity: 0;
          transform: translate(-50%, -50%) scale(0.7);
        }
      }
    }
  }
}

测试

// test.js
import React, { memo, useState } from 'react';
import { Button, Popup } from 'components';

const style = {
  color: '#fff',
  height: '100vh',
  display: 'flex',
  alignItems: 'flex-start',
  justifyContent: 'center',
};

const centerStyle = {
  display: 'flex',
  alignItems: 'center',
  justifyContent: 'center',
  fontSize: '2em',
};

/**
 * Test
 */
const Test = memo(props => {
  const [bottomVisible, setBottomVisble] = useState(false);
  const [topVisible, setTopVisible] = useState(false);
  const [leftVisible, setLeftVisible] = useState(false);
  const [rightVisible, setRightVisible] = useState(false);
  const [centerVisible, setCenterVisible] = useState(false);

  return (
    <div style={style}>
      <Button onClick={() => setBottomVisble(true)}>bottom</Button>
      <Button onClick={() => setTopVisible(true)}>top</Button>
      <Button onClick={() => setLeftVisible(true)}>left</Button>
      <Button onClick={() => setRightVisible(true)}>right</Button>
      <Button onClick={() => setCenterVisible(true)}>center</Button>

      <Popup visible={bottomVisible} position="bottom" onMaskClick={() => setBottomVisble(false)}>
        <div style={{ height: '20vh', ...centerStyle }}>popup-bottom</div>
      </Popup>
      <Popup visible={topVisible} position="top" onMaskClick={() => setTopVisible(false)}>
        <div style={{ height: '20vh', ...centerStyle }}>popup-top</div>
      </Popup>
      <Popup visible={leftVisible} position="left" onMaskClick={() => setLeftVisible(false)}>
        <div style={{ width: '50vw', height: '100%', ...centerStyle }}>popup-left</div>
      </Popup>
      <Popup visible={rightVisible} position="right" onMaskClick={() => setRightVisible(false)}>
        <div style={{ width: '50vw', height: '100%', ...centerStyle }}>popup-right</div>
      </Popup>
      <Popup visible={centerVisible} position="center" onMaskClick={() => setCenterVisible(false)}>
        <div style={{ width: '60vw', height: '60vw', ...centerStyle }}> popup-center</div>
      </Popup>
    </div>
  );
});

export default Test;

结语

🎉 🎉 大功告成 🎉 🎉,这样初版就完成了。当然还有很多细节的功能没有实现,这里只是提供一种思路,可以根据后续的反馈组件的功能去逐步的打磨完善Popup组件。