ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

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

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


实例:商品分页列表(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」的实践。

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

asyncswitchCategory(cat:string):Promise<void>{this.category=cat;this.page=0;this.goods=[];this.finished=false;awaitthis.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 + 浅灰底占位商品图
现价#DC262616fp 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{returnNumber.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)}
asyncswitchSort(s:string):Promise<void>{this.sortBy=s;// '综合' | '销量' | '价格'this.page=0;// 排序变更 = 全新列表this.goods=[];this.finished=false;awaitthis.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表现
首屏加载falsetruefalse加载中…
刷新中truetruefalse顶部转圈
翻页中falsetruefalse底部加载中…
全部加载falsefalsetrue已全部加载

三个布尔状态两两组合,构成了分页列表的完整状态机——状态机清晰,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 归零,是从新条件的第一页重新构建列表,避免「旧数据 + 新条件」混在一起。

返回列表