HarmonyOS-电梯导航DEMO(鸿蒙吸顶)

1,675 阅读6分钟

前提概要

最近在公司做业务时遇到了这样效果的需求,在安卓、ios和前端中这个效果都是非常容易实现的,但是放在鸿蒙中当时踩了非常多坑才实现出以下效果,网络上也没有类似的例子,所有今天可以跟大家分享一下如何实现。

71cc767caaf157a01a636ae931a7b23c.-迅捷PDF转换器.gif

分析需求

  1. 顶部的tab栏在下拉时要有一个吸顶的效果
  2. 点击tab栏需要跳到对应的卡片上
  3. 滑动列表时顶部tab栏需要做到一个同步切换

技术选型

  1. 吸顶效果鸿蒙目前只能使用ListItemGroup中的header属性实现效果, ListItemGroup官方文档 image.png
  2. 点击tab进行list跳转,我们可以使用List组件中的scroller来操控, Scroller官方文档 image.png
  3. 滑动同步tab栏更新,可以通过监听List组件的onScrollIndex事件实现, onScrollIndex官方文档 image.png

开始写代码

这边主要是分享一个实现思路,具体的数据结合你们实际的业务需求!!!

第一步创建我们组件

我们采用从上到下的布局

@Entry
@Component
struct Index {
  scroller = new Scroller() // 最外层List控制器
  columnsScroller = new Scroller() // 竖向List控制器
  data: number[] = [1,2,3,4,5,6] // 数据源
  colors:  string[] = ['#00f', '#98f', '#c9f', '#4ff','#00f','#98f'] // 随机背景颜色

  build() {
    Row() {
      this.getListView()
    }
  }

  @Builder
  getListView() {
    List({scroller: this.scroller}) {
      ListItemGroup() {
        ListItem() {
          Stack({alignContent: Alignment.Center}){
            Text('头部')
          }.width('100%').height('300vp').backgroundColor('#ff0')
        }
      }

      ListItemGroup() {
        ListItem() {
          this.columnsBuilder()
        }
      }
    }
    .width('100%')
    .height('100%')
    .sticky(StickyStyle.Header)
    .scrollBar(BarState.Off)
    .edgeEffect(EdgeEffect.None)
  }
}

@Builder
columnsBuilder() {
  List({scroller: this.columnsScroller}) {
    ForEach(this.data, (item: number, index: number) => {
      ListItem() {
        Stack({alignContent: Alignment.Center}){
          Text(item.toString())
        }.width('100%').height('300vp').backgroundColor(this.colors[index])
      }
    })
  }
}

这个是我们目前的效果,最外层一个List,头部是一个ListItemGroup,列表数据是第二个ListItemGroup,里面使用List包裹列表数据

image.png

加入横向Tab栏

因为我们横向tab栏需要一个吸顶的效果,所以我们需要依赖ListItemGroupheader属性,接下来书写我们的tab栏

// 在最上面加上我们的scroller
// horizonScroller = new Scroller()

@Builder
horizonBuilder() {
  List({space: '5vp', scroller: this.horizonScroller}) {
    ForEach(this.data, (item: number) => {
      ListItem() {
        Text(item.toString())
          .width('100vp')
          .textAlign(TextAlign.Center)
          .padding('5vp')
          .borderRadius('5vp')
          .backgroundColor(Color.Pink)
      }
    })
  }
  .width('100%')
  .listDirection(Axis.Horizontal)
  .scrollBar(BarState.Off)
  .edgeEffect(EdgeEffect.None)
  .backgroundColor('#fff')
}

把我们的tab栏加入到ListItemGroup

...
ListItemGroup({header: this.horizonBuilder}) {
  ListItem() {
    this.columnsBuilder()
  }
}

代码就不重复写了,目前的效果是这样的,上滑会有一个吸顶效果,大家可以试一下自己的是否可以

image.png

现在我们的布局基本上就完成了,剩下的就是交互效果

添加交互效果

首先我们先写,点击横向tab,横向tab栏吸顶然后跳转到对应元素,scroller中有一个scrollToIndex方法可以帮助我们实现这个需求,但是我们仅仅使用这个就可以吗?

@Builder
horizonBuilder() {
  List({space: '5vp', scroller: this.horizonScroller}) {
    ForEach(this.data, (item: number, index: number) => {
      ListItem() {
        Text(item.toString())
          .width('100vp')
          .textAlign(TextAlign.Center)
          .padding('5vp')
          .borderRadius('5vp')
          .backgroundColor(Color.Pink)
          .onClick(() => {
            this.scroller.scrollToIndex(1, true) // tab栏吸顶
            this.columnsScroller.scrollToIndex(index) // 加上跳转代码
          })
      }
    })
  }

事实证明是不可以的,为什么呢,我们可以通过编辑器的ArkUI Inspector查看我们的元素结构

image.png

因为我们的List组件没有限制高度所以我们无法滚动,那高度设置多少呢?

动态设置List高度

我们的思路是先算出最外层元素的高度,再减去横向tab栏的高度,就能得到我们实际可以显示的内容高度,元素有一个onAreaChange的事件在组件区域变化时触发,里面就可以得到我们的组件高度了, onAreaChange官方文档

// 在最上方定义两个变量
@State parentHeight: number = 0
@State horizonHeight: number = 0

@Builder
getListView() {
  List({scroller: this.scroller}) {
    ListItemGroup() {
      ListItem() {
        Stack({alignContent: Alignment.Center}){
          Text('头部')
        }.width('100%').height('300vp').backgroundColor('#ff0')
      }
    }

    ListItemGroup({header: this.horizonBuilder}) {
      ListItem() {
        this.columnsBuilder()
      }
    }
  }
  .width('100%')
  .height('100%')
  .sticky(StickyStyle.Header)
  .scrollBar(BarState.Off)
  .edgeEffect(EdgeEffect.None)
  .onAreaChange((oldValue, newValue) => { // 获取最外层盒子高度
    if(newValue.height !== 0) {
      this.parentHeight = newValue.height as number
    }
  })
}

@Builder
columnsBuilder() {
  List({scroller: this.columnsScroller}) {
    ForEach(this.data, (item: number, index: number) => {
      ListItem() {
        Stack({alignContent: Alignment.Center}){
          Text(item.toString())
        }.width('100%').height('300vp').backgroundColor(this.colors[index])
      }
    })
  } // 给列表设置高度的
  .height(this.parentHeight == 0 || this.horizonHeight == 0 ? '100%' : 
  this.parentHeight - this.horizonHeight)
}

@Builder
horizonBuilder() {
  List({space: '5vp', scroller: this.horizonScroller}) {
    ForEach(this.data, (item: number, index: number) => {
      ListItem() {
        Text(item.toString())
          .width('100vp')
          .textAlign(TextAlign.Center)
          .padding('5vp')
          .borderRadius('5vp')
          .backgroundColor(Color.Pink)
          .onClick(() => {
            this.scroller.scrollToIndex(1, true)
            this.columnsScroller.scrollToIndex(index, true)
          })
      }
    })
  }
  .width('100%')
  .listDirection(Axis.Horizontal)
  .scrollBar(BarState.Off)
  .edgeEffect(EdgeEffect.None)
  .backgroundColor('#fff')
  .onAreaChange((oldValue, newValue) => { // 获取tab栏高度
    if(newValue.height !== 0) {
      this.horizonHeight = newValue.height as number
    }
  })
}

现在我们的列表就可以开始滚动了

Tab栏跟随滚动

被选中的tab标签需要高亮显示并且排在第一位,这里很简单我们只需要添加几个变量记录当前的index值就可以了

// 记录当前index
@State currentHorizonIndex: number = 0

@Builder
horizonBuilder() {
  List({space: '5vp', scroller: this.horizonScroller}) {
    ForEach(this.data, (item: number, index: number) => {
      ListItem() {
        Text(item.toString())
          .width('100vp')
          .textAlign(TextAlign.Center)
          .padding('5vp')
          .borderRadius('5vp')
          .backgroundColor(this.currentHorizonIndex === index ? Color.Red : Color.Pink)
          .onClick(() => {
            this.currentHorizonIndex = index
            this.horizonScroller.scrollToIndex(index, true)
            this.scroller.scrollToIndex(1, true)
            this.columnsScroller.scrollToIndex(index, true)
          })
      }
    })
  }
  .width('100%')
  .listDirection(Axis.Horizontal)
  .scrollBar(BarState.Off)
  .edgeEffect(EdgeEffect.None)
  .backgroundColor('#fff')
  .onAreaChange((oldValue, newValue) => {
    if(newValue.height !== 0) {
      this.horizonHeight = newValue.height as number
    }
  })
}

目前我们就拥有了这样的效果,现在还差一个滚动列表同步tab栏切换的效果

image.png

列表滚动同步Tab栏切换

通过监听数据列表ListonScrollIndex拿到当前最上面的index

@Builder
columnsBuilder() {
  List({scroller: this.columnsScroller}) {
    ForEach(this.data, (item: number, index: number) => {
      ListItem() {
        Stack({alignContent: Alignment.Center}){
          Text(item.toString())
        }.width('100%').height('300vp').backgroundColor(this.colors[index])
      }
    })
  }
  .height(this.parentHeight == 0 || this.horizonHeight == 0 ? '100%' : 
  this.parentHeight - this.horizonHeight)
  .onScrollIndex((start) => {
     this.currentHorizonIndex = start
     this.horizonScroller.scrollToIndex(start, true)
  })
}

但是你会发现在点击tab跳转时可能会出现bug,比如从1到3,tab会先跳3再跳2再跳3,原因是点击tab触发List滚动从而触发了onScrollIndex函数修改了当前的index值,但是onScrollIndex在滚动到起始位置和结束为止之间会触发,导致了index值不准

所以我们需要加一个变量,我们的列表滚动是因为手指拖动的还是因为点击tab栏所触发的,手指拖动时onScrollIndex可以修改index值 ,但是tab栏触发的滚动就不需要修改index值

添加开关

我们的思路是定义一个开发变量flag,在点击tab时设置为true,列表滚动结束后设置为false,onScrollIndex回调中,当flag为false时才修改index

// 在最上面添加一个开关变量
flag: boolean = true

@Builder
columnsBuilder() {
  List({scroller: this.columnsScroller}) {
    ForEach(this.data, (item: number, index: number) => {
      ListItem() {
        Stack({alignContent: Alignment.Center}){
          Text(item.toString())
        }.width('100%').height('300vp').backgroundColor(this.colors[index])
      }
    })
  }
  .height(this.parentHeight == 0 || this.horizonHeight == 0 ? '100%' : 
  this.parentHeight - this.horizonHeight)
  .onScrollIndex((start) => {
    if(!this.flag) { // 添加开关
      return
    }
    this.currentHorizonIndex = start
    this.horizonScroller.scrollToIndex(start, true)
  })
  .onScrollStop(() => {
    this.flag = true // 开关打开
  })
}

@Builder
horizonBuilder() {
  List({space: '5vp', scroller: this.horizonScroller}) {
    ForEach(this.data, (item: number, index: number) => {
      ListItem() {
        Text(item.toString())
          .width('100vp')
          .textAlign(TextAlign.Center)
          .padding('5vp')
          .borderRadius('5vp')
          .backgroundColor(this.currentHorizonIndex === index ? Color.Red : Color.Pink)
          .onClick(() => {
            this.flag = false // 开关关闭
            this.currentHorizonIndex = index
            this.horizonScroller.scrollToIndex(index, true)
            this.scroller.scrollToIndex(1, true)
            this.columnsScroller.scrollToIndex(index, true)
          })
      }
    })
  }
  .width('100%')
  .listDirection(Axis.Horizontal)
  .scrollBar(BarState.Off)
  .edgeEffect(EdgeEffect.None)
  .backgroundColor('#fff')
  .onAreaChange((oldValue, newValue) => {
    if(newValue.height !== 0) {
      this.horizonHeight = newValue.height as number
    }
  })
}

设置子List的嵌套滚动模式

最后发现我们的List滚动到下面就无法滑动回到最顶部了,因为我们这里List嵌套了List,我们的子List没有设置嵌套滚动模式,所以我们一直触发了子List的滚动,没有触发到父List的滚动,我们这里只需要加一下嵌套滚动模式即可,嵌套滚动模式官方文档

image.png

@Builder
columnsBuilder() {
  List({scroller: this.columnsScroller}) {
    ForEach(this.data, (item: number, index: number) => {
      ListItem() {
        Stack({alignContent: Alignment.Center}){
          Text(item.toString())
        }.width('100%').height('300vp').backgroundColor(this.colors[index])
      }
    })
  }
  .height(this.parentHeight == 0 || this.horizonHeight == 0 ? '100%' : 
  this.parentHeight - this.horizonHeight)
  .onScrollIndex((start) => {
      if(this.flag) {
        return
      }
      this.currentHorizonIndex = start
      this.horizonScroller.scrollToIndex(start, true)
  })
  .onScrollStop(() => {
    this.flag = false
  })
  .scrollBar(BarState.Off)
  .edgeEffect(EdgeEffect.None)
  .nestedScroll({ // 设置滚动模式
    scrollForward: NestedScrollMode.PARENT_FIRST, // 父先子后
    scrollBackward: NestedScrollMode.SELF_FIRST // 子先父后
  })

最终完成了我们开头所需的效果,希望后续鸿蒙官方可以出更多的组件实现以上功能。