由于使用 Tabs 在滚动时,会默认选中的tab 会默认居中展示;同时底部的 indicator 滑动中不会存在平滑的移动效果;
实现效果如下:
想要实现功能如下:
1.实现一个类似于Android 中 底部indicator 可以在tab 间平滑移动。
2.实现在选中tab 后,如果 tab 处于不完全可见时,会滚动到完全可见状态;
3.在从可见 tab 滑动到不可见 tab时
1)在不抬起手指的情况下,不可见 tab 跟随翻页实现比例滑动效果;
2)在缓慢滑动中松开手指情况下,不可见 tab 跟随翻页效果滑动可见;
3)在块石滑动时,不可见 tab 跟随翻页效果实现滑动到可见;
功能思路:
1.针对indicator 滑动,在 tab 间平滑移动; 由于原生不具备上述功能,那么就可以通过定义一个组件作为 indicator ,然后通过记录各个 tab 的位置信息。在滑动时让该 indicator组件实现移动的动画效果; 这样就解决了 indicator 移动问题;
2.针对不想 tab 选中后居中效果;那么就定义一个 Scroll + Row + ForEach(Title)的组合方式来代替 Tabs的 标签列表;同时设置 Tabs .barHeight(0)便签高度为0,这样就实现了偷梁换柱效果;
3.将 indicator 组件放在 步骤 2 中的组合组件下方;基本布局就实现了;
在滑动中需要用到的动画触发点:
1.在点击模拟 tab 标题 时,要选中指定的标签;indicator 滚动到该标签下;tabContent 滚动到指定位置;如果点击的tab 处于完全可见要滚动到完全可见位置;
2.在滑动 tabContent 时进行动画触发,情况分为如下:
1)跟随手指滑动时进行动画的移动就是在 Tabs .onGestureSwipe()方法中,实现 indicator的滚动效果; 如果 tab 处于不完全可见状态,根据比例实现 tab 标签滚动到可见位置的效果;
2)跟随手指移动中,松开手指的进行滚动;主要在Tabs .onAnimationStart(),onAnimationEnd() 方法中进行实现;实现indicator 移动到指定位置;如果标签不完全可见,就将tab 滚动到可见位置;
3)快速滑动时,在Tabs.onAnimationStart()方法中,如果滚动目标的位置不可见,就滚动到可见位置;
想要实现在滑动TabContent的时候,顶部的 tabs 跟随移动;
动作分为两种情况:
1.就是手指不离开屏幕滑动进行滑动,然后顶部标签进行滚动 ; 2.就是在滑动中手指松开进行滑动 ;
场景1:
1.在跟随手势滑动的时候,获取到滑动的距离;获取到 tabs 需要滚动的距离(tabs.width),通过当前滚动的距离/滚动总的宽度获取到对应比例 ratio; 通过 标签之间距离 * ratio 获取 需要移动的距离;
通过 this.Scroller. scrollTo()进行移动效果;x轴的偏移量 = 在原来滚动的基础上 + 当前需要滚动的比例;(注意这个地方需要区分滚动方向)
//手势滚动到指定的 tabs
gestureScrollTabs(index: number, currentOffset: number) {
//判断滑动的目标标签是否可见,如果目标标签可见就无需滚动,如果不可见就进行滚动
this.targetIndex = index
//获取到目标的下标
if (index > 0 && currentOffset > 0) {
//向右滑动
this.targetIndex--
} else if (index < this.tabArray.length - 1 && currentOffset < 0) {
this.targetIndex++
}
//获取目标的 view 是否可见
this.scrollByLeft = this.textInfos[this.targetIndex].globalLeft
this.scrollByRight = this.scrollByLeft + this.textInfos[this.targetIndex].width
this.isOutSide = false
//这个地方能够确定目标标签是否在屏幕外
if (this.scrollByLeft < this.scroller.currentOffset().xOffset + this.scrollOffset ||
(this.scrollByRight > this.scroller.currentOffset().xOffset + this.tabsWidth - this.scrollOffset)) {
// 左边或者 右边 处于不可见
this.isOutSide = true
}
//如果没有在界外就无需滚动
if (!this.isOutSide) {
return
}
this.tabsPreviousWidth = (this.scrollByLeft - this.indicatorMarginLeft)
if (currentOffset > 0) {
//向右滑动,获取到 标签左侧的距离,
this.scroller.scrollTo({
xOffset: this.scroller.currentOffset().xOffset +
this.tabsPreviousWidth * (currentOffset / this.tabsWidth),
yOffset: 0,
animation: { duration: this.duration }
})
} else {
//向左滑动
this.scroller.scrollTo({
xOffset: this.scroller.currentOffset().xOffset -
this.tabsPreviousWidth * (currentOffset / this.tabsWidth),
yOffset: 0,
animation: { duration: this.duration }
})
}
}
场景2
属于在手指松开手进行滚动; 这种只需要滚动到指定目标 的位置即可;这个地方注意滚动的位置;滚动的时间为 设置时间* 滚动位置/tabsWidth 来保证滚动的流程;
this.scrollToVisible(this.currentPosition, this.duration * (Math.abs(event.currentOffset / this.tabsWidth)))
场景3
就是快速滑动然后松开,这种一定会滚动到下个位置;就需要将坐标变成targetIndex;
this.scrollToVisible(targetIndex, this.duration)
scrollToVisible(index: number, duration: number) {
this.scrollLeft = this.textInfos[index].globalLeft
this.scrollRight = this.scrollLeft + this.textInfos[index].width
this.currentOffset = this.scroller.currentOffset().xOffset
// console.info('-------- > scroll to visible ' + left + '.... current .... ' + currentOffset + " ... right " + right +
// " .. tabWidth " + this.tabsWidth)
if (this.scrollLeft < this.currentOffset + this.scrollOffset) {
this.scroller.scrollTo({
xOffset: (this.scrollLeft - this.scrollOffset) <= 0 ? 0 : this.scrollLeft -= this.scrollOffset,
yOffset: 0, animation: { duration: duration }
})
} else if (this.scrollRight > this.currentOffset + this.tabsWidth - this.scrollOffset) {
this.scroller.scrollTo({
xOffset: this.scrollRight - this.tabsWidth + this.scrollOffset,
yOffset: 0,
animation: { duration: duration }
})
}
}
针对各个标签坐标信息的搜集
想要实现indicator 滚动到指定标签;以及 tab 滚动到可见位置。不可缺少的就是各个标签的坐标信息;标签信息的获取如下:
private textInfos: (TextInfo)[] = []
Text(title)
.fontColor('#333')
.fontSize(20)
.onAreaChange((oldValue: Area, newValue: Area) => {
//在这个地方获取到 标签的位置信息,以及宽度信息,以及下标信息
if (this.textInfos[index]) {
return
}
this.textInfos[index] =
{ globalLeft: newValue.globalPosition.x as number, width: newValue.width as number, index: index }
if (this.currentPosition == index && !this.isStartAnimateTo) {
//获取到当前的位置信息
this.indicatorWidth = this.textInfos[index].width
this.indicatorMarginLeft = this.textInfos[index].globalLeft
this.currentPosition = index
}
})
interface TextInfo {
globalLeft: number
width: number
index: number
}
注意:由于滚动中 对应标签的位置会发生改变。隐藏在初始化一次后。就避免在滚动中进行继续更改坐标点;
根据坐标信息以及滚动的偏移量获取到对应的位置信息
在滚动过程中 针对indicator 的移动情况需要实时更新。那么就需要通过TabContent 的移动偏移量来获取到 indicator 对应的坐标信息;其实就是通过 TabContent 滚动距离占tabsWidth 宽度的比例;获取到对应 indicator 移动的距离;代码如下:
//获取到当前坐标的下标信息
getCurrentIndexInfo(index: number, event: TabsAnimationEvent): TextInfo {
let nextIndex = index
if (index > 0 && event.currentOffset > 0) {
//向右滑动
nextIndex--
} else if (index < this.tabArray.length - 1 && event.currentOffset < 0) {
//向左滑动
nextIndex++
}
//获取到移动的比例
let radio = Math.abs(event.currentOffset / this.tabsWidth)
//获取到新的 left 和width,以及 index
let currentIndex = radio > 0.5 ? nextIndex : index
let currentLeft = this.textInfos[index].globalLeft +
(this.textInfos[nextIndex].globalLeft - this.textInfos[index].globalLeft) * radio
let currentWidth =
this.textInfos[index].width + (this.textInfos[nextIndex].width - this.textInfos[index].width) * radio;
return { globalLeft: currentLeft, width: currentWidth, index: currentIndex }
}
在松开手时实现 indicator 滚动到指定位置代码如下:
//滚动到指定位置;
scrollerToAnimal(duration: number, left: number, width: number) {
this.isStartAnimateTo = true
this.getUIContext().animateTo({
curve: Curve.Linear,
duration: duration,
playMode: PlayMode.Normal,
iterations: 1,
onFinish: () => {
this.isStartAnimateTo = false
}
}, () => {
this.indicatorWidth = width
this.indicatorMarginLeft = left
})
}
获取到TabWidth:
在 Tabs.onAreaChange()方法中获取到Tabs 的宽度如下:
.onAreaChange((oldValue: Area, newValue: Area) => {
//获取到 tabs 的宽度,
this.tabsWidth = newValue.width as number
})
整体代码如下:
代码没有进行整体的抽取封装;如果需要可以直接抽取为一个自定义组件类;
private scroller: Scroller = new Scroller();
private tabArray: TabItemInfo[] = [
{ name: '推荐' }, { name: '关注' }, { name: '热门' },
{ name: '军事' }, { name: '体育' }, { name: '八卦' },
{ name: '数码' }, { name: '财经' }, { name: '美食' }, { name: '旅行' }
]
private info: TextInfo | null = null
private targetIndex: number = 0
private scrollByLeft: number = 0
private scrollByRight: number = 0
private isOutSide: boolean = false
private animal: AnimatorResult | null = null
//是否跳转到下一个页面
private isSkipNextPage: boolean = false
private tabsPreviousWidth: number = 0
@Local indicatorWidth: number = 0
@Local indicatorMarginLeft: number = 0
@Local tabsWidth: number = 0
private currentPosition: number = 0
private isStartAnimateTo: boolean = false
private duration: number = 300
private textInfos: (TextInfo)[] = []
private tabsControl: TabsController = new TabsController()
private common: CommonModifier = new CommonModifier()
resetConfig() {
this.targetIndex = 0
this.scrollByLeft = 0
this.scrollByRight = 0
this.isOutSide = false
this.isSkipNextPage = false
this.tabsPreviousWidth = 0
}
@Builder
scrolledIndicatorBuilder() {
Column() {
Scroll(this.scroller) {
Stack({ alignContent: Alignment.TopStart }) {
Row() {
ForEach(this.tabArray, (item: TabItemInfo, index: number) => {
this.scrollIndicator(item.name, index, 'scroll')
})
}
Column()
.width(this.indicatorWidth)
.margin({ left: this.indicatorMarginLeft, top: 40 })
.backgroundColor(Color.Blue)
.height(2)
.borderRadius(1)
}
}
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Fade)
.height(58)
.align(Alignment.Start)
.friction(0.6)
Tabs({
barPosition: BarPosition.Start,
index: 0,
controller: this.tabsControl,
barModifier: this.common
}) {
ForEach(this.tabArray, (item: TabItemInfo, index: number) => {
//生成对应的 数据
TabContent() {
Column() {
Text(item.name)
.fontSize(20)
.fontColor("#333")
}
.background(Color.Gray)
.width('100%')
.height('100%')
.alignItems(HorizontalAlign.Center)
.justifyContent(FlexAlign.Center)
}.tabBar(this.scrollIndicator(item.name, index, 'tabs '))
})
}
.vertical(false)
.barHeight(0)
.onAreaChange((oldValue: Area, newValue: Area) => {
//获取到 tabs 的宽度,
this.tabsWidth = newValue.width as number
})
.layoutWeight(1)
.scrollable(true)
.barMode(BarMode.Scrollable)
.scrollable(true)
.edgeEffect(EdgeEffect.Fade)
.onChange((num) => {
this.currentPosition = num
})
.onSelected((number) => {
// console.info(
// `------------- > onArea gestureAnimTabs onSelected ${number} `)
this.isSkipNextPage = true
})
.onAnimationStart((index: number, targetIndex: number, event: TabsAnimationEvent) => {
//在松手的时候indicator 滚动
this.scrollerToAnimal(this.duration, this.textInfos[targetIndex].globalLeft, this.textInfos[targetIndex].width)
//tab 不完全可见,实现标签滚动到可见位置
if (this.isSkipNextPage) {
//标签滚动到指定位置,如果标签处于可见或者不可见时;
if (Math.abs(event.velocity) > 200) {
//为啥快速滑动,然后松开手指时,指定该方法 无法滚动到指定位置呢
this.scrollToVisible(targetIndex, this.duration)
} else {
this.scrollToVisible(this.currentPosition, this.duration * (Math.abs(event.currentOffset / this.tabsWidth)))
}
}
})
.onAnimationEnd((index: number, event: TabsAnimationEvent) => {
console.info(`------------- > onArea gestureAnimTabs onAnimationEnd `)
//在动画结束的时候获取到目标的位置,然后进行滚动
let info = this.getCurrentIndexInfo(index, event)
this.scrollerToAnimal(0, info.globalLeft, info.width)
//标签滚动到指定位置,如果标签处于可见或者不可见时;
this.scrollToVisible(this.currentPosition, this.duration)
//重置参数
this.resetConfig()
})
.onGestureSwipe((index: number, event: TabsAnimationEvent) => {
//在跟随手指移动中,获取到要移动的位置信息;然后获取到对应的坐标,并获取到对应的 数据
this.info = this.getCurrentIndexInfo(index, event)
this.currentPosition = this.info.index
this.indicatorMarginLeft = this.info.globalLeft
this.indicatorWidth = this.info.width
//进行滚动处理
this.gestureScrollTabs(index, event.currentOffset)
})
}
.width('100%')
.height('100%')
}
aboutToDisappear(): void {
this.animal?.cancel()
this.animal = null
}
//手势滚动到指定的 tabs
gestureScrollTabs(index: number, currentOffset: number) {
//判断滑动的目标标签是否可见,如果目标标签可见就无需滚动,如果不可见就进行滚动
this.targetIndex = index
//获取到目标的下标
if (index > 0 && currentOffset > 0) {
//向右滑动
this.targetIndex--
} else if (index < this.tabArray.length - 1 && currentOffset < 0) {
this.targetIndex++
}
//获取目标的 view 是否可见
this.scrollByLeft = this.textInfos[this.targetIndex].globalLeft
this.scrollByRight = this.scrollByLeft + this.textInfos[this.targetIndex].width
this.isOutSide = false
//这个地方能够确定目标标签是否在屏幕外
if (this.scrollByLeft < this.scroller.currentOffset().xOffset + this.scrollOffset ||
(this.scrollByRight > this.scroller.currentOffset().xOffset + this.tabsWidth - this.scrollOffset)) {
// 左边或者 右边 处于不可见
this.isOutSide = true
}
//如果没有在界外就无需滚动
if (!this.isOutSide) {
return
}
this.tabsPreviousWidth = (this.scrollByLeft - this.indicatorMarginLeft)
if (currentOffset > 0) {
//向右滑动,获取到 标签左侧的距离,
this.scroller.scrollTo({
xOffset: this.scroller.currentOffset().xOffset +
this.tabsPreviousWidth * (currentOffset / this.tabsWidth),
yOffset: 0,
animation: { duration: this.duration }
})
} else {
//向左滑动
this.scroller.scrollTo({
xOffset: this.scroller.currentOffset().xOffset -
this.tabsPreviousWidth * (currentOffset / this.tabsWidth),
yOffset: 0,
animation: { duration: this.duration }
})
}
}
//获取到当前坐标的下标信息
getCurrentIndexInfo(index: number, event: TabsAnimationEvent): TextInfo {
let nextIndex = index
if (index > 0 && event.currentOffset > 0) {
//向右滑动
nextIndex--
} else if (index < this.tabArray.length - 1 && event.currentOffset < 0) {
//向左滑动
nextIndex++
}
//获取到移动的比例
let radio = Math.abs(event.currentOffset / this.tabsWidth)
//获取到新的 left 和width,以及 index
let currentIndex = radio > 0.5 ? nextIndex : index
let currentLeft = this.textInfos[index].globalLeft +
(this.textInfos[nextIndex].globalLeft - this.textInfos[index].globalLeft) * radio
let currentWidth =
this.textInfos[index].width + (this.textInfos[nextIndex].width - this.textInfos[index].width) * radio;
return { globalLeft: currentLeft, width: currentWidth, index: currentIndex }
}
@Builder
scrollIndicator(title: string, index: number, from: string) {
Column() {
Text(title)
.fontColor('#333')
.fontSize(20)
.onAreaChange((oldValue: Area, newValue: Area) => {
//在这个地方获取到 标签的位置信息,以及宽度信息,以及下标信息
if (this.textInfos[index]) {
return
}
this.textInfos[index] =
{ globalLeft: newValue.globalPosition.x as number, width: newValue.width as number, index: index }
if (this.currentPosition == index && !this.isStartAnimateTo) {
//获取到当前的位置信息
this.indicatorWidth = this.textInfos[index].width
this.indicatorMarginLeft = this.textInfos[index].globalLeft
this.currentPosition = index
}
})
.onClick(() => {
//在点击时 tabs 滚动到指定的卡片
this.tabsControl.changeIndex(index)
//需要获取到当前的位置
this.currentPosition = index
//点击时需要获取到位置,实现指示器的滚动
this.scrollToVisible(this.currentPosition, this.duration)
})
}
.alignItems(HorizontalAlign.Center)
.padding({
left: 20,
right: 20,
top: 10,
bottom: 10
})
}
private scrollOffset: number = 20
private scrollLeft: number = 0
private scrollRight: number = 0
private currentOffset: number = 0
scrollToVisible(index: number, duration: number) {
this.scrollLeft = this.textInfos[index].globalLeft
this.scrollRight = this.scrollLeft + this.textInfos[index].width
this.currentOffset = this.scroller.currentOffset().xOffset
// console.info('-------- > scroll to visible ' + left + '.... current .... ' + currentOffset + " ... right " + right +
// " .. tabWidth " + this.tabsWidth)
if (this.scrollLeft < this.currentOffset + this.scrollOffset) {
this.scroller.scrollTo({
xOffset: (this.scrollLeft - this.scrollOffset) <= 0 ? 0 : this.scrollLeft -= this.scrollOffset,
yOffset: 0, animation: { duration: duration }
})
} else if (this.scrollRight > this.currentOffset + this.tabsWidth - this.scrollOffset) {
this.scroller.scrollTo({
xOffset: this.scrollRight - this.tabsWidth + this.scrollOffset,
yOffset: 0,
animation: { duration: duration }
})
}
}
//滚动到指定位置;
scrollerToAnimal(duration: number, left: number, width: number) {
this.isStartAnimateTo = true
this.getUIContext().animateTo({
curve: Curve.Linear,
duration: duration,
playMode: PlayMode.Normal,
iterations: 1,
onFinish: () => {
this.isStartAnimateTo = false
}
}, () => {
this.indicatorWidth = width
this.indicatorMarginLeft = left
})
}