引言
Tab栏的自定义通过Builder自定义构建函数就可以实现大多数效果了,但想实现底部导航条跟随相对麻烦,这期分享一下通过鸿蒙原生提供的方法实现。
主要用到的两个关键点: SubTabBarStyle 和 ComponentContent
我们将结合实际代码,逐步拆解实现过程。
一、SubTabBarStyle的作用
SubTabBarStyle 是鸿蒙开发中用于自定义标签页样式的核心组件。它可以通过静态方法 of(content: ResourceStr | ComponentContent) 来定义标签栏的内容。其中,ComponentContent 提供了极高的灵活性,允许开发者自定义构建函数以控制标签页内容的展现。与只传入 ResourceStr 显示简单标题不同,ComponentContent 允许我们添加图片、动态文本等更加丰富的元素。
@Extend(TabContent) //为了可读性更好,这里抽取成Extend,但其实没什么必要
function MyTabbar(context: UIContext, tabItem: Params) {
.tabBar(SubTabBarStyle.of(new ComponentContent<Params>(context, wrapBuilder<[Params]>(buildText), tabItem))
//SubTabBarStyle.of('首页') 这里可以直接传入ResourceStr类型
.indicator({ //导航条属性
color: Color.Green,
height: 4,
width: 24,
borderRadius: 4,
marginTop: 8
}))
}
上面展示了如何通过 SubTabBarStyle 实现自定义的标签样式。我们将一个 ComponentContent 对象传入 SubTabBarStyle.of(),并通过 indicator() 设置了标签下划线的样式,包括颜色、宽度、圆角半径以及下划线与文字的距离。
SubTabBarStyle 可以直接接收一个字符串类型参数作为标签内容,这也足够应付一些场景。但是在一些应用中,被选中的标签需要高亮展示,这个效果就需要自定义样式来条件判断了。
二、ComponentContent的使用
SubTabBarStyle.of()中不支持直接传入Builder自定义构建函数,这里就需要ComponentContent了。
官方描述:
ComponentContent表示组件内容的实体封装,其对象支持在非UI组件中创建与传递,便于开发者对弹窗类组件进行解耦封装。ComponentContent底层使用了BuilderNode,相关使用规格参考BuilderNode。
听不懂思密达~
ComponentContent 是鸿蒙开发中用于封装组件内容的工具类,可以将复杂的组件传递给 SubTabBarStyle,实现更加复杂的UI交互逻辑。其主要构造函数 ComponentContent(uiContext, builder) 接受一个 UIContext 和一个 全局的Builder 函数,在其中定义具体的组件。
@Builder
function buildText(params: Params) {
Column({ space: 4 }) {
if (params.icon) {
Image(params.icon).width(24).borderRadius(3)
}
Text(params.title).fontSize(14)
}
.width(60)
}
这个 Builder 函数和常用的自定义TabBar自定义差不多。
这里展示一个基础效果:
三、具体实现-酷安案例
最初想在这个导航条上花时间研究就是看到了酷安这个效果,并且这个效果其实应用的很广泛。
实现思路
我们的实现分为两个部分:
- Tabbar样式构建:通过
SubTabBarStyleComponentContent创建基本样式。 - ComponentContent抽取,update更新:因为ComponentContent只能接收全局builder,无法通过状态变量直接交互判断,所以通过update方法更新传参。
- 滑动事件处理:在页面滑动幅度超过一半时,目标页面对应标签就高亮显示。
1. Tabbar样式构建
这里通过提前准备的数据批量生成页面。
tabList: string[] =
['关注', '头条', '热榜', '快讯', '话题', '新机', '开箱', '摄影', '教程', '酷品', '汽车', '视频', '问答', '直播',
'美化', '好物榜']
ForEach(this.tabList, (item: string, index) => {
TabContent() {
Column() {
Text(`我是页面 ${item}`)
.width('100%')
.height('100%')
.textAlign(TextAlign.Center)
.backgroundColor(`#CD${Math.floor(Math.random() * 0x1000000).toString(16)}`)
}.width('100%').height('100%').padding(16)
}.tabBar(SubTabBarStyle.of(this.tabComList[index]) //第二步创建的ComponentContent
.indicator({
color: '#149A5A', // 下划线颜色
height: 3, // 下划线高度
width: 11, // 下划线宽度 b
borderRadius: 4, // 下划线圆角半径
marginTop: 6 // 下划线与文字间距
}))
})
2. ComponentContent抽取
通过tab标签数据批量生成ComponentContent数组,以此创建对应标签页。
@State tabComList: ComponentContent<Params>[] = this.tabList.map((item, index) => {
return new ComponentContent<Params>(this.context, wrapBuilder<[Params]>(buildText),
{ title: item, index: index, selectIndex: 0 })
})
ComponentContent传入的builder,按需传入对应参数。
interface Params {
index: number,
selectIndex: number,
title: string
icon?: ResourceStr
icon_fill?: ResourceStr
}
@Builder
function buildText(params: Params) {
Column({ space: 4 }) {
if (params.icon) {
Image(params.icon)
.width(24)
.borderRadius(3)
}
Text(params.title)
.padding({ left: 4, right: 4 })
.fontSize(params.index === params.selectIndex ? 17 : 16)
.fontWeight(params.index === params.selectIndex ? FontWeight.Bold : FontWeight.Normal)
.fontColor(params.index === params.selectIndex ? '#149A5A' : '#8C8C8C')
.animation({ duration: 200 })
}
}
3. 滑动事件处理
通过onGestureSwipe事件来获取当前滑动幅度(vp),当滑动超过屏幕宽度一半时高亮切换成下一个页签,onChange事件用于验证判断。(应该有更好的解决方式)
.onChange((index) => {
this.tabComList[this.selectIndex].update({
title: this.tabList[this.selectIndex],
index: this.selectIndex,
selectIndex: index
})
this.tabComList[index].update({ title: this.tabList[index], index: index, selectIndex: index })
this.selectIndex = index
this.isSwiping = false; // 滑动结束后重置状态
})
.onGestureSwipe((index, event) => {
promptAction.showToast({ message: JSON.stringify(event, null, 2), bottom: 120 })
const swipeThreshold = 180; // 定义滑动阈值
let newIndex = this.selectIndex; // 初始化新索引
// 判断滑动方向
if (event.currentOffset > swipeThreshold && index !== 0) {
newIndex = this.selectIndex - 1; // 向左滑动
} else if (event.currentOffset < -swipeThreshold && index < this.tabList.length - 1) {
newIndex = this.selectIndex + 1; // 向右滑动
} else {
newIndex = this.selectIndex
}
this.tabComList.forEach((item, index) => {
item.update({
title: this.tabList[index],
index: index,
selectIndex: newIndex
});
});
})
为了实现滑动时(在不松手状态下)切过去还能切回来,这里随滑动一直进行判断。
四、总结
其实实现起来感觉有点麻烦,而且有瑕疵。
页面Demo代码:
import { ComponentContent, promptAction, UIContext } from "@kit.ArkUI"
interface Params {
index: number,
selectIndex: number,
title: string
icon?: ResourceStr
icon_fill?: ResourceStr
}
@Extend(TabContent)
function MyTabbar(context: UIContext, tabItem: Params) {
.tabBar(SubTabBarStyle.of(new ComponentContent<Params>(context, wrapBuilder<[Params]>(buildText),
tabItem))
.indicator({
color: '#149A5A', // 下划线颜色
height: 3, // 下划线高度
width: 11, // 下划线宽度 b
borderRadius: 4, // 下划线圆角半径
marginTop: 6 // 下划线与文字间距
}))
}
@Builder
function buildText(params: Params) {
Column({ space: 4 }) {
if (params.icon) {
Image(params.icon)
.width(24)
.borderRadius(3)
}
Text(params.title)
.padding({ left: 4, right: 4 })
.fontSize(params.index === params.selectIndex ? 17 : 16)
.fontWeight(params.index === params.selectIndex ? FontWeight.Bold : FontWeight.Normal)
.fontColor(params.index === params.selectIndex ? '#149A5A' : '#8C8C8C')
.animation({ duration: 200 })
}
}
@Entry
@Component
struct FYYYTabs {
private controller: TabsController = new TabsController();
context: UIContext = this.getUIContext()
@State selectIndex: number = 0
tabList: string[] =
['关注', '头条', '热榜', '快讯', '话题', '新机', '开箱', '摄影', '教程', '酷品', '汽车', '视频', '问答', '直播',
'美化', '好物榜']
@State tabComList: ComponentContent<Params>[] = this.tabList.map((item, index) => {
return new ComponentContent<Params>(this.context, wrapBuilder<[Params]>(buildText),
{ title: item, index: index, selectIndex: 0 })
})
@State isSwiping: boolean = false; // 状态标志
build() {
Column() {
Tabs({ barPosition: BarPosition.End }) {
ForEach(this.tabList, (item: string, index) => {
TabContent() {
Column() {
Text(`我是页面 ${item}`)
.width('100%')
.height('100%')
.textAlign(TextAlign.Center)
.backgroundColor(`#CD${Math.floor(Math.random() * 0x1000000).toString(16)}`)
}.width('100%').height('100%').padding(16)
}.tabBar(SubTabBarStyle.of(this.tabComList[index])
.indicator({
color: '#149A5A', // 下划线颜色
height: 3, // 下划线高度
width: 11, // 下划线宽度 b
borderRadius: 4, // 下划线圆角半径
marginTop: 6 // 下划线与文字间距
}))
})
}
.barPosition(BarPosition.Start)
.barBackgroundColor('#fff')
.scrollable(true)
.fadingEdge(false)
.barMode(BarMode.Scrollable)
.barHeight(80)
.animationDuration(180)
.backgroundColor(0xF5F5F5)
.width('100%')
.height('100%')
.onChange((index) => {
this.tabComList[this.selectIndex].update({
title: this.tabList[this.selectIndex],
index: this.selectIndex,
selectIndex: index
})
this.tabComList[index].update({ title: this.tabList[index], index: index, selectIndex: index })
this.selectIndex = index
this.isSwiping = false; // 滑动结束后重置状态
})
.onGestureSwipe((index, event) => {
promptAction.showToast({ message: JSON.stringify(event, null, 2), bottom: 120 })
const swipeThreshold = 180; // 定义滑动阈值
if (this.isSwiping) {
// return;
} // 如果正在滑动,则不处理
let newIndex = this.selectIndex; // 初始化新索引
// 判断滑动方向
if (event.currentOffset > swipeThreshold && index !== 0) {
newIndex = this.selectIndex - 1; // 向左滑动
} else if (event.currentOffset < -swipeThreshold && index < this.tabList.length - 1) {
newIndex = this.selectIndex + 1; // 向右滑动
} else {
newIndex = this.selectIndex
}
// 如果新索引发生变化,进行更新
// if (newIndex !== this.selectIndex) {
// this.isSwiping = true; // 设置状态为正在滑动
// promptAction.showToast({ message: '已经变化', bottom: 120 });
this.tabComList.forEach((item, index) => {
item.update({
title: this.tabList[index],
index: index,
selectIndex: newIndex
});
});
// this.selectIndex = newIndex; // 更新当前选中的索引
// }
})
// .onAnimationEnd((index) => {
// // promptAction.showToast({ message: '切换结束,索引为' + index, bottom: 120 })
// })
}.width('100%')
}
}