更新 README

This commit is contained in:
o.o.c.
2025-12-09 00:08:16 +08:00
parent 493a02d981
commit d9405c8584
21 changed files with 124 additions and 1647 deletions

197
README.md
View File

@@ -1,108 +1,141 @@
![title.png](./doc/imgs/title.png)
---
<img src="./doc/imgs/title.png" width="400">
## 开发环境
* 2.x
引擎版本Cocos Creator **2.4.13**
编程语言TypeScript
* 3.x
引擎版本Cocos Creator **3.8.0**
编程语言TypeScript
![platform](https://img.shields.io/badge/creator-2.4.13-blue?logo=cocos)
![platform](https://img.shields.io/badge/creator-3.8+-blue?logo=cocos)
## 基本特性
参考苹果 UIKit 的 UICollectionView 实现的供 Cocos Creator 使用的列表组件
* 节点回收复用(虚拟列表模式)
* 分帧预加载节点(非虚拟列表模式)
* 多种 cell 节点类型
* 列表嵌套
* 分区概念
* supplementary 补充视图概念
* 多种 supplementary 节点类型
* [布局解耦(组件核心)](./doc/md/layout.md)
## 演示场景
## table-layout
点击图片标题可跳转对应的实现场景
* 仿 TableView 样式,仅支持垂直方向排列
* 支持设置不同的行高
* 支持分区模式
* 支持添加区头/区尾
* 支持区头/区尾悬浮吸附效果
* [在线演示](https://568071718.github.io/cocos-creator-build/collection-view/table-layout/)
| [基本列表](list-3x/assets/home/table.ts) | [基本列表 (非固定高度)](list-3x/assets/home/table_anyheight.ts) | [网格布局](list-3x/assets/home/grid.ts) |
| - | - | - |
| <img src="./doc/imgs/jc.gif" width="220"> | <img src="./doc/imgs/jch.gif" width="220"> | <img src="./doc/imgs/jcg.gif" width="220"> |
| [PageView](list-3x/assets/home/page_view.ts) | [PageView 嵌套](list-3x/assets/home/table_in_page.ts) | [展开子列表](list-3x/assets/home/table_sublist.ts) |
| - | - | - |
| <img src="./doc/imgs/jcp.gif" width="220"> | <img src="./doc/imgs/jjp.gif" width="220"> | <img src="./doc/imgs/jtz.gif" width="220"> |
点击图片标题可跳转对应的实现场景
## ScrollView
YXCollectionView 是基于 ScrollView 组件实现的,一些常规的滚动视图相关的配置可以直接通过 ScrollView 的属性/方法来修改
```ts
// 回弹开关
listComp.scrollView.inertia = true
// 边界开关
listComp.scrollView.elastic = true
// 减速系数
listComp.scrollView.brake = 0.8
// 获取滚动状态
let isScrolling = listComp.scrollView.isScrolling()
// 滚动
listComp.scrollView.scrollToTop(0.5)
// 更多 ScrollView 开放的方法 ...
```
还可以监听 ScrollView 定义的事件类型
```ts
// 以下两种写法一样,两个组件是同级的,获取到的是同一个 Node
listComp.node.on(ScrollView.EventType.SCROLLING, <your func>, this)
listComp.scrollView.node.on(ScrollView.EventType.SCROLLING, <your func>, this)
```
基于 ScrollView 可以很方便的获取滚动视图的各种状态以及回调事件
## 使用
### 注册 cell 模板
通过 `registerCell` 方法注册你的 cell 模板。为了支持多模板,这个方法约定了需要传一个标识符用来做区分
举个栗子,如果你的列表内可能会显示多种不同类型的 cell你需要创建多个 Prefab 模板,然后多次调用 `registerCell` 方法并传入不同的标识符
```ts
listComp.numberOfItems = () => 10000
listComp.registerCell(`cell1`, () => { return instantiate(<your-prefab-1>) })
listComp.registerCell(`cell2`, () => { return instantiate(<your-prefab-2>) })
listComp.registerCell(`cell3`, () => { return instantiate(<your-prefab-3>) })
...
```
> 通过代码注册模板时接收的是 Node 对象,并非必须要求预制体 (Prefab)
除了代码注册,也可以在编辑器里直接关联模板,注意这里的标识符也是不能重复
<img src="./doc/imgs/erc.png" width="320">
### 关联数据源
**关联数据必须至少实现两个方法**
一个是实现 `numberOfItems` 确定一共有多少条数据
```ts
listComp.numberOfItems = (section) => {
return 10000 // 一般来说这里可能会是 <yourArray>.length
}
```
另一个是实现 `cellForItemAt` 确定要用哪个 cell 模板以及更新模板数据,这里需要通过 `dequeueReusableCell` 方法传入你注册阶段自定义的标识符来获取对应的 Node 实例对象,通过 `indexPath` 来获取当前项对应的数据
```ts
listComp.cellForItemAt = (indexPath, collectionView) => {
let data = <yourArray>[indexPath.row]
const cell = collectionView.dequeueReusableCell(`cell`)
cell.getChildByName('label').getComponent(Label).string = `${indexPath}`
return cell
}
```
### 确定布局排列方案
组件本身不负责布局业务,还需要通过指定一个 `YXLayout` 子类布局对象来确定当前列表的布局方案
以 TableView 布局为例,创建一个 YXTableLayout 对象并赋值给 `YXCollectionView.layout` 属性
```ts
let layout = new YXTableLayout()
layout.spacing = 20
layout.rowHeight = 100
layout.spacing = 10
layout.rowHeight = 120
listComp.layout = layout
```
再以网格布局为例,只需要将 YXTableLayout 替换为 GridLayout 即可
```ts
let layout = new GridLayout()
layout.horizontalSpacing = 20
layout.verticalSpacing = 20
layout.itemSize = new math.Size(150, 180)
listComp.layout = layout
```
### 刷新
当以上配置都设置好后,在任何需要刷新的时候执行 `reloadData`
```ts
listComp.reloadData()
```
## 更多接口
## 一些说明
* 内部 ScrollView 组件
```ts
let isScrolling = this.listComp.scrollView.isScrolling()
let isAutoScrolling = this.listComp.scrollView.isAutoScrolling()
this.listComp.scrollView.brake = 0.8
this.listComp.scrollView.bounceDuration = 0.25
this.listComp.scrollView.scrollToOffset(new math.Vec2(0, 200))
// ... 可以直接使用更多 ScrollView 属性或者方法
```
* 开启分区
```ts
// 注意: 分区需要自定义 YXLayout 支持
this.listComp.numberOfSections = () => 2 // 设置列表分 2 个区排列
this.listComp.numberOfItems = (section, collectionView) => {
if (section == 0) {
return 10 // 第 1 个区返回 10 条数据
}
if (section == 1) {
return 20 // 第 2 个区返回 20 条数据
}
return 0 // 默认情况
}
```
* 节点显示状态回调
```ts
this.listComp.onCellDisplay = (cell, indexPath, collectionView) => {
log(`onCellDisplay: ${indexPath}`)
}
this.listComp.onCellEndDisplay = (cell, indexPath, collectionView) => {
log(`onCellEndDisplay: ${indexPath}`)
}
```
* 滚动至指定位置
```ts
let indexPath = new YXIndexPath(0, 2) // 要滚动到的节点索引
this.listComp.scrollTo(indexPath)
```
* 预加载相关接口
```ts
this.listComp.preloadNodesLimitPerFrame = 2 // 每帧加载多少个节点
this.listComp.preloadProgress = (current, total) => {
log(`加载进度: ${current}/${total}`)
}
```
* 如果你了解 UICollectionView需要注意两者表现并非是完全一致的 (有所欠缺)
* 使用的话无需集成所有文件,可以把布局文件看作是插件,集成时候只需要 [核心组件文件](list-3x/assets/lib/yx-collection-view.ts) + 需要的布局文件即可
* 目前在任何情况下 cell 节点的锚点都应该是 (0.5, 0.5),如果发现 cell 位置不对可以检查一下锚点设置
* cell 根节点不应该有调整自身大小的能力 - 例如不能挂载 `Widget` 组件,或者是 `CONTAINER` 模式的 `Layout` 组件
* 监听不到 `scroll-ended` 事件,滚动时卡住(无惯性滚动,无回弹) - 将 `recycleInterval` 设置为 0
* 异步加载图片显示错乱 - 这个问题需要自身业务层解决,因为虚拟列表节点存在复用情况,异步加载回调时机不确定
* 列表有 Widget 组件或者列表自身节点大小改变时布局错乱 - 将 `autoReloadOnSizeChange` 设置为 true
* 列表刷新时部分 cell 会闪烁 - 在调用 `dequeueReusableCell` 方法匹配节点时额外传一个 `indexPath` 参数
## 相关链接
* [Github](https://github.com/568071718/creator-collection-view)
* [Gitee](https://gitee.com/568071718/creator-collection-view)
* [查看声明文件](./doc/declarations/yx-collection-view.d.ts)
* [旧版文档](https://gitee.com/568071718/creator-collection-view-doc)
* [Gitee 镜像](https://gitee.com/568071718/creator-collection-view)

View File

@@ -1,441 +0,0 @@
import { Component, math, Node, ScrollView } from 'cc';
/**
* 定义列表的滚动方向
*/
declare enum _yx_collection_view_scroll_direction {
/**
* 水平滚动
*/
HORIZONTAL = 0,
/**
* 垂直滚动
*/
VERTICAL = 1
}
/**
* 列表节点加载模式
*/
declare enum _yx_collection_view_list_mode {
/**
* 根据列表显示范围加载需要的节点,同类型的节点会进行复用
* 优点: 控制总节点数量,不会创建大量节点
* 缺点: 因为有复用逻辑节点内容会频繁更新cell 更新业务比较重的话列表会抖动,例如 Label (NONE) 很多的节点
*/
RECYCLE = 0,
/**
* 直接预加载所有的节点,处于列表显示范围外的节点透明化处理
* 优点: 避免 cell 频繁更新,优化大量 Label (NONE) 场景下的卡顿问题
* 缺点: 会实例化所有节点,并非真正的虚拟列表,仅仅是把显示范围外的节点透明了,如果列表数据量很大仍然会卡
*/
PRELOAD = 1
}
/**
* 表示索引的对象
*/
export declare class YXIndexPath {
static ZERO: Readonly<YXIndexPath>;
/**
* 区索引
*/
get section(): number;
/**
* 单元格在区内的位置
*/
get item(): number;
/**
* item 别名
*/
get row(): number;
constructor(section: number, item: number);
clone(): YXIndexPath;
equals(other: YXIndexPath): boolean;
toString(): string;
}
/**
* 表示边距的对象
*/
export declare class YXEdgeInsets {
static ZERO: Readonly<YXEdgeInsets>;
top: number;
left: number;
bottom: number;
right: number;
constructor(top: number, left: number, bottom: number, right: number);
clone(): YXEdgeInsets;
equals(other: YXEdgeInsets): boolean;
set(other: YXEdgeInsets): void;
toString(): string;
}
/**
* 节点的布局属性
*/
export declare class YXLayoutAttributes {
/**
* 创建一个 cell 布局属性实例
*/
static layoutAttributesForCell(indexPath: YXIndexPath): YXLayoutAttributes;
/**
* 创建一个 supplementary 布局属性实例
* @param kinds 自定义类别标识,更多说明查看 supplementaryKinds
*/
static layoutAttributesForSupplementary(indexPath: YXIndexPath, kinds: string): YXLayoutAttributes;
/**
* 构造方法,外部禁止直接访问,需要通过上面的静态方法创建对象
*/
protected constructor();
/**
* 节点索引
*/
get indexPath(): YXIndexPath;
/**
* 节点种类
*/
get elementCategory(): "Cell" | "Supplementary";
/**
* Supplementary 种类,本身无实际意义,具体作用由自定义布局规则决定
*/
get supplementaryKinds(): string;
/**
* 节点在滚动视图中的位置和大小属性
* origin 属性表示节点在父视图坐标系中的左上角的位置size 属性表示节点的宽度和高度
*/
get frame(): math.Rect;
/**
* 节点层级
* 越小会越早的添加到滚动视图上
* https://docs.cocos.com/creator/manual/zh/ui-system/components/editor/ui-transform.html?h=uitrans
* 备注: 内部暂时是通过节点的 siblingIndex 实现的,如果自定义 layout 有修改这个值的需求,需要重写 layout 的 @shouldUpdateAttributesZIndex 方法,默认情况下会忽略这个配置
*/
zIndex: number;
/**
* 节点透明度
* 备注: 内部通过 UIOpacity 组件实现,会修改节点 UIOpacity 组件的 opacity 值,如果自定义 layout 有修改这个值的需求,需要重写 layout 的 @shouldUpdateAttributesOpacity 方法,默认情况下会忽略这个配置
*/
opacity: number;
/**
* 节点变换 - 缩放
*/
scale: math.Vec3;
/**
* 节点变换 - 平移
*/
offset: math.Vec3;
/**
* 节点变换 - 旋转
* 备注: 3D 变换似乎需要透视相机???
*/
eulerAngles: math.Vec3;
}
/**
* 布局规则
* 这里只是约定出了一套接口,内部只是一些基础实现,具体布局方案通过子类重载去实现
*/
export declare abstract class YXLayout {
constructor();
/**
* @required
* 整个滚动区域大小
* 需要在 prepare 内初始化
*/
contentSize: math.Size;
/**
* @required
* 所有元素的布局属性
* 需要在 prepare 内初始化
* @todo 这个不应该限制为数组结构,准确来说是不应该限制开发者必须使用数组来保存所有布局属性,目前为了实现预加载模式暂时是必须要求数组结构,后续有好的方案的话应该考虑优化
*/
attributes: YXLayoutAttributes[];
/**
* @required
* 子类重写实现布局方案
* 注意: 必须初始化滚动区域大小并赋值给 contentSize 属性
* 注意: 必须初始化所有的元素布局属性,并保存到 attributes 数组
* 可选: 根据 collectionView 的 scrollDirection 支持不同的滚动方向
*/
abstract prepare(collectionView: YXCollectionView): void;
/**
* @optional
* 列表在首次更新数据后会执行这个方法
* 在这个方法里设置滚动视图的初始偏移量
*
* @example
* // 比如一个垂直列表希望初始化时从最顶部开始展示数据,那么可以在这个方法里通过 scrollToTop 实现
* initOffset(collectionView: YXCollectionView): void {
* collectionView.scrollView.scrollToTop()
* }
*/
initOffset(collectionView: YXCollectionView): void;
/**
* @optional
* 当一次手势拖动结束后会立即调用此方法,通过重写这个方法可以定制列表最终停留的位置
*
* @param collectionView 列表组件
* @param touchMoveVelocity 手势速度
* @param startOffset 此次手势开始时列表的偏移位置
* @param originTargetOffset 接下来将要自动滚动到的位置
* @param originScrollDuration 接下来的惯性滚动持续时间
* @returns 可以返回 null ,返回 null 执行默认的惯性滚动逻辑
*
* 另外关于返回值的字段说明
* @param offset 这个字段表示列表本次滚动结束时期望停留的位置,一旦返回了这个字段,列表最终将会停留至返回的这个位置
* @param time 可选,默认为 originScrollDuration这个字段表示自动滚动至期望停留位置需要的时间
* @param attenuated 可选,默认为 true这个字段表示惯性滚动速度是否衰减
*/
targetOffset(collectionView: YXCollectionView, touchMoveVelocity: math.Vec3, startOffset: math.Vec2, originTargetOffset: math.Vec2, originScrollDuration: number): {
offset: math.Vec2;
time?: number;
attenuated?: boolean;
} | null;
/**
* @optional
* 列表每次滚动结束后会调用此方法
*/
onScrollEnded(collectionView: YXCollectionView): void;
/**
* @optional
* 当滚动视图的可见范围变化后执行,这个方法会在列表滚动过程中频繁的执行
* 在这个方法里可以调整节点属性以实现交互性的节点变换效果,(如果在这个方法里调整了节点变换属性,需要重写 shouldUpdateAttributesForBoundsChange 以支持实时变换)
*
* @param rect 当前滚动视图的可见区域
*
* @returns
* 关于这个方法的返回值,最优的情况应该是根据实际的布局情况计算出当前显示区域内需要显示的所有布局属性
* 列表在更新可见节点时会遍历这个方法返回的数组并依次检查节点是否需要添加到列表内,默认这个方法是直接返回所有的布局属性,也就是在更新可见节点时的时间复杂度默认为 O(attributes.length),除非有更优的算法,否则建议直接返回所有的布局属性
*/
layoutAttributesForElementsInRect(rect: math.Rect, collectionView: YXCollectionView): YXLayoutAttributes[];
layoutAttributesForItemAtIndexPath(indexPath: YXIndexPath, collectionView: YXCollectionView): YXLayoutAttributes;
layoutAttributesForSupplementaryAtIndexPath(indexPath: YXIndexPath, collectionView: YXCollectionView, kinds: string): YXLayoutAttributes;
/**
* @optional
* 列表组件在调用 scrollTo 方法时会触发这个方法,如果实现了这个方法,最终的滚动停止位置以这个方法返回的为准
*/
scrollTo(indexPath: YXIndexPath, collectionView: YXCollectionView): math.Vec2;
/**
* @optional
* @see YXLayoutAttributes.zIndex
*/
shouldUpdateAttributesZIndex(): boolean;
/**
* @optional
* @see YXLayoutAttributes.opacity
*/
shouldUpdateAttributesOpacity(): boolean;
/**
* @optional
* 此布局下的节点,是否需要实时更新变换效果
* @returns 返回 true 会忽略 YXCollectionView 的 frameInterval 设置,强制在滚动过程中实时更新节点
*/
shouldUpdateAttributesForBoundsChange(): boolean;
/**
* @optional
* 列表组件销毁时执行
*/
onDestroy(): void;
}
/**
* @see NodePool.poolHandlerComp
* 节点的自定义组件可以通过这个接口跟 NodePool 的重用业务关联起来
*/
export interface YXCollectionViewCell extends Component {
unuse(): void;
reuse(args: any): void;
}
/**
* 列表组件
*/
export declare class YXCollectionView extends Component {
/**
* 访问定义的私有枚举
*/
static ScrollDirection: typeof _yx_collection_view_scroll_direction;
static Mode: typeof _yx_collection_view_list_mode;
/**
* 滚动视图组件
*/
get scrollView(): ScrollView;
/**
* 允许手势滚动
*/
scrollEnabled: boolean;
/**
* 允许鼠标滑轮滚动
*/
wheelScrollEnabled: boolean;
/**
* 列表滚动方向,默认垂直方向滚动
* 自定义 YXLayout 应该尽量根据这个配置来实现不同方向的布局业务
* 备注: 如果使用的 YXLayout 未支持对应的滚动方向,则此配置不会生效,严格来说这个滚动方向本就应该是由 YXLayout 决定的,定义在这里是为了编辑器配置方便
*/
scrollDirection: YXCollectionView.ScrollDirection;
/**
* 列表单元节点加载模式
*/
mode: YXCollectionView.Mode;
/**
* 预加载模式下每帧加载多少个节点
*/
preloadNodesLimitPerFrame: number;
/**
* 预加载进度
*/
preloadProgress: (current: number, total: number) => void;
/**
* 每多少帧刷新一次可见节点1 表示每帧都刷
*/
frameInterval: number;
/**
* 滚动过程中每多少帧回收一次不可见节点1表示每帧都回收0表示不在滚动过程中回收不可见节点
* @bug 滚动过程中如果实时的回收不可见节点,有时候会收不到 scroll view 的 cancel 事件,导致 scroll view 的滚动状态不会更新 (且收不到滚动结束事件)
* @fix 当这个属性设置为 0 时,只会在 `touch-up` 和 `scroll-ended` 里面回收不可见节点
*/
recycleInterval: number;
/**
* 注册 cell
* 可多次注册不同种类的 cell只要确保 identifier 的唯一性就好
* @param identifier cell 标识符,通过 dequeueReusableCell 获取重用 cell 时,会根据这个值匹配
* @param maker 生成节点,当重用池里没有可用的节点时,会通过这个回调获取节点,需要在这个回调里面生成节点
* @param poolComp (可选) 节点自定义组件,可以通过这个组件跟 NodePool 的重用业务关联起来
*/
registerCell(identifier: string, maker: () => Node, poolComp?: (new (...args: any[]) => YXCollectionViewCell) | string | null): void;
/**
* 注册 supplementary 追加视图,用法跟 registerCell 一样
*/
registerSupplementary(identifier: string, maker: () => Node, poolComp?: (new (...args: any[]) => YXCollectionViewCell) | string | null): void;
/**
* 通过标识符从重用池里取出一个可用的 cell 节点
* @param identifier 注册时候的标识符
*/
dequeueReusableCell(identifier: string): Node;
/**
* 通过标识符从重用池里取出一个可用的 supplementary 节点
* @param identifier 注册时候的标识符
*/
dequeueReusableSupplementary(identifier: string): Node;
/**
* 内容要分几个区展示,默认 1
* 没有分区展示的需求可以不管这个配置
*/
numberOfSections: number | ((collectionView: YXCollectionView) => number);
getNumberOfSections(): number;
/**
* 每个区里要展示多少条内容
*/
numberOfItems: number | ((section: number, collectionView: YXCollectionView) => number);
getNumberOfItems(section: number): number;
/**
* 配置每块内容对应的 UI 节点
* 在这个方法里,需要确定 indexPath 这个位置对应的节点应该是用注册过的哪个类型的 Node 节点,然后通过 dequeueReusableCell 生成对应的 Node
*
* @example
* yourList.cellForItemAt = (indexPath ,collectionView) => {
* let cell = collectionView.dequeueReusableCell(`your identifier`)
* let comp = cell.getComponent(YourCellComp)
* comp.label.string = `${indexPath}`
* return cell
* }
*
* @returns 注意: 不要在这个方法里创建新的节点对象,这个方法返回的 Node必须是通过 dequeueReusableCell 匹配到的 Node
*/
cellForItemAt: (indexPath: YXIndexPath, collectionView: YXCollectionView) => Node;
/**
* 用法跟 cellForItemAt 差不多,此方法内需要通过 dequeueReusableSupplementary 获取 Node 节点
* @param kinds 关于这个字段的具体含义应该根据使用的自定义 layout 决定
*/
supplementaryForItemAt: (indexPath: YXIndexPath, collectionView: YXCollectionView, kinds: string) => Node;
/**
* cell 节点可见状态回调
* 如果同类型的节点大小可能不一样,可以在这里调整子节点的位置
*/
onCellDisplay: (node: Node, indexPath: YXIndexPath, collectionView: YXCollectionView) => void;
onCellEndDisplay: (node: Node, indexPath: YXIndexPath, collectionView: YXCollectionView) => void;
/**
* supplementary 节点可见状态回调
*/
onSupplementaryDisplay: (node: Node, indexPath: YXIndexPath, collectionView: YXCollectionView, kinds: string) => void;
onSupplementaryEndDisplay: (node: Node, indexPath: YXIndexPath, collectionView: YXCollectionView, kinds: string) => void;
/**
* 点击到 cell 节点后执行
*/
onTouchCellAt: (indexPath: YXIndexPath, collectionView: YXCollectionView) => void;
/**
* 点击到 supplementary 节点后执行
*/
onTouchSupplementaryAt: (indexPath: YXIndexPath, collectionView: YXCollectionView, kinds: string) => void;
/**
* 布局规则
*/
layout: YXLayout;
/**
* 获取列表当前的可见范围
*/
getVisibleRect(): math.Rect;
/**
* 通过索引获取指定的可见的 cell 节点
*/
getVisibleCellNode(indexPath: YXIndexPath): Node;
/**
* 通过索引获取指定的可见的 supplementary 节点
*/
getVisibleSupplementaryNode(indexPath: YXIndexPath, kinds: string): Node;
/**
* 获取所有正在显示的 cell 节点
*/
getVisibleCellNodes(): Node[];
/**
* 获取所有正在显示的 supplementary 节点
* @param kinds 可选按种类筛选
*/
getVisibleSupplementaryNodes(kinds?: string): Node[];
/**
* 获取指定节点绑定的布局属性对象
*/
getElementAttributes(node: Node): YXLayoutAttributes;
/**
* 刷新列表数据
*/
reloadData(): void;
/**
* 刷新当前可见节点
* @param force true: 立即刷新; false: 根据设置的刷新帧间隔在合适的时候刷新
*/
markForUpdateVisibleData(force?: boolean): void;
/**
* 滚动到指定节点的位置
* @todo 支持偏移方位,目前固定是按顶部的位置的,有特殊需求的建议直接通过 .scrollView.scrollToOffset() 实现
*/
scrollTo(indexPath: YXIndexPath, timeInSecond?: number, attenuated?: boolean): void;
/**
* 生命周期方法
*/
protected onLoad(): void;
protected onDestroy(): void;
protected update(dt: number): void;
}
export declare namespace YXCollectionView {
/**
* 重定义私有类型
*/
type ScrollDirection = _yx_collection_view_scroll_direction;
type Mode = _yx_collection_view_list_mode;
}
/**
* *****************************************************************************************
* *****************************************************************************************
* 把二分查找的规则抽出来封装一下,继承这个类的布局,默认通过二分查找实现查找业务
* 这种查找规则对数据量很大的有序列表来说相对高效,具体是否使用还是要根据实际排列需求决定
* *****************************************************************************************
* *****************************************************************************************
*
* @deprecated 1.4.0 版本开始,在自定义布局规则的时候暂时不建议继承这个规则了,如何优化查找算法应该全靠开发者根据实际需求自行实现,目前保留这个是为了 flow-layout 使用,后续有更优方案的话可能会删除这部分代码
*/
export declare abstract class YXBinaryLayout extends YXLayout {
/**
* @bug 如果节点大小差距很大,可能会导致计算屏幕内节点时不准确,出现节点不被正确添加到滚动视图上的问题
* @fix 可以通过此属性,追加屏幕显示的节点数量
* 设置这个值会在检查是否可见的节点时,尝试检查更多的可能处于屏幕外的节点,具体设置多少要根据实际情况调试,一般如果都是正常大小的节点,不需要考虑这个配置
* 设置负值会检查所有的节点
*/
extraVisibleCount: number;
layoutAttributesForElementsInRect(rect: math.Rect, collectionView: YXCollectionView): YXLayoutAttributes[];
}
export {};

BIN
doc/imgs/erc.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

BIN
doc/imgs/jc.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 581 KiB

BIN
doc/imgs/jcg.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

BIN
doc/imgs/jch.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 610 KiB

BIN
doc/imgs/jcp.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 273 KiB

BIN
doc/imgs/jjp.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

BIN
doc/imgs/jtz.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

View File

@@ -1,80 +0,0 @@
YXCollectionView 的 layout 属性决定了单元节点在屏幕上的排列方式。
通过 YXLayout 作为布局管理器YXCollectionView 将所有的布局和展示逻辑交给了 YXLayout 来处理。也就是说YXCollectionView 本身不负责具体的布局实现,而是通过将布局职责委托给 YXLayout 来实现布局的完全解耦。
这一设计的最大优势是布局的独立性:你可以针对不同的需求实现不同的布局样式,无论是 TableView、网格布局还是其他任意排列方式都可以通过自定义 YXLayout 来实现。
每种布局都可以独立封装,不同布局之间相互隔离,互不依赖。使用时只需引入对应的布局规则,极大地提高了灵活性和可重用性。而且,由于布局规则是独立设计的,它们还可以很方便地被导出并分享给其他开发者使用。
---
## YXIndexPath
YXIndexPath 代表了索引,常见的列表组件里数据索引一般来说都是直接用整形来定义的 (例如 index: number),但是因为 YXCollectionView 有分区的概念所以是封装了一个对象用来表示节点的位置索引通俗的来说YXIndexPath 表示的是第 section 个区里的第 item 个节点,这个 item 就可以看做是 index只不过表示的是在某个区内的位置
## YXLayoutAttributes
YXLayoutAttributes 用来描述节点的 UI 相关的信息,在自定义布局的时候,需要对应的创建多个 YXLayoutAttributes 对象来描述节点的 UI 信息,假如说列表一共需要展示 100 条内容,那就是需要创建 100 个 YXLayoutAttributes 对象来描述这 100 个节点的位置YXLayoutAttributes 通过关键属性 indexPath 记录这个布局对象对应的是哪个节点,通过 frame 属性记录这个节点实际的 UI 位置,总结来说 YXLayoutAttributes 就是它**表示了第 indexPath 个节点的位置是 frame**
需要注意的是frame 是一个 Rect 类型,同时包含了节点的位置和大小信息,**参考坐标系为左上角原点坐标系**,也就是 origin (0,0) 的位置表示节点紧靠列表左边/上边的位置,举个例子,假如现在需要在列表内以左上角为起点垂直方向排列 3 个大小为 200x100 的节点,不考虑间距边距的情况下,最终的节点位置用 frame 来表示应该为:
```ts
1. (0, 0, 200, 100)
2. (0, 100, 200, 100)
3. (0, 200, 200, 100)
```
如果需要给节点之间加上一个间距 10则最终节点位置用 frame 来表示应该为:
```ts
1. (0, 0, 200, 100)
2. (0, 110, 200, 100)
3. (0, 220, 200, 100)
```
如果还需要给节点加一个左边距 20 的话,则最终节点位置用 frame 来表示应该为:
```ts
1. (20, 0, 200, 100)
2. (20, 110, 200, 100)
3. (20, 220, 200, 100)
```
把上面的例子通过代码实现的话就是:
```ts
// 伪代码
let spacing = 10 // 节点之间间距
let section_left = 20 // 左边距
let itemSize = new math.Size(200, 100) // 节点大小
let attr1 = new YXLayoutAttributes()
attr1.indexPath = new YXIndexPath(0, 0) // 第 0 个区第 0 个节点
attr1.frame = new math.Rect(section_left, 0, itemSize.width, itemSize.height) // 这个节点的位置
let attr2 = new YXLayoutAttributes()
attr2.indexPath = new YXIndexPath(0, 1) // 第 0 个区第 1 个节点
attr2.frame = new math.Rect(section_left, attr1.frame.yMax + spacing, itemSize.width, itemSize.height) // 这个节点的位置
let attr3 = new YXLayoutAttributes()
attr3.indexPath = new YXIndexPath(0, 2) // 第 0 个区第 2 个节点
attr3.frame = new math.Rect(section_left, attr2.frame.yMax + spacing, itemSize.width, itemSize.height) // 这个节点的位置
```
## YXLayout
YXLayout 主要就是负责管理自定义的 YXLayoutAttributes 对象,在 YXLayout 里面无需考虑节点管理,开发者可以放心的定义全部的 YXLayoutAttributes 对象以描述所有的节点位置
可以参考项目里的 table-layout 的实现,了解 YXLayout 的基本工作流程
## table-layout
1. [实现一个类似 TableView 的布局规则 table-layout (了解基本的自定义布局流程)](./table-layout-1.md)
1. [使 table-layout 支持不同高度的节点 (了解如何支持不同大小的单元项)](./table-layout-2.md)
1. [使 table-layout 支持分区配置 (了解组件的分区概念)](./table-layout-3.md)
1. [使 table-layout 支持区头/区尾配置 (简单了解 supplementary 补充视图概念)](./table-layout-4.md)
1. [使 table-layout 支持区头/区尾吸附效果 (了解如何实时更新节点布局属性)](./table-layout-5.md)
1. [table-layout 性能优化 (了解组件的性能缺陷)](./table-layout-6.md)
以上就是 table-layout 完整的实现过程,有兴趣的可以了解一下

View File

@@ -1,83 +0,0 @@
基本的实现一个垂直方向排列的布局规则
```ts
export class YXTableLayout extends YXLayout {
/**
* 行高
*/
rowHeight: number = 100
/**
* 内容上边距
*/
top: number = 0
/**
* 内容下边距
*/
bottom: number = 0
/**
* 节点之间间距
*/
spacing: number = 0
prepare(collectionView: YXCollectionView): void {
// 设置列表的滚动方向(这套布局固定为垂直方向滚动)
collectionView.scrollView.horizontal = false
collectionView.scrollView.vertical = true
if (collectionView.scrollDirection === YXCollectionView.ScrollDirection.HORIZONTAL) {
// 由于这套布局规则只支持垂直方向布局,当外部配置了水平方向滚动时这里可以给个警告
warn(`YXTableLayout 仅支持垂直方向排列`)
}
// 获取列表内一共多少数据,这里传的这个 0 表示的区,在目前不支持分区的情况可以先不用考虑这个,后面会说到
let numberOfItems = collectionView.getNumberOfItems(0)
// 清空一下布局属性数组
this.attributes = []
// 获取列表宽度
const contentWidth = collectionView.node.getComponent(UITransform).width
// 声明一个临时变量,用来记录当前所有内容的总高度
let contentHeight = this.top
// 为每条数据对应的生成一个布局属性
for (let item = 0; item < numberOfItems; item++) {
// 创建索引
let indexPath = new YXIndexPath(0, item)
// 通过索引创建一个 cell 节点的布局属性
let attr = YXLayoutAttributes.layoutAttributesForCell(indexPath)
// 确定这个节点的位置
attr.frame.x = 0
attr.frame.width = contentWidth
attr.frame.height = this.rowHeight
attr.frame.y = contentHeight + (item > 0 ? this.spacing : 0)
// 重要: 保存布局属性
this.attributes.push(attr)
// 更新当前内容高度
contentHeight = attr.frame.yMax
}
// 高度补一个底部间距
contentHeight = contentHeight + this.bottom
// 重要: 设置内容区域总大小,只有确定了滚动区域的大小列表才能滚动
this.contentSize = new math.Size(contentWidth, contentHeight)
}
initOffset(collectionView: YXCollectionView): void {
// 列表首次刷新时,调整一下列表的偏移位置
collectionView.scrollView.scrollToTop()
}
}
```

View File

@@ -1,86 +0,0 @@
使 table-layout 支持不同高度的节点
```ts
export class YXTableLayout extends YXLayout {
/**
* 行高
*/
rowHeight: number | ((indexPath: YXIndexPath) => number) = 100
/**
* 内容上边距
*/
top: number = 0
/**
* 内容下边距
*/
bottom: number = 0
/**
* 节点之间间距
*/
spacing: number = 0
prepare(collectionView: YXCollectionView): void {
// 设置列表的滚动方向(这套布局固定为垂直方向滚动)
collectionView.scrollView.horizontal = false
collectionView.scrollView.vertical = true
if (collectionView.scrollDirection === YXCollectionView.ScrollDirection.HORIZONTAL) {
// 由于这套布局规则只支持垂直方向布局,当外部配置了水平方向滚动时这里可以给个警告
warn(`YXTableLayout 仅支持垂直方向排列`)
}
// 获取列表内一共多少数据,这里传的这个 0 表示的区,在目前不支持分区的情况可以先不用考虑这个,后面会说到
let numberOfItems = collectionView.getNumberOfItems(0)
// 清空一下布局属性数组
this.attributes = []
// 获取列表宽度
const contentWidth = collectionView.node.getComponent(UITransform).width
// 声明一个临时变量,用来记录当前所有内容的总高度
let contentHeight = this.top
// 为每条数据对应的生成一个布局属性
for (let item = 0; item < numberOfItems; item++) {
// 创建索引
let indexPath = new YXIndexPath(0, item)
// 通过索引创建一个 cell 节点的布局属性
let attr = YXLayoutAttributes.layoutAttributesForCell(indexPath)
// 通过索引获取这个节点的高度
let rowHeight = this.rowHeight instanceof Function ? this.rowHeight(indexPath) : this.rowHeight
// 确定这个节点的位置
attr.frame.x = 0
attr.frame.width = contentWidth
attr.frame.height = rowHeight
attr.frame.y = contentHeight + (item > 0 ? this.spacing : 0)
// 重要: 保存布局属性
this.attributes.push(attr)
// 更新当前内容高度
contentHeight = attr.frame.yMax
}
// 高度补一个底部间距
contentHeight = contentHeight + this.bottom
// 重要: 设置内容区域总大小,只有确定了滚动区域的大小列表才能滚动
this.contentSize = new math.Size(contentWidth, contentHeight)
}
initOffset(collectionView: YXCollectionView): void {
// 列表首次刷新时,调整一下列表的偏移位置
collectionView.scrollView.scrollToTop()
}
}
```

View File

@@ -1,95 +0,0 @@
使 table-layout 支持分区配置
```ts
export class YXTableLayout extends YXLayout {
/**
* 行高
*/
rowHeight: number | ((indexPath: YXIndexPath) => number) = 100
/**
* 内容上边距
*/
top: number = 0
/**
* 内容下边距
*/
bottom: number = 0
/**
* 节点之间间距
*/
spacing: number = 0
prepare(collectionView: YXCollectionView): void {
// 设置列表的滚动方向(这套布局固定为垂直方向滚动)
collectionView.scrollView.horizontal = false
collectionView.scrollView.vertical = true
if (collectionView.scrollDirection === YXCollectionView.ScrollDirection.HORIZONTAL) {
// 由于这套布局规则只支持垂直方向布局,当外部配置了水平方向滚动时这里可以给个警告
warn(`YXTableLayout 仅支持垂直方向排列`)
}
// 清空一下布局属性数组
this.attributes = []
// 获取列表宽度
const contentWidth = collectionView.node.getComponent(UITransform).width
// 声明一个临时变量,用来记录当前所有内容的总高度
let contentHeight = 0
// 获取列表一共分多少个区
let numberOfSections = collectionView.getNumberOfSections()
// 为每条数据对应的生成一个布局属性
for (let section = 0; section < numberOfSections; section++) {
// 将 top 配置应用到每个区
contentHeight = contentHeight + this.top
// 获取这个区内的内容数量,注意这里传入的是 section
let numberOfItems = collectionView.getNumberOfItems(section)
for (let item = 0; item < numberOfItems; item++) {
// 创建索引,注意这里的 section 已经改为正确的 section 了
let indexPath = new YXIndexPath(section, item)
// 通过索引创建一个 cell 节点的布局属性
let attr = YXLayoutAttributes.layoutAttributesForCell(indexPath)
// 通过索引获取这个节点的高度
let rowHeight = this.rowHeight instanceof Function ? this.rowHeight(indexPath) : this.rowHeight
// 确定这个节点的位置
attr.frame.x = 0
attr.frame.width = contentWidth
attr.frame.height = rowHeight
attr.frame.y = contentHeight + (item > 0 ? this.spacing : 0)
// 重要: 保存布局属性
this.attributes.push(attr)
// 更新当前内容高度
contentHeight = attr.frame.yMax
}
// 高度补一个底部间距,跟 top 一样,也是应用到每个区
contentHeight = contentHeight + this.bottom
}
// 重要: 设置内容区域总大小,只有确定了滚动区域的大小列表才能滚动
this.contentSize = new math.Size(contentWidth, contentHeight)
}
initOffset(collectionView: YXCollectionView): void {
// 列表首次刷新时,调整一下列表的偏移位置
collectionView.scrollView.scrollToTop()
}
}
```

View File

@@ -1,159 +0,0 @@
使 table-layout 支持区头/区尾配置
```ts
enum _yx_table_layout_supplementary_kinds {
HEADER = 'header',
FOOTER = 'footer',
}
export class YXTableLayout extends YXLayout {
/**
* 行高
*/
rowHeight: number | ((indexPath: YXIndexPath) => number) = 100
/**
* 内容上边距
*/
top: number = 0
/**
* 内容下边距
*/
bottom: number = 0
/**
* 节点之间间距
*/
spacing: number = 0
/**
* 区头高度
*/
sectionHeaderHeight: number | ((section: number) => number) = null
/**
* 区尾高度
*/
sectionFooterHeight: number | ((section: number) => number) = null
/**
* 区头/区尾标识
*/
static SupplementaryKinds = _yx_table_layout_supplementary_kinds
prepare(collectionView: YXCollectionView): void {
// 设置列表的滚动方向(这套布局固定为垂直方向滚动)
collectionView.scrollView.horizontal = false
collectionView.scrollView.vertical = true
if (collectionView.scrollDirection === YXCollectionView.ScrollDirection.HORIZONTAL) {
// 由于这套布局规则只支持垂直方向布局,当外部配置了水平方向滚动时这里可以给个警告
warn(`YXTableLayout 仅支持垂直方向排列`)
}
// 清空一下布局属性数组
this.attributes = []
// 获取列表宽度
const contentWidth = collectionView.node.getComponent(UITransform).width
// 声明一个临时变量,用来记录当前所有内容的总高度
let contentHeight = 0
// 获取列表一共分多少个区
let numberOfSections = collectionView.getNumberOfSections()
// 为每条数据对应的生成一个布局属性
for (let section = 0; section < numberOfSections; section++) {
// 创建一个区索引
let sectionIndexPath = new YXIndexPath(section, 0)
// 通过区索引创建一个区头节点布局属性
let sectionHeaderHeight = 0
if (this.sectionHeaderHeight) {
sectionHeaderHeight = this.sectionHeaderHeight instanceof Function ? this.sectionHeaderHeight(section) : this.sectionHeaderHeight
}
if (sectionHeaderHeight > 0) {
let headerAttr = YXLayoutAttributes.layoutAttributesForSupplementary(sectionIndexPath, YXTableLayout.SupplementaryKinds.HEADER)
// 确定这个节点的位置
headerAttr.frame.x = 0
headerAttr.frame.width = contentWidth
headerAttr.frame.height = sectionHeaderHeight
headerAttr.frame.y = contentHeight
// 重要: 保存布局属性
this.attributes.push(headerAttr)
// 更新整体内容高度
contentHeight = headerAttr.frame.yMax
}
// 将 top 配置应用到每个区
contentHeight = contentHeight + this.top
// 获取这个区内的内容数量,注意这里传入的是 section
let numberOfItems = collectionView.getNumberOfItems(section)
for (let item = 0; item < numberOfItems; item++) {
// 创建索引,注意这里的 section 已经改为正确的 section 了
let indexPath = new YXIndexPath(section, item)
// 通过索引创建一个 cell 节点的布局属性
let attr = YXLayoutAttributes.layoutAttributesForCell(indexPath)
// 通过索引获取这个节点的高度
let rowHeight = this.rowHeight instanceof Function ? this.rowHeight(indexPath) : this.rowHeight
// 确定这个节点的位置
attr.frame.x = 0
attr.frame.width = contentWidth
attr.frame.height = rowHeight
attr.frame.y = contentHeight + (item > 0 ? this.spacing : 0)
// 重要: 保存布局属性
this.attributes.push(attr)
// 更新当前内容高度
contentHeight = attr.frame.yMax
}
// 高度补一个底部间距,跟 top 一样,也是应用到每个区
contentHeight = contentHeight + this.bottom
// 通过区索引创建一个区尾节点布局属性
let sectionFooterHeight = 0
if (this.sectionFooterHeight) {
sectionFooterHeight = this.sectionFooterHeight instanceof Function ? this.sectionFooterHeight(section) : this.sectionFooterHeight
}
if (sectionFooterHeight > 0) {
let footerAttr = YXLayoutAttributes.layoutAttributesForSupplementary(sectionIndexPath, YXTableLayout.SupplementaryKinds.FOOTER)
// 确定这个节点的位置
footerAttr.frame.x = 0
footerAttr.frame.width = contentWidth
footerAttr.frame.height = sectionFooterHeight
footerAttr.frame.y = contentHeight
// 重要: 保存布局属性
this.attributes.push(footerAttr)
// 更新整体内容高度
contentHeight = footerAttr.frame.yMax
}
}
// 重要: 设置内容区域总大小,只有确定了滚动区域的大小列表才能滚动
this.contentSize = new math.Size(contentWidth, contentHeight)
}
initOffset(collectionView: YXCollectionView): void {
// 列表首次刷新时,调整一下列表的偏移位置
collectionView.scrollView.scrollToTop()
}
}
```

View File

@@ -1,267 +0,0 @@
使 table-layout 支持区头/区尾吸附效果
```ts
enum _yx_table_layout_supplementary_kinds {
HEADER = 'header',
FOOTER = 'footer',
}
export class YXTableLayout extends YXLayout {
/**
* 行高
*/
rowHeight: number | ((indexPath: YXIndexPath) => number) = 100
/**
* 内容上边距
*/
top: number = 0
/**
* 内容下边距
*/
bottom: number = 0
/**
* 节点之间间距
*/
spacing: number = 0
/**
* 区头高度
*/
sectionHeaderHeight: number | ((section: number) => number) = null
/**
* 区尾高度
*/
sectionFooterHeight: number | ((section: number) => number) = null
/**
* 钉住 header 的位置 ( header 吸附在列表可见范围内 )
*/
sectionHeadersPinToVisibleBounds: boolean = false
/**
* 钉住 footer 的位置 ( footer 吸附在列表可见范围内 )
*/
sectionFootersPinToVisibleBounds: boolean = false
/**
* 区头/区尾标识
*/
static SupplementaryKinds = _yx_table_layout_supplementary_kinds
protected originalHeaderRect: Map<number, math.Rect> = new Map() // 保存所有 header 的原始位置
protected originalFooterRect: Map<number, math.Rect> = new Map() // 保存所有 footer 的原始位置
prepare(collectionView: YXCollectionView): void {
// 设置列表的滚动方向(这套布局固定为垂直方向滚动)
collectionView.scrollView.horizontal = false
collectionView.scrollView.vertical = true
if (collectionView.scrollDirection === YXCollectionView.ScrollDirection.HORIZONTAL) {
// 由于这套布局规则只支持垂直方向布局,当外部配置了水平方向滚动时这里可以给个警告
warn(`YXTableLayout 仅支持垂直方向排列`)
}
// 清空一下布局属性数组
this.attributes = []
this.originalHeaderRect.clear()
this.originalFooterRect.clear()
// 获取列表宽度
const contentWidth = collectionView.node.getComponent(UITransform).width
// 声明一个临时变量,用来记录当前所有内容的总高度
let contentHeight = 0
// 获取列表一共分多少个区
let numberOfSections = collectionView.getNumberOfSections()
// 为每条数据对应的生成一个布局属性
for (let section = 0; section < numberOfSections; section++) {
// 创建一个区索引
let sectionIndexPath = new YXIndexPath(section, 0)
// 通过区索引创建一个区头节点布局属性
let sectionHeaderHeight = 0
if (this.sectionHeaderHeight) {
sectionHeaderHeight = this.sectionHeaderHeight instanceof Function ? this.sectionHeaderHeight(section) : this.sectionHeaderHeight
}
if (sectionHeaderHeight > 0) {
let headerAttr = YXLayoutAttributes.layoutAttributesForSupplementary(sectionIndexPath, YXTableLayout.SupplementaryKinds.HEADER)
// 确定这个节点的位置
headerAttr.frame.x = 0
headerAttr.frame.width = contentWidth
headerAttr.frame.height = sectionHeaderHeight
headerAttr.frame.y = contentHeight
// 调整层级
headerAttr.zIndex = 1
// 重要: 保存布局属性
this.attributes.push(headerAttr)
this.originalHeaderRect.set(section, headerAttr.frame.clone())
// 更新整体内容高度
contentHeight = headerAttr.frame.yMax
}
// 将 top 配置应用到每个区
contentHeight = contentHeight + this.top
// 获取这个区内的内容数量,注意这里传入的是 section
let numberOfItems = collectionView.getNumberOfItems(section)
for (let item = 0; item < numberOfItems; item++) {
// 创建索引,注意这里的 section 已经改为正确的 section 了
let indexPath = new YXIndexPath(section, item)
// 通过索引创建一个 cell 节点的布局属性
let attr = YXLayoutAttributes.layoutAttributesForCell(indexPath)
// 通过索引获取这个节点的高度
let rowHeight = this.rowHeight instanceof Function ? this.rowHeight(indexPath) : this.rowHeight
// 确定这个节点的位置
attr.frame.x = 0
attr.frame.width = contentWidth
attr.frame.height = rowHeight
attr.frame.y = contentHeight + (item > 0 ? this.spacing : 0)
// 重要: 保存布局属性
this.attributes.push(attr)
// 更新当前内容高度
contentHeight = attr.frame.yMax
}
// 高度补一个底部间距,跟 top 一样,也是应用到每个区
contentHeight = contentHeight + this.bottom
// 通过区索引创建一个区尾节点布局属性
let sectionFooterHeight = 0
if (this.sectionFooterHeight) {
sectionFooterHeight = this.sectionFooterHeight instanceof Function ? this.sectionFooterHeight(section) : this.sectionFooterHeight
}
if (sectionFooterHeight > 0) {
let footerAttr = YXLayoutAttributes.layoutAttributesForSupplementary(sectionIndexPath, YXTableLayout.SupplementaryKinds.FOOTER)
// 确定这个节点的位置
footerAttr.frame.x = 0
footerAttr.frame.width = contentWidth
footerAttr.frame.height = sectionFooterHeight
footerAttr.frame.y = contentHeight
// 调整层级
footerAttr.zIndex = 1
// 重要: 保存布局属性
this.attributes.push(footerAttr)
this.originalFooterRect.set(section, footerAttr.frame.clone())
// 更新整体内容高度
contentHeight = footerAttr.frame.yMax
}
}
// 重要: 设置内容区域总大小,只有确定了滚动区域的大小列表才能滚动
this.contentSize = new math.Size(contentWidth, contentHeight)
}
initOffset(collectionView: YXCollectionView): void {
// 列表首次刷新时,调整一下列表的偏移位置
collectionView.scrollView.scrollToTop()
}
layoutAttributesForElementsInRect(rect: math.Rect, collectionView: YXCollectionView): YXLayoutAttributes[] {
let numberOfSections = collectionView.getNumberOfSections()
let scrollOffset = collectionView.scrollView.getScrollOffset()
for (let index = 0; index < this.attributes.length; index++) {
const element = this.attributes[index];
if (element.elementCategory === 'Supplementary') {
if (this.sectionHeadersPinToVisibleBounds && element.supplementaryKinds === YXTableLayout.SupplementaryKinds.HEADER) {
const originalFrame = this.originalHeaderRect.get(element.indexPath.section)
element.frame.y = originalFrame.y
if (scrollOffset.y > originalFrame.y) {
element.frame.y = scrollOffset.y
}
const nextOriginalFrame = this.getNextOriginalFrame(element.indexPath.section, YXTableLayout.SupplementaryKinds.FOOTER, numberOfSections)
if (nextOriginalFrame) {
if (element.frame.yMax > nextOriginalFrame.y) {
element.frame.y = nextOriginalFrame.y - element.frame.height
}
}
}
if (this.sectionFootersPinToVisibleBounds && element.supplementaryKinds === YXTableLayout.SupplementaryKinds.FOOTER) {
let bottom = scrollOffset.y + collectionView.scrollView.view.height
const originalFrame = this.originalFooterRect.get(element.indexPath.section)
const previousOriginalFrame = this.getPreviousOriginalFrame(element.indexPath.section, YXTableLayout.SupplementaryKinds.HEADER)
element.frame.y = originalFrame.y
if (bottom < originalFrame.yMax) {
element.frame.y = bottom - element.frame.height
if (previousOriginalFrame) {
if (element.frame.y < previousOriginalFrame.yMax) {
element.frame.y = previousOriginalFrame.yMax
}
}
}
}
}
}
return this.attributes
}
shouldUpdateAttributesZIndex(): boolean {
return this.sectionHeadersPinToVisibleBounds || this.sectionFootersPinToVisibleBounds
}
shouldUpdateAttributesForBoundsChange(): boolean {
return this.sectionHeadersPinToVisibleBounds || this.sectionFootersPinToVisibleBounds
}
/**
* 获取 `section` 下一个 header 或者 footer 的位置
*/
protected getNextOriginalFrame(section: number, kinds: _yx_table_layout_supplementary_kinds, total: number) {
if (section >= total) { return null }
if (kinds === YXTableLayout.SupplementaryKinds.HEADER) {
let result = this.originalHeaderRect.get(section)
if (result) { return result }
return this.getNextOriginalFrame(section, YXTableLayout.SupplementaryKinds.FOOTER, total)
}
if (kinds === YXTableLayout.SupplementaryKinds.FOOTER) {
let result = this.originalFooterRect.get(section)
if (result) { return result }
return this.getNextOriginalFrame(section + 1, YXTableLayout.SupplementaryKinds.HEADER, total)
}
return null
}
/**
* 获取 `section` 前一个 header 或者 footer 的位置
*/
protected getPreviousOriginalFrame(section: number, kinds: _yx_table_layout_supplementary_kinds) {
if (section < 0) { return null }
if (kinds === YXTableLayout.SupplementaryKinds.HEADER) {
let result = this.originalHeaderRect.get(section)
if (result) { return result }
return this.getPreviousOriginalFrame(section - 1, YXTableLayout.SupplementaryKinds.FOOTER)
}
if (kinds === YXTableLayout.SupplementaryKinds.FOOTER) {
let result = this.originalFooterRect.get(section)
if (result) { return result }
return this.getPreviousOriginalFrame(section, YXTableLayout.SupplementaryKinds.HEADER)
}
return null
}
}
```

View File

@@ -1,354 +0,0 @@
通过二分查找优化 table-layout 性能
```ts
enum _yx_table_layout_supplementary_kinds {
HEADER = 'header',
FOOTER = 'footer',
}
export class YXTableLayout extends YXLayout {
/**
* 行高
*/
rowHeight: number | ((indexPath: YXIndexPath) => number) = 100
/**
* 内容上边距
*/
top: number = 0
/**
* 内容下边距
*/
bottom: number = 0
/**
* 节点之间间距
*/
spacing: number = 0
/**
* 区头高度
*/
sectionHeaderHeight: number | ((section: number) => number) = null
/**
* 区尾高度
*/
sectionFooterHeight: number | ((section: number) => number) = null
/**
* 钉住 header 的位置 ( header 吸附在列表可见范围内 )
*/
sectionHeadersPinToVisibleBounds: boolean = false
/**
* 钉住 footer 的位置 ( footer 吸附在列表可见范围内 )
*/
sectionFootersPinToVisibleBounds: boolean = false
/**
* 区头/区尾标识
*/
static SupplementaryKinds = _yx_table_layout_supplementary_kinds
protected originalHeaderRect: Map<number, math.Rect> = new Map() // 保存所有 header 的原始位置
protected originalFooterRect: Map<number, math.Rect> = new Map() // 保存所有 footer 的原始位置
// 为了优化查找,额外维护几个数组按类别管理所有的布局属性,空间换时间
protected allCellAttributes: YXLayoutAttributes[] = []
protected allHeaderAttributes: YXLayoutAttributes[] = []
protected allFooterAttributes: YXLayoutAttributes[] = []
prepare(collectionView: YXCollectionView): void {
// 设置列表的滚动方向(这套布局固定为垂直方向滚动)
collectionView.scrollView.horizontal = false
collectionView.scrollView.vertical = true
if (collectionView.scrollDirection === YXCollectionView.ScrollDirection.HORIZONTAL) {
// 由于这套布局规则只支持垂直方向布局,当外部配置了水平方向滚动时这里可以给个警告
warn(`YXTableLayout 仅支持垂直方向排列`)
}
// 清空一下布局属性数组
this.attributes = []
this.allCellAttributes = []
this.allHeaderAttributes = []
this.allFooterAttributes = []
this.originalHeaderRect.clear()
this.originalFooterRect.clear()
// 获取列表宽度
const contentWidth = collectionView.node.getComponent(UITransform).width
// 声明一个临时变量,用来记录当前所有内容的总高度
let contentHeight = 0
// 获取列表一共分多少个区
let numberOfSections = collectionView.getNumberOfSections()
// 为每条数据对应的生成一个布局属性
for (let section = 0; section < numberOfSections; section++) {
// 创建一个区索引
let sectionIndexPath = new YXIndexPath(section, 0)
// 通过区索引创建一个区头节点布局属性
let sectionHeaderHeight = 0
if (this.sectionHeaderHeight) {
sectionHeaderHeight = this.sectionHeaderHeight instanceof Function ? this.sectionHeaderHeight(section) : this.sectionHeaderHeight
}
if (sectionHeaderHeight > 0) {
let headerAttr = YXLayoutAttributes.layoutAttributesForSupplementary(sectionIndexPath, YXTableLayout.SupplementaryKinds.HEADER)
// 确定这个节点的位置
headerAttr.frame.x = 0
headerAttr.frame.width = contentWidth
headerAttr.frame.height = sectionHeaderHeight
headerAttr.frame.y = contentHeight
// 调整层级
headerAttr.zIndex = 1
// 重要: 保存布局属性
this.attributes.push(headerAttr)
this.originalHeaderRect.set(section, headerAttr.frame.clone())
this.allHeaderAttributes.push(headerAttr)
// 更新整体内容高度
contentHeight = headerAttr.frame.yMax
}
// 将 top 配置应用到每个区
contentHeight = contentHeight + this.top
// 获取这个区内的内容数量,注意这里传入的是 section
let numberOfItems = collectionView.getNumberOfItems(section)
for (let item = 0; item < numberOfItems; item++) {
// 创建索引,注意这里的 section 已经改为正确的 section 了
let indexPath = new YXIndexPath(section, item)
// 通过索引创建一个 cell 节点的布局属性
let attr = YXLayoutAttributes.layoutAttributesForCell(indexPath)
// 通过索引获取这个节点的高度
let rowHeight = this.rowHeight instanceof Function ? this.rowHeight(indexPath) : this.rowHeight
// 确定这个节点的位置
attr.frame.x = 0
attr.frame.width = contentWidth
attr.frame.height = rowHeight
attr.frame.y = contentHeight + (item > 0 ? this.spacing : 0)
// 重要: 保存布局属性
this.attributes.push(attr)
this.allCellAttributes.push(attr)
// 更新当前内容高度
contentHeight = attr.frame.yMax
}
// 高度补一个底部间距,跟 top 一样,也是应用到每个区
contentHeight = contentHeight + this.bottom
// 通过区索引创建一个区尾节点布局属性
let sectionFooterHeight = 0
if (this.sectionFooterHeight) {
sectionFooterHeight = this.sectionFooterHeight instanceof Function ? this.sectionFooterHeight(section) : this.sectionFooterHeight
}
if (sectionFooterHeight > 0) {
let footerAttr = YXLayoutAttributes.layoutAttributesForSupplementary(sectionIndexPath, YXTableLayout.SupplementaryKinds.FOOTER)
// 确定这个节点的位置
footerAttr.frame.x = 0
footerAttr.frame.width = contentWidth
footerAttr.frame.height = sectionFooterHeight
footerAttr.frame.y = contentHeight
// 调整层级
footerAttr.zIndex = 1
// 重要: 保存布局属性
this.attributes.push(footerAttr)
this.originalFooterRect.set(section, footerAttr.frame.clone())
this.allFooterAttributes.push(footerAttr)
// 更新整体内容高度
contentHeight = footerAttr.frame.yMax
}
}
// 重要: 设置内容区域总大小,只有确定了滚动区域的大小列表才能滚动
this.contentSize = new math.Size(contentWidth, contentHeight)
}
initOffset(collectionView: YXCollectionView): void {
// 列表首次刷新时,调整一下列表的偏移位置
collectionView.scrollView.scrollToTop()
}
layoutAttributesForElementsInRect(rect: math.Rect, collectionView: YXCollectionView): YXLayoutAttributes[] {
let result = this.visibleElementsInRect(rect, collectionView)
if (this.sectionHeadersPinToVisibleBounds == false && this.sectionFootersPinToVisibleBounds == false) {
return result // 不需要调整节点位置,直接返回就好
}
let numberOfSections = collectionView.getNumberOfSections()
let scrollOffset = collectionView.scrollView.getScrollOffset()
for (let index = 0; index < result.length; index++) {
const element = result[index];
if (element.elementCategory === 'Supplementary') {
if (this.sectionHeadersPinToVisibleBounds && element.supplementaryKinds === YXTableLayout.SupplementaryKinds.HEADER) {
const originalFrame = this.originalHeaderRect.get(element.indexPath.section)
element.frame.y = originalFrame.y
if (scrollOffset.y > originalFrame.y) {
element.frame.y = scrollOffset.y
}
const nextOriginalFrame = this.getNextOriginalFrame(element.indexPath.section, YXTableLayout.SupplementaryKinds.FOOTER, numberOfSections)
if (nextOriginalFrame) {
if (element.frame.yMax > nextOriginalFrame.y) {
element.frame.y = nextOriginalFrame.y - element.frame.height
}
}
}
if (this.sectionFootersPinToVisibleBounds && element.supplementaryKinds === YXTableLayout.SupplementaryKinds.FOOTER) {
let bottom = scrollOffset.y + collectionView.scrollView.view.height
const originalFrame = this.originalFooterRect.get(element.indexPath.section)
const previousOriginalFrame = this.getPreviousOriginalFrame(element.indexPath.section, YXTableLayout.SupplementaryKinds.HEADER)
element.frame.y = originalFrame.y
if (bottom < originalFrame.yMax) {
element.frame.y = bottom - element.frame.height
if (previousOriginalFrame) {
if (element.frame.y < previousOriginalFrame.yMax) {
element.frame.y = previousOriginalFrame.yMax
}
}
}
}
}
}
return result
}
shouldUpdateAttributesZIndex(): boolean {
return this.sectionHeadersPinToVisibleBounds || this.sectionFootersPinToVisibleBounds
}
shouldUpdateAttributesForBoundsChange(): boolean {
return this.sectionHeadersPinToVisibleBounds || this.sectionFootersPinToVisibleBounds
}
/**
* 获取 `section` 下一个 header 或者 footer 的位置
*/
protected getNextOriginalFrame(section: number, kinds: _yx_table_layout_supplementary_kinds, total: number) {
if (section >= total) { return null }
if (kinds === YXTableLayout.SupplementaryKinds.HEADER) {
let result = this.originalHeaderRect.get(section)
if (result) { return result }
return this.getNextOriginalFrame(section, YXTableLayout.SupplementaryKinds.FOOTER, total)
}
if (kinds === YXTableLayout.SupplementaryKinds.FOOTER) {
let result = this.originalFooterRect.get(section)
if (result) { return result }
return this.getNextOriginalFrame(section + 1, YXTableLayout.SupplementaryKinds.HEADER, total)
}
return null
}
/**
* 获取 `section` 前一个 header 或者 footer 的位置
*/
protected getPreviousOriginalFrame(section: number, kinds: _yx_table_layout_supplementary_kinds) {
if (section < 0) { return null }
if (kinds === YXTableLayout.SupplementaryKinds.HEADER) {
let result = this.originalHeaderRect.get(section)
if (result) { return result }
return this.getPreviousOriginalFrame(section - 1, YXTableLayout.SupplementaryKinds.FOOTER)
}
if (kinds === YXTableLayout.SupplementaryKinds.FOOTER) {
let result = this.originalFooterRect.get(section)
if (result) { return result }
return this.getPreviousOriginalFrame(section, YXTableLayout.SupplementaryKinds.HEADER)
}
return null
}
/**
* 抽出来一个方法用来优化列表性能
* 在优化之前,可以先看一下 @see YXLayout.layoutAttributesForElementsInRect 关于返回值的说明
* 对于有序列表来说,一般都是可以通过二分查找来进行优化
*/
protected visibleElementsInRect(rect: math.Rect, collectionView: YXCollectionView) {
if (this.attributes.length <= 100) { return this.attributes } // 少量数据就不查了,直接返回全部
let result: YXLayoutAttributes[] = []
// header 跟 footer 暂时不考虑,数据相对来说不算很多,直接全部返回
result.push(...this.allHeaderAttributes)
result.push(...this.allFooterAttributes)
// 关于 cell这里用二分查找来优化一下
// 首先通过二分先查出个大概位置
let midIdx = -1
let left = 0
let right = this.allCellAttributes.length - 1
while (left <= right && right >= 0) {
let mid = left + (right - left) / 2
mid = Math.floor(mid)
let attr = this.allCellAttributes[mid]
if (rect.intersects(attr.frame)) {
midIdx = mid
break
}
if (rect.yMax < attr.frame.yMin || rect.xMax < attr.frame.xMin) {
right = mid - 1
} else {
left = mid + 1
}
}
// 二分查找出错了,返回全部的布局属性
if (midIdx < 0) {
return this.attributes
}
// 把模糊查到这个先加进来
result.push(this.allCellAttributes[midIdx])
// 然后依次往前检查,直到超出当前的显示范围
let startIdx = midIdx
while (startIdx > 0) {
let idx = startIdx - 1
let attr = this.allCellAttributes[idx]
if (rect.intersects(attr.frame) == false) {
break
}
result.push(attr)
startIdx = idx
}
// 依次往后检查,直到超出当前的显示范围
let endIdx = midIdx
while (endIdx < this.allCellAttributes.length - 1) {
let idx = endIdx + 1
let attr = this.allCellAttributes[idx]
if (rect.intersects(attr.frame) == false) {
break
}
result.push(attr)
endIdx = idx
}
return result
}
}
```

View File

@@ -8,6 +8,7 @@ export class page_view extends Component {
protected start(): void {
const listComp = this.node.getChildByName('list').getComponent(YXCollectionView)
listComp.recycleInterval = 0
listComp.ignoreScrollEndedDuringAutoScroll = true
listComp.numberOfItems = () => {
return 3

View File

@@ -298,6 +298,7 @@
"preloadNodesLimitPerFrame": 2,
"frameInterval": 1,
"recycleInterval": 1,
"autoReloadOnSizeChange": false,
"registerCellForEditor": [
{
"__id__": 8

View File

@@ -298,6 +298,7 @@
"preloadNodesLimitPerFrame": 2,
"frameInterval": 1,
"recycleInterval": 1,
"autoReloadOnSizeChange": false,
"registerCellForEditor": [
{
"__id__": 8

View File

@@ -8,6 +8,7 @@ export class table_in_page extends Component {
protected start(): void {
const listComp = this.node.getChildByName('list').getComponent(YXCollectionView)
listComp.recycleInterval = 0
listComp.ignoreScrollEndedDuringAutoScroll = true
listComp.numberOfItems = () => {
return 5

View File

@@ -17,10 +17,12 @@ export class PageLayout extends YXLayout {
/**
* 循环滚动,默认关闭
* 注意: 当开启循环滚动时YXCollectionView 需要额外设置 `recycleInterval = 0`
* 注意: 当开启循环滚动时YXCollectionView 需要额外设置 `ignoreScrollEndedDuringAutoScroll = true`
* 注意: 开启循环滚动会生成较大范围的 `indexPath`,在使用索引的时候需要进行取余处理
*
* @example
* listComp.recycleInterval = 0
* listComp.ignoreScrollEndedDuringAutoScroll = true
* listComp.numberOfItems = () => {
* return <data-length>
@@ -61,6 +63,9 @@ export class PageLayout extends YXLayout {
let numberOfItems = collectionView.getNumberOfItems(0)
if (this.loop) {
numberOfItems = numberOfItems * 3 * this.safeScrollRangeMultiplier
if (collectionView.recycleInterval != 0) {
warn(`PageLayout: 开启循环滚动时建议将 YXCollectionView.recycleInterval 设置为 0`)
}
if (collectionView.ignoreScrollEndedDuringAutoScroll == false) {
warn(`PageLayout: 开启循环滚动时建议将 YXCollectionView.ignoreScrollEndedDuringAutoScroll 设置为 true`)
}