弹框可见性的控制

总体来说就是通过click、hover、改变焦点的方式,打开或关闭弹层。
使用场景如:

  • 当鼠标移入帮助符号时,显示文本提示
  • 当鼠标移出时,隐藏文本提示。

视图层有两种要素:页面元素和用户行为。
仿照胡克定律可以得出 view = handler(action)

rc-trigger 集成了弹层显示隐藏的处理逻辑,以便在操作挂载元素时显示和隐藏弹层。

为了使弹层不受触发元素位置及大小的影响,rc-trigger 统一将弹层插入到 document 根节点中
Popup 组件既用于绘制蒙层,又用于组织弹层的动效以及调整弹层的位置
PopupInner 对接 Trigger,弹层实际内容外围挂载鼠标移入移出事件对弹层的影响
LazyRenderBox 根据 props.visible 等属性,决定是否需要绘制弹层实际内容,还是绘制空的 div
Popup 用于渲染弹层的实际内容
image.png
Trigger

  • 渲染时将弹层插入 document 根节点: 因为如果把弹窗渲染在正常文档流中,组件的定位会受到父元素定位的影响。如果父元素为静态定位,定位标的就会变成再父一级元素。将弹窗挂载在 body 下,就能解决这个问题。
  • 通过 props 中的 action 、showAction、hideAction 指定切换弹层显示隐藏状态的事件
  • 实现 onClick 等方法以切换弹层的显示隐藏状态
  • 实现 onPopupMouseLeave 等方法,透传到 PopupInner 组件中,以使鼠标移出弹层时隐藏弹层
  • PopupInner 渲染了个div,添了个 LazyRenderBox 功能组件
  • Trigger 将弹层动效、位置调整相关属性传入 Popup 组件中

弹层渲染

  • React 16,借助 ReactDOM.createPortal
  • React 15,通过 rc-utils 提供的 ContainerRender 组件完成

image.png

  1. // 没有指定 props.getPopupContainer 时,弹层在根节点中创建 div 元素并完成渲染
  2. getContainer = () => {
  3. const { props } = this;
  4. const popupContainer = document.createElement('div');
  5. // Make sure default popup container will never cause scrollbar appearing
  6. // https://github.com/react-component/trigger/issues/41
  7. popupContainer.style.position = 'absolute';
  8. popupContainer.style.top = '0';
  9. popupContainer.style.left = '0';
  10. popupContainer.style.width = '100%';
  11. const mountNode = props.getPopupContainer ?
  12. props.getPopupContainer(findDOMNode(this)) : props.getDocument().body;
  13. mountNode.appendChild(popupContainer);
  14. return popupContainer;
  15. }
  16. render(){
  17. // ...
  18. // getComponent 渲染 Popup 等弹层组件
  19. if (!IS_REACT_16) {
  20. return (
  21. <ContainerRender
  22. parent={this}
  23. visible={popupVisible}
  24. autoMount={false}
  25. forceRender={forceRender}
  26. getComponent={this.getComponent}
  27. getContainer={this.getContainer}
  28. >
  29. {({ renderComponent }) => {
  30. this.renderComponent = renderComponent;
  31. return trigger;
  32. }}
  33. </ContainerRender>
  34. );
  35. }
  36. let portal;
  37. // prevent unmounting after it's rendered
  38. if (popupVisible || this._component || forceRender) {
  39. portal = (
  40. <Portal
  41. key="portal"
  42. getContainer={this.getContainer}
  43. didUpdate={this.handlePortalUpdate}
  44. >
  45. {this.getComponent()}
  46. </Portal>
  47. );
  48. }
  49. return [
  50. trigger,
  51. portal,
  52. ];
  53. }

触发元素的绑定事件

显示弹层:

  • onClick
  • onMouseDown
  • onMouseEnter
  • onFocus
  • onTouchStart
  • onContextMenu

隐藏弹层:

  • onClick
  • onMouseLeave
  • onBlur

rc-trigger 有两层处理逻辑:

  • 若所处理的事件不影响弹层的显隐,将 createTwoChains 创建的兜底函数作为绑定函数,直接调用外层 Trigger 元素或 children 触发元素的 props 同名方法;
  • 若影响,使用内置的 onClick 方法作为事件的绑定函数,以切换弹层的显示隐藏状态。
  1. // 1. 事件的兜底处理函数
  2. // const ALL_HANDLERS = ['onClick', 'onMouseDown', 'onTouchStart', 'onMouseEnter',
  3. // 'onMouseLeave', 'onFocus', 'onBlur', 'onContextMenu'];
  4. componentWillMount() {
  5. ALL_HANDLERS.forEach((h) => {
  6. this[`fire${h}`] = (e) => {
  7. this.fireEvents(h, e);
  8. };
  9. });
  10. }
  11. // 先调用触发元素 children 的 props 方法,再调用 Trigger 元素的 props 方法
  12. fireEvents(type, e) {
  13. const childCallback = this.props.children.props[type];
  14. if (childCallback) {
  15. childCallback(e);
  16. }
  17. const callback = this.props[type];
  18. if (callback) {
  19. callback(e);
  20. }
  21. }
  22. // 在 render 时作为触发元素 children 实际绑定的方法
  23. createTwoChains(event) {
  24. const childPros = this.props.children.props;
  25. const props = this.props;
  26. if (childPros[event] && props[event]) {
  27. return this[`fire${event}`];
  28. }
  29. return childPros[event] || props[event];
  30. }
  31. // 2. 通过事件显隐弹层
  32. // 实时或延迟显隐弹窗
  33. delaySetPopupVisible(visible, delayS, event) {
  34. const delay = delayS * 1000;
  35. this.clearDelayTimer();
  36. if (delay) {
  37. const point = event ? { pageX: event.pageX, pageY: event.pageY } : null;
  38. this.delayTimer = setTimeout(() => {
  39. this.setPopupVisible(visible, point);
  40. this.clearDelayTimer();
  41. }, delay);
  42. } else {
  43. this.setPopupVisible(visible, event);
  44. }
  45. }
  46. onClick = (event) => {
  47. this.fireEvents('onClick', event);
  48. // focus will trigger click
  49. // 聚焦时快速点击,不必隐藏弹层
  50. if (this.focusTime) {
  51. let preTime;
  52. if (this.preClickTime && this.preTouchTime) {
  53. preTime = Math.min(this.preClickTime, this.preTouchTime);
  54. } else if (this.preClickTime) {
  55. preTime = this.preClickTime;
  56. } else if (this.preTouchTime) {
  57. preTime = this.preTouchTime;
  58. }
  59. if (Math.abs(preTime - this.focusTime) < 20) {
  60. return;
  61. }
  62. this.focusTime = 0;
  63. }
  64. this.preClickTime = 0;
  65. this.preTouchTime = 0;
  66. if (event && event.preventDefault) {
  67. event.preventDefault();
  68. }
  69. const nextVisible = !this.state.popupVisible;
  70. if (this.isClickToHide() && !nextVisible || nextVisible && this.isClickToShow()) {
  71. this.setPopupVisible(!this.state.popupVisible, event);
  72. }
  73. }
  74. onMouseDown = (e) => {
  75. this.fireEvents('onMouseDown', e);
  76. this.preClickTime = Date.now();
  77. }
  78. onTouchStart = (e) => {
  79. this.fireEvents('onTouchStart', e);
  80. this.preTouchTime = Date.now();
  81. }
  82. onFocus = (e) => {
  83. this.fireEvents('onFocus', e);
  84. // incase focusin and focusout
  85. this.clearDelayTimer();
  86. if (this.isFocusToShow()) {
  87. this.focusTime = Date.now();
  88. this.delaySetPopupVisible(true, this.props.focusDelay);
  89. }
  90. }


弹层的绑定事件

  • 支持在文档被点击时隐藏弹层:对于文档中挂载的事件,Trigger 组件在 componentDidUpdate 生命周期中对 document 节点绑定事件,所绑定的事件不限于点击,还包含文档滚动、窗口失焦;
  • 支持在鼠标移出弹层时隐藏弹层:对于弹层挂载的事件,Trigger 组件实现了 onPopupMouseEnter, onPopupMouseLeave, onPopupMouseDown 方法,并透传给 PopupInner 组件,作为该组件渲染内容的绑定函数
  1. // 1. 文档绑定事件处理函数
  2. // 根据可操控弹层显隐的事件,对 document 或 window 绑定事件处理函数
  3. componentDidUpdate(_, prevState) {
  4. const props = this.props;
  5. const state = this.state;
  6. const triggerAfterPopupVisibleChange = () => {
  7. if (prevState.popupVisible !== state.popupVisible) {
  8. props.afterPopupVisibleChange(state.popupVisible);
  9. }
  10. };
  11. if (!IS_REACT_16) {
  12. this.renderComponent(null, triggerAfterPopupVisibleChange);
  13. }
  14. this.prevPopupVisible = prevState.popupVisible;
  15. // We must listen to `mousedown` or `touchstart`, edge case:
  16. // https://github.com/ant-design/ant-design/issues/5804
  17. // https://github.com/react-component/calendar/issues/250
  18. // https://github.com/react-component/trigger/issues/50
  19. if (state.popupVisible) {
  20. let currentDocument;
  21. if (!this.clickOutsideHandler && (this.isClickToHide() || this.isContextMenuToShow())) {
  22. currentDocument = props.getDocument();
  23. this.clickOutsideHandler = addEventListener(currentDocument,
  24. 'mousedown', this.onDocumentClick);
  25. }
  26. // always hide on mobile
  27. if (!this.touchOutsideHandler) {
  28. currentDocument = currentDocument || props.getDocument();
  29. this.touchOutsideHandler = addEventListener(currentDocument,
  30. 'touchstart', this.onDocumentClick);
  31. }
  32. // close popup when trigger type contains 'onContextMenu' and document is scrolling.
  33. if (!this.contextMenuOutsideHandler1 && this.isContextMenuToShow()) {
  34. currentDocument = currentDocument || props.getDocument();
  35. this.contextMenuOutsideHandler1 = addEventListener(currentDocument,
  36. 'scroll', this.onContextMenuClose);
  37. }
  38. // close popup when trigger type contains 'onContextMenu' and window is blur.
  39. if (!this.contextMenuOutsideHandler2 && this.isContextMenuToShow()) {
  40. this.contextMenuOutsideHandler2 = addEventListener(window,
  41. 'blur', this.onContextMenuClose);
  42. }
  43. return;
  44. }
  45. this.clearOutsideHandler();
  46. }
  47. // 当蒙层可关闭时,点击文档关闭弹层
  48. onDocumentClick = (event) => {
  49. if (this.props.mask && !this.props.maskClosable) {
  50. return;
  51. }
  52. const target = event.target;
  53. const root = findDOMNode(this);
  54. if (!contains(root, target) && !this.hasPopupMouseDown) {
  55. this.close();
  56. }
  57. }
  58. // 鼠标右键可显示弹层时,通过文档滚动、窗口失焦可隐藏弹层
  59. onContextMenuClose = () => {
  60. if (this.isContextMenuToShow()) {
  61. this.close();
  62. }
  63. }
  64. close() {
  65. this.setPopupVisible(false);
  66. }
  67. // 2. 弹层绑定事件处理函数
  68. onPopupMouseEnter = () => {
  69. this.clearDelayTimer();
  70. }
  71. // 当鼠标移出弹层时,隐藏弹层
  72. // this._component 即 Popup 组件实例
  73. // this._component.getPopupDomNode 用于获取 PopupInner 组件绘制的节点内容
  74. onPopupMouseLeave = (e) => {
  75. // https://github.com/react-component/trigger/pull/13
  76. // react bug?
  77. if (e.relatedTarget && !e.relatedTarget.setTimeout &&
  78. this._component &&
  79. this._component.getPopupDomNode &&
  80. contains(this._component.getPopupDomNode(), e.relatedTarget)) {
  81. return;
  82. }
  83. this.delaySetPopupVisible(false, this.props.mouseLeaveDelay);
  84. }
  85. // 点击弹层变更 Trigger 实例的 hasPopupMouseDown 属性,以指定文档点击区域不是弹层内部
  86. // 当弹层相互嵌套时,向上递归调用 onPopupMouseDown 方法也用于阻止祖先弹层的隐藏
  87. onPopupMouseDown = (...args) => {
  88. const { rcTrigger = {} } = this.context;
  89. this.hasPopupMouseDown = true;
  90. clearTimeout(this.mouseDownTimeout);
  91. this.mouseDownTimeout = setTimeout(() => {
  92. this.hasPopupMouseDown = false;
  93. }, 0);
  94. if (rcTrigger.onPopupMouseDown) {
  95. rcTrigger.onPopupMouseDown(...args);
  96. }
  97. };

弹层的位置调整

Popup -> Animate -> AnimateChild -> rcAlign -> PopupInner -> LazyRenderBox -> content
Popup 组件实现了:

  • 是否需要遮罩
  • zIndex 设为多少
  • 隐藏要不要卸载掉弹框

弹层的位置调整基于 rc-align 类库。Popup 脱离了正常文档流,挂在了 document.body 上。那如何把两个风马牛不相及的元素对齐呢?还要考虑到窗口改变时也能对得上。靠的是元素的可视矩阵(区域)计算
在实现上,通过将 props.alignPoint 置为真值,弹层位置即可根据鼠标移动情况进行调整;默认情况下,弹层位置取决于触发元素的显示位置。Trigger 将动态计算鼠标的位置 state.point,随后将 props.align 注入到 Popup 组件。若 Popup 组件接受的 props.align 为否值,弹层位置即取决于触发元素的显示位置;否则,由鼠标位置决定。在位置调整过程中,Trigger 组件接受的 props.onPopupAlign 可用于监听弹层位置的调整状况,以便于动态微调。

  1. // 获取实际注入 dom-align 类库的 alignConfig 配置,用于调整弹层位置
  2. // 参见 https://github.com/yiminghe/dom-align
  3. function getAlignFromPlacement(builtinPlacements, placementStr, align) {
  4. const baseAlign = builtinPlacements[placementStr] || {};
  5. return {
  6. ...baseAlign,
  7. ...align,
  8. };
  9. }
  10. class Trigger extends React.Component {
  11. // props.builtinPlacements 内置多种弹层放置策略,实际使用 props.popupPlacement 放置策略
  12. // props.popupAlign 作为 dom-align 库获得的 alignConfig 配置,用于调整位置
  13. getPopupAlign() {
  14. const props = this.props;
  15. const { popupPlacement, popupAlign, builtinPlacements } = props;
  16. if (popupPlacement && builtinPlacements) {
  17. return getAlignFromPlacement(builtinPlacements, popupPlacement, popupAlign);
  18. }
  19. return popupAlign;
  20. }
  21. // 当 props.alignPoint 为真值,Popup 组件获得的 props.align 即为 state.point
  22. // 意义是弹层的定位位置将根据鼠标位置进行调整
  23. setPoint = (point) => {
  24. const { alignPoint } = this.props;
  25. if (!alignPoint || !point) return;
  26. this.setState({
  27. point: {
  28. pageX: point.pageX,
  29. pageY: point.pageY,
  30. },
  31. });
  32. }
  33. }
  34. class Popup extends Component {
  35. // 位置调整时调用
  36. onAlign = (popupDomNode, align) => {
  37. const props = this.props;
  38. const currentAlignClassName = props.getClassNameFromAlign(align);
  39. // FIX: https://github.com/react-component/trigger/issues/56
  40. // FIX: https://github.com/react-component/tooltip/issues/79
  41. if (this.currentAlignClassName !== currentAlignClassName) {
  42. this.currentAlignClassName = currentAlignClassName;
  43. popupDomNode.className = this.getClassName(currentAlignClassName);
  44. }
  45. props.onAlign(popupDomNode, align);
  46. }
  47. // 获取弹层的定位位置,有两种可能:根据鼠标调整,或者触发元素的位置
  48. getAlignTarget = () => {
  49. const { point } = this.props;
  50. if (point) {
  51. return point;
  52. }
  53. // getTargetElement 方法由 Trigger 注入,用于获取 Trigger 元素
  54. return this.getTargetElement;
  55. }
  56. // 使用 Align 虚拟组件包裹实际渲染内容,调整弹层的显示位置
  57. getPopupElement() {
  58. // ...
  59. <Align target={this.getAlignTarget()} key="popup" ref={this.saveAlignRef}
  60. monitorWindowResize align={align} onAlign={this.onAlign}>
  61. <PopupInner visible {...popupInnerProps}>
  62. {children}
  63. </PopupInner>
  64. </Align>
  65. // ...
  66. }
  67. }

弹层动效

弹层的动效基于 rc-animate 类库实现,包含蒙层和弹层实际内容的动效,它会调用子组件中的钩子函数,来控制动画效果,Antd 动画主要是 CSS3 动画。