# dzdp **Repository Path**: xiongwg/dzdp ## Basic Information - **Project Name**: dzdp - **Description**: 鸿蒙ArkUI ets仿大众点评首页吸顶tab,横向滑动tab标签效果 - **Primary Language**: Unknown - **License**: MulanPSL-2.0 - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 7 - **Forks**: 0 - **Created**: 2021-11-25 - **Last Updated**: 2024-09-04 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README ## 仿大众点评首页吸顶tab标签、横向滑动tab标签 ### 前言 ArkUI出来一段时间了,官方给我们提供了很多好用的组件,但部分组件还是不够尽善尽美。比如我想实现下面类似大众点评首页效果,就发现目前提供的[Swiper](https://developer.harmonyos.com/cn/docs/documentation/doc-references/ts-container-swiper-0000001111421434)滑动翻页组件因缺少页面滑动过程的监听,不能达到边滑动,边改变组件高度的需求;系统也提供了[Tabs](https://developer.harmonyos.com/cn/docs/documentation/doc-references/ts-container-tabs-0000001166728941)通过页签进行内容视图切换的容器组件,但用过的都知道,它对自定义样式支持的不太好。所以,本demo就通过自定义翻页组件、横向滑动tab组件来达到类似大众点评首页效果。 ### 效果图 ![](./img/fdzdp.gif) ### 代码结构解读 ![](./img/codelist.png) - component header.ets : 头部九宫格菜单及下标区域 horizontalTabs.ets:水平滚动tab标签组件 searchBar.ets:头部搜索栏 waterfalllayout.ets:瀑布流内容展示组件 - model imageListDataModel.ets: 瀑布流数据工具类 menuListDataModel.ets:头部九宫格数据获取工具类 - pages index.ets:首页容器 preview.ets: 图片展示页 ### 实现过程 界面上主要分为4块:顶部固定的搜索栏、九宫格菜单展示、滚动吸顶tab标签、瀑布流内容展示。 #### 搜索栏 搜索栏比较简单,最外层用的Row包裹,中间的垂直轮播用的Swiper组件。直接贴代码吧! ```typescript @Component export struct SearchBar { @Prop searchBarHeight: number private hotSearchKeywords: string[] = ['烤肉', '火锅', '海底捞', '东北菜'] build() { Row() { Text('西安').fontSize(16).padding({ left: 15 }).height('100%') Image($r('app.media.arrow_down')).width(20).height('100%').padding(3).objectFit(ImageFit.ScaleDown) // 搜索框 Swiper() { ForEach(this.hotSearchKeywords, item => { Flex({ direction: FlexDirection.Row, justifyContent: FlexAlign.Center, alignItems: ItemAlign.Center, }) { // 搜索图标 Image($r('app.media.search')) .width(15).height(15) Text(item) .height('100%') .fontSize(12) .fontColor('#505050') .margin({ left: 10 }) }.width('100%') }, item => item) } .layoutWeight(1) .height('60%') .margin({ left: 15, right: 15 }) .backgroundColor('#F1F1F1') .borderRadius(15) .vertical(true) // 方向:纵向 .autoPlay(true) // 自动播放 .indicator(false) // 隐藏指示器 .interval(3000) // 切换间隔时间3秒 Image($r('app.media.dot')).height('100%').width(30).margin({right:40}).padding(3).objectFit(ImageFit.ScaleDown) }.width('100%').height(this.searchBarHeight) } } ``` #### 滑动翻页切换九宫格菜单 看到这个效果,第一反应就是应该使用Swiper来实现,但查阅相关API后发现,监听不到滑动状态的改变,就无法动态的改变菜单区域的高度。遂采用了可横向滑动的Scroll来实现。 大体实现思路分为以下几步: 1、采用Scroll包裹两个宽度100%的Flex布局,使其可左右滑动 2、监听滚动事件,计算滑动比,改变组件高度 ```typescript Scroll(this.scroller) .onScroll((xOffset: number, yOffset: number) => { // 计算当前滑动的距离百分比 360是屏幕的宽度(与config.json中window的配置有关) this.slidPercent = this.scroller.currentOffset().xOffset / 360 this.headerHeight = this.pageHeight + (this.pageHeight - this.indicatorHeight) * this.slidPercent if (this.onSlidChange instanceof Function) { this.onSlidChange(this.slidPercent) } }) ``` 3、监听滚动结束事件,实现翻页效果 ​ Scroll组件有onScrollEnd监听,但一些情况下,该回调会不停的被执行,故采用Scroll外包一层Flex布局,来监听手势结束状态,处理滚动逻辑。 ```typescript // 处理手势,在手指抬起时处理展示页面 handTouchEvent(event: TouchEvent): void{ if (event.type === TouchType.Down) { this.lastX = event.touches[0].x } else if (event.type === TouchType.Move) { } else if (event.type === TouchType.Up || event.type === TouchType.Cancel) { // 滑动的距离 let dis: number = px2vp(event.touches[0].x - this.lastX) let needChangePage: boolean = Math.abs(dis) > 120 if (needChangePage) { // 需要切换 this.scroller.scrollTo({ xOffset: dis > 0 ? 0 : 360, yOffset: 0, animation: { duration: 200, curve: Curve.EaseOut } }) } else { // 不需切换页面 let showFirstPage: boolean = this.scroller.currentOffset().xOffset < 120 this.scroller.scrollTo({ xOffset: showFirstPage ? 0 : 360, yOffset: 0, animation: { duration: 200, curve: Curve.EaseOut } }) } } } ``` 4、利用Flex布局特性,实现九宫格菜单 5、添加下标指示器 ```typescript // 指示器 Row() { Column() .width(10) .height(10) .backgroundColor(Color.Orange) .borderRadius(5) .opacity((1 - this.slidPercent) < 0.3 ? 0.3 : (1 - this.slidPercent)) Progress({ value: this.slidPercent * 100, total: 100, style: ProgressStyle.Linear }) .color(Color.Orange) .value(this.slidPercent * 100) .cricularStyle({ strokeWidth: 10 }) .width(30) .height(10) .borderRadius(5) // .backgroundColor(Color.Grey) .margin({ left: 5 }) ``` 完整代码: ```typescript import {getFirstMenuList, getSecondMenuList, MenuData} from '../model/menuListDataModel.ets' @Component export struct MenuLayout { // 第一页九宫格数据 private firstMenuList = getFirstMenuList() // 第二页九宫格数据 private secondMenuList = getSecondMenuList() // 指示器的高度 private indicatorHeight = 20 // 页面滑动的百分比 private slidPercent: number = 0 private scroller: Scroller = new Scroller() // 容器的高度 @Link headerHeight: number // 容器的高度因为headerHeight在滑动时会不断变化,所以放在aboutToAppear中初始化 private pageHeight: number // 上次手指按下的位置 private lastX: number; // 滑动监听 private onSlidChange: (slidPercent: number) => void = (slidPercent: number) => { console.log('onSlidChange slidPercent = ' + slidPercent) } aboutToAppear() { this.pageHeight = this.headerHeight } @Builder genMenuLayout(menuList: MenuData[]) { Flex({ direction: FlexDirection.Row, wrap: FlexWrap.Wrap }) { ForEach(menuList, (item: MenuData) => { Column() { Image(item.src).width(45).height(45).objectFit(ImageFit.Cover).borderRadius(5) Text(item.name).height(20) }.width('20%') .height((this.pageHeight - this.indicatorHeight) / 2) .onClick(() => { console.log('=======onClick item.name ===' + item.name) }) }, item => item.id) }.width('100%') } build() { Flex({ direction: FlexDirection.Column, alignItems: ItemAlign.Center }) { Scroll(this.scroller) { Flex({ direction: FlexDirection.Row, wrap: FlexWrap.NoWrap }) { this.genMenuLayout(this.firstMenuList) this.genMenuLayout(this.secondMenuList) }.height('100%') } .scrollable(ScrollDirection.Horizontal) .height(this.headerHeight - this.indicatorHeight) .onScroll((xOffset: number, yOffset: number) => { // 计算当前滑动的距离百分比 this.slidPercent = this.scroller.currentOffset().xOffset / 360 this.headerHeight = this.pageHeight + (this.pageHeight - this.indicatorHeight) * this.slidPercent if (this.onSlidChange instanceof Function) { this.onSlidChange(this.slidPercent) } }) // 指示器 Row() { Column() .width(10) .height(10) .backgroundColor(Color.Orange) .borderRadius(5) .opacity((1 - this.slidPercent) < 0.3 ? 0.3 : (1 - this.slidPercent)) Progress({ value: this.slidPercent * 100, total: 100, style: ProgressStyle.Linear }) .color(Color.Orange) .value(this.slidPercent * 100) .cricularStyle({ strokeWidth: 10 }) .width(30) .height(10) .borderRadius(5) // .backgroundColor(Color.Grey) .margin({ left: 5 }) }.height(this.indicatorHeight) }.onTouch((event: TouchEvent) => { this.handTouchEvent(event) }).width('100%') } // 处理手势,在手指抬起时处理展示页面 handTouchEvent(event: TouchEvent): void{ if (event.type === TouchType.Down) { this.lastX = event.touches[0].x } else if (event.type === TouchType.Move) { } else if (event.type === TouchType.Up || event.type === TouchType.Cancel) { // 滑动的距离 let dis: number = px2vp(event.touches[0].x - this.lastX) let needChangePage: boolean = Math.abs(dis) > 120 if (needChangePage) { // 需要切换 this.scroller.scrollTo({ xOffset: dis > 0 ? 0 : 360, yOffset: 0, animation: { duration: 200, curve: Curve.EaseOut } }) } else { // 不需切换页面 let showFirstPage: boolean = this.scroller.currentOffset().xOffset < 120 this.scroller.scrollTo({ xOffset: showFirstPage ? 0 : 360, yOffset: 0, animation: { duration: 200, curve: Curve.EaseOut } }) } } } } ``` #### 横向滑动tab标签 吸顶的实现 实现可横向滚动的tab标签很简单,利用scroll很容易就实现了。但要实现点击某个标签,让其滚动到中间位置如何做到呢?我们无法拿到该tab相对父布局的位置,就不能计算出应该滚动的距离。这里 我使用了投机的方式来估算出每个tab所需的宽度,即:字体的大小*文本的长度+左右边距和 说说currentIndex为什么要用@Link 修饰,这是因为为了和父组件的Swipe关联,达到点击tab标签,切换Swipe页面,滑动切换Swipe页面,也能动态改变选中标签. horizontalTabs.ets 完整代码: ```typescript @Component export struct TabLayout { // 字体大小 private fontSizeNormal = 16 // tab左右margin private tabMargin = 15 // 标题列表,由父组件初始化 @Link titleArr: string[] // tab标签选中position @Link currentIndex: number private left: number= 0 // tab标签选中监听 父类可重写 private tabSelected: (position: number, title: string) => void = (position: number, title: string) => { console.log('tabSelected position = ' + position + ', title = ' + title) } @State private tabDataArr: TabModel[] = [] private scroller: Scroller = new Scroller() aboutToAppear() { this.tabDataArr = this.titleArr.map((title, index) => { let tabModel = new TabModel() tabModel.index = index tabModel.content = title tabModel.left = this.left let width = title.length * this.fontSizeNormal + this.tabMargin * 2 tabModel.width = width tabModel.left = this.left this.left += width return tabModel }) } build() { Scroll(this.scroller) { Flex({ direction: FlexDirection.Row }) { ForEach(this.tabDataArr, (item: TabModel) => { Flex({ direction: FlexDirection.Column, justifyContent: FlexAlign.Center, alignItems: ItemAlign.Center }) { Text(item.content) .fontSize(this.currentIndex == item.index ? this.fontSizeNormal + 3 : this.fontSizeNormal) .fontColor(this.currentIndex == item.index ? Color.Red : Color.Black) .fontWeight(this.currentIndex == item.index ? FontWeight.Bold : FontWeight.Normal) .textAlign(TextAlign.Center) Column() .height(3) .width(item.width - this.tabMargin * 2) .margin({ top: 10 }) .backgroundColor(Color.Red) .visibility(this.currentIndex == item.index ? Visibility.Visible : Visibility.Hidden) } .width(item.width) .height('100%') .backgroundColor(0xFFFFFF) .onClick((event: ClickEvent) => { this.currentIndex = item.index this.scrollToCenter(item.left, item.width) console.log('tabSelected-------- ' + typeof (this.tabSelected)) if (this.tabSelected instanceof Function) { this.tabSelected(item.index, item.content) } }) }, item => item.index) } }.scrollable(ScrollDirection.Horizontal) } // 滚动至中间位置 scrollToCenter(itemLeft: number, itemWidth: number): void{ console.log('scrollToCenter itemLeft = ' + itemLeft) let targetOffset: number = itemLeft + itemWidth / 2 - 180 console.log('scrollToCenter targetOffset = ' + targetOffset) this.scroller.scrollTo({ xOffset: targetOffset, yOffset: 0, animation: { duration: 200, curve: Curve.EaseInOut } }) } } export class TabModel { index: number content: string left: number width: number } ``` tab吸顶的原理也比较简单,监听页面滚动,计算tab组件应处的position即可,同时,还监听headerHeight的变化。可利用@Watch实现监听 index的完整代码: ```typescript import {WaterFallLayout} from '../component/waterfalllayout.ets' import {TabLayout} from '../component/horizontalTabs.ets' import {SearchBar} from '../component/searchBar.ets' import {MenuLayout} from '../component/header.ets' @Entry @Component struct Index { @State titleArr: string[] = ['关注', '附近', '达人探店', '优惠', '家居生活', '美食', '遛娃', '医美', '宠物', '运动健康', '教培'] @State currentIndex: number = 1 private swiperController: SwiperController = new SwiperController() // 头部banner高度 @State @Watch("onHeaderHeightUpdated") headerHeight: number = 160 // tab标签栏的高度 private tabHeight = 50 // 搜索栏高度 @State searchBarHeight: number = 50 private scrollY: number = 0 @State tabPosY: number = vp2px(this.headerHeight + this.searchBarHeight) build() { Stack({ alignContent: Alignment.Top }) { SearchBar({ searchBarHeight: this.searchBarHeight }) Row() { TabLayout({ titleArr: $titleArr, currentIndex: $currentIndex, tabSelected: (position: number, title: string) => { this.currentIndex = position console.log('onTabSelected position = ' + position + ', title = ' + title) // this.swiperController.showNext() }, }) } .width('100%') .height(this.tabHeight) .markAnchor({ x: 0, y: 0 }) .position({ y: this.tabPosY + 'px', x: 0 }) .zIndex(10) Scroll() { Column() { MenuLayout({ headerHeight: $headerHeight }) Swiper(this.swiperController) { ForEach(this.titleArr, (item) => { Column() { Text('Page ' + item).fontSize(25).margin(10) WaterFallLayout() } }, item => item) } .index(0) .autoPlay(false) .indicator(false) // 默认开启指示点 .loop(false) // 默认开启循环播放 .vertical(false) // 默认横向切换 .itemSpace(0) .index(this.currentIndex) .margin({ top: this.tabHeight }) .flexGrow(1) .width('100%') .onChange((index: number) => { console.info(index.toString()) this.currentIndex = index }) } } .scrollBar(BarState.Off) .margin({ top: this.searchBarHeight }) .backgroundColor('#F4F4F4') .onScroll((xOffset: number, yOffset: number) => { this.scrollY += yOffset this.tabPosY = vp2px(this.headerHeight) - this.scrollY <= 0 ? vp2px(this.searchBarHeight) : vp2px(this.headerHeight + this.searchBarHeight) - this.scrollY }) }.width('100%') } onHeaderHeightUpdated() { this.tabPosY = vp2px(this.headerHeight + this.searchBarHeight) } } ``` #### 瀑布流内容展示: 瀑布流布局的具体实现不是本文的重点,如有需要,请看我的另篇文章:[基于ArkUI实现瀑布流布局](https://developer.huawei.com/consumer/cn/forum/topic/0204708851074070254?fid=0101587866109860105) #### 遗留的问题: Swiper组件提供的swiperController只有showNext和showPrevious方法,切换指定页面是通过改变index属性。但在子组件切换时,会有重影出现,暂不知是何原因。 ### 地址: 项目中所有图片均来自于网络,如有侵权,请联系删除。 完整项目地址:[吸顶tab、横向滑动tab](https://gitee.com/xiongwg/dzdp)