弹框可见性的控制
总体来说就是通过click、hover、改变焦点的方式,打开或关闭弹层。
使用场景如:
- 当鼠标移入帮助符号时,显示文本提示
- 当鼠标移出时,隐藏文本提示。
视图层有两种要素:页面元素和用户行为。
仿照胡克定律可以得出 view = handler(action)
rc-trigger 集成了弹层显示隐藏的处理逻辑,以便在操作挂载元素时显示和隐藏弹层。
为了使弹层不受触发元素位置及大小的影响,rc-trigger 统一将弹层插入到 document 根节点中
Popup 组件既用于绘制蒙层,又用于组织弹层的动效以及调整弹层的位置
PopupInner 对接 Trigger,弹层实际内容外围挂载鼠标移入移出事件对弹层的影响
LazyRenderBox 根据 props.visible 等属性,决定是否需要绘制弹层实际内容,还是绘制空的 div
Popup 用于渲染弹层的实际内容
Trigger
- 渲染时将弹层插入 document 根节点: 因为如果把弹窗渲染在正常文档流中,组件的定位会受到父元素定位的影响。如果父元素为静态定位,定位标的就会变成再父一级元素。将弹窗挂载在 body 下,就能解决这个问题。
- 通过 props 中的 action 、showAction、hideAction 指定切换弹层显示隐藏状态的事件
- 实现 onClick 等方法以切换弹层的显示隐藏状态
- 实现 onPopupMouseLeave 等方法,透传到 PopupInner 组件中,以使鼠标移出弹层时隐藏弹层
- PopupInner 渲染了个div,添了个 LazyRenderBox 功能组件
- Trigger 将弹层动效、位置调整相关属性传入 Popup 组件中
弹层渲染
- React 16,借助 ReactDOM.createPortal
- React 15,通过 rc-utils 提供的 ContainerRender 组件完成
// 没有指定 props.getPopupContainer 时,弹层在根节点中创建 div 元素并完成渲染
getContainer = () => {
const { props } = this;
const popupContainer = document.createElement('div');
// Make sure default popup container will never cause scrollbar appearing
// https://github.com/react-component/trigger/issues/41
popupContainer.style.position = 'absolute';
popupContainer.style.top = '0';
popupContainer.style.left = '0';
popupContainer.style.width = '100%';
const mountNode = props.getPopupContainer ?
props.getPopupContainer(findDOMNode(this)) : props.getDocument().body;
mountNode.appendChild(popupContainer);
return popupContainer;
}
render(){
// ...
// getComponent 渲染 Popup 等弹层组件
if (!IS_REACT_16) {
return (
<ContainerRender
parent={this}
visible={popupVisible}
autoMount={false}
forceRender={forceRender}
getComponent={this.getComponent}
getContainer={this.getContainer}
>
{({ renderComponent }) => {
this.renderComponent = renderComponent;
return trigger;
}}
</ContainerRender>
);
}
let portal;
// prevent unmounting after it's rendered
if (popupVisible || this._component || forceRender) {
portal = (
<Portal
key="portal"
getContainer={this.getContainer}
didUpdate={this.handlePortalUpdate}
>
{this.getComponent()}
</Portal>
);
}
return [
trigger,
portal,
];
}
触发元素的绑定事件
显示弹层:
- onClick
- onMouseDown
- onMouseEnter
- onFocus
- onTouchStart
- onContextMenu
隐藏弹层:
- onClick
- onMouseLeave
- onBlur
rc-trigger 有两层处理逻辑:
- 若所处理的事件不影响弹层的显隐,将 createTwoChains 创建的兜底函数作为绑定函数,直接调用外层 Trigger 元素或 children 触发元素的 props 同名方法;
- 若影响,使用内置的 onClick 方法作为事件的绑定函数,以切换弹层的显示隐藏状态。
// 1. 事件的兜底处理函数
// const ALL_HANDLERS = ['onClick', 'onMouseDown', 'onTouchStart', 'onMouseEnter',
// 'onMouseLeave', 'onFocus', 'onBlur', 'onContextMenu'];
componentWillMount() {
ALL_HANDLERS.forEach((h) => {
this[`fire${h}`] = (e) => {
this.fireEvents(h, e);
};
});
}
// 先调用触发元素 children 的 props 方法,再调用 Trigger 元素的 props 方法
fireEvents(type, e) {
const childCallback = this.props.children.props[type];
if (childCallback) {
childCallback(e);
}
const callback = this.props[type];
if (callback) {
callback(e);
}
}
// 在 render 时作为触发元素 children 实际绑定的方法
createTwoChains(event) {
const childPros = this.props.children.props;
const props = this.props;
if (childPros[event] && props[event]) {
return this[`fire${event}`];
}
return childPros[event] || props[event];
}
// 2. 通过事件显隐弹层
// 实时或延迟显隐弹窗
delaySetPopupVisible(visible, delayS, event) {
const delay = delayS * 1000;
this.clearDelayTimer();
if (delay) {
const point = event ? { pageX: event.pageX, pageY: event.pageY } : null;
this.delayTimer = setTimeout(() => {
this.setPopupVisible(visible, point);
this.clearDelayTimer();
}, delay);
} else {
this.setPopupVisible(visible, event);
}
}
onClick = (event) => {
this.fireEvents('onClick', event);
// focus will trigger click
// 聚焦时快速点击,不必隐藏弹层
if (this.focusTime) {
let preTime;
if (this.preClickTime && this.preTouchTime) {
preTime = Math.min(this.preClickTime, this.preTouchTime);
} else if (this.preClickTime) {
preTime = this.preClickTime;
} else if (this.preTouchTime) {
preTime = this.preTouchTime;
}
if (Math.abs(preTime - this.focusTime) < 20) {
return;
}
this.focusTime = 0;
}
this.preClickTime = 0;
this.preTouchTime = 0;
if (event && event.preventDefault) {
event.preventDefault();
}
const nextVisible = !this.state.popupVisible;
if (this.isClickToHide() && !nextVisible || nextVisible && this.isClickToShow()) {
this.setPopupVisible(!this.state.popupVisible, event);
}
}
onMouseDown = (e) => {
this.fireEvents('onMouseDown', e);
this.preClickTime = Date.now();
}
onTouchStart = (e) => {
this.fireEvents('onTouchStart', e);
this.preTouchTime = Date.now();
}
onFocus = (e) => {
this.fireEvents('onFocus', e);
// incase focusin and focusout
this.clearDelayTimer();
if (this.isFocusToShow()) {
this.focusTime = Date.now();
this.delaySetPopupVisible(true, this.props.focusDelay);
}
}
弹层的绑定事件
- 支持在文档被点击时隐藏弹层:对于文档中挂载的事件,Trigger 组件在 componentDidUpdate 生命周期中对 document 节点绑定事件,所绑定的事件不限于点击,还包含文档滚动、窗口失焦;
- 支持在鼠标移出弹层时隐藏弹层:对于弹层挂载的事件,Trigger 组件实现了 onPopupMouseEnter, onPopupMouseLeave, onPopupMouseDown 方法,并透传给 PopupInner 组件,作为该组件渲染内容的绑定函数
// 1. 文档绑定事件处理函数
// 根据可操控弹层显隐的事件,对 document 或 window 绑定事件处理函数
componentDidUpdate(_, prevState) {
const props = this.props;
const state = this.state;
const triggerAfterPopupVisibleChange = () => {
if (prevState.popupVisible !== state.popupVisible) {
props.afterPopupVisibleChange(state.popupVisible);
}
};
if (!IS_REACT_16) {
this.renderComponent(null, triggerAfterPopupVisibleChange);
}
this.prevPopupVisible = prevState.popupVisible;
// We must listen to `mousedown` or `touchstart`, edge case:
// https://github.com/ant-design/ant-design/issues/5804
// https://github.com/react-component/calendar/issues/250
// https://github.com/react-component/trigger/issues/50
if (state.popupVisible) {
let currentDocument;
if (!this.clickOutsideHandler && (this.isClickToHide() || this.isContextMenuToShow())) {
currentDocument = props.getDocument();
this.clickOutsideHandler = addEventListener(currentDocument,
'mousedown', this.onDocumentClick);
}
// always hide on mobile
if (!this.touchOutsideHandler) {
currentDocument = currentDocument || props.getDocument();
this.touchOutsideHandler = addEventListener(currentDocument,
'touchstart', this.onDocumentClick);
}
// close popup when trigger type contains 'onContextMenu' and document is scrolling.
if (!this.contextMenuOutsideHandler1 && this.isContextMenuToShow()) {
currentDocument = currentDocument || props.getDocument();
this.contextMenuOutsideHandler1 = addEventListener(currentDocument,
'scroll', this.onContextMenuClose);
}
// close popup when trigger type contains 'onContextMenu' and window is blur.
if (!this.contextMenuOutsideHandler2 && this.isContextMenuToShow()) {
this.contextMenuOutsideHandler2 = addEventListener(window,
'blur', this.onContextMenuClose);
}
return;
}
this.clearOutsideHandler();
}
// 当蒙层可关闭时,点击文档关闭弹层
onDocumentClick = (event) => {
if (this.props.mask && !this.props.maskClosable) {
return;
}
const target = event.target;
const root = findDOMNode(this);
if (!contains(root, target) && !this.hasPopupMouseDown) {
this.close();
}
}
// 鼠标右键可显示弹层时,通过文档滚动、窗口失焦可隐藏弹层
onContextMenuClose = () => {
if (this.isContextMenuToShow()) {
this.close();
}
}
close() {
this.setPopupVisible(false);
}
// 2. 弹层绑定事件处理函数
onPopupMouseEnter = () => {
this.clearDelayTimer();
}
// 当鼠标移出弹层时,隐藏弹层
// this._component 即 Popup 组件实例
// this._component.getPopupDomNode 用于获取 PopupInner 组件绘制的节点内容
onPopupMouseLeave = (e) => {
// https://github.com/react-component/trigger/pull/13
// react bug?
if (e.relatedTarget && !e.relatedTarget.setTimeout &&
this._component &&
this._component.getPopupDomNode &&
contains(this._component.getPopupDomNode(), e.relatedTarget)) {
return;
}
this.delaySetPopupVisible(false, this.props.mouseLeaveDelay);
}
// 点击弹层变更 Trigger 实例的 hasPopupMouseDown 属性,以指定文档点击区域不是弹层内部
// 当弹层相互嵌套时,向上递归调用 onPopupMouseDown 方法也用于阻止祖先弹层的隐藏
onPopupMouseDown = (...args) => {
const { rcTrigger = {} } = this.context;
this.hasPopupMouseDown = true;
clearTimeout(this.mouseDownTimeout);
this.mouseDownTimeout = setTimeout(() => {
this.hasPopupMouseDown = false;
}, 0);
if (rcTrigger.onPopupMouseDown) {
rcTrigger.onPopupMouseDown(...args);
}
};
弹层的位置调整
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 可用于监听弹层位置的调整状况,以便于动态微调。
// 获取实际注入 dom-align 类库的 alignConfig 配置,用于调整弹层位置
// 参见 https://github.com/yiminghe/dom-align
function getAlignFromPlacement(builtinPlacements, placementStr, align) {
const baseAlign = builtinPlacements[placementStr] || {};
return {
...baseAlign,
...align,
};
}
class Trigger extends React.Component {
// props.builtinPlacements 内置多种弹层放置策略,实际使用 props.popupPlacement 放置策略
// props.popupAlign 作为 dom-align 库获得的 alignConfig 配置,用于调整位置
getPopupAlign() {
const props = this.props;
const { popupPlacement, popupAlign, builtinPlacements } = props;
if (popupPlacement && builtinPlacements) {
return getAlignFromPlacement(builtinPlacements, popupPlacement, popupAlign);
}
return popupAlign;
}
// 当 props.alignPoint 为真值,Popup 组件获得的 props.align 即为 state.point
// 意义是弹层的定位位置将根据鼠标位置进行调整
setPoint = (point) => {
const { alignPoint } = this.props;
if (!alignPoint || !point) return;
this.setState({
point: {
pageX: point.pageX,
pageY: point.pageY,
},
});
}
}
class Popup extends Component {
// 位置调整时调用
onAlign = (popupDomNode, align) => {
const props = this.props;
const currentAlignClassName = props.getClassNameFromAlign(align);
// FIX: https://github.com/react-component/trigger/issues/56
// FIX: https://github.com/react-component/tooltip/issues/79
if (this.currentAlignClassName !== currentAlignClassName) {
this.currentAlignClassName = currentAlignClassName;
popupDomNode.className = this.getClassName(currentAlignClassName);
}
props.onAlign(popupDomNode, align);
}
// 获取弹层的定位位置,有两种可能:根据鼠标调整,或者触发元素的位置
getAlignTarget = () => {
const { point } = this.props;
if (point) {
return point;
}
// getTargetElement 方法由 Trigger 注入,用于获取 Trigger 元素
return this.getTargetElement;
}
// 使用 Align 虚拟组件包裹实际渲染内容,调整弹层的显示位置
getPopupElement() {
// ...
<Align target={this.getAlignTarget()} key="popup" ref={this.saveAlignRef}
monitorWindowResize align={align} onAlign={this.onAlign}>
<PopupInner visible {...popupInnerProps}>
{children}
</PopupInner>
</Align>
// ...
}
}
弹层动效
弹层的动效基于 rc-animate 类库实现,包含蒙层和弹层实际内容的动效,它会调用子组件中的钩子函数,来控制动画效果,Antd 动画主要是 CSS3 动画。
- props.popupAnimation
- props.popupTransitionName
- props.maskAnimation
- props.maskTransitionName
资源
rc-trigger@3.0.0 - base abstract trigger component for react