HarmonyOS 商品推荐流 WaterFlow 实战项目(可直接跑)

18 阅读5分钟

HarmonyOS 商品推荐流 WaterFlow 实战项目(可直接跑)

鸿蒙第四期开发者活动

1)项目结构建议

 entry/src/main/ets/
   pages/
     ProductFeedPage.ets
     ProductDetailPage.ets
   components/
     ProductCard.ets

你如果已经有工程,只需要把这三个文件加进去,然后把入口 loadContent 指向 pages/ProductFeedPage(或你已有首页里跳转过去)。


2)数据模型:我们用“真实商品流”那套字段

  • id:稳定 key(分页追加/刷新都靠它)
  • title:标题
  • price:价格
  • sales:销量
  • imgHeight故意给不同高度,瀑布流才有“错落”效果
  • tags:角标(券/包邮/新品)

3)组件:ProductCard(卡片长这样,后续你改样式就改它)

components/ProductCard.ets

 // entry/src/main/ets/components/ProductCard.ets
 ​
 export type Product = {
   id: string;
   title: string;
   price: number;
   sales: number;
   imgHeight: number;
   tags: string[];
 };
 ​
 @Component
 export struct ProductCard {
   product: Product;
   onClick?: (p: Product) => void;
 ​
   build() {
     Column({ space: 8 }) {
       // 封面图(这里用 Rect 模拟,后续你换成 Image(url) 就行)
       Stack({ alignContent: Alignment.TopStart }) {
         Rect()
           .width('100%')
           .height(this.product.imgHeight)
           .fill(0xFFEAF2FF)
           .radius(12);
 ​
         // 左上角标签(最多显示2个,别太多)
         Row({ space: 6 }) {
           ForEach(this.product.tags.slice(0, 2), (t: string) => {
             Text(t)
               .fontSize(10)
               .fontColor(0xFFFFFFFF)
               .padding({ left: 8, right: 8, top: 3, bottom: 3 })
               .backgroundColor(0xFFFF3B30)
               .borderRadius(999);
           }, (t: string) => t)
         }
         .padding(10);
       }
 ​
       // 标题
       Text(this.product.title)
         .fontSize(14)
         .fontWeight(FontWeight.Medium)
         .maxLines(2)
         .textOverflow({ overflow: TextOverflow.Ellipsis });
 ​
       // 价格 + 销量
       Row() {
         Text(`¥${this.product.price.toFixed(2)}`)
           .fontSize(15)
           .fontColor(0xFFFF3B30)
           .fontWeight(FontWeight.Bold);
 ​
         Blank();
 ​
         Text(`已售 ${this.product.sales}`)
           .fontSize(11)
           .fontColor(0xFF999999);
       }
     }
     .padding(12)
     .backgroundColor(0xFFFFFFFF)
     .borderRadius(12)
     .onClick(() => this.onClick?.(this.product));
   }
 }

4)详情页:ProductDetailPage(先做“能进能退”,后面你再丰富)

pages/ProductDetailPage.ets

 // entry/src/main/ets/pages/ProductDetailPage.ets
 import { Product } from '../components/ProductCard';
 ​
 @Component
 export struct ProductDetailPage {
   product: Product;
 ​
   build() {
     Column({ space: 12 }) {
       // 顶部大图
       Rect()
         .width('100%')
         .height(260)
         .fill(0xFFEAF2FF);
 ​
       Text(this.product.title)
         .fontSize(18)
         .fontWeight(FontWeight.Bold)
         .padding({ left: 12, right: 12 });
 ​
       Row() {
         Text(`¥${this.product.price.toFixed(2)}`)
           .fontSize(20)
           .fontColor(0xFFFF3B30)
           .fontWeight(FontWeight.Bold);
 ​
         Blank();
 ​
         Text(`销量:${this.product.sales}`)
           .fontSize(12)
           .fontColor(0xFF666666);
       }
       .padding({ left: 12, right: 12 });
 ​
       Text('这里是商品详情介绍(你后面接接口/富文本/参数表都行)')
         .fontSize(13)
         .fontColor(0xFF666666)
         .padding(12);
 ​
       Blank().layoutWeight(1);
 ​
       Button('立即购买')
         .width('92%')
         .height(44)
         .margin({ bottom: 16 })
         .onClick(() => {
           // 你后续可接支付/下单
         });
     }
     .width('100%')
     .height('100%')
     .backgroundColor(0xFFF5F6F8);
   }
 }

5)核心页:商品推荐流 ProductFeedPage(筛选 + 骨架 + 分页 + 进详情)

pages/ProductFeedPage.ets

 // entry/src/main/ets/pages/ProductFeedPage.ets
 import { Product, ProductCard } from '../components/ProductCard';
 import { ProductDetailPage } from './ProductDetailPage';
 ​
 type SortType = 'RECOMMEND' | 'SALES' | 'PRICE';
 ​
 function sleep(ms: number) {
   return new Promise<void>((r) => setTimeout(() => r(), ms));
 }
 ​
 @Entry
 @Component
 struct ProductFeedPage {
   @State private sortType: SortType = 'RECOMMEND';
 ​
   @State private items: Product[] = [];
   @State private pageNo: number = 1;
   @State private pageSize: number = 20;
 ​
   @State private isInitLoading: boolean = true;   // 首屏骨架
   @State private isLoadingMore: boolean = false;  // 触底分页
   @State private hasMore: boolean = true;
 ​
   private navStack: NavPathStack = new NavPathStack();
 ​
   aboutToAppear() {
     this.refresh();
   }
 ​
   build() {
     Navigation(this.navStack) {
       Column() {
         this.topFilterBar()
 ​
         // 内容区:首屏骨架 or 瀑布流
         if (this.isInitLoading) {
           this.skeletonWaterFlow()
         } else {
           this.productWaterFlow()
         }
       }
       .width('100%')
       .height('100%')
       .backgroundColor(0xFFF5F6F8);
     }
     // 这里也可以自定义 titleBar,我为了“像项目里”更干净,自己写顶部筛选栏
   }
 ​
   // -------------------------
   // 顶部筛选栏:推荐 / 销量 / 价格(价格再点可以扩展“升降序”)
   // -------------------------
   private topFilterBar() {
     return Column({ space: 10 }) {
       Row() {
         Text('商品推荐流')
           .fontSize(18)
           .fontWeight(FontWeight.Bold);
         Blank();
         Text(this.isLoadingMore ? '加载中…' : (this.hasMore ? '上滑加载更多' : '没有更多了'))
           .fontSize(12)
           .fontColor(0xFF999999);
       }
 ​
       Row({ space: 10 }) {
         this.filterChip('推荐', 'RECOMMEND')
         this.filterChip('销量', 'SALES')
         this.filterChip('价格', 'PRICE')
 ​
         Blank();
 ​
         Text('刷新')
           .fontSize(12)
           .fontColor(0xFF1677FF)
           .padding({ left: 10, right: 10, top: 6, bottom: 6 })
           .backgroundColor(0xFFEAF2FF)
           .borderRadius(999)
           .onClick(() => this.refresh());
       }
     }
     .padding(12)
     .backgroundColor(0xFFFFFFFF);
   }
 ​
   private filterChip(label: string, type: SortType) {
     const active = this.sortType === type;
     return Text(label)
       .fontSize(12)
       .fontColor(active ? 0xFFFFFFFF : 0xFF333333)
       .padding({ left: 12, right: 12, top: 6, bottom: 6 })
       .backgroundColor(active ? 0xFF1677FF : 0xFFF2F3F5)
       .borderRadius(999)
       .onClick(() => {
         if (this.sortType === type) return;
         this.sortType = type;
         this.refresh();
       });
   }
 ​
   // -------------------------
   // WaterFlow:商品卡片流
   // -------------------------
   private productWaterFlow() {
     return WaterFlow() {
       ForEach(this.items, (p: Product) => {
         FlowItem() {
           ProductCard({
             product: p,
             onClick: (pp: Product) => this.openDetail(pp)
           })
         }
       }, (p: Product) => p.id)
 ​
       // 尾部提示(做出来会更像真实 App)
       FlowItem() {
         Row() {
           Text(this.isLoadingMore ? '正在加载更多…' : (this.hasMore ? '继续上滑加载更多' : '到底了~'))
             .fontSize(12)
             .fontColor(0xFF999999);
           Blank();
         }
         .padding({ top: 10, bottom: 16, left: 6, right: 6 });
       }
     }
     .columnsTemplate('1fr 1fr')
     .columnsGap(10)
     .rowsGap(10)
     .padding(12)
     .backgroundColor(0xFFF5F6F8)
     .width('100%')
     .height('100%')
     .onReachEnd(() => this.loadMore());
   }
 ​
   // -------------------------
   // 首屏骨架:别空白,用户会以为卡了
   // -------------------------
   private skeletonWaterFlow() {
     // 固定给 8 个骨架卡片
     const skeleton = Array.from({ length: 8 }, (_, i) => i);
 ​
     return WaterFlow() {
       ForEach(skeleton, (i: number) => {
         FlowItem() {
           Column({ space: 8 }) {
             Rect()
               .width('100%')
               .height(120 + (i % 3) * 26)
               .fill(0xFFE9EDF3)
               .radius(12);
 ​
             Rect().width('100%').height(14).fill(0xFFE9EDF3).radius(6);
             Rect().width('68%').height(14).fill(0xFFE9EDF3).radius(6);
 ​
             Row() {
               Rect().width(64).height(16).fill(0xFFE9EDF3).radius(6);
               Blank();
               Rect().width(46).height(12).fill(0xFFE9EDF3).radius(6);
             }
           }
           .padding(12)
           .backgroundColor(0xFFFFFFFF)
           .borderRadius(12);
         }
       }, (i: number) => `${i}`)
     }
     .columnsTemplate('1fr 1fr')
     .columnsGap(10)
     .rowsGap(10)
     .padding(12)
     .backgroundColor(0xFFF5F6F8)
     .width('100%')
     .height('100%');
   }
 ​
   // -------------------------
   // 交互:进详情页(Navigation push)
   // -------------------------
   private openDetail(p: Product) {
     // 这里用 pushPath,把 product 作为参数传过去
     this.navStack.pushPath({
       name: 'ProductDetailPage',
       param: p
     });
   }
 ​
   // 让 Navigation 知道路由表
   // 不同工程模板写法略有差异,这里用最直观的“按 name 分发”
   @Builder
   PageMap(name: string, param?: Object) {
     if (name === 'ProductDetailPage') {
       ProductDetailPage({ product: param as Product })
     }
   }
 ​
   // -------------------------
   // 数据:刷新(切换排序也走这里)
   // -------------------------
   private async refresh() {
     this.isInitLoading = true;
     this.isLoadingMore = false;
     this.hasMore = true;
 ​
     this.pageNo = 1;
     this.items = [];
 ​
     // 模拟请求
     const data = await this.mockFetch(this.pageNo, this.pageSize, this.sortType);
     this.items = data;
     this.isInitLoading = false;
 ​
     // 简单判断:返回少于 pageSize 就认为没更多
     this.hasMore = data.length >= this.pageSize;
   }
 ​
   // -------------------------
   // 数据:分页加载
   // -------------------------
   private async loadMore() {
     if (this.isInitLoading) return;
     if (this.isLoadingMore) return;
     if (!this.hasMore) return;
 ​
     this.isLoadingMore = true;
     this.pageNo += 1;
 ​
     const more = await this.mockFetch(this.pageNo, this.pageSize, this.sortType);
     this.items = [...this.items, ...more];
     this.isLoadingMore = false;
 ​
     if (more.length < this.pageSize) {
       this.hasMore = false;
     }
   }
 ​
   // -------------------------
   // Mock:模拟接口(你后面直接换成真实 HTTP 请求即可)
   // 重点:给不同高度,让 WaterFlow 真“瀑布”
   // -------------------------
   private async mockFetch(pageNo: number, pageSize: number, sort: SortType): Promise<Product[]> {
     await sleep(600);
 ​
     const start = (pageNo - 1) * pageSize + 1;
     let arr: Product[] = Array.from({ length: pageSize }, (_, i) => {
       const idx = start + i;
       return {
         id: `p_${sort}_${idx}`,
         title: `${sort === 'RECOMMEND' ? '精选推荐' : (sort === 'SALES' ? '热销爆款' : '价格优选')} · 商品标题 ${idx}(标题可能会很长,用两行省略)`,
         price: 19.9 + (idx % 17) * 3.2,
         sales: 100 + (idx % 30) * 57,
         imgHeight: 110 + (idx % 7) * 18,
         tags: (idx % 5 === 0) ? ['新品', '包邮'] : (idx % 4 === 0 ? ['满减'] : ['券'])
       };
     });
 ​
     // 排序只是做个感觉(真实项目你让后端排)
     if (sort === 'SALES') {
       arr = arr.sort((a, b) => b.sales - a.sales);
     } else if (sort === 'PRICE') {
       arr = arr.sort((a, b) => a.price - b.price);
     }
 ​
     // 模拟“最后一页不足 pageSize”
     if (pageNo >= 4) {
       arr = arr.slice(0, 10);
     }
     return arr;
   }
 }

如果你发现你当前 SDK/模板里 Navigation + NavPathStack 的页面映射方式略有不同(不同版本工程模板确实会有点差异),你告诉我你工程的 Navigation 写法/模板片段,我会把“路由表”这一段替你对齐成你项目能直接跑的版本。


6)你跑起来后,重点看这 5 个“项目关键点”

  1. 首屏骨架:避免白屏(用户第一感觉会直接变好)
  2. WaterFlow 卡片高度不一致:才有瀑布流的意义
  3. 稳定 key(id) :分页追加时不抖、不乱序
  4. 排序切换 = refresh:状态清理顺序别写错
  5. 触底加载的三重保护isInitLoading / isLoadingMore / hasMore