【HarmonyOS Next】自定义Tabs
背景
项目中Tabs的使用可以说是特别的频繁,但是官方提供的Tabs使用起来,存在tab选项卡切换动画滞后的问题。
原始动画无法满足产品的UI需求,因此,这篇文章将实现下面页面滑动,tab选项卡实时滑动的动画效果。
实现逻辑
需求讲解
- 需要实现固定宽度下,放下6个选项卡。
- 在没有选择时宽度均匀分配,选中时显示图标并且增加宽度。
- 实现下方内容区域滑动时,上面选项卡实时跳动。
- 实现动画效果,使整体操作更加流畅。
实现思路
1. 选项卡
- 选项卡使用Row布局组件+layoutWeight属性,来实现平均布局。通过选项卡的选择索引来实现是否选中判断。
- 选中时,layoutWeight值为1.5;没有选中时,layoutWeight值为1.
- 使用animation属性,只要layoutWeight值变化时,可以触发动画。
- 在外包裹的布局容器中,添加onAreaChange事件,用来计算整体Tab组件的宽度。
Row() {
Text(name)
.fontSize(16)
.fontWeight(this.SelectedTabIndex == index ? FontWeight.Bold : FontWeight.Normal)
.textAlign(TextAlign.Center)
.animation({ duration: 300 })
Image($r('app.media.send'))
.width(14)
.height(14)
.margin({ left: 2 })
.visibility(this.SelectedTabIndex == index ? Visibility.Visible : Visibility.None)
.animation({ duration: 300 })
}
.justifyContent(FlexAlign.Center)
.layoutWeight(this.SelectedTabIndex == index ? 1.5 : 1)
.animation({ duration: 300 })
2. 定位器
- 使用Rect定义背景的形状和颜色+Stack布局+position属性,实现定位器的移动。
- position属性中通过Left值的变化来实现Rect的移动。但是在swiper的滑动中会出现滑动一点然后松开的情况,因此,需要两个值同时在实现中间的移动过程。
Stack() {
Rect()
.height(30)
.stroke(Color.Black)
.radius(10)
.width(this.FirstWidth)
.fill("#bff9f2")
.position({
left: this.IndicatorLeftOffset + this.IndicatorOffset,
bottom: 0
})
.animation({ duration: 300, curve: Curve.LinearOutSlowIn })
}
.width("100%")
.alignRules({
center: { anchor: "Tabs", align: VerticalAlign.Center }
})
3.主要内容区
- 使用Swiper组件加载对应的组件,这里需要注意的是,Demo没有考虑到内容比较多的优化方案,可以设置懒加载方案来实现性能的提升。
- onAnimationStart事件,实现监测控件是向左移动还是向右移动,并且修改IndicatorLeftOffset偏移值。
- onAnimationEnd事件,将中间移动过程值IndicatorOffset恢复成0。
- onGestureSwipe事件,监测组件的实时滑动,这个事件在onAnimationStart和onAnimationEnd事件之前执行,执行完后才会执行onAnimationStart事件。因此,这个方法需要实时修改定位器的偏移数值。
- 偏移数值是通过swiper的移动数值和整体宽度的比例方式进行计算,松手后的偏移方向,由onAnimationStart和onAnimationEnd事件来确定最终的距离
Swiper(this.SwiperController) {
ForEach(this.TabNames, (name: string, index: number) => {
Column() {
Text(`${name} - ${index}`)
.fontSize(24)
.fontWeight(FontWeight.Bold)
}
.alignItems(HorizontalAlign.Center)
.justifyContent(FlexAlign.Center)
.height("100%")
.width("100%")
})
}
.onAnimationStart((index: number, targetIndex: number, extraInfo: SwiperAnimationEvent) => {
if (targetIndex > index) {
this.IndicatorLeftOffset += this.OtherWidth;
} else if (targetIndex < index) {
this.IndicatorLeftOffset -= this.OtherWidth;
}
this.IndicatorOffset = 0
this.SelectedTabIndex = targetIndex
})
.onAnimationEnd((index: number, extraInfo: SwiperAnimationEvent) => {
this.IndicatorOffset = 0
})
.onGestureSwipe((index: number, extraInfo: SwiperAnimationEvent) => {
let move: number = this.GetOffset(extraInfo.currentOffset);
//这里需要限制边缘情况
if ((this.SelectedTabIndex == 0 && extraInfo.currentOffset > 0) ||
(this.SelectedTabIndex == this.TabNames.length - 1 && extraInfo.currentOffset < 0)) {
return;
}
this.IndicatorOffset = extraInfo.currentOffset < 0 ? move : -move;
})
.onAreaChange((oldValue: Area, newValue: Area) => {
let width = newValue.width.valueOf() as number;
this.SwiperWidth = width;
})
.curve(Curve.LinearOutSlowIn)
.loop(false)
.indicator(false)
.width("100%")
.id("MainContext")
.alignRules({
top: { anchor: "Tabs", align: VerticalAlign.Bottom },
bottom: { anchor: "__container__", align: VerticalAlign.Bottom }
})
代码文件
- 里面涉及到资源的小图标,可以自己区定义的,文章就不提供了。
@Entry
@ComponentV2
struct Index {
/**
* 标头名称集合
*/
@Local TabNames: string[] = ["飞机", "铁路", "自驾", "地铁", "公交", "骑行"]
/**
* Tab选择索引
*/
@Local SelectedTabIndex: number = 0
/**
* 标点移动距离
*/
@Local IndicatorLeftOffset: number = 0
/**
* 标点在swiper的带动下移动的距离
*/
@Local IndicatorOffset: number = 0
/**
* 第一个宽度
*/
@Local FirstWidth: number = -1
/**
* 其他的宽度
*/
@Local OtherWidth: number = -1
/**
* Swiper控制器
*/
@Local SwiperController: SwiperController = new SwiperController()
/**
* Swiper容器宽度
*/
@Local SwiperWidth: number = 0
build() {
RelativeContainer() {
Stack() {
Rect()
.height(30)
.stroke(Color.Black)
.radius(10)
.width(this.FirstWidth)
.fill("#bff9f2")
.position({
left: this.IndicatorLeftOffset + this.IndicatorOffset,
bottom: 0
})
.animation({ duration: 300, curve: Curve.LinearOutSlowIn })
}
.width("100%")
.alignRules({
center: { anchor: "Tabs", align: VerticalAlign.Center }
})
Row() {
ForEach(this.TabNames, (name: string, index: number) => {
Row() {
Text(name)
.fontSize(16)
.fontWeight(this.SelectedTabIndex == index ? FontWeight.Bold : FontWeight.Normal)
.textAlign(TextAlign.Center)
.animation({ duration: 300 })
Image($r('app.media.send'))
.width(14)
.height(14)
.margin({ left: 2 })
.visibility(this.SelectedTabIndex == index ? Visibility.Visible : Visibility.None)
.animation({ duration: 300 })
}
.justifyContent(FlexAlign.Center)
.layoutWeight(this.SelectedTabIndex == index ? 1.5 : 1)
.animation({ duration: 300 })
.onClick(() => {
this.SelectedTabIndex = index;
this.SwiperController.changeIndex(index, false);
animateTo({ duration: 500, curve: Curve.LinearOutSlowIn }, () => {
this.IndicatorLeftOffset = this.OtherWidth * index;
})
})
})
}
.width("100%")
.height(30)
.id("Tabs")
.onAreaChange((oldValue: Area, newValue: Area) => {
let tabWidth = newValue.width.valueOf() as number;
this.FirstWidth = 1.5 * tabWidth / (this.TabNames.length + 0.5);
this.OtherWidth = tabWidth / (this.TabNames.length + 0.5);
})
Swiper(this.SwiperController) {
ForEach(this.TabNames, (name: string, index: number) => {
Column() {
Text(`${name} - ${index}`)
.fontSize(24)
.fontWeight(FontWeight.Bold)
}
.alignItems(HorizontalAlign.Center)
.justifyContent(FlexAlign.Center)
.height("100%")
.width("100%")
})
}
.onAnimationStart((index: number, targetIndex: number, extraInfo: SwiperAnimationEvent) => {
if (targetIndex > index) {
this.IndicatorLeftOffset += this.OtherWidth;
} else if (targetIndex < index) {
this.IndicatorLeftOffset -= this.OtherWidth;
}
this.IndicatorOffset = 0
this.SelectedTabIndex = targetIndex
})
.onAnimationEnd((index: number, extraInfo: SwiperAnimationEvent) => {
this.IndicatorOffset = 0
})
.onGestureSwipe((index: number, extraInfo: SwiperAnimationEvent) => {
let move: number = this.GetOffset(extraInfo.currentOffset);
//这里需要限制边缘情况
if ((this.SelectedTabIndex == 0 && extraInfo.currentOffset > 0) ||
(this.SelectedTabIndex == this.TabNames.length - 1 && extraInfo.currentOffset < 0)) {
return;
}
this.IndicatorOffset = extraInfo.currentOffset < 0 ? move : -move;
})
.onAreaChange((oldValue: Area, newValue: Area) => {
let width = newValue.width.valueOf() as number;
this.SwiperWidth = width;
})
.curve(Curve.LinearOutSlowIn)
.loop(false)
.indicator(false)
.width("100%")
.id("MainContext")
.alignRules({
top: { anchor: "Tabs", align: VerticalAlign.Bottom },
bottom: { anchor: "__container__", align: VerticalAlign.Bottom }
})
}
.height('100%')
.width('100%')
.padding(10)
}
/**
* 需要注意的点,当前方法仅计算偏移值,不带方向
* @param swiperOffset
* @returns
*/
GetOffset(swiperOffset: number): number {
let swiperMoveRatio: number = Math.abs(swiperOffset / this.SwiperWidth);
let tabMoveValue: number = swiperMoveRatio >= 1 ? this.OtherWidth : this.OtherWidth * swiperMoveRatio;
return tabMoveValue;
}
}
总结
这里实现了新的Tab选项卡的定义。但是没有进行高度封装,想法是方便读者理解组件的使用逻辑,而不是直接提供给读者进行调用。希望这篇文章可以帮助到你~~