前提概要
最近在公司做业务时遇到了这样效果的需求,在安卓、ios和前端中这个效果都是非常容易实现的,但是放在鸿蒙中当时踩了非常多坑才实现出以下效果,网络上也没有类似的例子,所有今天可以跟大家分享一下如何实现。
分析需求
- 顶部的tab栏在下拉时要有一个吸顶的效果
- 点击tab栏需要跳到对应的卡片上
- 滑动列表时顶部tab栏需要做到一个同步切换
技术选型
- 吸顶效果鸿蒙目前只能使用
ListItemGroup
中的header
属性实现效果, ListItemGroup官方文档 - 点击tab进行list跳转,我们可以使用
List
组件中的scroller
来操控, Scroller官方文档 - 滑动同步tab栏更新,可以通过监听
List
组件的onScrollIndex
事件实现, onScrollIndex官方文档
开始写代码
这边主要是分享一个实现思路,具体的数据结合你们实际的业务需求!!!
第一步创建我们组件
我们采用从上到下的布局
@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
包裹列表数据
加入横向Tab栏
因为我们横向tab栏需要一个吸顶的效果,所以我们需要依赖ListItemGroup
的header
属性,接下来书写我们的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()
}
}
代码就不重复写了,目前的效果是这样的,上滑会有一个吸顶效果,大家可以试一下自己的是否可以
现在我们的布局基本上就完成了,剩下的就是交互效果
添加交互效果
首先我们先写,点击横向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
查看我们的元素结构
因为我们的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栏切换的效果
列表滚动同步Tab栏切换
通过监听数据列表List
的onScrollIndex
拿到当前最上面的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
的滚动,我们这里只需要加一下嵌套滚动模式即可,嵌套滚动模式官方文档
@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 // 子先父后
})
最终完成了我们开头所需的效果,希望后续鸿蒙官方可以出更多的组件实现以上功能。