@pixui-dev/pixui-react-virtualwaterfall
v1.0.12
Published
pixui 高性能React虚拟瀑布流组件
Readme
VirtualWaterfallList 虚拟瀑布流组件
PixUI 里基于 react 实现的高性能虚拟化瀑布流列表组件。支持 虚拟滚动、元素复用、无限加载、下拉刷新、曝光回调 等能力。
以最小化节点变更为实现目的,可通过元素复用结合 Ref 更新,达到近乎无更新消耗的效果。
适用于列表项高度固定或不固定的瀑布流场景,具有优秀的性能表现。
安装
yarn add @pixui-dev/pixui-react-virtualwaterfallCPreact开启
可以结合CPreact提升性能。使用 pxw 模版 的情况下,路径为
.../node_modules/@pixui-dev/pxw/config/webpack.js
// wepack 配置添加
externals: {
preact: 'window.cpreact',
react: 'window.cpreact',
'preact/hooks': 'window.cpreact',
'preact/compat': 'window.cpreact',
'pxwdom': 'window.pxwdom',
},使用注意事项
不同配置下的更新瀑布流模式
useCache 为 false:此时,就是普通的增删瀑布流useCache 为 true:此时,瀑布流中的组件会回收,但会重新执行 renderItem,并创建 Vnode 重新比对差异,按需生成实际的 Dom。useCache 为 true,且 renderItem 返回 refs:此时,瀑布流中的组件会回收使用时,不触发 renderItem,直接调用 updateItem,让开发者通过 refs 动态修改变化部分。
高度计算:
getItemHeight函数应该尽可能准确,避免频繁的布局重计算类型分类: 使用
getItemType可以让相同类型的元素更好地复用,提升性能数据更新: 当
data数组发生变化时,组件会智能地只重新计算变化部分的布局滚动加载: 使用
hasMore和loadMore实现无限滚动时,新的 data 需包含老 data 的数据下拉刷新: 下拉刷新基于
滚动回弹,仅在 PixUI 环境下生效曝光监控: 利用
onItemExpose和onItemHide可以实现精确的项目曝光统计元素动态高度设置 利用
updateItemByIndex更新数据并同步到getItemHeight进行高度运算变化, 然后调用layoutItemsFromIndex从对应索引位置开始,重新进行排版
最佳实践
- 在
renderItem中返回refs对象,配合updateItem使用可以获得最佳性能 - 如果需要获得最低
内存占用,可以在onItemHide中通过refs手动置空图片内存,避免离屏缓存部分元素的图片持有。 - 对于瀑布流中完全不同类型的元素,建议使用
getItemType进行分类缓存 - 使用过程中,可以使用
CPreact进一步减少 Preact 进行 Diff 的时间 - 合理设置
overscan值,太小会影响滚动流畅度,太大会影响性能 - 对于需要曝光统计的场景,合理使用
onItemExpose和onItemHide回调 - 下拉刷新和加载更多可以配合使用,提供完整的数据加载体验
案例效果与性能报告
不规则瀑布流滚动时,会循环使用场上元素组件,并可通过 Ref 直接更新变化数据,免去Dom的删除创建,达到 Diff 最小化,达到近乎无消耗的性能。(具体性能可以参考 虚拟瀑布流组件性能测试报告)
通用属性
Tips: 以下通用属性适用于 pixui 大部分前端组件,不支持的组件会单独说明。
| 属性名 | 类型 | 说明 | 默认值 | | --- | --- | --- | --- | | rootId | string | 添加在组件最外层的 id | - | | rootClassName | string | 添加在组件最外层的 className | - | | rootStyle | CSSProperties | 添加在组件最外层的样式对象 | - |
必需属性
| 属性名 | 类型 | 说明 | 默认值 | 版本 | | --- | --- | --- | --- | --- | | data | any[] | 输入数据列表,每个数据项可包含任意字段,排版基于数据项进行索引 | - | | | itemWidth | number | 单个项目的宽度(像素) | - | | | containerHeight | number | 容器的高度(像素) | - | | | columns | number | 瀑布流的列数 | - | | | getItemHeight | (item: any, index: number, itemWidth: number) => number | 获取单个项目高度的函数,接收(item, index, itemWidth)参数,返回高度值,用于当前元素的瀑布流排版 | - | | | renderItem | (item: any, index: number) => {element: JSX.Element, refs?: object} | 渲染单个项目的函数,接收(item, index)参数,返回{element, refs?}对象。如返回 ref 且存在 updateItem,则重用组件时,会直接进行 updateItem,否则会重新进行 renderItem | - | |
可选属性
| 属性名 | 类型 | 说明 | 默认值 | 版本 | | --- | --- | --- | --- | --- | | contentStyle? | CSSProperties | 内容区域style,可以按需覆盖,不过可能导致预期外的结果,用于适配用户定制化的样式设定 | - | | | gap? | number | 列之间的间距(像素) | 10 | | | overscan? | number | 可视区域外预加载的尺寸大小(像素) | 0 | | | useCache? | boolean | 是否启用元素缓存复用 | true | | | getItemType? | (item: any) => string | 获取项目类型的函数,用于分类缓存,返回类型字符串,不设定则认为全局为同一类组件进行缓存 | - | | | updateItem? | (item: any, index: number, refs: object) => void | 更新已复用项目内容的函数,用于元素复用时,存在 Refs 才生效,直接通过 Ref 更新内容 | - | | | hasMore? | () => boolean | 是否还有更多数据可加载的函数 | - | | | loadMore? | () => void | 加载更多数据的回调函数 | - | | | loadMoreThreshold? | number | 触底距离阈值,到底前多少具体触发loadMore | 100 | | | renderLoadMoreArea? | () => JSX.Element | JSX.Element[] | 加载更多渲染区域,通常用于显示加载状态 | - | |
滚动配置属性
| 属性名 | 类型 | 说明 | 默认值 | 版本 | | --- | --- | --- | --- | --- | | scrollProps? | VirtualListScrollProps | 滚动相关配置对象 | - | |
VirtualListScrollProps 配置项
| 属性名 | 类型 | 说明 | 默认值 | | --- | --- | --- | --- | | movementType? | 'elastic' | 'clamped' | 滚动回弹类型,elastic: 触顶触底会回弹,clamped: 触顶触底不回弹 | 'elastic' | | scrollSensitivity? | number | 滑动灵敏度,值越大滑动越灵敏 | 1 | | inertia? | boolean | 是否有惯性,true: 滑动后会有惯性滚动,false: 滑动后立即停止 | true | | inertiaVersion? | number | 惯性函数版本,1: 倾向于unity的惯性效果,2: 倾向于ue的惯性效果 | 2 | | decelerationRate? | number | 惯性衰减率,仅在inertiaVersion为1时生效,取值范围:0(立即停止)到 1(无减速) | 0.135 | | staticVelocityDrag? | number | 静态速度阻力,仅在inertiaVersion为2时生效,每秒速度的固定衰减值 | 100 | | frictionCoefficient? | number | 摩擦系数,仅在inertiaVersion为2时生效,每秒速度的动态衰减值 | 2.0 |
曝光回调属性
| 属性名 | 类型 | 说明 | 默认值 | 版本 | | --- | --- | --- | --- | --- | | onItemExpose? | (item: any, index: number, refs?: object) => void | 项目曝光回调函数,当项目进入可视区域时触发 | - | | | onItemHide? | (item: any, index: number, refs?: object) => void | 项目隐藏回调函数,当项目离开可视区域时触发 | - | |
下拉刷新属性
| 属性名 | 类型 | 说明 | 默认值 | 版本 | | --- | --- | --- | --- | --- | | hasPullDownRefresh? | () => boolean | 是否启用下拉刷新的函数 | () => false | | | pullDownRefreshThreshold? | number | 下拉刷新阈值,下拉超过此距离时触发刷新 | 50 | | | onPullDown? | (scrollTop: number) => void | 下拉过程中的回调函数 | - | | | onPullDownRefresh? | () => void | 下拉刷新触发时的回调函数 | - | | | onPullDownEnd? | () => void | 下拉结束时的回调函数 | - | | | renderPullDownArea? | () => JSX.Element | JSX.Element[] | 下拉刷新渲染区域,用于显示下拉刷新状态 | - | |
公共信息
公共属性
| 属性名 | 类型 | 说明 | 默认值 | 版本 | | --- | --- | --- | --- | --- | | renderData | any[] | 具体的渲染数据,只读 | - | 1.0.9 | | containerRef | ref | 外层容器的 Dom Ref | - | 1.0.9 | | contentRef | ref | 内容容器的 Dom Ref | - | 1.0.9 |
公共方法
| 方法名 | 参数 | 返回值 | 说明 | | --- | --- | --- | --- | | reset | - | void | 重新初始化瀑布流组件,包括清理缓存、重新计算布局、重新渲染 | | getVisibleIndices | - | number | 获取当前滚动位置 | | getScrollTop | - | number | 获取当前滚动位置 | | getVisibleIndices | - | number[] | 用于获取可视区域项目索引的拷贝 | | getContentHeight | - | number | 用于获取总内容高度 | | scrollTo | (scrollLeft: number, scrollTop: number) | void | 设定滚动距离(1.0.8) | | updateItemByIndex | (index: number, item: any) | void | 更新指定索引的项目数据 | | removeItemByIndex | (index: number) | void | 删除指定索引的项目 | | addSubData | (data: any[]) | void | 添加子数据到列表末尾 | | addSubDataAtIndex | (data: any[], index) | void | 在指定索引添加子数据(1.0.9) | | layoutItemsFromIndex | (index: number) | void | 从指定索引开始重新计算布局(1.0.6) | | finishPullDownRefresh | - | void | 手动完成下拉刷新,结束刷新状态 |
使用示例
1. 最简单案例,默认增删
import { VirtualWaterfall } from "@pixui-dev/pixui-react-virtualwaterfall"
export function BasicDemo() {
const data = Array.from({ length: 50 }, (_, i) => ({
id: i,
content: `Item ${i + 1}`
}));
const getItemHeight = (item: any, width: number) => {
return 120 + Math.floor(Math.random() * 100);
};
const renderItem = (item: any, index: number) => ({
element: (
<div style={{
width: '100%',
height: '100%',
padding: '16px',
border: '1px solid #ddd',
borderRadius: '8px',
backgroundColor: '#fff'
}}>
{item.content}
</div>
)
});
return (
<VirtualWaterfall
data={data}
columns={2}
itemWidth={200}
containerHeight={400}
getItemHeight={getItemHeight}
renderItem={renderItem}
/>
);
}2. 带Ref回收示例
import { VirtualWaterfall } from "@pixui-dev/pixui-react-virtualwaterfall"
import { createRef } from 'preact';
export function SimpleListDemo() {
const data = Array.from({ length: 2000 }, (_, i) => ({
id: i,
title: `标题 ${i + 1}`,
content: `这是第 ${i + 1} 项的内容描述`
}));
const getItemHeight = (item: any, width: number) => 120;
const renderItem = (item: any, index: number) => {
const titleRef = createRef();
const contentRef = createRef();
const element = (
<div style={{ width: '100%', height: '100%', padding: '12px', border: '1px solid #ddd', backgroundColor: '#fff', flexDirection: 'column' }}>
<text ref={titleRef} style={{ margin: '0 0 8px 0', color: '#000' }}>
{item.title}
</text>
<text ref={contentRef} style={{ margin: 0, fontSize: '14px', color: '#000' }}>
{item.content}
</text>
</div>
);
return {
element,
refs: { title: titleRef, content: contentRef }
};
};
const updateItem = (item: any, index: number, refs: any) => {
if (refs.title.current) {
refs.title.current.textContent = item.title;
}
if (refs.content.current) {
refs.content.current.textContent = item.content;
}
};
return (
<VirtualWaterfall
data={data}
columns={2}
itemWidth={220}
containerHeight={400}
getItemHeight={getItemHeight}
renderItem={renderItem}
updateItem={updateItem}
useCache={true}
/>
);
}3. ref更新 + 下拉刷新 + 加载更多 + 动态变高 案例
import { h, Component } from 'preact';
import { VirtualWaterfall } from "@pixui-dev/pixui-react-virtualwaterfall"
interface NormalCardProps {
data: any;
key: string;
index: number;
titleRef?: (ref: any) => void;
keyRef?: (ref: any) => void;
imageRef?: (ref: any) => void;
statusWrapRef?: (ref: any) => void;
statusRef?: (ref: any) => void;
deleteButtonRef?: (ref: any) => void;
bounsWrapRef?: (ref: any) => void;
bounsIconRef?: (ref: any) => void;
bounsBigRef?: (ref: any) => void;
bounsRef?: (ref: any) => void;
linkButtonRef?: (ref: any) => void;
linkIconRef?: (ref: any) => void;
likeButtonRef?: (ref: any) => void;
likeIconRef?: (ref: any) => void;
shareButtonRef?: (ref: any) => void;
shareIconRef?: (ref: any) => void;
linkRef?: (ref: any) => void;
likeRef?: (ref: any) => void;
shareRef?: (ref: any) => void;
}
interface NormalCardState {}
export default class NormalCard extends Component<NormalCardProps, NormalCardState> {
private index: number;
private useData: any;
constructor(props: NormalCardProps) {
super(props);
this.index = props.index;
this.useData = props.data;
}
/**
* 更新数据, 重用情况下,必须手动更新数据
* @param data 新的数据
*/
updateData(data: any) {
this.useData = data;
}
/**
* 更新索引, 重用情况下,必须手动更新索引
* @param index 新的索引
*/
updateIndex(index: number) {
this.index = index;
}
render() {
const { data, index } = this.props;
// 更新 props 索引
if (this.index !== index) {
this.index = index;
}
// 更新 props 数据
if (this.useData !== data) {
this.useData = data;
}
// console.log('PPCard render', this.index, this.useData);
return (
<div style={{ display: 'flex', flexDirection: 'column', width: '353px', height: '100%', backgroundColor: 'rgb(22,38, 72)' }}>
<div style={{ position: 'relative', width: '100%', height: '200px', backgroundColor: '#fff' }}>
{/* 图片 */}
<img src={data.imageUrl} style={{ width: '100%', height: '100%' }} ref={this.props.imageRef} />
{/* 状态 */}
<div
ref={this.props.statusWrapRef}
style={{
position: 'absolute',
left: 0,
top: 0,
padding: '5px 10px',
height: '25px',
backgroundColor: data.status === 'Finished' ? 'rgb(219, 255, 87)' : data.status === 'Not Shortlisted' ? 'rgb(182, 54, 45)' : 'rgb(48, 134, 5)',
}}
>
<text style={{ color: '#000', fontSize: '12px', fontWeight: 'bold' }} ref={this.props.statusRef}>{data.status}</text>
</div>
{/* 奖金 */}
<div
style={{
display: data.bouns !== null ? 'flex' : 'none',
position: 'absolute',
left: 0,
bottom: 0,
padding: '10px',
width: '100%',
height: data.bouns === '600' ? '60px' : '30px',
backgroundColor: 'rgb(219, 194, 6)',
flexDirection: 'row', alignItems: 'center', justifyContent: 'flex-start'
}}
ref={this.props.bounsWrapRef}
>
<div
style={{
display: data.bouns === '600' ? 'flex' : 'none',
width: '26%',
height: '100%',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'flex-start',
}}
ref={this.props.bounsIconRef}
>
<div style={{ width: '80%', height: '80%', backgroundColor: 'rgb(255, 89, 0)' }}></div>
</div>
<div style={{
width: '85%',
height: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'flex-start',
justifyContent: 'center',
}}>
<text style={{
display: data.bouns === '600' ? 'flex' : 'none',
color: '#000', fontSize: '14px', fontWeight: 'bold'
}} ref={this.props.bounsBigRef}>The GOLD AWARD</text>
<text style={{
color: '#fff', fontSize: '14px'
}} ref={this.props.bounsRef}>Bouns: ${data.bouns}</text>
</div>
</div>
{/* 删除按钮 */}
<div
ref={this.props.deleteButtonRef}
style={{
display: data.showDelete ? 'flex' : 'none',
position: 'absolute',
right: 10,
top: 10,
width: '60px',
height: '30px',
backgroundColor: 'rgb(255, 89, 0)',
borderRadius: '10px',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer'
}}
onClick={() => data.deleteFunc(this.index)}
>
<text style={{ color: '#000', fontSize: '10px', fontWeight: 'bold' }}>delete</text>
</div>
</div>
{/* 下方栏目 */}
<div style={{ width: '100%', height: '70px', backgroundColor: 'rgb(22,38, 72)',
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center'
}}>
{/* 标题 */}
<div style={{
width: '50%', color: '#fff', fontSize: '20px', fontWeight: 'bold',
height: '100%',
display: 'flex',
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'flex-start',
overflow: 'hidden',
}}>
<text
ref={this.props.titleRef}
style={{ width: '70%', height: '40px', fontSize: '14px', lineHeight: '20px', textAlign: 'left', overflow: 'hidden', textOverflow: 'ellipsis', color: '#fff' }}
>
{data.title}
</text>
<text ref={this.props.keyRef} style={{ marginLeft: '10px', color: '#fff', fontSize: '20px', fontWeight: 'bold' }}>{data.key}</text>
</div>
{/* 多个栏目 */}
<div
style={{ width: '14%', height: '100%', backgroundColor: 'rgb(219, 255, 87)' }}>
<div
ref={this.props.linkButtonRef}
style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', cursor: 'pointer' }}
onClick={() => {
data.linkFunc(this.index, this.useData);
}}
>
<div
style={{
backgroundColor: data.link ? 'rgb(255, 255, 255)' : 'rgb(80, 80, 80)',
width: '60%', height: '40%', borderRadius: '50%', position: 'relative'
}}
ref={this.props.linkIconRef}
></div>
<text ref={this.props.linkRef} style={{ marginTop: '4px', height: '20%', color: '#000', fontSize: '12px', fontWeight: 'bold' }}>1200</text>
</div>
</div>
<div style={{ width: '14%', height: '100%', backgroundColor: 'rgb(182, 54, 45)' }}>
<div
ref={this.props.likeButtonRef}
style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', cursor: 'pointer' }}
onClick={() => {
data.likeFunc(this.index, this.useData);
}}
>
<div
style={{
backgroundColor: data.liked ? 'rgb(255, 255, 255)' : 'rgb(80, 80, 80)',
width: '60%', height: '40%', borderRadius: '50%', position: 'relative'
}}
ref={this.props.likeIconRef}
></div>
<text ref={this.props.likeRef} style={{ marginTop: '4px', height: '20%', color: '#000', fontSize: '12px', fontWeight: 'bold' }}>3554</text>
</div>
</div>
<div style={{ width: '14%', height: '100%', backgroundColor: 'rgb(48, 134, 5)' }}>
<div
ref={this.props.shareButtonRef}
style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', cursor: 'pointer' }}
onClick={() => {
data.shareFunc(this.index, this.useData);
}}
>
<div
style={{
backgroundColor: data.shared ? 'rgb(255, 255, 255)' : 'rgb(80, 80, 80)',
width: '60%', height: '40%', borderRadius: '50%', position: 'relative'
}}
ref={this.props.shareIconRef}
></div>
<text ref={this.props.shareRef} style={{ marginTop: '4px', height: '20%', color: '#000', fontSize: '12px', fontWeight: 'bold' }}>3200</text>
</div>
</div>
</div>
</div>
)
}
}
const words = [
'Jojo\'s Bizarre Adventure: Golden Wind',
'The Legend of Zelda: Breath of the Wild',
'The Witcher 3: Wild Hunt',
'The Elder Scrolls V: Skyrim',
'The Last of Us: Left Behind',
'The Last of Us 2: Juggernaut Edition',
]
interface NormalListDemoState {}
interface NormalListDemoProps {
columns?: number;
wrapHeight?: number;
}
class NormalListDemo extends Component<NormalListDemoProps, NormalListDemoState> {
private VirtualWaterfallRef: any;
private initData: any[] = [];
private data: any[] = [];
private hadPullDownRefresh: boolean = false;
private pullDownRefreshed: boolean = false;
private isLoading: boolean = false;
private mockDataIndex: number = 0;
constructor(props: NormalListDemoProps) {
super(props);
// 生成示例数据
this.initData = this.generateSampleData();
this.data = this.initData;
setTimeout(() => {
this.initData = this.generateSampleData();
this.data = this.initData;
this.forceUpdate();
}, 1000);
}
private generateSampleData = (isLoadMore: boolean = false): any[] => {
const data: any[] = [];
if (!isLoadMore) {
this.mockDataIndex = 0;
}
for (let i = 0; i < 120; i++) {
let imageUrls = [
`https://down.pandora.qq.com/public/open/gex/anythings/ORG-100001/gooseFeathers-avatar/27988b9757cf05575f61ada27a5119ec4caf33edb3887c55537a5aa9854c236e/gooseFeathers.png`
,`https://down.pandora.qq.com/public/open/gex/anythings/ORG-100001/INFP-TEST-avatar/a6c332446f9b4354b263fcdb56396ee57b9defb87b41cf1fc723817e0b973ebf/skin_infp.png`
,`https://down.pandora.qq.com/public/open/gex/anythings/ORG-100001/farmCommunity-avatar/63f8ff8af6b4a2fbe40f167274cd8cd0498516df903a9cba5cec6e29e8dfe9ba/image.png`
];
const key = this.mockDataIndex++;
const randomImageUrl = imageUrls[Math.floor(Math.random() * imageUrls.length)];
const randomIndex = Math.floor(Math.random() * words.length);
data.push({
key: key, // 确保ID唯一
imageUrl: randomImageUrl,
title: words[randomIndex],
status: i % 3 === 0 ? 'Finished' : i % 3 === 1 ? 'Not Shortlisted' : 'In Progress',
bouns: i % 3 === 0 ? '600' : i % 3 === 1 ? null : '0',
liked: i % 3 === 1 ? true : false,
showDelete: true,
linkFunc: (index, data) => {
// console.log('linkFunc', index, data);
if (this.VirtualWaterfallRef) {
// 这里是更新本地数据
this.VirtualWaterfallRef.updateItemByIndex(index, {
...data,
link: !data.link
});
}
},
likeFunc: (index, data) => {
// console.log('likeFunc', index, data);
if (this.VirtualWaterfallRef) {
// 这里是更新本地数据
this.VirtualWaterfallRef.updateItemByIndex(index, {
...data,
liked: !data.liked
});
}
},
shareFunc: (index, data) => {
// console.log('shareFunc', index, data);
// 额外添加重新设置高度逻辑
this.VirtualWaterfallRef.updateItemByIndex(index, {
...data,
shared: !data.shared,
height: !data.shared ? 450 : 270,
});
// 从索引开始重新排版
this.VirtualWaterfallRef.layoutItemsFromIndex(index);
},
deleteFunc: (index) => {
if (this.VirtualWaterfallRef) {
// console.log('deleteFunc', i, index);
this.VirtualWaterfallRef.removeItemByIndex(index);
}
}
});
}
return data;
}
// 渲染元素
renderItem(item: any, index: number) {
// 创建refs对象来存储ref
const refs = {
cardRef: null,
titleRef: null,
keyRef: null,
imageRef: null,
statusWrapRef: null,
statusRef: null,
deleteButtonRef: null,
bounsWrapRef: null,
bounsIconRef: null,
bounsBigRef: null,
bounsRef: null,
linkButtonRef: null,
linkIconRef: null,
likeButtonRef: null,
likeIconRef: null,
shareButtonRef: null,
shareIconRef: null,
linkRef: null,
likeRef: null,
shareRef: null,
};
const element = <NormalCard
data={item}
key={item.key}
index={index}
ref={(ref) => {
refs.cardRef = ref;
}}
titleRef={(ref) => {
refs.titleRef = ref;
}}
keyRef={(ref) => {
refs.keyRef = ref;
}}
imageRef={(ref) => {
refs.imageRef = ref;
}}
statusWrapRef={(ref) => {
refs.statusWrapRef = ref;
}}
statusRef={(ref) => {
refs.statusRef = ref;
}}
deleteButtonRef={(ref) => {
refs.deleteButtonRef = ref;
}}
bounsWrapRef={(ref) => {
refs.bounsWrapRef = ref;
}}
bounsIconRef={(ref) => {
refs.bounsIconRef = ref;
}}
bounsBigRef={(ref) => {
refs.bounsBigRef = ref;
}}
bounsRef={(ref) => {
refs.bounsRef = ref;
}}
linkButtonRef={(ref) => {
refs.linkButtonRef = ref;
}}
linkIconRef={(ref) => {
refs.linkIconRef = ref;
}}
likeButtonRef={(ref) => {
refs.likeButtonRef = ref;
}}
likeIconRef={(ref) => {
refs.likeIconRef = ref;
}}
shareButtonRef={(ref) => {
refs.shareButtonRef = ref;
}}
shareIconRef={(ref) => {
refs.shareIconRef = ref;
}}
linkRef={(ref) => {
refs.linkRef = ref;
}}
likeRef={(ref) => {
refs.likeRef = ref;
}}
shareRef={(ref) => {
refs.shareRef = ref;
}}
/>
return {
element,
// 返回 refs, 后续会直接通过 updateItem 更新组件,
// 不返回 refs 则不会更新组件,每次重新调用 renderItem 重新渲染组件
refs
};
}
updateItem(item: any, index: number, refs: { [key: string]: any }) {
/*
* !!!重要!!!
* 如内部使用了索引 或者 数据,
* 重用情况必须手动更新组件索引 和 数据
* 否则会导致组件索引错误,导致组件无法正常工作
*/
// 获取组件Ref
if (refs.cardRef) {
// 更新index
refs.cardRef.updateIndex(index);
// 更新数据,避免引用到旧数据
refs.cardRef.updateData(item);
}
// 手动更新数据
if (refs.titleRef) {
if (item.title !== refs.titleRef.textContent) {
refs.titleRef.textContent = item.title;
}
}
if (refs.keyRef) {
if (item.key !== refs.keyRef.textContent) {
refs.keyRef.textContent = item.key;
}
}
if (refs.imageRef) {
if (item.imageUrl !== refs.imageRef.src) {
refs.imageRef.src = item.imageUrl;
}
}
if (refs.statusRef) {
if (item.status !== refs.statusRef.textContent) {
refs.statusRef.textContent = item.status;
if (refs.statusWrapRef) {
if (item.status === 'Finished') {
refs.statusWrapRef.style.backgroundColor = 'rgb(219, 255, 87)';
} else if (item.status === 'Not Shortlisted') {
refs.statusWrapRef.style.backgroundColor = 'rgb(182, 54, 45)';
} else {
refs.statusWrapRef.style.backgroundColor = 'rgb(48, 134, 5)';
}
}
}
}
if (refs.deleteButtonRef) {
if (item.showDelete !== refs.deleteButtonRef.style.display) {
refs.deleteButtonRef.style.display = item.showDelete ? 'flex' : 'none';
}
}
if (refs.bounsWrapRef) {
if (item.bouns !== null) {
refs.bounsWrapRef.style.display = 'flex';
if (item.bouns === '600') {
refs.bounsWrapRef.style.height = '60px';
} else {
refs.bounsWrapRef.style.height = '30px';
}
if (item.bouns === '600') {
refs.bounsIconRef.style.display = 'flex';
} else {
refs.bounsIconRef.style.display = 'none';
}
if (item.bouns === '600') {
refs.bounsBigRef.style.display = 'flex';
} else {
refs.bounsBigRef.style.display = 'none';
}
} else {
refs.bounsWrapRef.style.display = 'none';
}
}
if (refs.bounsRef) {
const nextBouns =`Bouns: ${item.bouns}`;
if (nextBouns !== refs.bounsRef.textContent) {
refs.bounsRef.textContent = nextBouns;
}
}
if (refs.likeIconRef) {
const nextLikeIconColor = item.liked ? 'rgb(255, 255, 255)' : 'rgb(80, 80, 80)';
if (nextLikeIconColor !== refs.likeIconRef.style.backgroundColor) {
refs.likeIconRef.style.backgroundColor = nextLikeIconColor;
}
}
if (refs.linkIconRef) {
const nextLinkIconColor = item.link ? 'rgb(255, 255, 255)' : 'rgb(80, 80, 80)';
if (nextLinkIconColor !== refs.linkIconRef.style.backgroundColor) {
refs.linkIconRef.style.backgroundColor = nextLinkIconColor;
}
}
if (refs.shareIconRef) {
const nextShareIconColor = item.shared ? 'rgb(255, 255, 255)' : 'rgb(80, 80, 80)';
if (nextShareIconColor !== refs.shareIconRef.style.backgroundColor) {
refs.shareIconRef.style.backgroundColor = nextShareIconColor;
}
}
/* 其他的 ref 更新 没有实现 */
}
// 获取元素高度
getItemHeight(item: any, index: number, itemWidth: number) {
return item.height;
}
// 是否启用下拉刷新
hasPullDownRefresh() {
return true;
}
// 下拉回调
pullDown(scrollTop: number) {
// console.log('pullDown', scrollTop);
}
// 下拉刷新回调
pullDownRefresh() {
// console.log('pullDownRefresh');
this.hadPullDownRefresh = true;
this.pullDownRefreshed = true;
}
// 下拉结束回调
pullDownEnd() {
// console.log('pullDownEnd');
if (this.hadPullDownRefresh) {
// 成功 触发下拉刷新
// 更新数据,这里相当于 props 里面 data 的更新
this.initData = this.generateSampleData();
// 更新本地数据
this.data = this.initData;
// 更新组件后,手动更新组件
this.forceUpdate();
// 重置下拉刷新状态
this.hadPullDownRefresh = false;
}
this.pullDownRefreshed = false;
}
// 下拉刷新渲染区域
renderPullDownArea() {
// 只会在触发下拉刷新时渲染 以及 回到初始状态触发渲染, 不会在滚动过程中渲染
// 如果 需要动画过渡,可以自己基于 ref 实现
return <div style={{ width: '100%', height: '60px', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
<text style={{ color: '#000', fontSize: '20px', fontWeight: 'bold' }}>
{this.pullDownRefreshed ? '松手触发更新' : '下拉刷新...'}
</text>
</div>;
}
// 是否还有更多数据
hasMore() {
return this.data.length < 1200;
}
// 加载更多数据
loadMore() {
if (this.isLoading || !this.hasMore()) {
return;
}
// console.log('loadMore');
this.isLoading = true;
// 模拟网络请求
setTimeout(() => {
if (this.VirtualWaterfallRef) {
const subData = this.generateSampleData(true);
// 添加数据,这里是直接往 瀑布流组件里面添加数据
this.VirtualWaterfallRef.addSubData(subData);
// 更新本地数据
this.data = [...this.data, ...subData];
this.isLoading = false;
}
}, 200);
}
// 加载更多渲染区域
renderLoadMoreArea() {
return <div style={{ width: '100%', height: '60px', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
{
this.hasMore() ? <text style={{ color: '#000', fontSize: '20px', fontWeight: 'bold' }}>加载更多...</text> : <text style={{ color: '#000', fontSize: '20px', fontWeight: 'bold' }}>没有更多数据了</text>
}
</div>;
}
// 元素曝光
onItemExpose(item: any, index: number, refs: any) {
// console.log('onItemExpose', index);
// setTimeout(() => {
// 如果要获取新创建的refs,需要等一帧渲染
// console.log('refs', index, refs);
// }, 0);
}
// 元素隐藏
onItemHide(item: any, index: number, refs: any) {
// console.log('onItemHide', index, refs);
// 隐藏的时候,手动去掉图片,避免加载时候的原图片显示,以及完全避免内存占用。
if (refs.imageRef) {
refs.imageRef.style.src = '';
}
}
render() {
return (
<div style={{ width: '100%', height:'100%', flexDirection: 'column', alignItems: 'center', justifyContent: 'center' }}>
{/* 等高 虚拟瀑布流 通过 refs 更新 */}
<VirtualWaterfall
rootId="normal-list-root"
rootClassName="normal-list-root"
ref={(ref) => this.VirtualWaterfallRef = ref}
data={this.initData}
// 列数
columns={this.props.columns || 3}
// 元素宽度
itemWidth={353}
// 间距
gap={20}
// 额外渲染距离,默认 0
overscan={0}
// 容器高度
containerHeight={this.props.wrapHeight || document.body.clientHeight * 0.9}
// 开启缓存
useCache={true}
// 瀑布流元素相关
renderItem={this.renderItem}
updateItem={this.updateItem}
getItemHeight={this.getItemHeight}
// 下拉刷新
hasPullDownRefresh={this.hasPullDownRefresh.bind(this)}
pullDownRefreshThreshold={60}
onPullDown={this.pullDown.bind(this)}
onPullDownRefresh={this.pullDownRefresh.bind(this)}
onPullDownEnd={this.pullDownEnd.bind(this)}
renderPullDownArea={this.renderPullDownArea.bind(this)}
// 加载更多
loadMoreThreshold={20}
hasMore={this.hasMore.bind(this)}
loadMore={this.loadMore.bind(this)}
renderLoadMoreArea={this.renderLoadMoreArea.bind(this)}
// 元素曝光隐藏
onItemExpose={this.onItemExpose.bind(this)}
onItemHide={this.onItemHide.bind(this)}
// 滚动效果
scrollProps={{
movementType: 'elastic',
scrollSensitivity: 1,
inertiaVersion: 2,
decelerationRate: 0.135,
staticVelocityDrag: 100,
frictionCoefficient: 2.0,
}}
>
</VirtualWaterfall>
</div>
);
}
}
export { NormalListDemo };
4. 复杂案例集
具体请直接咨询 PixUI,获取官方案例。
版本更新记录
1.0.12
- 修复 removeItemByIndex 最后一个元素的报错,以及高度更新问题。
1.0.11
- 提供 getVisibleIndices, 用于获取可视区域项目索引的拷贝
- 提供 getContentHeight, 用于获取总内容高度
1.0.10
- renderLoadMoreArea 兼容 Unity/Unreal 情况下,滚动不包含当前区域的问题。
- 修复连续删除最后一个节点,导致的排版异常。
1.0.9
- 暴露 containerRef, contentRef,提供 getLayoutInfoByIndex 获取指定 index 的 排版信息。
- 添加 addSubDataAtIndex 用于在指定索引添加,一个或者多个渲染信息。
1.0.8
- 添加 scrollTo 方法
1.0.7
- 添加可选参数 contentStyle,用于自定义内容容器样式。可用于适配 em、rem 或 用户定制的样式方式。
1.0.6
- 修复1.0.5 去除 key后,下拉刷新的更新问题
- 添加 layoutItemsFromIndex,用于更新排版,进行动态高度设置
1.0.5
- 修复 preact 重用 vNode,子节点的父节点引用没有清理,导致引用图片的内存泄漏问题。
- onItemExpose onItemHide 添加可选参数 refs
