Merge pull request #15150 from ant-design/affox

Fix Affix updatePosition logic.
This commit is contained in:
偏右 2019-03-07 17:59:40 +08:00 committed by GitHub
commit 93820ba90e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 266 additions and 272 deletions

View File

@ -17,31 +17,21 @@ class AffixMounter extends React.Component {
render() {
return (
<div
style={{
height: 100,
overflowY: 'scroll',
}}
ref={node => {
this.container = node;
}}
className="container"
>
<div
className="background"
style={{
paddingTop: 60,
height: 300,
<Affix
className="fixed"
target={this.getTarget}
ref={ele => {
this.affix = ele;
}}
{...this.props}
>
<Affix
target={() => this.container}
ref={ele => {
this.affix = ele;
}}
{...this.props}
>
<Button type="primary">Fixed at the top of container</Button>
</Affix>
</div>
<Button type="primary">Fixed at the top of container</Button>
</Affix>
</div>
);
}
@ -50,24 +40,36 @@ class AffixMounter extends React.Component {
describe('Affix Render', () => {
let wrapper;
const classRect = {
container: {
top: 0,
bottom: 100,
},
};
const originGetBoundingClientRect = HTMLElement.prototype.getBoundingClientRect;
HTMLElement.prototype.getBoundingClientRect = function() {
return (
classRect[this.className] || {
top: 0,
bottom: 0,
}
);
};
beforeAll(() => {
jest.useFakeTimers();
});
afterAll(() => {
jest.useRealTimers();
HTMLElement.prototype.getBoundingClientRect = originGetBoundingClientRect;
});
const scrollTo = top => {
wrapper.instance().affix.fixedNode.parentNode.getBoundingClientRect = jest.fn(() => ({
bottom: 100,
height: 28,
left: 0,
right: 0,
top: 50 - top,
width: 195,
}));
wrapper.instance().container.scrollTop = top;
const movePlaceholder = top => {
classRect.fixed = {
top,
bottom: top,
};
events.scroll({
type: 'scroll',
});
@ -80,14 +82,14 @@ describe('Affix Render', () => {
wrapper = mount(<AffixMounter />, { attachTo: document.getElementById('mounter') });
jest.runAllTimers();
scrollTo(0);
expect(wrapper.instance().affix.state.affixStyle).toBe(null);
movePlaceholder(0);
expect(wrapper.instance().affix.state.affixStyle).toBeFalsy();
scrollTo(100);
expect(wrapper.instance().affix.state.affixStyle).not.toBe(null);
movePlaceholder(-100);
expect(wrapper.instance().affix.state.affixStyle).toBeTruthy();
scrollTo(0);
expect(wrapper.instance().affix.state.affixStyle).toBe(null);
movePlaceholder(0);
expect(wrapper.instance().affix.state.affixStyle).toBeFalsy();
});
it('support offsetBottom', () => {
@ -96,16 +98,17 @@ describe('Affix Render', () => {
wrapper = mount(<AffixMounter offsetBottom={0} />, {
attachTo: document.getElementById('mounter'),
});
jest.runAllTimers();
scrollTo(0);
expect(wrapper.instance().affix.state.affixStyle).not.toBe(null);
movePlaceholder(300);
expect(wrapper.instance().affix.state.affixStyle).toBeTruthy();
scrollTo(100);
expect(wrapper.instance().affix.state.affixStyle).toBe(null);
movePlaceholder(0);
expect(wrapper.instance().affix.state.affixStyle).toBeFalsy();
scrollTo(0);
expect(wrapper.instance().affix.state.affixStyle).not.toBe(null);
movePlaceholder(300);
expect(wrapper.instance().affix.state.affixStyle).toBeTruthy();
});
it('updatePosition when offsetTop changed', () => {
@ -116,7 +119,7 @@ describe('Affix Render', () => {
});
jest.runAllTimers();
scrollTo(100);
movePlaceholder(-100);
expect(wrapper.instance().affix.state.affixStyle.top).toBe(0);
wrapper.setProps({
offsetTop: 10,

View File

@ -1,40 +1,12 @@
import * as React from 'react';
import * as ReactDOM from 'react-dom';
import * as PropTypes from 'prop-types';
import addEventListener from 'rc-util/lib/Dom/addEventListener';
import { polyfill } from 'react-lifecycles-compat';
import classNames from 'classnames';
import shallowequal from 'shallowequal';
import omit from 'omit.js';
import { ConfigConsumer, ConfigConsumerProps } from '../config-provider';
import getScroll from '../_util/getScroll';
import { throttleByAnimationFrameDecorator } from '../_util/throttleByAnimationFrame';
function getTargetRect(target: HTMLElement | Window | null): ClientRect {
return target !== window
? (target as HTMLElement).getBoundingClientRect()
: ({ top: 0, left: 0, bottom: 0 } as ClientRect);
}
function getOffset(element: HTMLElement, target: HTMLElement | Window | null) {
const elemRect = element.getBoundingClientRect();
const targetRect = getTargetRect(target);
const scrollTop = getScroll(target, true);
const scrollLeft = getScroll(target, false);
const docElem = window.document.body;
const clientTop = docElem.clientTop || 0;
const clientLeft = docElem.clientLeft || 0;
return {
top: elemRect.top - targetRect.top + scrollTop - clientTop,
left: elemRect.left - targetRect.left + scrollLeft - clientLeft,
width: elemRect.width,
height: elemRect.height,
};
}
function noop() {}
import warning from '../_util/warning';
import { addObserveTarget, removeObserveTarget, getTargetRect } from './utils';
function getDefaultTarget() {
return typeof window !== 'undefined' ? window : null;
@ -58,231 +30,168 @@ export interface AffixProps {
className?: string;
}
export interface AffixState {
affixStyle: React.CSSProperties | undefined;
placeholderStyle: React.CSSProperties | undefined;
enum AffixStatus {
None,
Prepare,
}
export default class Affix extends React.Component<AffixProps, AffixState> {
static propTypes = {
offsetTop: PropTypes.number,
offsetBottom: PropTypes.number,
target: PropTypes.func,
export interface AffixState {
affixStyle?: React.CSSProperties;
placeholderStyle?: React.CSSProperties;
status: AffixStatus;
lastAffix: boolean;
}
class Affix extends React.Component<AffixProps, AffixState> {
static defaultProps = {
target: getDefaultTarget,
};
state: AffixState = {
affixStyle: undefined,
placeholderStyle: undefined,
status: AffixStatus.None,
lastAffix: false,
};
placeholderNode: HTMLDivElement;
fixedNode: HTMLDivElement;
private timeout: number;
private eventHandlers: Record<string, any> = {};
private fixedNode: HTMLElement;
private placeholderNode: HTMLElement;
private readonly events = [
'resize',
'scroll',
'touchstart',
'touchmove',
'touchend',
'pageshow',
'load',
];
setAffixStyle(e: Event, affixStyle: React.CSSProperties | null) {
const { onChange = noop, target = getDefaultTarget } = this.props;
const originalAffixStyle = this.state.affixStyle;
const isWindow = target() === window;
if (e.type === 'scroll' && originalAffixStyle && affixStyle && isWindow) {
return;
}
if (shallowequal(affixStyle, originalAffixStyle)) {
return;
}
this.setState({ affixStyle: affixStyle as React.CSSProperties }, () => {
const affixed = !!this.state.affixStyle;
if ((affixStyle && !originalAffixStyle) || (!affixStyle && originalAffixStyle)) {
onChange(affixed);
}
});
}
setPlaceholderStyle(placeholderStyle: React.CSSProperties | null) {
const originalPlaceholderStyle = this.state.placeholderStyle;
if (shallowequal(placeholderStyle, originalPlaceholderStyle)) {
return;
}
this.setState({ placeholderStyle: placeholderStyle as React.CSSProperties });
}
syncPlaceholderStyle(e: Event) {
const { affixStyle } = this.state;
if (!affixStyle) {
return;
}
this.placeholderNode.style.cssText = '';
this.setAffixStyle(e, {
...affixStyle,
width: this.placeholderNode.offsetWidth,
});
this.setPlaceholderStyle({
width: this.placeholderNode.offsetWidth,
});
}
@throttleByAnimationFrameDecorator()
updatePosition(e: Event) {
const { offsetBottom, offset, target = getDefaultTarget } = this.props;
let { offsetTop } = this.props;
const targetNode = target();
// Backwards support
// Fix: if offsetTop === 0, it will get undefined,
// if offsetBottom is type of number, offsetMode will be { top: false, ... }
offsetTop = typeof offsetTop === 'undefined' ? offset : offsetTop;
const scrollTop = getScroll(targetNode, true);
const affixNode = ReactDOM.findDOMNode(this) as HTMLElement;
const elemOffset = getOffset(affixNode, targetNode);
const elemSize = {
width: this.fixedNode.offsetWidth,
height: this.fixedNode.offsetHeight,
};
const offsetMode = {
top: false,
bottom: false,
};
// Default to `offsetTop=0`.
if (typeof offsetTop !== 'number' && typeof offsetBottom !== 'number') {
offsetMode.top = true;
offsetTop = 0;
} else {
offsetMode.top = typeof offsetTop === 'number';
offsetMode.bottom = typeof offsetBottom === 'number';
}
const targetRect = getTargetRect(targetNode);
const targetInnerHeight =
(targetNode as Window).innerHeight || (targetNode as HTMLElement).clientHeight;
// ref: https://github.com/ant-design/ant-design/issues/13662
if (scrollTop >= elemOffset.top - (offsetTop as number) && offsetMode.top) {
// Fixed Top
const width = elemOffset.width;
const top = targetRect.top + (offsetTop as number);
this.setAffixStyle(e, {
position: 'fixed',
top,
left: targetRect.left + elemOffset.left,
width,
});
this.setPlaceholderStyle({
width,
height: elemSize.height,
});
} else if (
scrollTop <=
elemOffset.top + elemSize.height + (offsetBottom as number) - targetInnerHeight &&
offsetMode.bottom
) {
// Fixed Bottom
const targetBottomOffet = targetNode === window ? 0 : window.innerHeight - targetRect.bottom;
const width = elemOffset.width;
this.setAffixStyle(e, {
position: 'fixed',
bottom: targetBottomOffet + (offsetBottom as number),
left: targetRect.left + elemOffset.left,
width,
});
this.setPlaceholderStyle({
width,
height: elemOffset.height,
});
} else {
const { affixStyle } = this.state;
if (
e.type === 'resize' &&
affixStyle &&
affixStyle.position === 'fixed' &&
affixNode.offsetWidth
) {
this.setAffixStyle(e, { ...affixStyle, width: affixNode.offsetWidth });
} else {
this.setAffixStyle(e, null);
}
this.setPlaceholderStyle(null);
}
if (e.type === 'resize') {
this.syncPlaceholderStyle(e);
}
}
// Event handler
componentDidMount() {
const target = this.props.target || getDefaultTarget;
// Wait for parent component ref has its value
this.timeout = setTimeout(() => {
this.setTargetEventListeners(target);
// Mock Event object.
this.updatePosition({} as Event);
});
const { target } = this.props;
if (target) {
// [Legacy] Wait for parent component ref has its value.
// We should use target as directly element instead of function which makes element check hard.
this.timeout = setTimeout(() => {
addObserveTarget(target(), this);
// Mock Event object.
this.updatePosition({} as Event);
});
}
}
componentWillReceiveProps(nextProps: AffixProps) {
if (this.props.target !== nextProps.target) {
this.clearEventListeners();
this.setTargetEventListeners(nextProps.target!);
// Mock Event object.
this.updatePosition({} as Event);
componentDidUpdate(prevProps: AffixProps) {
const { target } = this.props;
if (prevProps.target !== target) {
removeObserveTarget(this);
if (target) {
addObserveTarget(target(), this);
// Mock Event object.
this.updatePosition({} as Event);
}
}
if (
this.props.offsetTop !== nextProps.offsetTop ||
this.props.offsetBottom !== nextProps.offsetBottom
prevProps.offsetTop !== this.props.offsetTop ||
prevProps.offsetBottom !== this.props.offsetBottom
) {
this.updatePosition({} as Event);
}
this.measure();
}
componentWillUnmount() {
this.clearEventListeners();
clearTimeout(this.timeout);
removeObserveTarget(this);
(this.updatePosition as any).cancel();
}
setTargetEventListeners(getTarget: () => HTMLElement | Window | null) {
const target = getTarget();
if (!target) {
return;
}
this.clearEventListeners();
this.events.forEach(eventName => {
this.eventHandlers[eventName] = addEventListener(target, eventName, this.updatePosition);
});
}
clearEventListeners() {
this.events.forEach(eventName => {
const handler = this.eventHandlers[eventName];
if (handler && handler.remove) {
handler.remove();
}
});
}
saveFixedNode = (node: HTMLDivElement) => {
this.fixedNode = node;
};
savePlaceholderNode = (node: HTMLDivElement) => {
this.placeholderNode = node;
};
saveFixedNode = (node: HTMLDivElement) => {
this.fixedNode = node;
};
// =================== Measure ===================
// Handle realign logic
@throttleByAnimationFrameDecorator()
// @ts-ignore TS6133
updatePosition(e: Event) {
// event param is used before. Keep compatible ts define here.
this.setState({
status: AffixStatus.Prepare,
affixStyle: undefined,
placeholderStyle: undefined,
});
}
measure = () => {
const { status, lastAffix } = this.state;
const { target, offset, offsetBottom, onChange } = this.props;
if (status !== AffixStatus.Prepare || !this.fixedNode || !this.placeholderNode || !target) {
return;
}
let { offsetTop } = this.props;
if (typeof offsetTop === 'undefined') {
offsetTop = offset;
warning(
typeof offset === 'undefined',
'Affix',
'`offset` is deprecated. Please use `offsetTop` instead.',
);
}
if (offsetBottom === undefined && offsetTop === undefined) {
offsetTop = 0;
}
const targetNode = target();
if (!targetNode) {
return;
}
const newState: Partial<AffixState> = {
status: AffixStatus.None,
};
const targetRect = getTargetRect(targetNode);
const placeholderReact = getTargetRect(this.placeholderNode);
if (offsetTop !== undefined && targetRect.top > placeholderReact.top - offsetTop) {
newState.affixStyle = {
position: 'fixed',
top: offsetTop + targetRect.top,
width: placeholderReact.width,
height: placeholderReact.height,
};
newState.placeholderStyle = {
width: placeholderReact.width,
height: placeholderReact.height,
};
} else if (
offsetBottom !== undefined &&
targetRect.bottom < placeholderReact.bottom + offsetBottom
) {
const targetBottomOffset = targetNode === window ? 0 : window.innerHeight - targetRect.bottom;
newState.affixStyle = {
position: 'fixed',
bottom: offsetBottom + targetBottomOffset,
width: placeholderReact.width,
height: placeholderReact.height,
};
newState.placeholderStyle = {
width: placeholderReact.width,
height: placeholderReact.height,
};
}
newState.lastAffix = !!newState.affixStyle;
if (onChange && lastAffix !== newState.lastAffix) {
onChange(newState.lastAffix);
}
this.setState(newState as AffixState);
};
// =================== Render ===================
renderAffix = ({ getPrefixCls }: ConfigConsumerProps) => {
const { prefixCls } = this.props;
const { affixStyle, placeholderStyle, status } = this.state;
const { prefixCls, style, children } = this.props;
const className = classNames({
[getPrefixCls('affix', prefixCls)]: this.state.affixStyle,
[getPrefixCls('affix', prefixCls)]: affixStyle,
});
const props = omit(this.props, [
@ -292,11 +201,14 @@ export default class Affix extends React.Component<AffixProps, AffixState> {
'target',
'onChange',
]);
const placeholderStyle = { ...this.state.placeholderStyle, ...this.props.style };
const mergedPlaceholderStyle = {
...(status === AffixStatus.None ? placeholderStyle : null),
...style,
};
return (
<div {...props} style={placeholderStyle} ref={this.savePlaceholderNode}>
<div {...props} style={mergedPlaceholderStyle} ref={this.savePlaceholderNode}>
<div className={className} ref={this.saveFixedNode} style={this.state.affixStyle}>
{this.props.children}
{children}
</div>
</div>
);
@ -306,3 +218,7 @@ export default class Affix extends React.Component<AffixProps, AffixState> {
return <ConfigConsumer>{this.renderAffix}</ConfigConsumer>;
}
}
polyfill(Affix);
export default Affix;

75
components/affix/utils.ts Normal file
View File

@ -0,0 +1,75 @@
import addEventListener from 'rc-util/lib/Dom/addEventListener';
import Affix from './';
// ======================== Observer ========================
const TRIGGER_EVENTS = [
'resize',
'scroll',
'touchstart',
'touchmove',
'touchend',
'pageshow',
'load',
];
interface ObserverEntity {
target: HTMLElement | Window;
affixList: Affix[];
eventHandlers: { [eventName: string]: any };
}
let observerEntities: ObserverEntity[] = [];
export function addObserveTarget(target: HTMLElement | Window | null, affix: Affix): void {
if (!target) return;
let entity: ObserverEntity | undefined = observerEntities.find(item => item.target === target);
if (entity) {
entity.affixList.push(affix);
} else {
entity = {
target,
affixList: [affix],
eventHandlers: {},
};
observerEntities.push(entity);
// Add listener
TRIGGER_EVENTS.forEach(eventName => {
entity!.eventHandlers[eventName] = addEventListener(target, eventName, (event: Event) => {
entity!.affixList.forEach(affix => {
affix.updatePosition(event);
});
});
});
}
}
export function removeObserveTarget(affix: Affix): void {
const observerEntity = observerEntities.find(oriObserverEntity => {
const hasAffix = oriObserverEntity.affixList.some(item => item === affix);
if (hasAffix) {
oriObserverEntity.affixList = oriObserverEntity.affixList.filter(item => item !== affix);
}
return hasAffix;
});
if (observerEntity && observerEntity.affixList.length === 0) {
observerEntities = observerEntities.filter(item => item !== observerEntity);
// Remove listener
TRIGGER_EVENTS.forEach(eventName => {
const handler = observerEntity.eventHandlers[eventName];
if (handler && handler.remove) {
handler.remove();
}
});
}
}
export function getTargetRect(target: HTMLElement | Window | null): ClientRect {
return target !== window
? (target as HTMLElement).getBoundingClientRect()
: ({ top: 0, bottom: window.innerHeight } as ClientRect);
}