UNPKG

@pixui-dev/pixui-react-virtualwaterfall

Version:

pixui 高性能React虚拟瀑布流组件

1,040 lines (906 loc) 42.5 kB
# VirtualWaterfallList 虚拟瀑布流组件 PixUI 里基于 react 实现的高性能虚拟化瀑布流列表组件。支持 `虚拟滚动、元素复用、无限加载、下拉刷新、曝光回调` 等能力。 以最小化节点变更为实现目的,可通过元素复用结合 Ref 更新,达到近乎无更新消耗的效果。 适用于列表项高度固定或不固定的瀑布流场景,具有优秀的性能表现。 ## 安装 ```bash yarn add @pixui-dev/pixui-react-virtualwaterfall ``` ## CPreact开启 可以结合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', }, ``` ## 使用注意事项 1. **不同配置下的更新瀑布流模式** * `useCache 为 false`:此时,就是普通的增删瀑布流 * `useCache 为 true`:此时,瀑布流中的组件会回收,但会重新执行 renderItem,并创建 Vnode 重新比对差异,按需生成实际的 Dom。 * `useCache 为 true,且 renderItem 返回 refs`:此时,瀑布流中的组件会回收使用时,不触发 renderItem,直接调用 updateItem,让开发者通过 refs 动态修改变化部分。 2. **高度计算**: `getItemHeight` 函数应该尽可能准确,避免频繁的布局重计算 3. **类型分类**: 使用 `getItemType` 可以让相同类型的元素更好地复用,提升性能 4. **数据更新**: 当 `data` 数组发生变化时,组件会智能地只重新计算变化部分的布局 5. **滚动加载**: 使用 `hasMore` 和 `loadMore` 实现无限滚动时,新的 data 需包含老 data 的数据 6. **下拉刷新**: 下拉刷新基于`滚动回弹`,仅在 PixUI 环境下生效 7. **曝光监控**: 利用 `onItemExpose` 和 `onItemHide` 可以实现精确的项目曝光统计 8. **元素动态高度设置** 利用 `updateItemByIndex` 更新数据并同步到 `getItemHeight` 进行高度运算变化, 然后调用 `layoutItemsFromIndex` 从对应索引位置开始,重新进行排版 ## 最佳实践 - 在 `renderItem` 中返回 `refs` 对象,配合 `updateItem` 使用可以获得最佳性能 - 如果需要获得最低 `内存占用`,可以在 `onItemHide` 中通过 `refs` 手动置空图片内存,避免离屏缓存部分元素的图片持有。 - 对于瀑布流中完全不同类型的元素,建议使用 `getItemType` 进行分类缓存 - 使用过程中,可以使用 `CPreact` 进一步减少 Preact 进行 Diff 的时间 - 合理设置 `overscan` 值,太小会影响滚动流畅度,太大会影响性能 - 对于需要曝光统计的场景,合理使用 `onItemExpose` 和 `onItemHide` 回调 - 下拉刷新和加载更多可以配合使用,提供完整的数据加载体验 ## 案例效果与性能报告 不规则瀑布流滚动时,会循环使用场上元素组件,并可通过 Ref 直接更新变化数据,免去Dom的删除创建,达到 Diff 最小化,达到近乎无消耗的性能。(具体性能可以参考 [虚拟瀑布流组件性能测试报告](https://docs.qq.com/doc/DS215VmVISG5TcU1J)) ### 通用属性 > 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. 最简单案例,默认增删 ```tsx 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回收示例 ```tsx 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更新 + 下拉刷新 + 加载更多 + 动态变高 案例 ```tsx 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 1. 修复 removeItemByIndex 最后一个元素的报错,以及高度更新问题。 ### 1.0.11 1. 提供 getVisibleIndices, 用于获取可视区域项目索引的拷贝 2. 提供 getContentHeight, 用于获取总内容高度 ### 1.0.10 1. renderLoadMoreArea 兼容 Unity/Unreal 情况下,滚动不包含当前区域的问题。 2. 修复连续删除最后一个节点,导致的排版异常。 ### 1.0.9 1. 暴露 containerRef, contentRef,提供 getLayoutInfoByIndex 获取指定 index 的 排版信息。 2. 添加 addSubDataAtIndex 用于在指定索引添加,一个或者多个渲染信息。 ### 1.0.8 1. 添加 scrollTo 方法 ### 1.0.7 1. 添加可选参数 contentStyle,用于自定义内容容器样式。可用于适配 em、rem 或 用户定制的样式方式。 ### 1.0.6 1. 修复1.0.5 去除 key后,下拉刷新的更新问题 2. 添加 layoutItemsFromIndex,用于更新排版,进行动态高度设置 ### 1.0.5 1. 修复 preact 重用 vNode,子节点的父节点引用没有清理,导致引用图片的内存泄漏问题。 2. onItemExpose onItemHide 添加可选参数 refs