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 个“项目关键点”
- 首屏骨架:避免白屏(用户第一感觉会直接变好)
- WaterFlow 卡片高度不一致:才有瀑布流的意义
- 稳定 key(id) :分页追加时不抖、不乱序
- 排序切换 = refresh:状态清理顺序别写错
- 触底加载的三重保护:
isInitLoading / isLoadingMore / hasMore