欢迎光临
我们一直在努力

双列瀑布流之美:ArkUI 触底加载动画让鸿蒙商品页刷到手软

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

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

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

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

  • 信息密度高:双列比单列多展示近一倍的商品,一屏能看 6~8 件,浏览效率高;
  • 卡片高度自由:瀑布流的卡片高度不必对齐(标题行数、标签有无导致高度不一),错落有致不死板;
  • 电商惯例:淘宝、京东、拼多多的商品流都是双列,「双列 = 购物」的心智已养成。
  • 实例 8 的页面风格定为「双列瀑布流」,配合触底加载动画(滚动到底自动加载下一页 + 「加载中…」状态)和总数卡(共 N 件商品),构成完整的电商列表体验。

    页面信息架构:

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

    卡片设计细节:

  • emoji 占位图:48fp 大号 emoji 居中,浅灰背景圆角——无需图片资源即可展示「商品图」位置;
  • 价格双色:现价红色加粗 + 原价灰色删除线(.decoration({ type: TextDecorationType.LineThrough }))——折扣感的视觉呈现;
  • 标签徽标:if (g.tag) 有值才显示红色小徽标(热卖/新品/清仓),无标签的商品不占位;
  • 销量信息:已售 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 承载三种状态,用三元表达式切换:

  • ⏳ 加载中…:请求发出后 loading=true,给用户「正在工作」的反馈;
  • 上滑加载更多:空闲待触发状态,暗示「还有内容」;
  • — 已全部加载 —: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 对懒加载的友好抽象:开发者只管「触底了,去加载」,不用管手势细节。

    刷新与分页的状态机

    状态isRefreshingloadingfinished表现
    首屏加载 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 归零,是从新条件的第一页重新构建列表,避免「旧数据 + 新条件」混在一起。

    赞(0)
    未经允许不得转载:171主机测评 » 双列瀑布流之美:ArkUI 触底加载动画让鸿蒙商品页刷到手软
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址