仿大众点评首页吸顶tab标签、横向滑动tab标签

809 篇文章 5 订阅
606 篇文章 11 订阅

前言

ArkUI出来一段时间了,官方给我们提供了很多好用的组件,但部分组件还是不够尽善尽美。比如我想实现下面类似大众点评首页效果,就发现目前提供的Swiper滑动翻页组件因缺少页面滑动过程的监听,不能达到边滑动,边改变组件高度的需求;系统也提供了Tabs通过页签进行内容视图切换的容器组件,但用过的都知道,它对自定义样式支持的不太好。所以,本demo就通过自定义翻页组件、横向滑动tab组件来达到类似大众点评首页效果。

效果图

代码结构解读

  • component

    header.ets : 头部九宫格菜单及下标区域

    horizontalTabs.ets:水平滚动tab标签组件

    searchBar.ets:头部搜索栏

    waterfalllayout.ets:瀑布流内容展示组件

  • model

    imageListDataModel.ets: 瀑布流数据工具类

    menuListDataModel.ets:头部九宫格数据获取工具类

  • pages

    index.ets:首页容器

    preview.ets: 图片展示页

实现过程

界面上主要分为4块:顶部固定的搜索栏、九宫格菜单展示、滚动吸顶tab标签、瀑布流内容展示。

搜索栏

搜索栏比较简单,最外层用的Row包裹,中间的垂直轮播用的Swiper组件。直接贴代码吧!

@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、监听滚动事件,计算滑动比,改变组件高度

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布局,来监听手势结束状态,处理滚动逻辑。

// 处理手势,在手指抬起时处理展示页面
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、添加下标指示器

// 指示器
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 })

完整代码:

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 完整代码:

@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的完整代码:

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实现瀑布流布局

遗留的问题:

Swiper组件提供的swiperController只有showNext和showPrevious方法,切换指定页面是通过改变index属性。但在子组件切换时,会有重影出现,暂不知是何原因。

为了能让大家更好的学习鸿蒙(HarmonyOS NEXT)开发技术,这边特意整理了《鸿蒙开发学习手册》(共计890页),希望对大家有所帮助:https://qr21.cn/FV7h05

《鸿蒙开发学习手册》:

如何快速入门:https://qr21.cn/FV7h05

  1. 基本概念
  2. 构建第一个ArkTS应用
  3. ……

开发基础知识:https://qr21.cn/FV7h05

  1. 应用基础知识
  2. 配置文件
  3. 应用数据管理
  4. 应用安全管理
  5. 应用隐私保护
  6. 三方应用调用管控机制
  7. 资源分类与访问
  8. 学习ArkTS语言
  9. ……

基于ArkTS 开发:https://qr21.cn/FV7h05

  1. Ability开发
  2. UI开发
  3. 公共事件与通知
  4. 窗口管理
  5. 媒体
  6. 安全
  7. 网络与链接
  8. 电话服务
  9. 数据管理
  10. 后台任务(Background Task)管理
  11. 设备管理
  12. 设备使用信息统计
  13. DFX
  14. 国际化开发
  15. 折叠屏系列
  16. ……

鸿蒙开发面试真题(含参考答案):https://qr18.cn/F781PH

鸿蒙开发面试大盘集篇(共计319页):https://qr18.cn/F781PH

1.项目开发必备面试题
2.性能优化方向
3.架构方向
4.鸿蒙开发系统底层方向
5.鸿蒙音视频开发方向
6.鸿蒙车载开发方向
7.鸿蒙南向开发方向

  • 6
    点赞
  • 12
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值