Skip to content

Commit 72d4ac9

Browse files
committed
basic measure
1 parent 3e391b6 commit 72d4ac9

File tree

7 files changed

+281
-2
lines changed

7 files changed

+281
-2
lines changed

.eslintrc.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
const base = require('@umijs/fabric/dist/eslint');
2+
3+
module.exports = {
4+
...base,
5+
};

examples/basic.tsx

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import * as React from 'react';
2+
import List from '../src/List';
3+
4+
interface Item {
5+
id: number;
6+
}
7+
8+
const MyItem: React.FC<Item> = ({ id }, ref) => {
9+
return (
10+
<span
11+
ref={ref}
12+
style={{
13+
border: '1px solid gray',
14+
padding: '0 16px',
15+
height: 30,
16+
lineHeight: '30px',
17+
boxSizing: 'border-box',
18+
display: 'inline-block',
19+
}}
20+
>
21+
{id}
22+
</span>
23+
);
24+
};
25+
26+
const ForwardMyItem = React.forwardRef(MyItem);
27+
28+
const dataSource: Item[] = [];
29+
for (let i = 0; i < 100; i += 1) {
30+
dataSource.push({
31+
id: i,
32+
});
33+
}
34+
35+
const Demo = () => {
36+
return (
37+
<React.StrictMode>
38+
<div>
39+
<h2>Basic</h2>
40+
<List
41+
dataSource={dataSource}
42+
height={200}
43+
itemHeight={30}
44+
style={{ border: '1px solid red', boxSizing: 'border-box' }}
45+
>
46+
{item => <ForwardMyItem {...item} />}
47+
</List>
48+
</div>
49+
</React.StrictMode>
50+
);
51+
};
52+
53+
export default Demo;

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
"main": "./lib/index",
2727
"module": "./es/index",
2828
"scripts": {
29-
"start": "father doc dev --storybook",
29+
"start": "cross-env NODE_ENV=development father doc dev --storybook",
3030
"build": "father doc build --storybook",
3131
"compile": "father build",
3232
"prepublishOnly": "npm run compile && np --no-cleanup --yolo --no-publish",
@@ -42,6 +42,7 @@
4242
"@types/react": "^16.8.19",
4343
"@types/react-dom": "^16.8.4",
4444
"@types/warning": "^3.0.0",
45+
"cross-env": "^5.2.0",
4546
"enzyme": "^3.1.0",
4647
"enzyme-adapter-react-16": "^1.0.2",
4748
"enzyme-to-json": "^3.1.4",

src/Filler.tsx

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import * as React from 'react';
2+
3+
interface FillerProps {
4+
height: number;
5+
}
6+
7+
/**
8+
* Fill component to provided the scroll content real height.
9+
*/
10+
const Filler: React.FC<FillerProps> = ({ height, children }) => (
11+
<div style={{ height, position: 'relative', overflow: 'hidden' }}>
12+
<div
13+
style={{
14+
position: 'absolute',
15+
left: 0,
16+
right: 0,
17+
top: 0,
18+
display: 'flex',
19+
flexDirection: 'column',
20+
}}
21+
>
22+
{children}
23+
</div>
24+
</div>
25+
);
26+
27+
export default Filler;

src/List.tsx

Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import * as React from 'react';
2+
import { findDOMNode } from 'react-dom';
3+
import Filler from './Filler';
4+
import { getLocationItem, getScrollPercentage } from './util';
5+
6+
type RenderFunc<T> = (item: T) => React.ReactNode;
7+
8+
export interface ListProps<T> extends React.HTMLAttributes<any> {
9+
children: RenderFunc<T>;
10+
dataSource: T[];
11+
height?: number;
12+
itemHeight?: number;
13+
component?: string | React.FC<any> | React.ComponentClass<any>;
14+
}
15+
16+
interface ListState {
17+
status: 'NONE' | 'MEASURE_START' | 'MEASURE_DONE';
18+
19+
itemIndex: number;
20+
itemOffsetPtg: number;
21+
startIndex: number;
22+
endIndex: number;
23+
}
24+
25+
/**
26+
* We use class component here since typescript can not support generic in function component
27+
*
28+
* Virtual list display logic:
29+
* 1. scroll / initialize trigger measure
30+
* 2. Get location item of current `scrollTop`
31+
* 3. [Render] Render visible items
32+
* 4. Get all the visible items height
33+
* 5. [Render] Update top item `margin-top` to fit the position
34+
*/
35+
class List<T> extends React.Component<ListProps<T>, ListState> {
36+
static defaultProps = {
37+
itemHeight: 15,
38+
dataSource: [],
39+
};
40+
41+
state: ListState = {
42+
status: 'NONE',
43+
itemIndex: 0,
44+
itemOffsetPtg: 0,
45+
startIndex: 0,
46+
endIndex: 0,
47+
};
48+
49+
listRef = React.createRef<HTMLElement>();
50+
51+
itemElements: { [index: number]: HTMLElement } = {};
52+
53+
/**
54+
* Initial should sync with default scroll top
55+
*/
56+
public componentDidMount() {
57+
this.listRef.current.scrollTop = 0;
58+
this.onScroll();
59+
}
60+
61+
public componentDidUpdate() {
62+
const { status, startIndex, endIndex } = this.state;
63+
if (status === 'MEASURE_START') {
64+
const heightList: number[] = [];
65+
for (let index = startIndex; index <= endIndex; index += 1) {
66+
const element: HTMLElement = this.itemElements[index];
67+
heightList[index] =
68+
'offsetHeight' in element
69+
? element.offsetHeight
70+
: (findDOMNode(element) as HTMLElement).offsetHeight;
71+
}
72+
this.setState({ status: 'MEASURE_DONE' });
73+
}
74+
}
75+
76+
/**
77+
* Phase 2: Trigger render since we should re-calculate current position.
78+
*/
79+
public onScroll = () => {
80+
const { dataSource, height, itemHeight } = this.props;
81+
82+
const scrollTopPtg = getScrollPercentage(this.listRef.current);
83+
const { index, offsetPtg } = getLocationItem(scrollTopPtg, dataSource.length);
84+
const visibleCount = Math.ceil(height / itemHeight);
85+
86+
const beforeCount = Math.ceil(scrollTopPtg * visibleCount);
87+
const afterCount = Math.ceil((1 - scrollTopPtg) * visibleCount);
88+
89+
this.setState({
90+
status: 'MEASURE_START',
91+
itemIndex: index,
92+
itemOffsetPtg: offsetPtg,
93+
startIndex: Math.max(0, index - beforeCount),
94+
endIndex: Math.min(dataSource.length - 1, index + afterCount),
95+
});
96+
};
97+
98+
public renderChildren = (list: T[], renderFunc: RenderFunc<T>) =>
99+
// We should measure rendered item height
100+
list.map((item, index) => {
101+
const node = renderFunc(item) as React.ReactElement;
102+
103+
// Pass `key` and `ref` for internal measure
104+
return React.cloneElement(node, {
105+
key: index,
106+
ref: (ele: HTMLElement) => {
107+
this.itemElements[index] = ele;
108+
},
109+
});
110+
})
111+
;
112+
113+
public render() {
114+
const {
115+
style,
116+
component: Component = 'div',
117+
height,
118+
itemHeight,
119+
dataSource,
120+
children,
121+
...restProps
122+
} = this.props;
123+
124+
// Render pure list if not set height
125+
if (height === undefined) {
126+
return (
127+
<Component style={style} {...restProps}>
128+
{this.renderChildren(dataSource, children)}
129+
</Component>
130+
);
131+
}
132+
133+
const { itemIndex, startIndex, endIndex } = this.state;
134+
135+
const contentHeight = dataSource.length * itemHeight;
136+
137+
return (
138+
<Component
139+
style={{
140+
...style,
141+
height,
142+
overflowY: 'auto',
143+
}}
144+
{...restProps}
145+
onScroll={this.onScroll}
146+
ref={this.listRef}
147+
>
148+
<Filler height={contentHeight}>
149+
{this.renderChildren(dataSource.slice(startIndex, endIndex + 1), children)}
150+
</Filler>
151+
</Component>
152+
);
153+
}
154+
}
155+
156+
export default List;

src/index.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,3 @@
1-
export default {};
1+
import List from './List';
2+
3+
export default List;

src/util.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
interface LocationItemResult {
2+
/** Located item index */
3+
index: number;
4+
/** Current item display baseline related with current container baseline */
5+
offsetPtg: number;
6+
}
7+
8+
/**
9+
* Get location item and its align percentage with the scroll percentage.
10+
* We should measure current scroll position to decide which item is the location item.
11+
* And then fill the top count and bottom count with the base of location item.
12+
*/
13+
export function getLocationItem(scrollPtg: number, total: number): LocationItemResult {
14+
const measureTotal = total - 1;
15+
16+
const itemIndex = Math.floor(scrollPtg * measureTotal);
17+
const itemTopPtg = itemIndex / measureTotal;
18+
const itemBottomPtg = (itemIndex + 1) / measureTotal;
19+
const itemOffsetPtg = (scrollPtg - itemTopPtg) / (itemBottomPtg - itemTopPtg);
20+
21+
return {
22+
index: itemIndex,
23+
offsetPtg: itemOffsetPtg,
24+
};
25+
}
26+
27+
export function getScrollPercentage(element: HTMLElement | null) {
28+
if (!element) {
29+
return 0;
30+
}
31+
32+
const { scrollTop, scrollHeight, clientHeight } = element;
33+
const scrollTopPtg = scrollTop / (scrollHeight - clientHeight);
34+
return scrollTopPtg;
35+
}

0 commit comments

Comments
 (0)