双列瀑布流之美: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」的实践。
切换分类时重置分页状态重新加载:
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承载三种状态,用三元表达式切换:
- ⏳ 加载中…:请求发出后 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)}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 对懒加载的友好抽象:开发者只管「触底了,去加载」,不用管手势细节。
刷新与分页的状态机
| 状态 | 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 归零,是从新条件的第一页重新构建列表,避免「旧数据 + 新条件」混在一起。
