在这里插入图片描述
在这里插入图片描述

实例:商品分页列表(Product)|风格:双列瀑布流

一、设计理念:为什么电商列表用双列瀑布流

电商列表的视觉形态经历过单列 → 双列 → 瀑布流的演进。双列瀑布流(Masonry)之所以成为主流,三个原因:

  1. 信息密度高:双列比单列多展示近一倍的商品,一屏能看 6~8 件,浏览效率高;
  2. 卡片高度自由:瀑布流的卡片高度不必对齐(标题行数、标签有无导致高度不一),错落有致不死板;
  3. 电商惯例:淘宝、京东、拼多多的商品流都是双列,「双列 = 购物」的心智已养成。

实例 8 的页面风格定为「双列瀑布流」,配合触底加载动画(滚动到底自动加载下一页 + 「加载中…」状态)和总数卡(共 N 件商品),构成完整的电商列表体验。

页面信息架构:

  1. 标题栏:商品分页标题 + 已加载/总数统计 + 刷新;
  2. 分类筛选条:横向滚动胶囊(全部/数码/服饰/食品…);
  3. 双列瀑布流:List + lanes(2) 双列网格,触底加载下一页;
  4. 底部状态:加载中… / 已全部加载 / 上滑加载更多。

二、List 双列布局:lanes 属性的妙用

ArkUI 的 List 组件用 lanes 属性实现多列——这是比 columnsTemplate 更简洁的写法(columnsTemplate 是 Grid 的属性,List 不适用,早期版本踩过坑):

List({ space: 10 }) {
  ForEach(this.goods, (g: Goods) => {
    ListItem() {
      Column() {
        // 商品卡片内容
      }
      .padding(10).backgroundColor(Color.White).borderRadius(12)
    }
  }, (g: Goods) => `${g.id}-${g.name}`)
}
.width('94%').layoutWeight(1).margin({ top: 10 })
.scrollBar(BarState.Off)
.lanes(2, 10)   // 双列,列间距 10
.onReachEnd(() => {
  if (!this.finished) {
    this.loadMore();  // 触底加载
  }
})

lanes(2, 10):第一个参数是列数(2),第二个是列间距(10vp)。List 自动把 ListItem 平均分到两列,每列内部按顺序排列——这就是双列瀑布流的核心一行代码。

关于 Grid vs List lanes 的选型:Grid(网格)适合「单元格等高等宽」的场景(如图片墙);List + lanes 适合「高度不固定的卡片流」——本实例商品卡片高度不一(标题 1~2 行、有无标签),用 List lanes 更合适。瀑布流 = 高度不一的列表,所以选 List 而非 Grid

三、商品卡片:emoji 图 + 价格 + 销量

每个商品卡片的结构:

Column() {
  Text(g.image).fontSize(48).textAlign(TextAlign.Center).width('100%')
    .backgroundColor('#F3F4F6').borderRadius(10).height(110)  // emoji 占位图
  Column() {
    Text(g.name).fontSize(13).fontWeight(FontWeight.Medium).maxLines(2)
    Row({ space: 4 }) {
      Text(`¥${this.fmtPrice(g.price)}`).fontSize(16).fontWeight(FontWeight.Bold).fontColor('#DC2626')
      Text(`¥${this.fmtPrice(g.originalPrice)}`).fontSize(11).fontColor('#9CA3AF')
        .decoration({ type: TextDecorationType.LineThrough })  // 划线价
    }.margin({ top: 4 })
    Row() {
      if (g.tag) {
        Text(g.tag).fontSize(10).padding({ left: 5, right: 5, top: 2, bottom: 2 })
          .borderRadius(8).backgroundColor('#FEE2E2').fontColor('#DC2626')  // 红色标签
      }
      Blank()
      Text(`已售 ${g.sales}`).fontSize(10).fontColor('#9CA3AF')
    }.width('100%').margin({ top: 4 })
  }
  .width('100%').padding({ top: 8 })
}
.padding(10).backgroundColor(Color.White).borderRadius(12)

卡片设计细节

  1. emoji 占位图:48fp 大号 emoji 居中,浅灰背景圆角——无需图片资源即可展示「商品图」位置;
  2. 价格双色:现价红色加粗 + 原价灰色删除线(.decoration({ type: TextDecorationType.LineThrough }))——折扣感的视觉呈现;
  3. 标签徽标if (g.tag) 有值才显示红色小徽标(热卖/新品/清仓),无标签的商品不占位;
  4. 销量信息已售 N 灰色小字右下角,Blank() 把标签和销量分居两端。

四、分类筛选条:从 GROUP BY 结果生成

筛选条的数据源是 DAO 的 categoryCounts()(8-1 文章讲过 GROUP BY 统计):

Scroll() {
  Row({ space: 8 }) {
    ForEach(['全部'].concat(Object.keys(this.categories)), (cat: string) => {
      Text(cat)
        .fontSize(13).padding({ left: 12, right: 12, top: 6, bottom: 6 })
        .borderRadius(16)
        .backgroundColor(this.category === cat ? '#3B82F6' : '#FFFFFF')
        .fontColor(this.category === cat ? Color.White : '#4B5563')
        .onClick(() => this.switchCategory(cat))
    }, (cat: string) => cat)
  }.padding({ left: 16, right: 16, top: 10 })
}.scrollable(ScrollDirection.Horizontal).scrollBar(BarState.Off).width('100%')

['全部'].concat(Object.keys(this.categories))——分类列表 = 「全部」+ 数据库里的实际分类。分类列表由数据驱动(数据库里有什么分类就显示什么),而不是硬编码——新增分类自动出现在筛选条,这是「数据驱动 UI」的实践。

切换分类时重置分页状态重新加载:

async switchCategory(cat: string): Promise<void> {
  this.category = cat;
  this.page = 0;
  this.goods = [];
  this.finished = false;
  await this.loadMore();  // 从第一页重新加载
}

切分类必须重置翻页:page 归零、列表清空、finished 复位——否则会从上一分类的第 N 页继续加载,数据错乱。

五、触底加载与底部状态

onReachEnd 是 List 的触底回调——滚动到列表底部时触发:

.onReachEnd(() => {
  if (!this.finished) {
    this.loadMore();
  }
})

列表底部用一行文本展示加载状态:

Text(this.finished ? '— 已全部加载 —' : (this.loading ? '⏳ 加载中…' : '上滑加载更多'))
  .fontSize(12).fontColor('#9CA3AF').padding({ top: 8, bottom: 16 })

三态文案:加载中(⏳ 加载中…)/ 完成(— 已全部加载 —)/ 待触发(上滑加载更多)——给用户明确的进度反馈。懒加载的体验闭环:滚动 → 触底 → 加载 → 追加 → 再滚动。

六、标题栏与刷新

Row() {
  Column() {
    Text('🛍️ 商品分页列表').fontSize(22).fontWeight(FontWeight.Bold)
    Text(`${this.total} 件商品 · 已加载 ${this.goods.length}`)
      .fontSize(12).fontColor('#999999').margin({ top: 2 })
  }.alignItems(HorizontalAlign.Start).layoutWeight(1)
  Text('↻').fontSize(22).onClick(() => this.refresh())
}.width('100%').padding({ left: 16, right: 16, top: 12 })

**「已加载 N / 共 M 件」**是分页列表的核心信息位——用户知道总量(M)和当前进度(N),对「列表有多长」有掌控感。刷新按钮重新初始化:总数重查、分类重查、页码归零、第一页重载。

七、UI 风格要素一览

风格项 取值 说明
页面背景 #F1F4F8 浅冷灰 衬托白卡
商品卡 白底圆角 12 双列瀑布流
emoji 图区 48fp + 浅灰底 占位商品图
现价 #DC2626 16fp Bold 价格锚点
划线价 灰 + 删除线 折扣感
标签 红底白字/浅红底 热卖/新品/清仓
筛选条 选中蓝底白字 分类切换

八、文章小结

本篇文章完成了实例 8 的 UI 层:List lanes 双列瀑布流 + emoji 占位图卡片 + 数据驱动分类筛选条 + 触底加载三态文案。核心是 lanes(2, 10) 一行实现双列布局、onReachEnd 触底回调驱动懒加载、切分类重置翻页状态。所有视觉元素(分类、标签、价格、销量)都由数据库数据驱动,页面只是「翻译器」。

下一篇(8-3)深入分页数据层,讲解 LIMIT/OFFSET 翻页、总条数统计与懒加载状态机——那是本实例的技术核心。

九、商品卡片网格 UI 逐块拆解

双列瀑布流的每一格都是同一张商品卡,卡片写得好不好直接决定整个页面的质感。逐块拆解卡片四要素:图片占位 → 标题 → 价格 → 销量,每一块都有独立的 ArkTS 细节。

Column() {   // 整卡:白底圆角 12
  // ① 图片占位区:emoji 大图 + 浅灰底
  Text(g.image)
    .fontSize(48).textAlign(TextAlign.Center)
    .width('100%').height(110)
    .backgroundColor('#F3F4F6').borderRadius(10)
  // ② 文字信息区
  Column() {
    Text(g.name).fontSize(13).fontWeight(FontWeight.Medium)
      .maxLines(2).textOverflow({ overflow: TextOverflow.Ellipsis })
    Row({ space: 4 }) { /* ③ 价格行 */ }
    Row() { /* ④ 标签 + 销量行 */ }
  }.width('100%').padding({ top: 8 })
}
.padding(10).backgroundColor(Color.White).borderRadius(12)

① 图片占位:没有图片资源怎么撑起「图感」

电商卡片最占视觉的就是商品图。本实例没有引入任何图片资源,用 48fp 大号 emoji + 浅灰底圆角 撑起图区:

  • fontSize(48):emoji 字号越大越有「商品图」的视觉重量,48fp 在 110vp 高的图区里比例协调;
  • backgroundColor('#F3F4F6'):浅灰底模拟图片底色,让卡片不显得空;
  • borderRadius(10):图区四角微圆,与卡片圆角(12)形成内圆外圆的双层层次。

为什么不用 Image 组件:种子数据里没有真实图片 URL,用 emoji 既零资源又直观。真实项目中,把 Text(g.image) 换成 Image(g.imageUrl).objectFit(ImageFit.Cover) 即可无缝升级。

② 标题:maxLines + textOverflow 双保险

Text(g.name).fontSize(13).fontWeight(FontWeight.Medium)
  .maxLines(2)
  .textOverflow({ overflow: TextOverflow.Ellipsis })

商品名长短不一(5~20 字),双列卡片宽度固定,标题必须截断:

  • maxLines(2):最多显示两行,配合瀑布流「高度不固定」的特性——一行标题的卡比两行的矮,正是瀑布流错落感的来源;
  • textOverflow({ overflow: TextOverflow.Ellipsis }):超长文字以「…」收尾,两行都生效。

不加 .height() 固定高度是瀑布流的关键:让标题行数自然决定卡片高度,两列才会参差错落。

③ 价格:fmtPrice 清洗浮点

fmtPrice(p: number): string {
  return Number.isInteger(p) ? `${p}` : p.toFixed(2);
}

SQLite 的 REAL 存价格可能带浮点误差(9.9 存成 9.9000000001),直接拼接会显示一长串小数。fmtPrice 先判断是否整数:整数原样输出(¥9),非整数保留两位(¥9.90)。价格显示前必须过清洗函数,这是电商 UI 的通用细节。

④ 销量:Blank() 把标签挤到两端

Row() {
  if (g.tag) {
    Text(g.tag).fontSize(10).padding({ left: 5, right: 5, top: 2, bottom: 2 })
      .borderRadius(8).backgroundColor('#FEE2E2').fontColor('#DC2626')
  }
  Blank()   // 弹性空白,把剩余空间吃掉
  Text(`已售 ${g.sales}`).fontSize(10).fontColor('#9CA3AF')
}.width('100%').margin({ top: 4 })

Blank() 是 Row 内的弹性占位组件:标签在左、销量在右,中间空隙自动分配。无标签的商品 if 不渲染,Blank 依然把销量推到最右——布局不因数据有无而错位。销量用「已售 N」的灰色小字,是电商卡片的「信任背书」信息。

十、分页加载交互:触底、加载中、无更多

分页列表的交互闭环是三段式:滚动触底 → 加载下一页 → 状态反馈。对应代码里的三个机制:

机制 触发时机 代码落点
触底加载更多 滚动到列表底部 onReachEnd 回调
加载状态提示 加载进行中 loading 标志 + 底部文案
无更多数据 已加载完所有页 finished 标志 + 「已全部加载」

触底加载:onReachEnd 与防重复

.onReachEnd(() => {
  if (!this.finished && !this.loading) {
    this.loadMore();   // 双保险:finished + loading 都判断
  }
})

onReachEnd 在滚动到底部时触发一次,但连续快速滚动可能触发多次——所以回调里必须同时判断 finished(还有没有下一页)和 loading(上一页是否在加载中),否则会出现「同一页请求两次、列表重复追加」的经典 bug。

加载中与无更多:底部三态文案

Text(this.finished ? '— 已全部加载 —' : (this.loading ? '⏳ 加载中…' : '上滑加载更多'))
  .fontSize(12).fontColor('#9CA3AF').padding({ top: 8, bottom: 16 })

一行 Text 承载三种状态,用三元表达式切换:

  1. ⏳ 加载中…:请求发出后 loading=true,给用户「正在工作」的反馈;
  2. 上滑加载更多:空闲待触发状态,暗示「还有内容」;
  3. — 已全部加载 —:finished=true 后的终态,明确的「到底了」。

终态文案必须显式:如果加载完直接消失,用户会误以为列表出 bug 了。「已全部加载」是给用户的分页闭环确认。

十一、排序切换条:综合 / 销量 / 价格

电商列表的排序条一般提供三种排序:综合(默认)/ 销量 / 价格。对应到 SQL 就是 ORDER BY 的三个变体:

排序项 ORDER BY 子句 体验语义
综合 ORDER BY id 默认入库顺序
销量 ORDER BY sales DESC 卖得好的排前面
价格 ORDER BY price ASC 便宜的排前面
Row({ space: 8 }) {
  ForEach(this.sortOptions, (s: string) => {
    Text(s).fontSize(13).padding({ left: 12, right: 12, top: 6, bottom: 6 })
      .borderRadius(16)
      .backgroundColor(this.sortBy === s ? '#3B82F6' : '#FFFFFF')
      .fontColor(this.sortBy === s ? Color.White : '#4B5563')
      .onClick(() => this.switchSort(s))
  }, (s: string) => s)
}
async switchSort(s: string): Promise<void> {
  this.sortBy = s;      // '综合' | '销量' | '价格'
  this.page = 0;        // 排序变更 = 全新列表
  this.goods = [];
  this.finished = false;
  await this.loadMore(); // 按新排序从第一页重载
}

排序切换与切分类是同一套逻辑:page 归零、列表清空、finished 复位、重载第一页。数据层只需把 sortBy 映射为 orderByAsc/orderByDesc 即可——UI 的状态重置模式一旦建立,多个入口复用同一段代码

十二、上下拉刷新实现思路

List 组件原生支持下拉刷新(onRefresh)与上拉加载(onReachEnd),本实例的刷新按钮是简化版,完整实现思路如下:

下拉刷新:Refresh 容器包裹 List

Refresh({ refreshing: this.isRefreshing, onRefreshing: () => this.refresh() }) {
  List({ space: 10 }) { /* 双列瀑布流 */ }
}

Refresh 是 ArkUI 官方的下拉刷新容器:下拉超过阈值触发 onRefreshing 回调,refreshing 状态控制转圈动画的显隐。refresh() 与首次加载完全同构——总数重查、页码归零、列表清空、重载第一页,唯一的差别是刷新时不清空旧数据展示(避免列表闪空)。

上拉加载:onReachEnd 就是上拉的等价物

List 的 onReachEnd 本质是「内容滚动到底」——比传统「手指上拉到阈值」的判定更省事,不需要监听滚动位移。这是 ArkUI 对懒加载的友好抽象:开发者只管「触底了,去加载」,不用管手势细节。

刷新与分页的状态机

状态 isRefreshing loading finished 表现
首屏加载 false true false 加载中…
刷新中 true true false 顶部转圈
翻页中 false true false 底部加载中…
全部加载 false false true 已全部加载

三个布尔状态两两组合,构成了分页列表的完整状态机——状态机清晰,UI 就不会出现「转圈不消失」「重复加载」这类问题

十三、ArkTS 细节与 FAQ

细节一:@State 数组替换要整体赋值

// 错误:this.goods.push(...newItems) 可能不触发刷新
// 正确:整体赋值,保证 @State 触发 UI 更新
this.goods = this.goods.concat(newItems);

ArkUI 的 @State 对数组整体替换的响应是可靠的,而 push 等原地修改在某些场景下不触发重渲染——分页追加数据务必用 concat 生成新数组整体赋值。

细节二:ForEach 的 key 生成器用业务主键

ForEach(this.goods, (g: Goods) => { ... }, (g: Goods) => `${g.id}-${g.name}`)

key 用 id + name 组合而非数组下标:分页追加新数据时,旧 item 的 key 不变,ArkUI 只增量渲染新增项,避免整列重建——分页列表性能的关键在 key 的稳定性

FAQ

Q1:onReachEnd 不触发怎么办?
检查 List 是否被外层容器撑满:layoutWeight(1) 让 List 占满剩余高度,内容高度超过视口才会产生滚动,onReachEnd 才有触发条件。列表太短(一屏放得下)时触底事件不会触发,这是正常现象。

Q2:双列下为什么卡片高度不一样?
瀑布流的本质就是「高度不一」:标题 1 行 vs 2 行、有无标签徽标,都会改变卡片高度。List 的 lanes 会把两列独立排版——每列内部顺序排列、两列互不影响,这是瀑布流错落感的来源,不是 bug。

Q3:切排序/切分类后为什么列表要清空?
分页的 page 是「第几页」的游标,排序或分类改变后,原页码对应的数据已不成立。清空列表 + page 归零,是从新条件的第一页重新构建列表,避免「旧数据 + 新条件」混在一起。

Logo

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

更多推荐