仓颉 ArkUI List 列表开发实战:从静态列表到分组、懒加载与滚动交互

原创2026-10-15 15:44:211,855 阅读
文章标签:文档知识库

仓颉 ArkUI List 列表开发实战:从静态列表到分组、懒加载与滚动交互

本文是面向 OpenHarmony 应用开发的仓颉(Cangjie)ArkUI 布局指南,系统讲解 List 列表组件的完整用法:包括主轴/交叉轴布局约束、静态与循环渲染、分组与粘性标题、滚动控制与监听、侧滑菜单、标记、刷新加载及长列表懒加载优化。阅读本文后,你将能够用仓颉 ArkUI 独立构建通讯录、消息列表、购物清单等各类可滚动、可交互的数据列表页面。

概述:List 组件能做什么

列表是一种复杂的容器组件,当列表项达到一定数量、内容超过屏幕大小时,它会自动提供滚动功能。列表适合呈现同类数据类型或数据类型集,例如图片和文本。在列表中显示数据集合是许多应用的常见需求,如通讯录、音乐列表、购物清单等。

使用列表可以轻松高效地显示结构化、可滚动的信息。其基本用法是:在 List 组件中按垂直或水平方向线性排列子组件 ListItemGroup 或 ListItem,为列表中的行或列提供单个视图;也可以使用循环渲染(ForEach)迭代一组行或列,或混合任意数量的单个视图和 ForEach 结构来构建列表。

从源码结构的约束看(见 List 参考文档),List 的子组件仅支持 ListItem、ListItemGroup,以及渲染控制类型(if/else、ForEach、LazyForEach)。这三点是构建任何列表的前提:

  • ListItem:表示单个列表项,可以包含单个子组件;
  • ListItemGroup:用于列表数据的分组展示,其子组件也是 ListItem;
  • List 的子组件必须是 ListItemGroup 或 ListItem,且这两个组件必须配合 List 使用。

布局与约束

布局能力

List 除了提供垂直和水平布局能力、超出屏幕时可以滚动的自适应延伸能力之外,还提供了自适应交叉轴方向上排列个数的布局能力:

  • 利用垂直布局能力可以构建单列或者多列垂直滚动列表;
  • 利用水平布局能力可以构建单行或多行水平滚动列表。

注意与 Grid 的区别:Grid 也可以实现单列、多列布局,但如果布局每列等宽、且不需要跨行跨列布局,相比 Grid 更推荐使用 List。

主轴与交叉轴约束

列表的主轴方向是指子组件排列的方向,也是列表的滚动方向;垂直于主轴的轴称为交叉轴。垂直列表的主轴是垂直方向、交叉轴是水平方向;水平列表则相反。List 组件的尺寸约束遵循以下三条规则:

  1. 显式设置优先:如果 List 组件主轴或交叉轴方向设置了尺寸,则对应方向上的尺寸为设置值。
  2. 主轴自适应(未超父组件时):如果主轴方向没有设置尺寸,且 List 子组件主轴方向总尺寸小于父组件尺寸,则 List 主轴方向尺寸自动适应子组件的总尺寸。例如,垂直列表 B 未设置高度,父组件 A 高度为 200.vp,所有子组件 C 高度总和为 150.vp,则 B 的高度为 150.vp。
  3. 主轴自适应(超过父组件时):如果子组件主轴方向总尺寸超过父组件尺寸,则 List 主轴方向尺寸适应父组件尺寸。例如同样未设置高度的 B,父组件 A 高度 200.vp、子组件总高度 300.vp,则 B 的高度为 200.vp。

交叉轴方向在没有设置尺寸时,其尺寸默认自适应父组件尺寸。

开发布局

设置主轴方向:listDirection

List 组件主轴默认是垂直方向,即默认情况下无需手动设置即可构建垂直滚动列表。若是水平滚动列表场景,将 listDirection 属性设置为 Axis.Horizontal 即可:

List() {
  // ...
}
.listDirection(Axis.Horizontal)

从 List 参考文档 可以看到,listDirection 的参数类型为 ?Axis,初始值为 Axis.Vertical,系统能力为 SystemCapability.ArkUI.ArkUI.Full。

设置交叉轴布局:lanes 与 alignListItem

List 组件的交叉轴布局通过 lanes 和 alignListItem 两个属性设置:

  • lanes:确定交叉轴排列的列表项数量(列数或行数);
  • alignListItem:设置子组件在交叉轴方向的对齐方式。

lanes 属性常用于在不同尺寸的设备上自适应构建不同行数或列数的列表,即"一次开发、多端部署"的场景。声明方式参见 lanes 参考文档。以垂直列表为例,将 lanes 设为 2,表示构建两列垂直列表;lanes 默认值为 1(即默认单列):

List() {
  // ...
}
.lanes(2)

当使用 lanes(minLength: Length, maxLength: Length) 声明属性时,会根据 minLength、maxLength 与 List 组件的尺寸自适应决定行或列数:

List() {
  // ...
}
.lanes(minLength: 200, maxLength: 300)

例如在垂直列表中设置 lanes(minLength: 200, maxLength: 300):

  • 当 List 宽度为 300.vp 时,由于不满足两倍 minLength(200×2=400),此时列表为一列;
  • 当 List 宽度变化至 400.vp 时,符合两倍的 minLength,列表自动适配为两列。

从参考文档还可确认该重载的初始值:minLength 初始值为 (-1.0).vp,maxLength 初始值同样为 (-1.0).vp。

同样以垂直列表为例,alignListItem 设置为 ListItemAlign.Center 表示列表项在水平方向上居中对齐;其默认值为 ListItemAlign.Start,即列表项在交叉轴方向上默认按首部对齐:

List() {
  // ...
}
.alignListItem(ListItemAlign.Center)

说明:alignListItem 的语义是"当 List 交叉轴方向宽度大于 ListItem 交叉轴宽度 × lanes 时,ListItem 在交叉轴方向的布局方式",即它是配合 lanes 多列/多行场景使用的对齐属性。

在列表中显示数据

列表视图垂直或水平显示项目集合,在行或列超出屏幕时提供滚动功能,适合显示大型数据集合。在最简单的形式中,List 静态创建其列表项 ListItem 的内容。

以城市列表为例,每个列表项只包含一个 Text 文本:

package ohos_app_cangjie_entry

import kit.ArkUI.*
import ohos.arkui.state_macro_manage.*

@Entry
@Component
public class EntryView {
    func build() {
        List() {
            ListItem() {
                Text('北京').fontSize(24)
            }

            ListItem() {
                Text('杭州').fontSize(24)
            }

            ListItem() {
                Text('上海').fontSize(24)
            }
        }
        .backgroundColor(0xfff1f3f5)
        .alignListItem(ListItemAlign.Center)
    }
}

关键约束:在 ListItem 中只能有一个根节点组件,不支持以平铺形式使用多个组件。因此,若列表项由多个组件元素组成,需要将这多个元素组合到一个容器组件内(如 Row、Column)或组成一个自定义组件。

例如联系人列表的列表项中,每个联系人都有头像和名称,此时需要将 Image 和 Text 封装到一个 Row 容器内:

List() {
    ListItem() {
        Row() {
            Image(@r(app.media.startIcon))
                .width(40)
                .height(40)
                .margin(10)
            Text('小明').fontSize(20)
        }
    }
    ListItem() {
        Row() {
            Image(@r(app.media.startIcon))
                .width(40)
                .height(40)
                .margin(10)
            Text('小红').fontSize(20)
        }
    }
}

这与 ListItem 参考文档中"子组件:可以包含单个子组件"的定义一致。

迭代列表内容:ForEach 循环渲染

通常,应用通过数据集合动态地创建列表。使用循环渲染可从数据源中迭代获取数据,并在每次迭代过程中创建相应组件,降低代码复杂度。

以联系人列表为例,将联系人名称和头像数据以 Contact 类结构存储到 contacts 数组,使用 ForEach 中嵌套 ListItem 来代替多个平铺的、内容相似的 ListItem,从而减少重复代码:

package ohos_app_cangjie_entry

import kit.ArkUI.*
import ohos.arkui.state_macro_manage.*
import ohos.resource.*

public class Contact {
    var name: String
    var icon: AppResource

    public init(name: String, icon: AppResource) {
        this.name = name
        this.icon = icon
    }
}

@Entry
@Component
public class EntryView {
    private var contacts: Array<Contact> = [Contact('小明', @r(app.media.startIcon)), Contact('小红', @r(app.media.startIcon))]
    func build() {
        List() {
            ForEach(this.contacts, itemGeneratorFunc: { item: Contact, _: Int64 =>
                    ListItem() {
                        Row() {
                            Image(item.icon)
                                .width(40)
                                .height(40)
                                .margin(10)
                            Text(item.name).fontSize(20)
                        }
                            .width(100.percent)
                            .justifyContent(FlexAlign.Start)
                    }
                },
                keyGeneratorFunc: {item: Contact, idx: Int64 => idx.toString()}
            )
        }.width(100.percent)
    }
}

关于 keyGeneratorFunc(键值生成规则)的底层原理,ForEach 渲染控制文档给出了明确说明:系统会为每个数组元素生成一个唯一且持久的键值用于标识对应组件;当键值变化时,仓颉会视为该数组元素已被替换或修改,并基于新键值创建新组件。若不同数组项生成的键值相同,框架行为是未定义的(例如数据源中存在相同键值的项时,只创建对应数量的组件而非全部组件),因此实际开发中务必保证键值唯一(如上例使用索引 idx.toString())。

在 List 组件中,ForEach 除了可以循环渲染 ListItem,也可以用来循环渲染 ListItemGroup(详见下文支持分组列表)。

自定义列表样式

设置内容间距:space

在初始化列表时,如需在列表项之间添加间距,可以使用 space 参数。例如在每个列表项之间沿主轴方向添加 10.vp 的间距:

List(space: 10) {
  // ...
}

在 List 构造参数中,space 的类型为 ?Int64,为命名参数,默认值 None,语义即"子组件主轴方向的间隔"。

添加分隔线:divider

分隔线用来将界面元素隔开,使单个元素更加容易识别。当列表项左边有图标(如蓝牙图标)时,由于图标本身就能很好区分,此时分隔线从图标之后开始显示即可。

List 提供 divider 属性用于给列表项之间添加分隔线。设置时可使用 strokeWidth 和 color 设置分隔线的粗细和颜色;startMargin 和 endMargin 分别用于设置分隔线距离列表侧边起始端和结束端的距离:

List() {
  // ...
}
.divider(strokeWidth: 1, color: 0xffe9f0f0, startMargin: 60, endMargin: 10)

此示例表示从距离列表侧边起始端 60.vp 开始到距离结束端 10.vp 的位置,画一条粗细为 1.vp 的分割线。

关于分隔线,有几点行为需要特别注意:

  • 分隔线的宽度会使 ListItem 之间存在一定间隔,当 List 设置的内容间距小于分隔线宽度时,ListItem 之间的间隔会使用分隔线的宽度;
  • 当 List 存在多列时,分割线的 startMargin 和 endMargin 作用于每一列上;
  • List 组件的分隔线画在两个 ListItem 之间,第一个 ListItem 上方和最后一个 ListItem 下方不会绘制分隔线。

从源码结构看,divider 的参数类型为 Option<ListDividerOptions>,默认没有分割线;ListDividerOptions 结构体包含 strokeWidth(线宽)、color(颜色)、startMargin(距起始端距离)、endMargin(距结束端距离)四个字段,详见 ListDividerOptions 类定义。

添加滚动条:scrollBar

当列表项高度(宽度)超出屏幕高度(宽度)时,列表可以沿垂直(水平)方向滚动。在页面内容很多时,若用户需快速定位,可拖拽滚动条。

scrollBar 属性控制列表滚动条的显示,其取值类型为 BarState。当取值为 BarState.Auto 表示按需显示滚动条:触摸到滚动条区域时显示控件,可上下拖拽滚动条快速浏览内容,拖拽时会变粗;若不进行任何操作,2 秒后滚动条自动消失。

List() {
    // ...
}
.scrollBar(BarState.Auto)

支持分组列表:ListItemGroup

在列表中支持数据的分组展示,可以使列表结构清晰、查找方便,从而提高使用效率。分组列表在实际应用中十分常见,如联系人列表按字母 A、B 分组。

在 List 组件中使用 ListItemGroup 对项目进行分组,可以构建二维列表。在 List 组件中可以直接使用一个或多个 ListItemGroup 组件,ListItemGroup 的宽度默认充满 List 组件。在初始化 ListItemGroup 时,可通过 header 参数设置列表分组的头部组件:

package ohos_app_cangjie_entry

import kit.ArkUI.*
import ohos.arkui.state_macro_manage.*
import ohos.resource.*

@Entry
@Component
public class EntryView {
    @Builder
    public func itemHead(text: String) {
        // 列表分组的头部组件,对应联系人分组A、B等位置的组件
        Text(text)
            .fontSize(20)
            .backgroundColor(0xfff1f3f5)
            .width(100.percent)
            .padding(5)
    }

    func build() {
        List() {
            ListItemGroup(
                header: {=> bind(this.itemHead, this)("a")}){
                    =>
                    // 循环渲染分组A的ListItem
                }

            ListItemGroup(
                header: {=> bind(this.itemHead, this)("b")}) {
                    =>
                    // 循环渲染分组A的ListItem
                }
        }
    }
}

从 ListItemGroup 参考文档的构造参数可知,header、footer 类型为 ?CustomBuilder(头部/尾部组件),space 类型为 ?Length(只作用于 ListItem 与 ListItem 之间,不作用于 header/footer 与 ListItem 之间),style 类型为 ?ListItemGroupStyle(卡片样式)。

如果多个 ListItemGroup 结构类似,可以将多个分组的数据组成数组,然后使用 ForEach 对多个分组进行循环渲染。例如在联系人列表中,将每个分组的联系人数据 contacts(可参考上文迭代列表内容)和对应分组标题 title 组合为数组 contactsGroups,然后在 ForEach 中对 contactsGroups 循环渲染即可实现多分组联系人列表,完整示例可参考下文添加粘性标题章节代码。

添加粘性标题:sticky

粘性标题是一种常见的标题模式,常用于定位字母列表的头部元素:在联系人列表中滚动 A 部分时,B 部分开始的头部元素始终处于 A 的下方;开始滚动 B 部分时,B 的头部会固定在屏幕顶部,直到所有 B 的项均完成滚动后,才被后面的头部替代。粘性标题有助于阐明数据的表示形式和用途,帮助用户在大量信息中定位数据,避免在表头与感兴趣区域间反复滚动。

List 组件的 sticky 属性配合 ListItemGroup 组件使用,用于设置 ListItemGroup 中的头部组件是否呈现吸顶效果、或尾部组件是否呈现吸底效果。通过给 List 设置 sticky 为 StickyStyle.Header 即可实现粘性标题;如需吸底效果,可通过 footer 参数初始化 ListItemGroup 的底部组件,并将 sticky 设置为 StickyStyle.Footer。

完整示例(分组联系人列表 + 粘性标题):

package ohos_app_cangjie_entry

import ohos.arkui.state_macro_manage.*
import ohos.resource.*
import kit.ArkUI.*

public class Contact {
    var name: String
    var icon: AppResource

    public init(name: String, icon: AppResource) {
        this.name = name
        this.icon = icon
    }
}

public class ContactGroup {
    var title: String
    var contacts: Array<Contact>

    public init(title: String, contacts: Array<Contact>) {
        this.title = title
        this.contacts = contacts
    }
}

@Entry
@Component
public class EntryView {
    // 定义分组联系人数据集合contactsGroups数组
    private var contactsGroups : Array<ContactGroup> = [
            ContactGroup('A', [Contact('艾佳', @r(app.media.startIcon)),Contact('安安', @r(app.media.startIcon)),Contact('Angela', @r(app.media.startIcon))]),
            ContactGroup('B', [Contact('白叶', @r(app.media.startIcon)),Contact('伯明', @r(app.media.startIcon))])
        ]

    @Builder
    func itemHead(text: String) {
        // 列表分组的头部组件,对应联系人分组A、B等位置的组件
        Text(text)
          .fontSize(20)
          .backgroundColor(0xfff1f3f5)
          .width(100.percent)
          .padding(5)
    }

    @Builder
    func footertest(itemGroup: ContactGroup) {
        ForEach(itemGroup.contacts, itemGeneratorFunc: { item: Contact, _:Int64 =>
                ListItem() {
                    Row() {
                        Image(item.icon).width(36).height(36).margin(8)
                        itemHead(item.name)
                    }
                }.backgroundColor(Color(0XFFFFFFFF))
            }
        )
    }

    func build() {
        List() {
            // 循环渲染ListItemGroup,contactsGroups为多个分组联系人contacts和标题title的数据集合
            ForEach(this.contactsGroups, itemGeneratorFunc: { itemGroup: ContactGroup, _: Int64 =>
                    ListItemGroup(header: { => bind(this.itemHead, this)(itemGroup.title)}) {
                        this.footertest(itemGroup)
                    }
                    .divider(ListDividerOptions(strokeWidth: 1, color: Color(0X08000000), startMargin: 48, endMargin: 48))
                },
                keyGeneratorFunc: {item: ContactGroup, idx: Int64 => idx.toString()}
            )
        }
        .backgroundColor(Color(0X08000000))
        .divider(ListDividerOptions(strokeWidth: 1, color: Color(0X08000000), startMargin: 48, endMargin: 48))
        .sticky(StickyStyle.Header) // 设置吸顶,实现粘性标题效果
    }
}

从 sticky 属性参考可见,sticky 参数类型为 ?StickyStyle,初始值为 StickyStyle.None。

控制滚动位置:Scroller

控制滚动位置在实际应用中十分常见,例如新闻页列表项数量庞大,用户滚动到一定位置后希望快速返回列表顶部,此时可通过控制滚动位置实现列表的快速定位。

List 组件初始化时,可以通过 scroller 参数绑定一个 Scroller 对象进行列表的滚动控制。例如点击新闻页面底部的"返回顶部"按钮时,通过 Scroller 对象的 scrollToIndex 方法使列表滚动到指定列表项索引位置。

首先创建 Scroller 对象:

var listScroller: Scroller = Scroller()

然后通过 scroller 参数完成绑定。需要跳转的位置指定 scrollToIndex 参数为 0,表示返回列表顶部:

Stack(alignContent: Alignment.Bottom) {
    List(space: 20, scroller: this.listScroller) {
        // ...
    }
}

Button() {
    // ...
}
.onClick({ event =>
    this.listScroller.scrollToIndex(0)
})

响应滚动位置:onScrollIndex

许多应用需要监听列表的滚动位置变化并作出响应。例如联系人列表滚动时,如果跨越了不同字母开头的分组,侧边字母索引栏也需要更新到对应字母位置;购物应用的商品分类页面中,多级分类也需要监听列表的滚动位置。

此场景可以通过监听 List 组件的 onScrollIndex 事件实现,右侧索引栏需要使用字母表索引组件 AlphabetIndexer。在列表滚动时,根据列表此时所在的索引值位置 firstIndex,重新计算字母索引栏对应字母的位置 selectedIndex。由于 AlphabetIndexer 组件通过 selected 属性设置选中项索引值,当 selectedIndex 变化时会触发 AlphabetIndexer 重新渲染,从而显示为选中对应字母的状态:

package ohos_app_cangjie_entry

import kit.ArkUI.*
import ohos.arkui.state_macro_manage.*
import ohos.resource.*

@Entry
@Component
public class EntryView {
    let alphabets = ['#', 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K','L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z']
    @State var selectedIndex: Int32 = 0;
    private var listScroller:Scroller = Scroller()

    func build() {
        Stack(alignContent: Alignment.End) {
            List(scroller: this.listScroller) {}
                .onScrollIndex({ firstIndex, scrollState, _ =>
                    // 根据列表滚动到的索引值,重新计算对应联系人索引栏的位置this.selectedIndex
                })

            // 字母表索引组件
            AlphabetIndexer(arrayValue: this.alphabets, selected: 0)
                .selected(this.selectedIndex)
        }
    }
}

关于 onScrollIndex 的回调参数,参考文档给出了精确定义:回调参数一表示 List 显示区域内第一个子组件的索引值;参数二表示显示区域内最后一个子组件的索引值;参数三表示显示区域内中间位置子组件的索引值。

说明: 计算索引值时,ListItemGroup 作为一个整体占一个索引值,不计算 ListItemGroup 内部 ListItem 的索引值。这一点与 List 参考文档中"子组件索引值计算规则"的说明一致(ListItemGroup 整体计算一个索引值)。

响应列表项侧滑:swipeAction

侧滑菜单在许多应用中都很常见,例如通讯类应用通常给消息列表提供侧滑删除功能:用户向左滑列表某一项,再点击删除按钮删除消息。

ListItem 的 swipeAction 属性可用于实现列表项的左右滑动功能。其初始化时有必填参数 SwipeActionOptions:

  • start 参数:设置列表项右滑时起始端滑出的组件;
  • end 参数:设置列表项左滑时尾端滑出的组件。

在消息列表中,end 参数表示左滑时尾端划出的自定义组件,即删除按钮。初始化 end 方法时,将滑动列表项的索引传入删除按钮组件,点击删除按钮时即可根据索引值删除对应数据,实现侧滑删除。

实现分为两步。

第一步:实现尾端滑出组件:

@Builder
func itemEnd(index: Int64) {
  // 构建尾端滑出组件
  Button(ButtonOptions(shape: ButtonType.Circle)) {
    Image(@r(app.media.ic_public_delete_filled))
      .width(20)
      .height(20)
  }
  .onClick({ event =>
    // this.messages为列表数据源,可根据实际场景构造。点击后从数据源删除指定数据项。
    this.message.remove(index)
  })
}

第二步:绑定 swipeAction 属性到可左滑的 ListItem 上:

// 构建List时,通过ForEach基于数据源this.messages循环渲染ListItem。
ListItem(){
    Text('1111').height(20)
}
.swipeAction(end: { => bind(this.itemEnd, this)(index)}) // index为该ListItem在List中的索引值

从 swipeAction 参考文档还可以了解其完整参数:除 start、end 外,还有 edgeEffect(滑动效果,初始值 SwipeEdgeEffect.Spring)和 onOffsetChange(滑动操作偏移量更改时的回调)。

给列表项添加标记:Badge

添加标记是一种无干扰性且直观的方法,用于显示通知或将注意力集中到应用内的某个区域。例如消息列表接收到新消息时,联系人头像右上方会出现标记,提示未读消息数量。

在 ListItem 中使用 Badge 组件可实现标记功能——Badge 是可以附加在单个组件上用于信息标记的容器组件。在消息列表中,若希望在联系人头像右上角添加标记,可在实现 ListItem 的联系人头像时,将头像 Image 组件作为 Badge 的子组件。Badge 组件的 count 和 position 参数用于设置消息数量和提示点显示位置,还可以通过 style 参数灵活设置标记样式:

ListItem(){
  Badge(
    count: 1, style: BadgeStyle(color: 0xfa2a2d, badgeSize: 16),
        position: BadgePosition.RightTop,
        child: { =>
            Image(@r(app.media.startIcon))
        }
    )
}

下拉刷新与上拉加载

页面的下拉刷新与上拉加载功能在移动应用中十分常见,例如新闻页面的内容刷新和加载。这两种操作的原理都是通过响应用户的触摸事件,在顶部或底部显示一个刷新或加载视图,完成后再将此视图隐藏。

以下拉刷新为例,实现主要分成三步:

  1. 监听手指按下事件,记录其初始位置的值;
  2. 监听手指按压移动事件,记录并计算当前移动位置与初始值的差值,大于 0 表示向下移动,同时设置一个允许移动的最大值;
  3. 监听手指抬起事件,若此时移动达到最大值,则触发数据加载并显示刷新视图,加载完成后将此视图隐藏。

说明: 页面的下拉刷新操作推荐使用 Refresh 组件实现,由框架内置的刷新容器来承载上述触摸逻辑,避免手写复杂的触摸事件处理。

编辑列表

列表的编辑模式用途十分广泛,常见于待办事项管理、文件管理、备忘录记录管理等场景。在编辑模式下,新增和删除列表项是最基础的功能,其核心是对列表项对应的数据集合进行数据添加和删除——即列表项的增删本质上就是数据源的增删,UI 通过渲染控制机制随之更新。

在 List 参考文档的示例 3中可以看到一个典型的删除流程:数据源使用 ObservedArrayList<Int64> 状态变量存储,点击删除按钮后执行 this.arr.remove(index) 从数据源移除元素,同时将 editFlag 置为 false 退出编辑模式;此外 List 还提供 multiSelectable 属性(初始值 false)用于控制是否启用多选,配合 ListItem 的 selectable(初始值 true)实现更复杂的可交互列表。

长列表的处理:LazyForEach 懒加载与 cachedCount

循环渲染(ForEach)适用于短列表。当构建具有大量列表项的长列表时,如果直接采用循环渲染,会一次性加载所有列表元素,导致页面启动时间过长,影响用户体验。因此推荐使用数据懒加载(LazyForEach)方式实现按需迭代加载数据,从而提升列表性能。

LazyForEach 从数据源中按需迭代数据并创建相应组件;当在滚动容器中使用 LazyForEach 时,框架会根据可视区域按需创建组件,组件滑出可视区域外时会被销毁回收以降低内存占用。使用 LazyForEach 时有几点重要限制(详见 LazyForEach 文档):

  • 仅 List、Grid、Swiper 组件支持数据懒加载(可配置 cachedCount 属性);
  • 容器组件内只能包含一个 LazyForEach,且每次迭代必须创建且只允许创建一个子组件;
  • 键值生成器必须为每个数据生成唯一值;
  • LazyForEach 必须使用 DataChangeListener 对象进行更新,对 dataSource 重新赋值会异常;
  • LazyForEach 必须与 @Reusable 装饰器一起使用才能触发节点复用。

当使用懒加载渲染列表时,为了更好的滚动体验、减少滑动时出现白块,List 提供了 cachedCount 参数设置列表项缓存数(只在懒加载 LazyForEach 中生效,默认值为 1):

List() {
  // ...
}.cachedCount(3)

以垂直列表为例,缓存行为如下:

  • 若懒加载用于 ListItem:单列模式下,会在 List 显示的 ListItem 前后各缓存 cachedCount 个 ListItem;多列模式下,会在显示的 ListItem 前后各缓存 cachedCount × 列数 个 ListItem;
  • 若懒加载用于 ListItemGroup:无论单列还是多列模式,都是在 List 显示的 ListItem 前后各缓存 cachedCount 个 ListItemGroup。

说明:

  • cachedCount 的增加会增大 UI 的 CPU、内存开销,使用时需要根据实际情况综合性能和用户体验进行调整;
  • 列表使用数据懒加载时,除了显示区域的列表项和前后缓存的列表项,其他列表项会被销毁。

折叠与展开

列表项的折叠与展开用途广泛,常用于信息清单的展示、填写等应用场景,例如"个人信息"设置页中分组展开明细项的典型交互。其实现主要分为三步。

第一步:定义列表项数据结构。使用 ItemInfo 表示叶子项,ItemGroupInfo 继承自 ItemInfo 并持有 children 数组表示可展开的分组:

open class ItemInfo {
    var index: Int64
    var name: String
    var label: String
    var `type`: String = ''

    init(index: Int64, name: String, label: String, `type`: String) {
        this.index = index
        this.name = name
        this.label = label
        this.`type` = `type`
    }
}

public class ItemGroupInfo <: ItemInfo {
    var children: Array<ItemInfo>

    init(index: Int64, name: String, label: String, children: Array<ItemInfo>) {
        super(index, name, label, '')
        this.children = children
    }
}

第二步:构造列表结构。使用 @State var routes 保存分组数据,@State var expandedItems(ObservedArrayList)保存每个分组当前的展开状态值(180.0 表示展开),在 ListItemGroup 内部通过 if (this.expandedItems[itemGroup.index] == 180.0) 条件渲染子项:

@State var routes: Array<ItemGroupInfo> = [
    ItemGroupInfo(
        0,
        'basicInfo',
        '个人基本资料',
        [
            ItemInfo(0, '昵称', 'xxxx', 'Text'),
            ItemInfo(1, '头像', "resource://rawfile/startIcon.png", 'Image'),
            ItemInfo(2, '年龄', 'xxxx', 'Text'),
            ItemInfo(3, '生日', 'xxxxxxxx', 'Text'),
            ItemInfo(4, '性别', 'xxxxxx', 'Text')
        ]
    ),
    ItemGroupInfo(1, 'equipInfo', '设备信息', []),
    ItemGroupInfo(2, 'appInfo', '应用使用信息', []),
    ItemGroupInfo(3, 'uploadInfo', '您主动上传的数据', []),
    ItemGroupInfo(4, 'tradeInfo', '交易与资产信息', []),
    ItemGroupInfo(5, 'otherInfo', '其他资料', [])
]
@State var expandedItems: ObservedArrayList<Float32> = ObservedArrayList<Float32>([0.0, 0.0, 0.0, 0.0, 0.0, 0.0])

func build() {
    Column() {
        // ...
        List(space: 10) {
            ForEach(
                this.routes,
                itemGeneratorFunc: { itemGroup: ItemGroupInfo, _: Int64 =>
                    ListItemGroup(header: {=> bind(this.ListItemGroupHeader, this)(itemGroup)},
                        footer: {=>}, space: 0, style: ListItemGroupStyle.Card) {
                            if (this.expandedItems[itemGroup.index] == 180.0) {
                                ForEach(itemGroup.children, itemGeneratorFunc: { item: ItemInfo, _: Int64 =>
                                    ListItem() {
                                            Row() {
                                                Text(item.name)
                                                Blank()
                                                if (item.`type` == 'Image') {
                                                    Image(item.label)
                                                        .height(20)
                                                        .width(20)
                                                } else {
                                                    Text(item.label)
                                                }
                                                Image(@r(sys.media.ohos_ic_public_arrow_right))
                                                    .fillColor(@r(sys.color.ohos_id_color_fourth))
                                                    .height(30)
                                                    .width(30)
                                            }
                                            .width(100.percent)
                                    }.width(100.percent)
                                })
                            }
                        }
                }
            )
        }.width(100.percent)
    }
    .width(100.percent)
    .height(100.percent)
    .justifyContent(FlexAlign.Start)
    .backgroundColor(@r(sys.color.ohos_id_color_sub_background))
}

第三步:通过改变 ListItem 的状态控制每个列表项是否展开,并通过 animation 和 animateTo 实现展开与折叠过程中的动效。这里通过在点击事件中切换 expandedItems[index] 的值(180.0 - expandedItems[index]),并配合箭头图标 rotate 旋转与 AnimateParam(curve: Curve.EaseInOut, duration: 500) 动画参数实现平滑动效:

@Builder
func ListItemGroupHeader(itemGroup: ItemGroupInfo) {
    Row() {
        Text(itemGroup.label)
        Blank()
        Image(@r(sys.media.ohos_ic_public_arrow_down))
            .fillColor(@r(sys.color.ohos_id_color_fourth))
            .height(30)
            .width(30)
            .rotate(x: this.expandedItems[itemGroup.index])
            .animation(AnimateParam(curve: Curve.EaseInOut, duration: 500))
    }
    .width(100.percent)
    .padding(10)
    .onClick({
        event => this.expandedItems[itemGroup.index] = 180.0 - this.expandedItems[itemGroup.index]
    })
}

总结

围绕 List 组件,仓颉 ArkUI 提供了从静态列表、ForEach 循环渲染、LazyForEach 懒加载,到分组(ListItemGroup)、粘性标题(sticky)、滚动控制(Scroller)、滚动监听(onScrollIndex)、侧滑(swipeAction)、标记(Badge)、刷新(Refresh)以及折叠展开动效的完整能力矩阵。实践中可以遵循以下选型思路:

  • 短列表用 ForEach 循环渲染,注意键值唯一;
  • 长列表改用 LazyForEach + cachedCount 按需加载,兼顾启动速度与滚动流畅度;
  • 同类数据直接用 List + ListItem;带分组结构则引入 ListItemGroup,并可用 sticky 实现字母吸顶定位;
  • 需要快速定位时绑定 Scroller 并配合 onScrollIndex + AlphabetIndexer 实现字母索引联动;
  • 交互增强(侧滑删除、未读标记、下拉刷新、折叠展开)分别对应 swipeAction、Badge、Refresh 和状态驱动的动画方案。

所有属性签名、默认值与系统能力说明均可在仓库的 arkui-cj 参考文档目录中进一步查阅,渲染控制的底层键值生成与缓存机制可参考 ForEach 与 LazyForEach 两篇文档。

登录后查看全文
CangjieCorpus