HarmonyOS 6.0 卡片式信息流与瀑布流布局
刷微博、刷小红书、刷淘宝——这些App的信息流都不是整齐的列表而是高度不等的卡片交错排列。这就是瀑布流Waterfall Layout。HarmonyOS的WaterFlow组件就是为这个场景设计的但它跟List的用法差别不小——数据源要IDataSourceAdapterlayoutMode要配置卡片高度要预计算。这篇把瀑布流从布局到数据到交互的完整方案讲清楚。WaterFlow基础最简单的瀑布流WaterFlow(){ForEach(this.items,(item:CardItem){FlowItem(){Column(){Image(item.coverUrl).width(100%).height(item.coverHeight).objectFit(ImageFit.Cover)Text(item.title).fontSize(14).fontColor(#333333).maxLines(2).padding(8)}.backgroundColor(Color.White).borderRadius(8)}},(item:CardItem)item.id)}.columnsTemplate(1fr 1fr).columnsGap(8).rowsGap(8).padding({left:8,right:8})columnsTemplate1fr 1fr’表示两列等宽。FlowItem是瀑布流的子项跟ListItem类似。关键区别WaterFlow跟List不同FlowItem的高度可以不同。List中所有ListItem高度一致或者动态计算WaterFlow中每个FlowItem高度可以不一样系统自动把新项放到最短的那列。列数配置columnsTemplate控制列数和宽度比例Template效果‘1fr 1fr’两列等宽‘1fr 1fr 1fr’三列等宽‘1fr 2fr’两列右侧宽一倍‘1fr 1fr 1fr 1fr’四列等宽响应式列数——根据屏幕宽度动态切换StatecolumnsTemplate:string1fr 1fraboutToAppear():void{letscreenWidthdisplay.getDefaultDisplaySync().width;letdensitydisplay.getDefaultDisplaySync().densityPixels;letwidthVpscreenWidth/density;if(widthVp600){this.columnsTemplate1fr 1fr 1fr;}else{this.columnsTemplate1fr 1fr;}}屏幕宽度超过600vp平板/折叠屏用三列手机用两列。卡片高度问题瀑布流的核心难题是卡片高度不一致。有两种策略固定高度模式所有卡片等高只是内容区高度不同动态高度模式每个卡片高度由内容决定真正的不规则排列固定高度最简单FlowItem(){Column(){Image(item.coverUrl).width(100%).height(120)Text(item.title).fontSize(14).padding(8)}}.height(180)所有FlowItem高180vp图片固定120vp。简单但浪费空间——短标题的卡片有大段空白长标题被截断。动态高度需要预计算interfaceCardItem{id:string;coverUrl:string;coverHeight:number;title:string;estimatedHeight:number;}// 在数据加载时预计算高度privatecalculateEstimatedHeight(item:CardItem):number{letimageHeightitem.coverHeight*0.5;lettitleLinesMath.ceil(item.title.length/14);lettitleHeighttitleLines*2016;returnimageHeighttitleHeight16;}coverHeight是图片原始高度服务端返回0.5是缩放比例。标题高度按每行14字、行高20vp估算。注意预计算高度不要求精确。WaterFlow会根据实际布局调整位置。预计算只是给系统一个参考值避免高度0→实际高度的闪烁。LazyForEach数据源大数据量用LazyForEachclassCardDataSourceimplementsIDataSource{privatedata:CardItem[][];privatelisteners:DataChangeListener[][];totalCount():number{returnthis.data.length;}getData(index:number):CardItem{returnthis.data[index];}registerDataChangeListener(listener:DataChangeListener):void{if(this.listeners.indexOf(listener)0){this.listeners.push(listener);}}unregisterDataChangeListener(listener:DataChangeListener):void{letposthis.listeners.indexOf(listener);if(pos0){this.listeners.splice(pos,1);}}pushData(item:CardItem):void{this.data.push(item);for(leti0;ithis.listeners.length;i){this.listeners[i].onDataAdd(this.data.length-1);}}addData(index:number,item:CardItem):void{this.data.splice(index,0,item);for(leti0;ithis.listeners.length;i){this.listeners[i].onDataAdd(index);}}}IDataSource的实现跟List的LazyForEach一样。pushData在末尾追加上拉加载addData在指定位置插入。使用privatedataSource:CardDataSourcenewCardDataSource();WaterFlow(){LazyForEach(this.dataSource,(item:CardItem){FlowItem(){// 卡片内容}},(item:CardItem)item.id)}上拉加载更多瀑布流的上拉加载StateisLoadingMore:booleanfalseStatehasMore:booleantrueWaterFlow(){LazyForEach(this.dataSource,(item:CardItem){FlowItem(){// 卡片}},(item:CardItem)item.id)}.onReachEnd((){if(!this.isLoadingMorethis.hasMore){this.loadMore();}})privateasyncloadMore():Promisevoid{this.isLoadingMoretrue;letnewItemsawaitthis.fetchCards(this.nextPage);if(newItems.length0){this.hasMorefalse;}else{for(leti0;inewItems.length;i){this.dataSource.pushData(newItems[i]);}this.nextPage;}this.isLoadingMorefalse;}onReachEnd跟List一样。pushData逐项追加每追加一项触发一次UI更新。批量追加的优化// 优化批量追加pushDataList(items:CardItem[]):void{letstartIndexthis.data.length;for(leti0;iitems.length;i){this.data.push(items[i]);}for(leti0;ithis.listeners.length;i){this.listeners[i].onDataReloaded();}}onDataReloaded通知全量刷新比逐项onDataAdd效率高。但会重建所有可见项有短暂闪烁。数据量小时用onDataAdd大数据量时用onDataReloaded。下拉刷新WaterFlow跟Refresh组件配合StateisRefreshing:booleanfalseRefresh({refreshing:$$this.isRefreshing}){WaterFlow(){LazyForEach(this.dataSource,(item:CardItem){FlowItem(){// 卡片}},(item:CardItem)item.id)}.columnsTemplate(1fr 1fr).columnsGap(8).rowsGap(8)}.onRefreshing((){this.refreshData();})privateasyncrefreshData():Promisevoid{this.nextPage1;this.hasMoretrue;letitemsawaitthis.fetchCards(1);this.dataSourcenewCardDataSource();for(leti0;iitems.length;i){this.dataSource.pushData(items[i]);}this.isRefreshingfalse;}刷新时重建整个dataSource从第一页加载。重建dataSource是必要的——旧的dataSource里的数据要清空但IDataSource没有clear方法不如直接new一个。卡片点击与详情卡片点击跳详情详情返回后刷新该卡片FlowItem(){Column(){Image(item.coverUrl)Text(item.title)}.onClick((){this.navPathStack.pushPath({name:CardDetail,param:{id:item.id}});})}// 详情页返回后刷新this.navPathStack.on(pop,(info:PopInfo){if(info.param!undefined){letresultinfo.paramasRecordstring,Object;letupdatedIdresult[updatedId]asstring;this.refreshCard(updatedId);}})privaterefreshCard(id:string):void{letindex-1;for(leti0;ithis.allItems.length;i){if(this.allItems[i].idid){indexi;break;}}if(index0){this.allItems[index]this.allItems[index];// 触发引用变化this.dataSourcenewCardDataSource();for(leti0;ithis.allItems.length;i){this.dataSource.pushData(this.allItems[i]);}}}单个卡片刷新代价太大重建整个dataSource。更好的方式是在DataSource中添加updateData方法updateData(index:number,item:CardItem):void{this.data[index]item;for(leti0;ithis.listeners.length;i){this.listeners[i].onDataChange(index);}}onDataChange只更新指定位置的项不影响其他项。标签筛选顶部标签切换过滤瀑布流内容StateselectedTag:string全部Row(){ForEach(this.tags,(tag:string){Text(tag).fontSize(14).fontColor(this.selectedTagtag?#1a73e8:#666666).backgroundColor(this.selectedTagtag?#e3f2fd:#f5f5f5).borderRadius(16).padding({left:12,right:12,top:6,bottom:6}).margin({right:8}).onClick((){this.selectedTagtag;this.filterByTag(tag);})},(tag:string)tag)}.padding({left:12,right:12,bottom:8})privatefilterByTag(tag:string):void{if(tag全部){this.currentItemsthis.allItems;}else{this.currentItemsthis.allItems.filter(itemitem.tagtag);}this.dataSourcenewCardDataSource();for(leti0;ithis.currentItems.length;i){this.dataSource.pushData(this.currentItems[i]);}}切换标签时重建dataSource。filter是全量过滤小数据集没问题大数据集建议后端分标签分页。多类型卡片信息流经常混合不同类型的卡片——图文卡、视频卡、广告卡FlowItem(){if(item.typeimage){this.imageCard(item)}elseif(item.typevideo){this.videoCard(item)}elseif(item.typead){this.adCard(item)}}BuilderimageCard(item:CardItem){Column(){Image(item.coverUrl).width(100%).height(item.coverHeight)Text(item.title).padding(8)}.backgroundColor(Color.White).borderRadius(8)}BuildervideoCard(item:CardItem){Column(){Stack(){Image(item.coverUrl).width(100%).height(160)Image($r(sys.media.ohos_ic_public_play)).width(40).height(40)}Text(item.title).padding(8)}.backgroundColor(Color.White).borderRadius(8)}不同类型卡片的高度和布局完全不同。type字段决定用哪个Builder。视频卡固定高度160vp播放按钮覆盖层。踩坑清单问题原因解决卡片高度不一致但排成等高columnsTemplate没配WaterFlow会自动不等高检查FlowItem图片加载后卡片跳变没预计算高度数据中带estimatedHeightLazyForEach不更新IDataSource没通知pushData后调onDataAdd下拉刷新后数据重叠没清空旧dataSource刷新时new新dataSource单项更新太重重建整个dataSource用onDataChange只更新一项卡片间距不均匀rowsGap/columnsGap太小8~12vp间距合适多列卡片宽高比变形图片objectFit没设用ImageFit.Cover上拉加载重复触发没用isLoadingMore防重加布尔锁标签切换卡顿全量过滤重建小数据量OK大用后端过滤滚动位置记忆失败刷新重建了dataSource保存firstVisibleId瀑布流的核心是不等高卡片自动排列——WaterFlow把新项放到最短列不需要你手动计算哪列该放什么。你的工作是提供正确的数据含预估高度和响应式的列数配置。其余的交给组件。