Reinit project
This commit is contained in:
267
src/components/List/index.tsx
Normal file
267
src/components/List/index.tsx
Normal file
@@ -0,0 +1,267 @@
|
||||
import React, { useRef, useState, useCallback, useEffect } from 'react';
|
||||
import {
|
||||
VariableSizeList,
|
||||
ListChildComponentProps,
|
||||
ListOnScrollProps,
|
||||
} from 'react-window';
|
||||
import AutoSizer, { Size } from 'react-virtualized-auto-sizer';
|
||||
import { Icon } from 'antd';
|
||||
import { formatMessage } from 'umi-plugin-locale';
|
||||
|
||||
import styles from './style.less';
|
||||
import { connect } from 'dva';
|
||||
|
||||
const listPosition: { [key: string]: number } = {};
|
||||
|
||||
type ActionCallback<T = any> = () => T | Promise<T>;
|
||||
|
||||
export interface ListProps<T = any> {
|
||||
className?: string;
|
||||
distance?: number;
|
||||
refreshDistance?: number;
|
||||
loadDistance?: number;
|
||||
itemSize?: number;
|
||||
pullText?: string;
|
||||
releaseText?: string;
|
||||
refreshText?: string;
|
||||
loadingText?: string;
|
||||
loadFinishedText?: string;
|
||||
refreshing?: boolean;
|
||||
enableRefresh?: boolean;
|
||||
loading?: boolean;
|
||||
enableLoading?: boolean;
|
||||
data?: T[];
|
||||
onRefresh?: ActionCallback;
|
||||
onLoadMore?: ActionCallback;
|
||||
renderItem?: (item: T, index: number, array: T[]) => React.ReactNode;
|
||||
itemKey?: (data: T) => string;
|
||||
}
|
||||
|
||||
interface Point {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
const List: React.FC<ListProps> = ({
|
||||
className = '',
|
||||
distance = 120,
|
||||
refreshDistance = 80,
|
||||
loadDistance = 200,
|
||||
itemSize = 80,
|
||||
pullText = 'common.pullToRefresh',
|
||||
releaseText = 'common.releaseToRefresh',
|
||||
refreshText = 'common.refreshing',
|
||||
loadingText = 'common.loading',
|
||||
loadFinishedText = 'common.loadFinished',
|
||||
refreshing = false,
|
||||
loading = false,
|
||||
enableRefresh = true,
|
||||
enableLoading = true,
|
||||
data = [],
|
||||
renderItem,
|
||||
itemKey,
|
||||
onRefresh,
|
||||
onLoadMore,
|
||||
...props
|
||||
}) => {
|
||||
if (refreshDistance > distance) {
|
||||
throw Error('refreshDistance 应该小于 distance');
|
||||
}
|
||||
|
||||
const scrollOffset = useRef(0);
|
||||
const touchEnable = useRef(false);
|
||||
const startPoint = useRef<Point>();
|
||||
const scrollContainer = useRef<VariableSizeList>();
|
||||
const [pullOffset, setPullOffset] = useState(0);
|
||||
const validToRefresh = pullOffset >= refreshDistance;
|
||||
const key = (props as any).location.pathname;
|
||||
|
||||
useEffect(() => {
|
||||
if (refreshing && loading) {
|
||||
throw Error('refreshing 与 loading 不能同时进行');
|
||||
}
|
||||
if (refreshing === loading) { // 同时为 false
|
||||
setPullOffset(0);
|
||||
} else {
|
||||
setPullOffset(refreshing ? refreshDistance : 0);
|
||||
}
|
||||
}, [refreshing, refreshDistance, loading]);
|
||||
|
||||
useEffect(() => {
|
||||
const { current } = scrollContainer;
|
||||
if (current) {
|
||||
current.resetAfterIndex(0, false);
|
||||
}
|
||||
return () => { listPosition[key] = scrollOffset.current; }
|
||||
}, [data.length]);
|
||||
|
||||
const attachContainer = useCallback((node: VariableSizeList) => {
|
||||
if (node) {
|
||||
scrollContainer.current = node;
|
||||
const position = listPosition[key];
|
||||
if (typeof position === 'number') {
|
||||
node.scrollTo(position);
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
const onScroll = useCallback((e: ListOnScrollProps) => {
|
||||
const { current } = scrollContainer;
|
||||
scrollOffset.current = e.scrollOffset;
|
||||
if (
|
||||
enableLoading && !loading && !refreshing && current &&
|
||||
data.length * itemSize - (+current.props.height) - e.scrollOffset < loadDistance
|
||||
) {
|
||||
onLoadMore && onLoadMore();
|
||||
}
|
||||
}, [loading, data.length, itemSize, enableLoading, key]);
|
||||
|
||||
const onTouchStart = useCallback((e: React.TouchEvent) => {
|
||||
if (!enableRefresh || refreshing || loading) return;
|
||||
const touch = e.touches[0];
|
||||
if (!touch || scrollOffset.current) return;
|
||||
startPoint.current = { x: touch.clientX, y: touch.clientY };
|
||||
}, [enableRefresh, refreshing, loading]);
|
||||
|
||||
const onTouchEnd = useCallback(() => {
|
||||
if (!enableRefresh || refreshing || loading) return;
|
||||
touchEnable.current = false;
|
||||
startPoint.current = undefined;
|
||||
if (validToRefresh) {
|
||||
onRefresh && onRefresh();
|
||||
} else {
|
||||
if (pullOffset !== 0) {
|
||||
setPullOffset(0);
|
||||
}
|
||||
}
|
||||
}, [pullOffset, validToRefresh, refreshing, enableRefresh, loading]);
|
||||
|
||||
const onTouchMove = useCallback((e: React.TouchEvent) => {
|
||||
if (!enableRefresh || refreshing) return;
|
||||
|
||||
const touch = e.touches[0];
|
||||
const start = startPoint.current;
|
||||
if (!touch || !start) return;
|
||||
if (!touchEnable.current) {
|
||||
const deltaX = Math.abs(touch.clientX - start.x);
|
||||
const deltaY = Math.abs(touch.clientY - start.y);
|
||||
const yDirection = deltaY !== 0 && deltaX / deltaY < 0.5;
|
||||
const enable = yDirection && scrollOffset.current === 0;
|
||||
touchEnable.current = enable;
|
||||
if (!enable) {
|
||||
onTouchEnd();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (touchEnable.current && e.touches[0]) {
|
||||
const { clientY } = e.touches[0];
|
||||
const offset = clientY - start.y;
|
||||
if (offset > 0) {
|
||||
const variable = offset / distance;
|
||||
if (variable <= Math.PI / 2) {
|
||||
const result = distance * Math.sin(offset / distance);
|
||||
setPullOffset(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [enableRefresh, distance]);
|
||||
|
||||
// 每一行的渲染
|
||||
const Row: React.FC<ListChildComponentProps> = useCallback(({ style, index }) => {
|
||||
let child;
|
||||
if (index === data.length) {
|
||||
child = (
|
||||
<div className={styles.loadcontent}>
|
||||
{loading && <Icon className="logo" type="loading" spin={true} />}
|
||||
<span>
|
||||
{formatMessage({ id: loading ? loadingText : loadFinishedText })}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
child = renderItem && renderItem(data[index], index, data);
|
||||
}
|
||||
return (
|
||||
<div className={styles.item} style={style}>
|
||||
{child}
|
||||
</div>
|
||||
);
|
||||
}, [data, loading]);
|
||||
|
||||
const calcItemSize = useCallback((index: number) => {
|
||||
if (!refreshing && index === data.length) return 40;
|
||||
return itemSize;
|
||||
}, [data, refreshing]);
|
||||
|
||||
const keyGenerator = useCallback((index: number) => {
|
||||
if (itemKey) {
|
||||
const item = data[index];
|
||||
return item ? itemKey(item) : 'loader';
|
||||
}
|
||||
return `${index}`;
|
||||
}, [data, itemKey]);
|
||||
|
||||
// 列表内容
|
||||
const ActualList: React.FC<Size> = useCallback(({ width, height }) => (
|
||||
<VariableSizeList
|
||||
ref={attachContainer}
|
||||
style={{
|
||||
transform: `translateY(${pullOffset}px)`,
|
||||
transition: pullOffset === 0 || refreshing || loading ? 'all .2s' : undefined,
|
||||
touchAction: refreshing ? 'none' : 'unset',
|
||||
}}
|
||||
className={styles.listcontent}
|
||||
itemCount={data.length + (refreshing ? 0 : 1)}
|
||||
itemSize={calcItemSize}
|
||||
height={height}
|
||||
width={width}
|
||||
onScroll={onScroll}
|
||||
itemKey={keyGenerator}
|
||||
>
|
||||
{Row}
|
||||
</VariableSizeList>
|
||||
), [pullOffset, data, itemSize, Row, refreshing]);
|
||||
|
||||
let pullContent = validToRefresh ? releaseText : pullText;
|
||||
if (refreshing) {
|
||||
pullContent = refreshText;
|
||||
}
|
||||
const RefreshContent = (
|
||||
<div
|
||||
style={{
|
||||
transform: `translateY(${pullOffset / 3}px)`,
|
||||
transition: pullOffset === 0 || refreshing ? 'all .2s' : undefined,
|
||||
}}
|
||||
className={styles.pullcontent}
|
||||
>
|
||||
{refreshing ? (
|
||||
<Icon type="loading" className="loading" spin={true} />
|
||||
) : (
|
||||
<Icon
|
||||
type="arrow-down"
|
||||
className={`logo ${validToRefresh ? 'active' : ''}`}
|
||||
/>
|
||||
)}
|
||||
<span className="desc">{formatMessage({ id: pullContent })}</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`${styles.container} ${className}`}
|
||||
onTouchStart={onTouchStart}
|
||||
onTouchMove={onTouchMove}
|
||||
onTouchEnd={onTouchEnd}
|
||||
>
|
||||
{RefreshContent}
|
||||
<AutoSizer>
|
||||
{ActualList}
|
||||
</AutoSizer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default connect(
|
||||
({ router } : any) => ({ ...router }),
|
||||
)(List);
|
||||
62
src/components/List/style.less
Normal file
62
src/components/List/style.less
Normal file
@@ -0,0 +1,62 @@
|
||||
@import '../../global.less';
|
||||
|
||||
.item {
|
||||
height: 60px;
|
||||
}
|
||||
|
||||
.container {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
background-color: @bg-color;
|
||||
}
|
||||
|
||||
.pullcontent {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
color: @hint-color;
|
||||
|
||||
:global {
|
||||
.logo, .desc {
|
||||
transition: all .3s;
|
||||
}
|
||||
|
||||
.logo, .loading {
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
|
||||
&.active {
|
||||
transform: rotateZ(180deg);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.loadcontent {
|
||||
background-color: @bg-color;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
color: @hint-color;
|
||||
|
||||
:global {
|
||||
.logo {
|
||||
margin-right: 6px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.listcontent {
|
||||
background-color: #fff;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
Reference in New Issue
Block a user