本篇学习 List、Grid、Swiper 等组件,实现职官词典列表页

图:古今职鉴开源教程封面。本篇围绕「列表与滚动:数据展示的核心」展开。

学习目标

完成本篇后,你将能够:

  • ✅ 掌握 List 列表组件的使用
  • ✅ 掌握 Grid 网格组件的使用
  • ✅ 掌握 Swiper 轮播组件的使用
  • ✅ 理解 ForEach 的 key 生成函数
  • ✅ 实现职官词典列表页

预计学习时间

约 90 分钟

---

实战一:创建基础列表页

第一步:创建 lesson05 目录

products/jiaocheng/src/main/ets/ 下创建 lesson05 文件夹。

第二步:创建 Lesson05Page.ets 文件

lesson05 目录下新建文件 Lesson05Page.ets,输入以下代码:

// 文件路径:products/jiaocheng/src/main/ets/lesson05/Lesson05Page.ets

// 【原理】定义数据接口
// ArkTS 要求所有对象必须有明确的类型,不能使用 any
interface PositionData {
  id: number;       // 唯一标识,ForEach 需要用它生成 key
  name: string;     // 官职名称
  dynasty: string;  // 朝代
  description: string; // 描述
}

@Entry
@Component
struct Lesson05Page {
  // 【原理】@State 声明响应式数据
  // 当 positions 变化时,UI 会自动更新
  @State positions: PositionData[] = [
    { id: 1, name: '丞相', dynasty: '秦', description: '百官之长,辅佐皇帝处理政务' },
    { id: 2, name: '太尉', dynasty: '秦', description: '掌管全国军事' },
    { id: 3, name: '御史大夫', dynasty: '秦', description: '监察百官,掌管图籍' },
    { id: 4, name: '大司马', dynasty: '汉', description: '最高军事长官' },
    { id: 5, name: '尚书令', dynasty: '唐', description: '尚书省长官' }
  ];

  build() {
    Column() {
      // 页面标题
      Text('职官词典')
        .fontSize(22)
        .fontWeight(FontWeight.Bold)
        .fontColor('#1e293b')
        .padding(16)
        .width('100%')

      // 【核心】List 列表组件
      // List 只渲染可见区域,性能比 Scroll + Column 好
      List() {
        // 【核心】ForEach 循环渲染
        ForEach(this.positions, (item: PositionData) => {
          // ListItem 是列表项容器,必须使用
          ListItem() {
            // 列表项内容
            Row() {
              // 左侧朝代标签
              Text(item.dynasty)
                .fontSize(12)
                .fontColor(Color.White)
                .backgroundColor('#c41e3a')
                .padding({ left: 8, right: 8, top: 4, bottom: 4 })
                .borderRadius(4)

              // 右侧信息
              Column() {
                Text(item.name)
                  .fontSize(16)
                  .fontWeight(FontWeight.Medium)
                  .fontColor('#1e293b')

                Text(item.description)
                  .fontSize(13)
                  .fontColor('#64748b')
                  .margin({ top: 4 })
              }
              .alignItems(HorizontalAlign.Start)
              .layoutWeight(1)
              .margin({ left: 12 })
            }
            .width('100%')
            .padding(16)
            .backgroundColor(Color.White)
          }
        }, (item: PositionData) => item.id.toString())
        // ↑【重要】第二个参数是 key 生成函数
        // 用于标识每个列表项的唯一性,优化渲染性能
      }
      .width('100%')
      .layoutWeight(1)
      // 添加分割线
      .divider({
        strokeWidth: 1,
        color: '#f0f0f0',
        startMargin: 16,
        endMargin: 16
      })
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#f8f6f5')
  }
}

第三步:理解代码结构

List 组件的作用

  • 只渲染屏幕可见区域的列表项
  • 滚动时动态创建/销毁列表项
  • Scroll + Column 性能更好

ForEach 的两个参数

ForEach(
  数据数组,
  (item) => { /* 渲染每一项 */ },
  (item) => item.id.toString()  // key 生成函数
)

为什么需要 key?

  • 数据变化时,框架通过 key 判断哪些项需要更新
  • 没有 key 或 key 重复会导致渲染异常

第四步:运行查看效果

hvigorw assembleHap --no-daemon

预期效果

  • 显示 5 条官职数据
  • 每条左侧有红色朝代标签
  • 列表项之间有分割线
  • 可以上下滚动

---

实战二:添加轮播图

第一步:在文件顶部添加轮播数据接口

PositionData 接口下方添加:

// 轮播数据接口
interface BannerData {
  id: number;
  title: string;
  subtitle: string;
  color: string;
}

第二步:在组件中添加轮播数据

@State positions 下方添加:

// 轮播数据
private banners: BannerData[] = [
  { id: 1, title: '秦朝官制', subtitle: '三公九卿制度的起源', color: '#1a1a1a' },
  { id: 2, title: '汉代官制', subtitle: '中央集权的完善', color: '#c41e3a' },
  { id: 3, title: '唐朝官制', subtitle: '三省六部的巅峰', color: '#ffd700' }
];

第三步:添加轮播图 Builder

build() 方法之前添加:

// 轮播图组件
@Builder
BannerSection() {
  Swiper() {
    ForEach(this.banners, (item: BannerData) => {
      // 每个轮播项
      Stack() {
        // 背景
        Column()
          .width('100%')
          .height('100%')
          .backgroundColor(item.color)
          .borderRadius(12)

        // 文字
        Column() {
          Text(item.title)
            .fontSize(22)
            .fontWeight(FontWeight.Bold)
            .fontColor(Color.White)

          Text(item.subtitle)
            .fontSize(14)
            .fontColor('rgba(255, 255, 255, 0.8)')
            .margin({ top: 8 })
        }
        .alignItems(HorizontalAlign.Start)
        .padding(20)
      }
      .width('100%')
      .height('100%')
      .alignContent(Alignment.BottomStart)
    })
  }
  .width('100%')
  .height(160)
  .padding(16)
  .autoPlay(true)      // 自动播放
  .interval(3000)      // 3秒切换
  .loop(true)          // 循环播放
  .indicator(          // 指示器样式
    new DotIndicator()
      .color('rgba(255, 255, 255, 0.5)')
      .selectedColor(Color.White)
  )
}

第四步:在 List 中使用轮播图

修改 build() 中的 List 部分,在 ForEach 之前添加轮播图:

List() {
  // 轮播图作为第一个列表项
  ListItem() {
    this.BannerSection()
  }

  // 官职列表
  ForEach(this.positions, (item: PositionData) => {
    // ... 原有代码不变
  })
}

第五步:理解 Swiper 组件

Swiper 常用属性

属性 作用 示例值
autoPlay 是否自动播放 true
interval 播放间隔(毫秒) 3000
loop 是否循环 true
indicator 指示器样式 DotIndicator

第六步:运行查看效果

hvigorw assembleHap --no-daemon

预期效果

  • 顶部显示轮播图,3秒自动切换
  • 底部有白色圆点指示器
  • 可以手动左右滑动切换

---

实战三:添加朝代筛选 Tab

第一步:添加筛选相关状态

在组件中添加:

// 当前选中的朝代
@State currentDynasty: string = '全部';

// 朝代列表
private dynastyTabs: string[] = ['全部', '秦', '汉', '唐', '宋', '明', '清'];

第二步:添加筛选 Tab Builder

BannerSection() 下方添加:

// 朝代筛选 Tab
@Builder
DynastyTabs() {
  // 【原理】Scroll 包裹 Row 实现横向滚动
  Scroll() {
    Row() {
      ForEach(this.dynastyTabs, (dynasty: string) => {
        Text(dynasty)
          .fontSize(14)
          // 选中状态:白字红底,未选中:黑字灰底
          .fontColor(this.currentDynasty === dynasty ? Color.White : '#1e293b')
          .backgroundColor(this.currentDynasty === dynasty ? '#c41e3a' : '#f0f0f0')
          .padding({ left: 16, right: 16, top: 8, bottom: 8 })
          .borderRadius(16)
          .margin({ right: 8 })
          .onClick(() => {
            // 点击切换选中状态
            this.currentDynasty = dynasty;
          })
      })
    }
    .padding({ left: 16, right: 16, top: 12, bottom: 12 })
  }
  .scrollable(ScrollDirection.Horizontal)  // 横向滚动
  .scrollBar(BarState.Off)                  // 隐藏滚动条
  .width('100%')
  .backgroundColor(Color.White)
}

第三步:添加数据筛选逻辑

在组件中添加 getter 计算属性:

// 【原理】getter 计算属性
// 当 currentDynasty 或 positions 变化时,自动重新计算
get filteredPositions(): PositionData[] {
  if (this.currentDynasty === '全部') {
    return this.positions;
  }
  return this.positions.filter(p => p.dynasty === this.currentDynasty);
}

第四步:修改 List 使用筛选后的数据

List() {
  ListItem() {
    this.BannerSection()
  }

  // 添加筛选 Tab
  ListItem() {
    this.DynastyTabs()
  }

  // 使用筛选后的数据
  ForEach(this.filteredPositions, (item: PositionData) => {
    ListItem() {
      // ... 原有卡片代码
    }
  }, (item: PositionData) => item.id.toString())
}

第五步:运行查看效果

hvigorw assembleHap --no-daemon

预期效果

  • 轮播图下方显示朝代筛选 Tab
  • Tab 可以横向滚动
  • 点击不同朝代,列表自动筛选
  • 选中的 Tab 显示红色背景

---

实战四:优化卡片样式

第一步:将卡片提取为 Builder

在组件中添加:

// 官职卡片
@Builder
PositionCard(item: PositionData) {
  Row() {
    // 左侧朝代标签
    Column() {
      Text(item.dynasty)
        .fontSize(14)
        .fontWeight(FontWeight.Bold)
        .fontColor(Color.White)
    }
    .width(44)
    .height(44)
    .justifyContent(FlexAlign.Center)
    .backgroundColor('#c41e3a')
    .borderRadius(8)

    // 中间信息
    Column() {
      Text(item.name)
        .fontSize(16)
        .fontWeight(FontWeight.Medium)
        .fontColor('#1e293b')

      Text(item.description)
        .fontSize(13)
        .fontColor('#64748b')
        .margin({ top: 4 })
        .maxLines(1)
        .textOverflow({ overflow: TextOverflow.Ellipsis })
    }
    .alignItems(HorizontalAlign.Start)
    .layoutWeight(1)
    .margin({ left: 12 })

    // 右侧箭头
    Image($r('app.media.ic_chevron_right'))
      .width(20)
      .height(20)
      .fillColor('#cccccc')
  }
  .width('100%')
  .padding(16)
  .margin({ left: 16, right: 16, bottom: 12 })
  .backgroundColor(Color.White)
  .borderRadius(12)
  .shadow({
    radius: 4,
    color: 'rgba(0, 0, 0, 0.05)',
    offsetX: 0,
    offsetY: 2
  })
  .onClick(() => {
    console.info(`点击了:${item.name}`);
  })
}

第二步:在 ForEach 中使用

ForEach(this.filteredPositions, (item: PositionData) => {
  ListItem() {
    this.PositionCard(item)
  }
}, (item: PositionData) => item.id.toString())

第三步:移除 List 的分割线

因为卡片已经有间距,不需要分割线了:

List() {
  // ...
}
.width('100%')
.layoutWeight(1)
// 删除 .divider(...) 这一行

第四步:运行查看效果

hvigorw assembleHap --no-daemon

预期效果

  • 卡片有圆角和阴影
  • 卡片之间有间距
  • 点击卡片控制台输出日志

---

实战五:添加搜索功能

第一步:添加搜索状态

在组件中添加:

@State searchText: string = '';

第二步:创建搜索栏 Builder

在组件中添加:

@Builder
SearchBar() {
  Row() {
    // 返回按钮
    Image($r('app.media.ic_back'))
      .width(24)
      .height(24)
      .fillColor('#1e293b')
      .onClick(() => {
        console.log('返回');
      })

    // 搜索框
    Row() {
      Image($r('app.media.ic_search'))
        .width(20)
        .height(20)
        .fillColor('#999999')

      TextInput({ placeholder: '搜索官职名称', text: this.searchText })
        .fontSize(14)
        .backgroundColor(Color.Transparent)
        .layoutWeight(1)
        .onChange((value: string) => {
          this.searchText = value;
        })
    }
    .height(40)
    .layoutWeight(1)
    .margin({ left: 12 })
    .padding({ left: 12, right: 12 })
    .backgroundColor('#f0f0f0')
    .borderRadius(20)
  }
  .width('100%')
  .height(56)
  .padding({ left: 16, right: 16 })
  .backgroundColor(Color.White)
}

第三步:修改筛选逻辑

更新 filteredPositions getter:

get filteredPositions(): PositionData[] {
  let result = this.positions;

  // 朝代筛选
  if (this.currentDynasty !== '全部') {
    result = result.filter(p => p.dynasty === this.currentDynasty);
  }

  // 搜索筛选
  if (this.searchText.length > 0) {
    result = result.filter(p =>
      p.name.includes(this.searchText) ||
      p.description.includes(this.searchText)
    );
  }

  return result;
}

第四步:在 build() 中添加搜索栏

build() {
  Column() {
    // 搜索栏(固定在顶部)
    this.SearchBar()

    // 列表
    List() {
      // ... 原有内容
    }
    .width('100%')
    .layoutWeight(1)
  }
  .width('100%')
  .height('100%')
  .backgroundColor('#f8f6f5')
}

第五步:运行查看效果

hvigorw assembleHap --no-daemon

预期效果

  • 顶部显示搜索栏
  • 输入文字后列表实时筛选
  • 搜索和朝代筛选可以组合使用

---

完整代码

将以上所有实战整合后的完整代码:

// 文件路径:products/jiaocheng/src/main/ets/lesson05/Lesson05Page.ets

// ----- 数据接口定义 -----
interface PositionData {
  id: number;
  name: string;
  dynasty: string;
  rank: number;
  category: string;
  description: string;
}

interface BannerData {
  id: number;
  title: string;
  subtitle: string;
  color: string;
}

@Entry
@Component
struct Lesson05Page {
  // ----- 状态变量 -----
  @State currentDynasty: string = '全部';
  @State searchText: string = '';

  // ----- 轮播数据 -----
  private banners: BannerData[] = [
    { id: 1, title: '秦朝官制', subtitle: '三公九卿制度的起源', color: '#1a1a1a' },
    { id: 2, title: '汉代官制', subtitle: '中央集权的完善', color: '#c41e3a' },
    { id: 3, title: '唐朝官制', subtitle: '三省六部的巅峰', color: '#ffd700' }
  ];

  // ----- 朝代筛选数据 -----
  private dynastyTabs: string[] = ['全部', '秦', '汉', '唐', '宋', '明', '清'];

  // ----- 官职数据 -----
  private allPositions: PositionData[] = [
    { id: 1, name: '丞相', dynasty: '秦', rank: 1, category: '文官', description: '百官之长,辅佐皇帝处理政务' },
    { id: 2, name: '太尉', dynasty: '秦', rank: 1, category: '武官', description: '掌管全国军事' },
    { id: 3, name: '御史大夫', dynasty: '秦', rank: 2, category: '文官', description: '监察百官,掌管图籍' },
    { id: 4, name: '廷尉', dynasty: '秦', rank: 3, category: '文官', description: '掌管司法刑狱' },
    { id: 5, name: '大司马', dynasty: '汉', rank: 1, category: '武官', description: '最高军事长官' },
    { id: 6, name: '大将军', dynasty: '汉', rank: 1, category: '武官', description: '统领全军' },
    { id: 7, name: '太常', dynasty: '汉', rank: 3, category: '文官', description: '掌管宗庙礼仪' },
    { id: 8, name: '尚书令', dynasty: '唐', rank: 2, category: '文官', description: '尚书省长官' },
    { id: 9, name: '中书令', dynasty: '唐', rank: 2, category: '文官', description: '中书省长官' },
    { id: 10, name: '门下侍中', dynasty: '唐', rank: 2, category: '文官', description: '门下省长官' }
  ];

  // ----- 计算属性:筛选后的数据 -----
  get filteredPositions(): PositionData[] {
    let result = this.allPositions;

    if (this.currentDynasty !== '全部') {
      result = result.filter(p => p.dynasty === this.currentDynasty);
    }

    if (this.searchText.length > 0) {
      result = result.filter(p =>
        p.name.includes(this.searchText) ||
        p.description.includes(this.searchText)
      );
    }

    return result;
  }

  // ----- 构建 UI -----
  build() {
    Column() {
      this.SearchBar()

      List() {
        ListItem() {
          this.BannerSection()
        }

        ListItem() {
          this.DynastyTabs()
        }

        ForEach(this.filteredPositions, (item: PositionData) => {
          ListItem() {
            this.PositionCard(item)
          }
        }, (item: PositionData) => item.id.toString())
      }
      .width('100%')
      .layoutWeight(1)
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#f8f6f5')
  }

  // ========== 搜索栏 ==========
  @Builder
  SearchBar() {
    Row() {
      Image($r('app.media.ic_back'))
        .width(24)
        .height(24)
        .fillColor('#1e293b')
        .onClick(() => {
          console.log('返回');
        })

      Row() {
        Image($r('app.media.ic_search'))
          .width(20)
          .height(20)
          .fillColor('#999999')

        TextInput({ placeholder: '搜索官职名称', text: this.searchText })
          .fontSize(14)
          .backgroundColor(Color.Transparent)
          .layoutWeight(1)
          .onChange((value: string) => {
            this.searchText = value;
          })
      }
      .height(40)
      .layoutWeight(1)
      .margin({ left: 12 })
      .padding({ left: 12, right: 12 })
      .backgroundColor('#f0f0f0')
      .borderRadius(20)
    }
    .width('100%')
    .height(56)
    .padding({ left: 16, right: 16 })
    .backgroundColor(Color.White)
  }

  // ========== 轮播图区域 ==========
  @Builder
  BannerSection() {
    Swiper() {
      ForEach(this.banners, (item: BannerData) => {
        Stack() {
          Column()
            .width('100%')
            .height('100%')
            .backgroundColor(item.color)
            .borderRadius(12)

          Column() {
            Text(item.title)
              .fontSize(22)
              .fontWeight(FontWeight.Bold)
              .fontColor(Color.White)

            Text(item.subtitle)
              .fontSize(14)
              .fontColor('rgba(255, 255, 255, 0.8)')
              .margin({ top: 8 })
          }
          .alignItems(HorizontalAlign.Start)
          .padding(20)
        }
        .width('100%')
        .height('100%')
        .alignContent(Alignment.BottomStart)
      })
    }
    .width('100%')
    .height(160)
    .padding(16)
    .autoPlay(true)
    .interval(4000)
    .loop(true)
    .indicator(
      new DotIndicator()
        .color('rgba(255, 255, 255, 0.5)')
        .selectedColor(Color.White)
    )
  }

  // ========== 朝代筛选 Tab ==========
  @Builder
  DynastyTabs() {
    Scroll() {
      Row() {
        ForEach(this.dynastyTabs, (dynasty: string) => {
          Text(dynasty)
            .fontSize(14)
            .fontColor(this.currentDynasty === dynasty ? Color.White : '#1e293b')
            .backgroundColor(this.currentDynasty === dynasty ? '#c41e3a' : '#f0f0f0')
            .padding({ left: 16, right: 16, top: 8, bottom: 8 })
            .borderRadius(16)
            .margin({ right: 8 })
            .onClick(() => {
              this.currentDynasty = dynasty;
            })
        })
      }
      .padding({ left: 16, right: 16, top: 12, bottom: 12 })
    }
    .scrollable(ScrollDirection.Horizontal)
    .scrollBar(BarState.Off)
    .width('100%')
    .backgroundColor(Color.White)
  }

  // ========== 官职卡片 ==========
  @Builder
  PositionCard(item: PositionData) {
    Row() {
      Column() {
        Text(`${item.rank}品`)
          .fontSize(14)
          .fontWeight(FontWeight.Bold)
          .fontColor(Color.White)

        Text(item.category)
          .fontSize(10)
          .fontColor('rgba(255, 255, 255, 0.8)')
          .margin({ top: 4 })
      }
      .width(50)
      .height(50)
      .justifyContent(FlexAlign.Center)
      .backgroundColor(item.category === '文官' ? '#c41e3a' : '#4169e1')
      .borderRadius(8)

      Column() {
        Row() {
          Text(item.name)
            .fontSize(16)
            .fontWeight(FontWeight.Medium)
            .fontColor('#1e293b')

          Text(item.dynasty)
            .fontSize(12)
            .fontColor('#64748b')
            .backgroundColor('#f0f0f0')
            .padding({ left: 6, right: 6, top: 2, bottom: 2 })
            .borderRadius(4)
            .margin({ left: 8 })
        }

        Text(item.description)
          .fontSize(13)
          .fontColor('#64748b')
          .margin({ top: 6 })
          .maxLines(1)
          .textOverflow({ overflow: TextOverflow.Ellipsis })
      }
      .alignItems(HorizontalAlign.Start)
      .margin({ left: 12 })
      .layoutWeight(1)

      Image($r('app.media.ic_chevron_right'))
        .width(20)
        .height(20)
        .fillColor('#cccccc')
    }
    .width('100%')
    .padding(16)
    .margin({ left: 16, right: 16, bottom: 12 })
    .backgroundColor(Color.White)
    .borderRadius(12)
    .shadow({
      radius: 4,
      color: 'rgba(0, 0, 0, 0.05)',
      offsetX: 0,
      offsetY: 2
    })
    .onClick(() => {
      console.log(`点击了:${item.name}`);
    })
  }
}

---

本课小结

核心知识点

组件 用途 关键属性
List 高性能列表 divider, layoutWeight
ListItem 列表项容器 必须包裹列表内容
Swiper 轮播图 autoPlay, interval, loop, indicator
Scroll 滚动容器 scrollable, scrollBar
ForEach 循环渲染 数据数组, 渲染函数, key函数

ForEach 的 key 函数

ForEach(
  数据数组,
  (item) => { /* 渲染 */ },
  (item) => item.id.toString()  // ← key 必须唯一
)

为什么重要

  • 数据变化时,框架通过 key 判断哪些项需要更新
  • key 重复会导致渲染异常
  • 推荐使用数据的唯一 ID

getter 计算属性

get filteredPositions(): PositionData[] {
  // 依赖的状态变化时自动重新计算
  return this.positions.filter(...);
}

优势

  • 自动响应依赖变化
  • 代码更简洁
  • 避免手动调用更新

---

课后练习

练习1:添加 Grid 网格布局

将官职列表改为 2 列网格布局:

Grid() {
  ForEach(this.filteredPositions, (item: PositionData) => {
    GridItem() {
      // 卡片内容
    }
  })
}
.columnsTemplate('1fr 1fr')  // 两列等宽
.rowsGap(12)
.columnsGap(12)

练习2:添加下拉刷新

为 List 添加下拉刷新功能:

Refresh({ refreshing: $$this.isRefreshing }) {
  List() {
    // ...
  }
}
.onRefreshing(() => {
  // 模拟加载数据
  setTimeout(() => {
    this.isRefreshing = false;
  }, 1500);
})

练习3:添加空状态

当筛选结果为空时显示提示:

if (this.filteredPositions.length === 0) {
  Column() {
    Text('暂无数据')
      .fontSize(16)
      .fontColor('#999999')
  }
  .width('100%')
  .height(200)
  .justifyContent(FlexAlign.Center)
}

---

下一课预告

第6课我们将学习布局与样式,包括:

  • Flex 布局深度解析
  • @Styles 和 @Extend 样式复用
  • @Builder 构建函数的高级用法
  • 实现更复杂的卡片组件

项目开源地址

https://gitcode.com/daleishen/gujinzhijian

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐