【学习目标】

  • 掌握 List、ListItem、ListItemGroup 三大核心组件的组合使用;
  • 理解 List 的主轴与交叉轴布局概念,掌握基础布局、样式配置全能力;
  • 熟练运用 List 核心属性(sticky、divider、scrollBar、edgeEffect)实现业务效果;
  • 掌握 Scroller 滚动控制器的使用,实现返回顶部、指定位置跳转等交互;
  • 通过 List+Scroller+AlphabetIndexer 组件完成模仿微信联系人。

一、本节工程目录结构

ListDemo/
├── entry/
│   └── src/main/
│       ├── ets/
│       │   ├── pages/
│       │   │   ├── Index.ets              # 首页:功能导航入口
│       │   │   ├── BasicListDemo.ets      # 基础静态列表示例
│       │   │   └── WeChatContactsDemo.ets # 仿微信通讯录
│       │   ├── model/
│       │   │   └── ContactModel.ets       # 数据模型
│       │   ├── utils/
│       │   │   └── JsonUtil.ets           # JSON工具类
│       └── resources/
│           └── rawfile/
│               └── contacts.json          # 联系人数据
└── build-profile.json5

二、List 核心认知

2.1 什么是 List?

List 是鸿蒙应用中用于构建可滚动列表的核心容器组件,专门用于呈现同类数据集合,当列表内容超出屏幕大小时,会自动提供滚动能力。它是移动端开发中最高频的组件之一,系统设置页、通讯录、商品列表、消息列表等场景均基于 List 实现。

2.2 核心组成三要素

List 的使用有严格的父子组件约束,必须由以下三层结构构成,否则会触发编译警告或渲染异常:

  1. List:根容器,负责列表的整体布局规则、滚动控制、全局样式配置,是整个列表的载体。
  2. ListItemGroup(可选):分组容器,专门用于列表的分组展示、粘性吸顶效果,其子组件只能是 ListItem
  3. ListItem:列表项容器,列表的每一行/每一项内容,必须包裹在 ListItem 内,且它必须是 List 或 ListItemGroup 的直接子元素。ListItem 内部只能包含一个根组件,可通过 Row/Column 等容器嵌套多个子组件。

强制规则:List 的子组件必须是 ListItem 或 ListItemGroup;ListItem 和 ListItemGroup 必须配合 List 使用,不可单独在其他容器中使用。

2.3 核心布局概念:主轴与交叉轴

列表的布局方向、滚动能力,都围绕主轴和交叉轴展开,这是理解 List 布局的核心:

  • 主轴:列表项的排列方向,也是列表的滚动方向。
    • 默认值:Axis.Vertical 垂直方向,主轴为垂直轴,列表上下滚动;
    • 可配置:Axis.Horizontal 水平方向,主轴为水平轴,列表左右滚动。
  • 交叉轴:与主轴相互垂直的方向。
    • 垂直列表的交叉轴是水平方向,控制列表项的水平对齐、列数;
    • 水平列表的交叉轴是垂直方向,控制列表项的垂直对齐、行数。

三、核心组件常用接口

3.1 List 根容器组件

构造函数
参数名 说明
space 列表项之间沿主轴方向的间距
scroller 滚动控制器,用于手动控制列表滚动位置
initialIndex 列表首次渲染时,默认显示的项索引,默认 0
常用布局属性
属性 说明
listDirection(value: Axis) 设置列表主轴方向(滚动方向)
• Vertical:垂直列表
• Horizontal:水平列表
lanes(value) 设置交叉轴排列的列表项数量
• 数字:固定列数
• 对象:自适应列数
alignListItem(value) 设置列表项在交叉轴方向的对齐方式
常用样式属性
属性 说明
divider(options) 设置列表项之间的分隔线
• strokeWidth:粗细(必填)
• startMargin:左侧间距
• endMargin:右侧间距
• color:颜色
scrollBar(value) 滚动条显示策略
• Auto:触摸显示
• Off:始终隐藏
• On:始终显示
sticky(value) 粘性标题(配合 ListItemGroup)
• Header:头部吸顶
• Footer:底部吸底
edgeEffect(value) 边缘回弹效果
• Spring:弹性回弹
• None:无效果
cachedCount(value) 懒加载缓存数量,配合 LazyForEach 使用
核心事件回调
事件 说明
onReachEnd() 列表滚动到底部时触发,用于上拉加载更多
onReachStart() 列表滚动到顶部时触发
onScrollIndex((first, last) => {}) 可视区域索引变化时触发
onScroll((x, y) => {}) 滚动过程中持续触发
onScrollStop() 滚动停止时触发

3.2 ListItem 列表项组件

列表的最小单元,必须作为 List 或 ListItemGroup 的直接子组件。

构造函数
参数 说明
style 列表项样式
• Normal:普通样式
• CARD:卡片样式(自带圆角、内边距、阴影)
常用属性
属性 说明
swipeAction(options) 配置侧滑菜单(start 右滑 / end 左滑)
selected(value) 设置列表项是否选中
draggable(value) 是否启用拖拽,用于拖拽排序

3.3 ListItemGroup 分组容器组件

用于实现列表的分组展示,配合 sticky 属性实现粘性吸顶标题,是联系人列表、设置页的核心组件。

构造函数
参数 说明
header 分组头部组件(@Builder 函数)
footer 分组尾部组件(@Builder 函数)
style 分组样式
• None:默认
• CARD:卡片分组

3.4 Scroller 滚动控制器

用于手动控制列表的滚动行为,比如返回顶部、滚动到指定位置、滚动到列表边缘等。

核心方法
方法 说明
scrollToIndex(index, smooth, align) 滚动到指定索引项,smooth 开启平滑滚动
scrollEdge(edge, smooth) 滚动到边缘:Top / Bottom / Start / End
scrollTo(x, y, animation) 滚动到指定偏移量位置

四、基础实战:从零构建静态列表

4.1 最简静态列表

// pages/BasicListDemo.ets

@Entry
@Component
struct BasicListDemo {
  @State lanes:number = 1;
  @State axis:Axis = Axis.Vertical;

  build() {
    // List 根容器
    List({ space: 1 }) {
      // 每一行内容必须用 ListItem 包裹
      ListItem() {
        Text('单列布局')
          .fontSize(18)
          .width('100%')
          .padding(16)
          .onClick(()=>{
            this.lanes = 1
          })

      }

      ListItem() {
        Text('双列布局')
          .fontSize(18)
          .width('100%')
          .padding(16)
          .onClick(()=>{
            this.lanes = 2
          })
      }

      ListItem() {
        Text('垂直排列')
          .fontSize(18)
          .width('100%')
          .padding(16)
          .onClick(()=>{
            this.axis = Axis.Vertical;
          })
      }

      ListItem() {
        Text('横向排列')
          .fontSize(18)
          .width('100%')
          .padding(16)
          .onClick(()=>{
            this.axis = Axis.Horizontal;
          })
      }
    }
    .backgroundColor('#F5F5F5')
    .listDirection(this.axis) // 默认垂直布局Axis.Vertical,设置Horizontal水平布局
    .scrollBar(BarState.Off) // 隐藏滚动条
    .divider({  // 核心:分隔线配置
      strokeWidth: 0.5, // 分隔线粗细
      startMargin: 56, // 距离左侧56vp
      endMargin: 16, // 距离右侧16vp
      color: '#E5E5E5' // 分隔线颜色
    })
    .lanes(this.lanes) // 核心:设置交叉轴列数为2,实现两列网格 通常用于动态适配不同的屏幕宽度
    .width('100%')
    .height('100%')
  }
}

这是最直观的示例,没有使用循环遍历组件。List作为根容器,每一行列表元素由ListItem包裹Text组件,给List设置不同的链式属性添加不同的样式。
List基础使用方法

五、仿微信联系人页面

我们使用ForEach+List复刻一个微信通讯录:

  • 实现分组每一组标题是大写首字母;
  • 实现侧边字母索引,选中字母快速滚动到对应分组;
  • 侧滑拉出备注菜单。
  • 点击或按压有反馈效果

5.1 准备数据

我们需要准备一份本地数据contacts.json

[
  {
    "initial": "A",
    "list": [
      { "name": "艾小米", "avatar": "https://picsum.photos/id/1/200/200" },
      { "name": "安小北", "avatar": "https://picsum.photos/id/2/200/200" }
    ]
  },
  // 总共52条数据 每个字母对应两条联系人信息,完整数据内容通过代码仓库下载。或自行生成属性字段不变就可以。
]

5.2 配置:module.json5申请联网权限

{
  "module": {
    "requestPermissions": [
      {  // 图片通过网络加载,需要开通连网权限
        "name": "ohos.permission.INTERNET"
      }
    ]
  }
}

5.3 JSON数据读取

JSON数据读取(零基础鸿蒙应用开发第三十二节:JSON核心基础与文件的读写)已学习过,完整代码如下:

// main/ets/utils/JsonUtil.ets
import { BusinessError } from '@kit.BasicServicesKit';
import { util } from '@kit.ArkTS';
import { Context } from '@kit.AbilityKit';
import fs from '@ohos.file.fs';

export class JsonUtil {
  /**
   * 读取 rawfile 目录下的 JSON 文件并返回字符串内容
   * @param context 应用/组件上下文
   * @param fileName JSON 文件名(支持子目录如 "user/user_info.json")
   * @returns JSON 字符串
   */
  static async readRawFileJson(context: Context, fileName: string): Promise<string> {
    try {
      const buffer = await context.resourceManager.getRawFileContent(fileName);
      const uint8Array = new Uint8Array(buffer);
      const decoder = new util.TextDecoder();
      return decoder.decodeToString(uint8Array);
    } catch (err) {
      const error = err as BusinessError;
      const msg = `读取JSON文件 ${fileName} 失败:${error.code} - ${error.message}`;
      console.error(msg);
      throw new Error(msg);
    }
  }

  /**
   * 写入JSON数据到应用沙箱的cache目录(可读写)
   * @param context 应用上下文
   * @param fileName 文件名(如 "user_config.json")
   * @param data 要序列化的对象
   */
  static async writeSandboxJson(context: Context, fileName: string, data: object): Promise<void> {
    let file: fs.File | undefined = undefined;
    try {
      const cacheDir = context.cacheDir;
      const filePath = `${cacheDir}/${fileName}`;
      const jsonStr = JSON.stringify(data, null, 2);

      // 打开/创建文件
      file = fs.openSync(filePath, fs.OpenMode.READ_WRITE | fs.OpenMode.CREATE);
      // 写入数据
      fs.writeSync(file.fd, jsonStr);
      console.log(`JSON数据已写入沙箱:${filePath}`);
    } catch (err) {
      const error = err as BusinessError;
      const msg = `写入沙箱JSON文件 ${fileName} 失败:${error.code} - ${error.message}`;
      console.error(msg);
      throw new Error(msg);
    } finally {
      // 无论是否异常,都要关闭文件句柄,避免泄漏
      if (file) {
        fs.closeSync(file);
      }
    }
  }

  /**
   * 从应用沙箱的cache目录读取JSON文件
   * @param context 应用上下文
   * @param fileName 文件名
   * @returns JSON 字符串
   */
  static async readSandboxJson(context: Context, fileName: string): Promise<string> {
    let file: fs.File | undefined = undefined;
    try {
      const cacheDir = context.cacheDir;
      const filePath = `${cacheDir}/${fileName}`;

      // 1. 检查文件是否存在
      fs.accessSync(filePath);
      // 2. 打开文件(只读模式)
      file = fs.openSync(filePath, fs.OpenMode.READ_ONLY);
      // 3. 获取文件大小
      const fileStat = fs.statSync(filePath);
      // 4. 创建缓冲区读取数据
      const buffer = new ArrayBuffer(fileStat.size);
      const readBytes = fs.readSync(file.fd, buffer, { length: fileStat.size });
      // 5. 校验读取完整性
      if (readBytes !== fileStat.size) {
        throw new Error(`文件读取不完整:预期${fileStat.size}字节,实际${readBytes}字节`);
      }

      // 6. 解码为字符串
      const decoder = new util.TextDecoder();
      return decoder.decodeToString(new Uint8Array(buffer));
    } catch (err) {
      const error = err as BusinessError;
      const msg = `读取沙箱JSON文件 ${fileName} 失败:${error?.code} - ${error?.message || err}`;
      console.error(msg);
      throw new Error(msg);
    } finally {
      if (file) {
        fs.closeSync(file);
      }
    }
  }

  /**
   * 类型安全的JSON反序列化
   * @param jsonStr JSON字符串
   * @returns 指定类型的对象
   */
  static parseJson<T>(jsonStr: string): T {
    try {
      return JSON.parse(jsonStr) as T;
    } catch (err) {
      const msg = `JSON反序列化失败:${(err as Error).message}`;
      console.error(msg);
      throw new Error(msg);
    }
  }
}

5.4 联系人数据模型

/**
 * 单个联系人模型
 * 对应 json 里的 list 项
 */
export interface ContactItem {
  name: string;
  avatar: string;
}

/**
 * 联系人分组模型
 * 对应 json 里的每一组数据
 */
export interface ContactGroup {
  initial: string;
  list: ContactItem[];
}

5.4 微信联系人页面

import { ContactGroup, ContactItem } from '../model/ContactModel';
import { JsonUtil } from '../utils/JsonUtil';
import { BusinessError } from '@kit.BasicServicesKit';

@Entry
@Component
struct WeChatContactsPage {
  private scroller: Scroller = new Scroller();
  private context: Context | undefined = this.getUIContext().getHostContext();
  @State contactGroups: ContactGroup[] = [];
  @State letterList: string[] = [];

  async aboutToAppear() {
    try {
      if (this.context) {
        // 读取联系人信息
        const jsonStr = await JsonUtil.readRawFileJson(this.context, 'contacts.json');
        // 反序列化数据模型
        const contactGroups = JsonUtil.parseJson<ContactGroup[]>(jsonStr);

        this.contactGroups = contactGroups;
        // 生成索引需要的字母数组
        this.letterList = this.contactGroups.map(g => g.initial);
      }
    } catch (err) {
      console.error("加载失败", JSON.stringify(err as BusinessError));
    }
  }

  // 分组标题
  @Builder
  groupHeaderBuilder(title: string) {
    Text(title)
      .fontSize(16)
      .fontWeight(FontWeight.Medium)
      .width('100%')
      .padding({ left: 16, top: 8, bottom: 8 })
      .backgroundColor('#F7F7F7')
  }

  // 联系人条目
  @Builder
  contactItemViewBuilder(item: ContactItem, index: number, count: number) {
    Column() {
      Row() {
        Image(item.avatar)
          .width(44)
          .height(44)
          .borderRadius(6)
          .margin({ right: 15 });

        Text(item.name)
          .fontSize(17)
          .fontColor('#000')
      }
      .width('100%')
      .padding({ left: 15, right: 15, top: 12, bottom: 12 })

      Divider()
        .strokeWidth(0.5)
        .color('#E5E5E5')
        .padding({ left: 74, right: 0 })
        .visibility(index + 1 === count ? Visibility.Hidden : Visibility.Visible)
    }
  }

  // 侧滑菜单
  @Builder
  SwipeMenu() {
    Row() {
      Text("备注")
        .layoutWeight(1)
        .fontColor(Color.White)
        .textAlign(TextAlign.Center)
        .height("100%")
        .backgroundColor("#007AFF")

      Text("删除")
        .layoutWeight(1)
        .fontColor(Color.White)
        .textAlign(TextAlign.Center)
        .height("100%")
        .backgroundColor("#FF3B30")
    }.width(150)
  }

  build() {
    RelativeContainer() {
      // 1. 顶部固定标题
      Text("微信通讯录")
        .fontSize(18)
        .fontWeight(FontWeight.Medium)
        .textAlign(TextAlign.Center)
        .padding(12)
        .backgroundColor('#FFF')
        .id('constant_title')
        .alignRules({
          top: { anchor: '__container__', align: VerticalAlign.Top },
          left: { anchor: '__container__', align: HorizontalAlign.Start },
          right: { anchor: '__container__', align: HorizontalAlign.End }
        });

      // 2. 联系人列表
      List({ scroller: this.scroller }) {
        ForEach(this.contactGroups, (group: ContactGroup) => {
          ListItemGroup({
            header: this.groupHeaderBuilder(group.initial)
          }) {
            ForEach(group.list, (item: ContactItem, row: number) => {
              ListItem({ style: ListItemStyle.NONE }) { // 默认样式可以不写
                this.contactItemViewBuilder(item, row, group.list.length)
              }
              .stateStyles({
                normal: {
                  .backgroundColor('#fff') // 正常白色
                },
                clicked: {
                  .backgroundColor('#F2F3F5') // 点击后的颜色
                }
              })
              .onClick(() => {
                console.log(`点击了${item.name}`)
              })
              .swipeAction({ end: this.SwipeMenu() })
            });
          };
        });
      }
      .id('contactList')
      .sticky(StickyStyle.Header) // 吸顶
      .scrollBar(BarState.Off) // 关闭滚动条
      .alignRules({
        top: { anchor: 'constant_title', align: VerticalAlign.Bottom },
        left: { anchor: '__container__', align: HorizontalAlign.Start },
        bottom: { anchor: '__container__', align: VerticalAlign.Bottom },
        right: { anchor: '__container__', align: HorizontalAlign.End }
      });

      // 3. 右侧字母索引(垂直居中,贴右边)
      AlphabetIndexer({
        arrayValue: this.letterList,
        selected: 0 // 默认选中第1个
      })
      .id('alphabetIndex')
      .color(Color.Black) // 未选中的文本颜色
      .selectedColor(Color.White) // 设置选中项文本颜色
      .selectedBackgroundColor(Color.Green) // 设置选中项背景色
      .onSelect((index: number) => {
        this.scroller.scrollToIndex(index, true);
      })
      .margin({ right: 5 })
      .alignRules({
        center: { anchor: '__container__', align: VerticalAlign.Center },
        right: { anchor: '__container__', align: HorizontalAlign.End }
      });
    }
    .width('100%')
    .height('100%')
  }
}

运行效果

我们通过ForEach + List + AlphabetIndexer模仿了微信联系人的简单页面,熟悉了List的能力与样式设定。但当前页面存在诸多性能问题。(关于ForEach渲染细节等问题我们后边章节讲清楚)
List基础仿微信联系人

六、内容总结

  1. 核心结构约束:List 组件必须配合 ListItem 使用,分组场景使用 ListItemGroup,三者构成列表的核心架构,必须遵守父子组件强制约束。
  2. 布局核心能力:通过 listDirection 控制主轴滚动方向(垂直/水平),通过 lanes 控制交叉轴列数,实现单列、多列、水平列表等多种布局。
  3. 样式配置能力:通过 space 设置列表项间距,divider 配置分隔线(分组时分割线以组为单位需自定义分割线),scrollBar 控制滚动条,edgeEffect 配置回弹效果。
  4. 进阶核心能力
    • 分组列表:通过 ListItemGroup 实现功能分组,配合 .sticky(StickyStyle.Header) 实现分组标题吸顶效果;
    • 滚动控制:通过 Scroller 控制器实现返回顶部、滚动到底部、指定索引跳转等交互;
    • 事件监听:通过 onScrollIndexonReachEnd 等回调,实现列表滚动状态的监听与业务联动;
    • 侧边字母索引:通过 AlphabetIndexer 组件及属性配置实现侧边栏字母索引。

七、代码仓库

  • 工程名称:ListDemo
  • 仓库地址:https://gitee.com/HarmonyOS-UI-Basics/harmony-os-ui-basics.git

八、下节预告

下一节我们深入研究ForEach的渲染过程、提升渲染性能、掌握ForEach的适用场景。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐