实例商品分页列表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.categorycat?#3B82F6:#FFFFFF).fontColor(this.categorycat?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):Promisevoid{this.categorycat;this.page0;this.goods[];this.finishedfalse;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承载三种状态用三元表达式切换⏳ 加载中…请求发出后 loadingtrue给用户「正在工作」的反馈上滑加载更多空闲待触发状态暗示「还有内容」— 已全部加载 —finishedtrue 后的终态明确的「到底了」。终态文案必须显式如果加载完直接消失用户会误以为列表出 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.sortBys?#3B82F6:#FFFFFF).fontColor(this.sortBys?Color.White:#4B5563).onClick(()this.switchSort(s))},(s:string)s)}asyncswitchSort(s:string):Promisevoid{this.sortBys;// 综合 | 销量 | 价格this.page0;// 排序变更 全新列表this.goods[];this.finishedfalse;awaitthis.loadMore();// 按新排序从第一页重载}排序切换与切分类是同一套逻辑page 归零、列表清空、finished 复位、重载第一页。数据层只需把sortBy映射为orderByAsc/orderByDesc即可——UI 的状态重置模式一旦建立多个入口复用同一段代码。十二、上下拉刷新实现思路List 组件原生支持下拉刷新onRefresh与上拉加载onReachEnd本实例的刷新按钮是简化版完整实现思路如下下拉刷新Refresh 容器包裹 ListRefresh({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.goodsthis.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 的稳定性。FAQQ1onReachEnd 不触发怎么办检查 List 是否被外层容器撑满layoutWeight(1)让 List 占满剩余高度内容高度超过视口才会产生滚动onReachEnd 才有触发条件。列表太短一屏放得下时触底事件不会触发这是正常现象。Q2双列下为什么卡片高度不一样瀑布流的本质就是「高度不一」标题 1 行 vs 2 行、有无标签徽标都会改变卡片高度。List 的 lanes 会把两列独立排版——每列内部顺序排列、两列互不影响这是瀑布流错落感的来源不是 bug。Q3切排序/切分类后为什么列表要清空分页的 page 是「第几页」的游标排序或分类改变后原页码对应的数据已不成立。清空列表 page 归零是从新条件的第一页重新构建列表避免「旧数据 新条件」混在一起。