文章目录
- 前言
- 首页整体布局
- Swiper 轮播图
- 金刚区:Grid 宫格布局
- 商品瀑布流:WaterFlow + LazyForEach
- 下拉刷新 + 上拉加载更多
- 实际开发中的几个坑
前言
上一篇把工程骨架搭好了,这篇直接进首页。首页是用户打开 App 看到的第一个页面,信息密度很高:搜索栏、轮播图、分类入口、商品流,全得塞进一个屏幕里,还得滑动流畅。
我拆成了四个区块来做,从上到下:搜索栏 → 轮播 → 金刚区 → 商品瀑布流。外面套一个 Scroll,整体可滚动。
首页整体布局
先把大框架搭起来。首页用 Scroll 包裹一个 Column,Column 里按顺序放各个区块:
// entry/src/main/ets/pages/HomePage.etsimport{SwiperBanner}from'../components/SwiperBanner'import{CategoryGrid}from'../components/CategoryGrid'import{ProductWaterFlow}from'../components/ProductWaterFlow'import{SearchBar}from'../components/SearchBar'@Componentexportstruct HomePage{@StateisRefreshing:boolean=false@StateisLoadingMore:boolean=false@StateproductList:ProductItem[]=[]@StatecurrentPage:number=1@StatehasMore:boolean=truescroller:Scroller=newScroller()build(){Column(){// 顶部搜索栏(固定在顶部,不跟着滚)SearchBar().width('100%')// 可滚动区域Scroll(this.scroller){Column(){// 轮播图SwiperBanner().width('100%').height(180).margin({top:8})// 金刚区分类导航CategoryGrid().width('100%').margin({top:12})// 商品瀑布流ProductWaterFlow({products:this.productList,onLoadMore:()=>this.loadMore()}).width('100%').margin({top:12})}.width('100%')}.layoutWeight(1).scrollBar(BarState.Off).edgeEffect(EdgeEffect.Spring).onScrollEdge((side:Edge)=>{if(side===Edge.Top){this.onRefresh()}if(side===Edge.Bottom){this.loadMore()}})}.width('100%').height('100%').backgroundColor('#F5F5F5').onAppear(()=>{this.loadProducts()})}privateasyncloadProducts(){// 首页商品数据加载this.currentPage=1// TODO: 调用 ProductRepository.getHomeProductsthis.productList=generateMockProducts(20)}privateasyncloadMore(){if(this.isLoadingMore||!this.hasMore)returnthis.isLoadingMore=truethis.currentPage++constmore=generateMockProducts(20)if(more.length===0){this.hasMore=false}else{this.productList=[...this.productList,...more]}this.isLoadingMore=false}privateasynconRefresh(){if(this.isRefreshing)returnthis.isRefreshing=trueawaitthis.loadProducts()this.hasMore=truethis.isRefreshing=false}}interfaceProductItem{id:stringname:stringprice:numberoriginalPrice:numberimageUrl:stringsales:number}搜索栏是固定在顶部的,不跟 Scroll 一起滚动。这个决策很重要——用户滑到中间想搜索的时候,不需要滚回顶部。
Swiper 轮播图
轮播图用 Swiper 组件,ArkUI 内置的,开箱即用。我加了一些定制:自动播放、圆点指示器、圆角卡片样式。
// entry/src/main/ets/components/SwiperBanner.ets@Componentexportstruct SwiperBanner{@StatebannerList:BannerItem[]=[{id:'1',imageUrl:$rawfile('banner_1.png'),link:''},{id:'2',imageUrl:$rawfile('banner_2.png'),link:''},{id:'3',imageUrl:$rawfile('banner_3.png'),link:''},{id:'4',imageUrl:$rawfile('banner_4.png'),link:''},]@StatecurrentIndex:number=0build(){Stack({alignContent:Alignment.Bottom}){Swiper(){ForEach(this.bannerList,(item:BannerItem)=>{Image(item.imageUrl).width('100%').height(180).objectFit(ImageFit.Cover).borderRadius(12).onClick(()=>{// 点击跳转对应活动页})},(item:BannerItem)=>item.id)}.indicator(false).loop(true).autoPlay(true).interval(3000).duration(500).onChange((index:number)=>{this.currentIndex=index}).width('100%').height(180)// 自定义指示器Row({space:6}){ForEach(this.bannerList,(item:BannerItem,index:number)=>{Circle().width(this.currentIndex===index?16:6).height(6).fill(this.currentIndex===index?'#FF6B35':'#FFFFFF80').animation({duration:200})},(item:BannerItem,index:number)=>item.id)}.margin({bottom:12})}.padding({left:12,right:12})}}interfaceBannerItem{id:stringimageUrl:Resource link:string}我关掉了默认 indicator,自己画了一个。当前页的指示器拉长成椭圆形,其余是小圆点,视觉上比默认的好看不少。animation加了 200ms 过渡,切换时指示器有伸缩动画,细节感就出来了。
金刚区:Grid 宫格布局
金刚区就是首页中间那一排分类入口图标,电商 App 的标配。我用 Grid 做了一个两行五列的布局:
// entry/src/main/ets/components/CategoryGrid.ets@Componentexportstruct CategoryGrid{privatecategories:GridItem[]=[{id:'1',name:'新鲜水果',icon:$rawfile('ic_fruit.png')},{id:'2',name:'时令蔬菜',icon:$rawfile('ic_vegetable.png')},{id:'3',name:'肉禽蛋',icon:$rawfile('ic_meat.png')},{id:'4',name:'海鲜水产',icon:$rawfile('ic_seafood.png')},{id:'5',name:'乳品烘焙',icon:$rawfile('ic_dairy.png')},{id:'6',name:'休闲零食',icon:$rawfile('ic_snack.png')},{id:'7',name:'酒水饮料',icon:$rawfile('ic_drink.png')},{id:'8',name:'粮油调味',icon:$rawfile('ic_grain.png')},{id:'9',name:'速食冻品',icon:$rawfile('ic_frozen.png')},{id:'10',name:'全部分类',icon:$rawfile('ic_all.png')},]build(){Grid(){ForEach(this.categories,(item:GridItem)=>{GridItem(){Column({space:6}){Image(item.icon).width(44).height(44).objectFit(ImageFit.Contain)Text(item.name).fontSize(12).fontColor('#333333').maxLines(1)}.width('100%').height('100%').justifyContent(FlexAlign.Center).onClick(()=>{// 跳转到对应分类页})}},(item:GridItem)=>item.id)}.columnsTemplate('1fr 1fr 1fr 1fr 1fr').rowsTemplate('1fr 1fr').rowsGap(12).columnsGap(0).height(170).padding({left:12,right:12}).backgroundColor(Color.White).borderRadius(12).margin({left:12,right:12})}}interfaceGridItem{id:stringname:stringicon:Resource}10 个图标分两行排列,用columnsTemplate控制 5 列等宽。高度固定 170vp,给图标和文字留够空间。
这里有个小细节:背景是白色圆角卡片,外面是灰色页面底色,形成视觉层次感。金刚区和轮播图都用了 12vp 圆角,保持统一。
商品瀑布流:WaterFlow + LazyForEach
这是首页最复杂的部分。商品列表用瀑布流展示——两列不等高的卡片,高度由商品图片和信息决定。ArkUI 的WaterFlow组件天然支持这个布局。
数据量大的时候不能全量渲染,得用LazyForEach做懒加载。这里需要一个IDataSource实现:
// entry/src/main/ets/model/ProductDataSource.etsimport{LazyForEach}from'@kit.ArkUI'exportclassProductDataSourceimplementsIDataSource{privateproducts:ProductItem[]=[]privatelisteners:DataChangeListener[]=[]publictotalCount():number{returnthis.products.length}publicgetData(index:number):ProductItem{returnthis.products[index]}publicgetDataIndex(item:ProductItem):number{returnthis.products.findIndex(p=>p.id===item.id)}publicregisterDataChangeListener(listener:DataChangeListener):void{this.listeners.push(listener)}publicunregisterDataChangeListener(listener:DataChangeListener):void{constindex=this.listeners.indexOf(listener)if(index>=0){this.listeners.splice(index,1)}}publicappendData(items:ProductItem[]):void{conststart=this.products.lengththis.products.push(...items)this.listeners.forEach(listener=>{listener.onDataAdd(this.products.length-items.length,this.products.length-1)})}publicreloadData(items:ProductItem[]):void{this.products=itemsthis.listeners.forEach(listener=>{listener.onDataReload()})}}有了 DataSource,WaterFlow 商品卡片写起来就清爽了:
// entry/src/main/ets/components/ProductWaterFlow.etsimport{ProductDataSource}from'../model/ProductDataSource'@Componentexportstruct ProductWaterFlow{@StatedataSource:ProductDataSource=newProductDataSource()@Propproducts:ProductItem[]=[]onLoadMore?:()=>voidaboutToAppear(){this.dataSource.reloadData(this.products)}build(){WaterFlow({scroller:newScroller()}){LazyForEach(this.dataSource,(item:ProductItem)=>{FlowItem(){this.ProductCard(item)}},(item:ProductItem)=>item.id)}.columnsTemplate('1fr 1fr').columnsGap(8).rowsGap(8).padding({left:12,right:12}).height(600)// 给一个足够大的高度让 Scroll 接管滚动.onReachEnd(()=>{this.onLoadMore?.()})}@BuilderProductCard(item:ProductItem){Column(){// 商品图片(高度随机,制造瀑布流效果)Image(item.imageUrl).width('100%').height(item.id.charCodeAt(0)%2===0?180:150).objectFit(ImageFit.Cover).borderRadius({topLeft:8,topRight:8})Column({space:4}){Text(item.name).fontSize(14).fontColor('#333333').maxLines(2).textOverflow({overflow:TextOverflow.Ellipsis}).width('100%')Row(){Text(`¥${item.price}`).fontSize(18).fontWeight(FontWeight.Bold).fontColor('#FF4D4F')if(item.originalPrice>item.price){Text(`¥${item.originalPrice}`).fontSize(12).fontColor('#999999').decoration({type:TextDecorationType.LineThrough}).margin({left:6})}Blank()Text(`已售${item.sales}`).fontSize(11).fontColor('#BBBBBB')}.width('100%')}.padding(10)}.backgroundColor(Color.White).borderRadius(8)}}WaterFlow 的columnsTemplate('1fr 1fr')指定两列等宽,columnsGap和rowsGap控制间距。每张卡片的高度不同(图片高度随机),自然就形成了瀑布流的错落效果。
下拉刷新 + 上拉加载更多
刷新和加载更多我在首页的 Scroll 上统一处理的。onScrollEdge监听滚动到边缘的事件,Edge.Top触发下拉刷新,Edge.Bottom触发加载更多。
有个体验上的小问题:加载更多的时候如果数据还没回来,用户继续滑会重复触发。所以我用了isLoadingMore和hasMore两个标志位做防抖,在loadMore方法里一开始就判断:
privateasyncloadMore(){if(this.isLoadingMore||!this.hasMore)return// ...}hasMore在返回数据为空时设为 false,之后就再也不会触发加载了。刷新时把它重置回 true,又能继续翻页。
实际项目中我还加了一个底部加载提示,在 ProductWaterFlow 下面放一个 Row 显示「加载中…」或「没有更多了」,这里为了篇幅就不展开了。
实际开发中的几个坑
WaterFlow 放在 Scroll 里高度问题:WaterFlow 本身不会自动撑开,需要给一个足够大的固定高度,或者用constraintSize限制。我一开始没给高度,整个瀑布流就塌了。
LazyForEach 的 key 必须唯一:用item.id做 key,千万别用 index。用 index 的话,追加数据后前面的 key 不变但位置变了,LazyForEach 会重新渲染所有卡片,滑动位置也会跳。
图片加载闪烁:网络图片首次加载会有短暂空白,可以加一个灰色占位背景在 Image 组件上,用.backgroundColor('#F0F0F0')就行。
首页这个页面信息密度大,组件也多,建议先把布局跑通,用 mock 数据把样式调好,最后再接真实接口。下一篇我们搞搜索模块,输入联想、搜索历史、搜索结果页,一个都不能少。