From 27a48bd270bd392dbfb4fad093cc34f33e6dada3 Mon Sep 17 00:00:00 2001 From: "o.o.c" <568071718@qq.com> Date: Wed, 8 Jan 2025 23:39:02 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E4=B8=80=E4=BA=9B=E9=A1=B9?= =?UTF-8?q?=E7=9B=AE=E6=8F=8F=E8=BF=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 80 ++--- doc/declarations/yx-collection-view.d.ts | 215 ++++++++------ doc/md/layout.md | 11 + doc/md/table-layout-1.md | 83 ++++++ doc/md/table-layout-2.md | 86 ++++++ doc/md/table-layout-3.md | 95 ++++++ doc/md/table-layout-4.md | 159 ++++++++++ doc/md/table-layout-5.md | 267 +++++++++++++++++ doc/md/table-layout-6.md | 354 +++++++++++++++++++++++ 9 files changed, 1204 insertions(+), 146 deletions(-) create mode 100644 doc/md/table-layout-1.md create mode 100644 doc/md/table-layout-2.md create mode 100644 doc/md/table-layout-3.md create mode 100644 doc/md/table-layout-4.md create mode 100644 doc/md/table-layout-5.md create mode 100644 doc/md/table-layout-6.md diff --git a/README.md b/README.md index 92a7a43..0d616b8 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,6 @@ ![title.png](./doc/imgs/title.png) - -> YXCollectionView 的主要作用是管理数据的渲染和展示。为了提升性能,它通过节点池机制高效地复用单元节点,这使得它具备虚拟列表的特性。但需要特别指出的是,YXCollectionView 的核心业务不仅限于虚拟列表的管理,它更侧重于布局排列的全面控制。 -> -> *简介由 AI 生成* -> +--- ## 开发环境 * 2.x @@ -18,72 +14,40 @@ * 节点回收复用(虚拟列表模式) * 分帧预加载节点(非虚拟列表模式) -* 多种单元 cell 节点类型 +* 多种 cell 节点类型 * 列表嵌套 * 分区概念 +* supplementary 补充视图概念 +* 多种 supplementary 节点类型 * [布局解耦(组件核心)](./doc/md/layout.md) +## table-layout + +* 仿 TableView 样式,仅支持垂直方向排列 +* 支持设置不同的行高 +* 支持分区模式 +* 支持添加区头/区尾 +* 支持区头/区尾悬浮吸附效果 +* [在线演示](https://568071718.github.io/cocos-creator-build/collection-view/table-layout/) + ## 使用 -* 注册 cell,通过 register() 注册列表内可能用到的节点类型,可多次注册 - ```ts -this.listComp.register(`cell1`, () => instantiate()) -this.listComp.register(`cell2`, () => instantiate()) -this.listComp.register(`cell3`, () => instantiate()) -this.listComp.register(`cell4`, () => instantiate()) -this.listComp.register(`cell5`, () => instantiate()) -``` - -* 绑定数据源,更新节点数据 - -```ts -// this.testData 是模拟数据 - -// 确定列表内一共需要显示多少条内容 -this.listComp.numberOfItems = () => this.testData.length; - -this.listComp.cellForItemAt = (indexPath, collectionView) => { - // 通过下标可以获取到对应的数据 - const data = this.testData[indexPath.item] - - // 通过标识符获取重用池内的节点 - const cell = collectionView.dequeueReusableCell(`your cell identifier`) - - // 更新数据显示 - const comp = cell.getComponent(CommonCell) - comp.label.string = `${indexPath}` - comp.randomIcon() - comp.randomShapeColor() - comp.randomStar() - comp.randomLevelSign() - - return cell // 返回这个节点给列表显示 +listComp.numberOfItems = () => 10000 +listComp.cellForItemAt = (indexPath, collectionView) => { + const cell = collectionView.dequeueReusableCell(`cell`) + cell.getChildByName('label').getComponent(Label).string = `${indexPath}` + return cell } -``` -* 确定布局方案 - -```ts let layout = new YXTableLayout() layout.spacing = 20 -layout.itemSize = new math.Size(400, 100) -this.listComp.layout = layout +layout.rowHeight = 100 +listComp.layout = layout + +listComp.reloadData() ``` -以上几个步骤不分先后,确保都配置好就好,数据源绑定/布局对象配置好之后,在需要刷新的时候执行 reloadData - -```ts -// 更新列表 -this.listComp.reloadData() -``` - ---- - -补充: 附张通过编辑器内注册 cell 的截图 - -![img.png](./doc/imgs/editor-panel.png) - ## 更多接口 * 内部 ScrollView 组件 diff --git a/doc/declarations/yx-collection-view.d.ts b/doc/declarations/yx-collection-view.d.ts index 0cf4d78..667558d 100644 --- a/doc/declarations/yx-collection-view.d.ts +++ b/doc/declarations/yx-collection-view.d.ts @@ -1,7 +1,4 @@ -import { Component, math, Node, ScrollView, ValueType } from 'cc'; -type _yx_readonly_deep = { - readonly [P in keyof T]: T[P] extends Record ? _yx_readonly_deep : T[P]; -}; +import { Component, math, Node, ScrollView } from 'cc'; /** * 定义列表的滚动方向 */ @@ -35,28 +32,29 @@ declare enum _yx_collection_view_list_mode { /** * 表示索引的对象 */ -export declare class YXIndexPath extends ValueType { +export declare class YXIndexPath { static ZERO: Readonly; /** * 区索引 */ - section: number; + get section(): number; /** * 单元格在区内的位置 */ - item: number; - set row(value: number); + get item(): number; + /** + * item 别名 + */ get row(): number; constructor(section: number, item: number); clone(): YXIndexPath; equals(other: YXIndexPath): boolean; - set(other: YXIndexPath): void; toString(): string; } /** * 表示边距的对象 */ -export declare class YXEdgeInsets extends ValueType { +export declare class YXEdgeInsets { static ZERO: Readonly; top: number; left: number; @@ -68,34 +66,40 @@ export declare class YXEdgeInsets extends ValueType { set(other: YXEdgeInsets): void; toString(): string; } -/** - * 私有组件 - * cell 节点添加到 YXCollectionView 上时,自动挂载此组件,用来记录一些实时参数 - */ -declare class _cell_ extends Component { - /** - * 此节点是通过哪个标识符创建的 - */ - identifier: string; - /** - * 此节点目前绑定的布局属性 - */ - attributes: YXLayoutAttributes; -} /** * 节点的布局属性 */ 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; - constructor(indexPath: YXIndexPath); + /** + * 节点种类 + */ + get elementCategory(): "Cell" | "Supplementary"; + /** + * Supplementary 种类,本身无实际意义,具体作用由自定义布局规则决定 + */ + get supplementaryKinds(): string; /** * 节点在滚动视图中的位置和大小属性 * origin 属性表示节点在父视图坐标系中的左上角的位置,size 属性表示节点的宽度和高度 */ - frame: math.Rect; + get frame(): math.Rect; /** * 节点层级 * 越小会越早的添加到滚动视图上 @@ -131,20 +135,21 @@ export declare abstract class YXLayout { /** * @required * 整个滚动区域大小 - * 需要在 @prepare 内初始化 + * 需要在 prepare 内初始化 */ contentSize: math.Size; /** * @required * 所有元素的布局属性 - * 需要在 @prepare 内初始化 + * 需要在 prepare 内初始化 + * @todo 这个不应该限制为数组结构,准确来说是不应该限制开发者必须使用数组来保存所有布局属性,目前为了实现预加载模式暂时是必须要求数组结构,后续有好的方案的话应该考虑优化 */ attributes: YXLayoutAttributes[]; /** * @required * 子类重写实现布局方案 - * 注意: 必须初始化滚动区域大小并赋值给 @contentSize 属性 - * 注意: 必须初始化所有的元素布局属性,并保存到 @attributes 数组 + * 注意: 必须初始化滚动区域大小并赋值给 contentSize 属性 + * 注意: 必须初始化所有的元素布局属性,并保存到 attributes 数组 * 可选: 根据 collectionView 的 scrollDirection 支持不同的滚动方向 */ abstract prepare(collectionView: YXCollectionView): void; @@ -152,6 +157,12 @@ export declare abstract class YXLayout { * @optional * 列表在首次更新数据后会执行这个方法 * 在这个方法里设置滚动视图的初始偏移量 + * + * @example + * // 比如一个垂直列表希望初始化时从最顶部开始展示数据,那么可以在这个方法里通过 scrollToTop 实现 + * initOffset(collectionView: YXCollectionView): void { + * collectionView.scrollView.scrollToTop() + * } */ initOffset(collectionView: YXCollectionView): void; /** @@ -178,41 +189,35 @@ export declare abstract class YXLayout { /** * @optional * 列表每次滚动结束后会调用此方法 - * @param collectionView */ onScrollEnded(collectionView: YXCollectionView): void; /** * @optional - * 返回区域内可见的节点属性,并实时的调整这些节点变换效果 (如果在这个方法里调整了节点变换属性,需要重写 shouldUpdateAttributesForBoundsChange 以支持实时变换) - * 根据实际的布局情况,计算出当前屏幕内需要显示的布局属性 - * 这个方法会直接影响到列表的性能,如果在自定义的时候对性能要求不高(比如明确知道数据量不多的情况下),可以忽略此方法 (默认会检查所有的布局属性并返回所有的处于可见范围内的单元格布局属性) + * 当滚动视图的可见范围变化后执行,这个方法会在列表滚动过程中频繁的执行 + * 在这个方法里可以调整节点属性以实现交互性的节点变换效果,(如果在这个方法里调整了节点变换属性,需要重写 shouldUpdateAttributesForBoundsChange 以支持实时变换) + * * @param rect 当前滚动视图的可见区域 + * + * @returns + * 关于这个方法的返回值,最优的情况应该是根据实际的布局情况计算出当前显示区域内需要显示的所有布局属性 + * 列表在更新可见节点时会遍历这个方法返回的数组并依次检查节点是否需要添加到列表内,默认这个方法是直接返回所有的布局属性,也就是在更新可见节点时的时间复杂度默认为 O(attributes.length),除非有更优的算法,否则建议直接返回所有的布局属性 */ layoutAttributesForElementsInRect(rect: math.Rect, collectionView: YXCollectionView): YXLayoutAttributes[]; - /** - * @optional - * 通过索引查找布局属性,默认 Array.find() - * @param indexPath - * @param collectionView - */ layoutAttributesForItemAtIndexPath(indexPath: YXIndexPath, collectionView: YXCollectionView): YXLayoutAttributes; + layoutAttributesForSupplementaryAtIndexPath(indexPath: YXIndexPath, collectionView: YXCollectionView, kinds: string): YXLayoutAttributes; /** * @optional - * YXCollectionView 在调用 @scrollTo 方法时会触发这个方法,如果实现了这个方法,最终的滚动停止位置以这个方法返回的为准 - * @param indexPath - * @returns 滚动视图偏移位置 + * 列表组件在调用 scrollTo 方法时会触发这个方法,如果实现了这个方法,最终的滚动停止位置以这个方法返回的为准 */ scrollTo(indexPath: YXIndexPath, collectionView: YXCollectionView): math.Vec2; /** * @optional * @see YXLayoutAttributes.zIndex - * @returns */ shouldUpdateAttributesZIndex(): boolean; /** * @optional * @see YXLayoutAttributes.opacity - * @returns */ shouldUpdateAttributesOpacity(): boolean; /** @@ -221,21 +226,11 @@ export declare abstract class YXLayout { * @returns 返回 true 会忽略 YXCollectionView 的 frameInterval 设置,强制在滚动过程中实时更新节点 */ shouldUpdateAttributesForBoundsChange(): boolean; -} -/** - * 把二分查找的规则抽出来封装一下,继承这个类的布局,默认通过二分查找实现查找业务 - * 这种查找规则对数据量很大的有序列表来说相对高效,具体是否使用还是要根据实际排列需求决定 - */ -export declare abstract class YXBinaryLayout extends YXLayout { /** - * @bug 如果节点大小差距很大,可能会导致计算屏幕内节点时不准确,出现节点不被正确添加到滚动视图上的问题 - * @fix 可以通过此属性,追加屏幕显示的节点数量 - * 设置这个值会在检查是否可见的节点时,尝试检查更多的可能处于屏幕外的节点,具体设置多少要根据实际情况调试,一般如果都是正常大小的节点,不需要考虑这个配置 - * 设置负值会检查所有的节点 + * @optional + * 列表组件销毁时执行 */ - extraVisibleCount: number; - layoutAttributesForElementsInRect(rect: math.Rect, collectionView: YXCollectionView): YXLayoutAttributes[]; - layoutAttributesForItemAtIndexPath(indexPath: YXIndexPath, collectionView: YXCollectionView): YXLayoutAttributes; + onDestroy(): void; } /** * @see NodePool.poolHandlerComp @@ -269,7 +264,7 @@ export declare class YXCollectionView extends Component { /** * 列表滚动方向,默认垂直方向滚动 * 自定义 YXLayout 应该尽量根据这个配置来实现不同方向的布局业务 - * 注意: 如果使用的 YXLayout 未支持对应的滚动方向,则此配置不会生效 + * 备注: 如果使用的 YXLayout 未支持对应的滚动方向,则此配置不会生效,严格来说这个滚动方向本就应该是由 YXLayout 决定的,定义在这里是为了编辑器配置方便 */ scrollDirection: YXCollectionView.ScrollDirection; /** @@ -296,18 +291,26 @@ export declare class YXCollectionView extends Component { recycleInterval: number; /** * 注册 cell - * 可多次注册不同种类的 cell,只要确保 @identifier 的唯一性就好 - * @param identifier cell 标识符,通过 @dequeueReusableCell 获取重用 cell 时,会根据这个值匹配 + * 可多次注册不同种类的 cell,只要确保 identifier 的唯一性就好 + * @param identifier cell 标识符,通过 dequeueReusableCell 获取重用 cell 时,会根据这个值匹配 * @param maker 生成节点,当重用池里没有可用的节点时,会通过这个回调获取节点,需要在这个回调里面生成节点 - * @param poolComp (可选) 节点自定义组件,可以通过这个组件跟 @NodePool 的重用业务关联起来 + * @param poolComp (可选) 节点自定义组件,可以通过这个组件跟 NodePool 的重用业务关联起来 */ - register(identifier: string, maker: () => Node, poolComp?: (new (...args: any[]) => YXCollectionViewCell) | string | null): void; + 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 注册时候的标识符 - * @returns */ dequeueReusableCell(identifier: string): Node; + /** + * 通过标识符从重用池里取出一个可用的 supplementary 节点 + * @param identifier 注册时候的标识符 + */ + dequeueReusableSupplementary(identifier: string): Node; /** * 内容要分几个区展示,默认 1 * 没有分区展示的需求可以不管这个配置 @@ -335,18 +338,29 @@ export declare class YXCollectionView extends Component { */ cellForItemAt: (indexPath: YXIndexPath, collectionView: YXCollectionView) => Node; /** - * 当 cell 进入当前可见范围后执行 + * 用法跟 cellForItemAt 差不多,此方法内需要通过 dequeueReusableSupplementary 获取 Node 节点 + * @param kinds 关于这个字段的具体含义应该根据使用的自定义 layout 决定 + */ + supplementaryForItemAt: (indexPath: YXIndexPath, collectionView: YXCollectionView, kinds: string) => Node; + /** + * cell 节点可见状态回调 * 如果同类型的节点大小可能不一样,可以在这里调整子节点的位置 */ - onCellDisplay: (cell: Node, indexPath: YXIndexPath, collectionView: YXCollectionView) => void; + onCellDisplay: (node: Node, indexPath: YXIndexPath, collectionView: YXCollectionView) => void; + onCellEndDisplay: (node: Node, indexPath: YXIndexPath, collectionView: YXCollectionView) => void; /** - * 当 cell 移出当前可见范围后执行 + * supplementary 节点可见状态回调 */ - onCellEndDisplay: (cell: Node, indexPath: YXIndexPath, collectionView: YXCollectionView) => void; + onSupplementaryDisplay: (node: Node, indexPath: YXIndexPath, collectionView: YXCollectionView, kinds: string) => void; + onSupplementaryEndDisplay: (node: Node, indexPath: YXIndexPath, collectionView: YXCollectionView, kinds: string) => void; /** - * 点击到节点后执行这个方法 + * 点击到 cell 节点后执行 */ - onTouchItemAt: (indexPath: YXIndexPath, collectionView: YXCollectionView) => void; + onTouchCellAt: (indexPath: YXIndexPath, collectionView: YXCollectionView) => void; + /** + * 点击到 supplementary 节点后执行 + */ + onTouchSupplementaryAt: (indexPath: YXIndexPath, collectionView: YXCollectionView, kinds: string) => void; /** * 布局规则 */ @@ -354,29 +368,40 @@ export declare class YXCollectionView extends Component { /** * 获取列表当前的可见范围 */ - get visibleRect(): math.Rect; + getVisibleRect(): math.Rect; /** - * 获取当前正在显示的所有节点/组件 + * 通过索引获取指定的可见的 cell 节点 */ - get visibleNodes(): Node[]; - get visibleCells(): YXCollectionView.Cell[]; + getVisibleCellNode(indexPath: YXIndexPath): Node; /** - * 获取当前正在显示的某个节点/组件 - * @param indexPath + * 通过索引获取指定的可见的 supplementary 节点 */ - getVisibleNode(indexPath: YXIndexPath): Node | null; - getVisibleCell(indexPath: YXIndexPath): YXCollectionView.Cell | null; + getVisibleSupplementaryNode(indexPath: YXIndexPath, kinds: string): Node; /** - * 获取指定节点的私有 cell 组件 + * 获取所有正在显示的 cell 节点 */ - getCellComp(node: Node): YXCollectionView.Cell | null; + getVisibleCellNodes(): Node[]; + /** + * 获取所有正在显示的 supplementary 节点 + * @param kinds 可选按种类筛选 + */ + getVisibleSupplementaryNodes(kinds?: string): Node[]; + /** + * 获取指定节点绑定的布局属性对象 + */ + getElementAttributes(node: Node): YXLayoutAttributes; /** * 刷新列表数据 */ reloadData(): void; + /** + * 刷新当前可见节点 + * @param force true: 立即刷新; false: 根据设置的刷新帧间隔在合适的时候刷新 + */ + markForUpdateVisibleData(force?: boolean): void; /** * 滚动到指定节点的位置 - * @returns + * @todo 支持偏移方位,目前固定是按顶部的位置的,有特殊需求的建议直接通过 .scrollView.scrollToOffset() 实现 */ scrollTo(indexPath: YXIndexPath, timeInSecond?: number, attenuated?: boolean): void; /** @@ -385,11 +410,6 @@ export declare class YXCollectionView extends Component { protected onLoad(): void; protected onDestroy(): void; protected update(dt: number): void; - /** - * 刷新当前可见节点 - * @param force true: 立即刷新 false: 下帧刷新 - */ - markForUpdateVisibleData(force?: boolean): void; } export declare namespace YXCollectionView { /** @@ -397,6 +417,25 @@ export declare namespace YXCollectionView { */ type ScrollDirection = _yx_collection_view_scroll_direction; type Mode = _yx_collection_view_list_mode; - type Cell = _yx_readonly_deep<_cell_>; } -export { }; +/** + * ***************************************************************************************** + * ***************************************************************************************** + * 把二分查找的规则抽出来封装一下,继承这个类的布局,默认通过二分查找实现查找业务 + * 这种查找规则对数据量很大的有序列表来说相对高效,具体是否使用还是要根据实际排列需求决定 + * ***************************************************************************************** + * ***************************************************************************************** + * + * @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 {}; diff --git a/doc/md/layout.md b/doc/md/layout.md index 08156e8..134a37c 100644 --- a/doc/md/layout.md +++ b/doc/md/layout.md @@ -66,4 +66,15 @@ YXLayout 主要就是负责管理自定义的 YXLayoutAttributes 对象,在 YX 可以参考项目里的 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 完整的实现过程,有兴趣的可以了解一下 + diff --git a/doc/md/table-layout-1.md b/doc/md/table-layout-1.md new file mode 100644 index 0000000..aeb15bc --- /dev/null +++ b/doc/md/table-layout-1.md @@ -0,0 +1,83 @@ + +基本的实现一个垂直方向排列的布局规则 + +```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() + } +} +``` + diff --git a/doc/md/table-layout-2.md b/doc/md/table-layout-2.md new file mode 100644 index 0000000..3ac6fda --- /dev/null +++ b/doc/md/table-layout-2.md @@ -0,0 +1,86 @@ + +使 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() + } +} +``` + diff --git a/doc/md/table-layout-3.md b/doc/md/table-layout-3.md new file mode 100644 index 0000000..0b6a609 --- /dev/null +++ b/doc/md/table-layout-3.md @@ -0,0 +1,95 @@ + +使 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() + } +} +``` + diff --git a/doc/md/table-layout-4.md b/doc/md/table-layout-4.md new file mode 100644 index 0000000..c4c3d0d --- /dev/null +++ b/doc/md/table-layout-4.md @@ -0,0 +1,159 @@ + +使 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() + } +} +``` diff --git a/doc/md/table-layout-5.md b/doc/md/table-layout-5.md new file mode 100644 index 0000000..84e1ad4 --- /dev/null +++ b/doc/md/table-layout-5.md @@ -0,0 +1,267 @@ + +使 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 = new Map() // 保存所有 header 的原始位置 + protected originalFooterRect: Map = 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 + } +} +``` + diff --git a/doc/md/table-layout-6.md b/doc/md/table-layout-6.md new file mode 100644 index 0000000..33b2c42 --- /dev/null +++ b/doc/md/table-layout-6.md @@ -0,0 +1,354 @@ + +通过二分查找优化 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 = new Map() // 保存所有 header 的原始位置 + protected originalFooterRect: Map = 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 + } +} + +``` +