场景描述
在页面布局过程中,Tabs可以将产品包含的所有内容进行清晰分类,一目了然地呈现应用的内容范围,方便概览与跳转
- 场景一:tab嵌套list的吸顶效果
场景二:tabbar样式自定义:
- tabs切换、监听
- 样式自定义
- tabbar尾端文字渐变
- 场景三:tabContent切换动画
方案描述
场景一:tab嵌套list的吸顶效果
方案一:
实现思路:
1、最外层为tabs组件,首页tabContent主要用的stack组件嵌套了scroll组件+导航输入框组件,其中scroll组件嵌套了tabs组件,tabs里面嵌套list组件。
2、外层的滚动组件scroll主要通过onScroll,onScrollEdge以及onScrollFrameBegin回调判断页面是否在顶部,中间还是底部。
3、里层list组件也是通过onReachStart,onReachEnd,onScrollFrameBegin回调来判断list列表是否在顶部,中间还是底部,使用scrollBy滑动指定距离。如Scroll嵌套List滚动时,List组件的edgeEffect属性需设置为EdgeEffect.None。
核心代码
// scroll部分主要逻辑
enum ScrollPosition{
start,
center,
end
}
@Entry
@Component
struct NestedScroll {
@State listPosition: number = ScrollPosition.start; // 0代表滚动到List顶部,1代表中间值,2代表滚动到List底部。
@State scrollPosition: number = ScrollPosition.start; // 0代表滚动到页面顶部,1代表中间值,2代表滚动到页面底部。
...
build() {
Column() {
Tabs({ barPosition: BarPosition.End, index: this.currentIndex, controller: this.TabsController }) {
TabContent() {
Stack({ alignContent: Alignment.Top }) {
Scroll(this.scrollerForScroll) {
Column() {
Column(){
}
.width("100%")
.height("40%")
.backgroundColor(Color.Pink)
// tabbar
Row({ space: 7 }) {
Scroll() {
...
}
}
//tabs
Tabs({ barPosition: BarPosition.Start, controller: this.subsController }) {
TabContent() {
List({ space: 10, scroller: this.scrollerForList }) {
...
}
.onReachStart(() => {
this.listPosition = ScrollPosition.start
})
.onReachEnd(() => {
this.listPosition = ScrollPosition.end
})
.onScrollFrameBegin((offset: number, state: ScrollState) => {
console.info('chenoffset::'+offset)
// 滑动到列表中间时
if (!((this.listPosition == ScrollPosition.start && offset < 0)
|| (this.listPosition == ScrollPosition.end && offset > 0))) {
this.listPosition = ScrollPosition.center
}
// 如果页面已滚动到底部 且 列表不在顶部或列表有正向偏移量
if (this.scrollPosition == ScrollPosition.end
&& (this.listPosition != ScrollPosition.start || offset > 0)) {
console.info('chenoffsetscrollBy::'+offset)
return { offsetRemain: offset };
} else {
// scrollBy滑动指定距离
console.info('chenoffsetscrollBy滑动指定距离::'+offset)
this.scrollerForScroll.scrollBy(0, offset)
return { offsetRemain: 0 };
}
})
}.tabBar('关注')
...
}
}
.width("100%")
.height("92%")
.backgroundColor('#F1F3F5')
}
}
.scrollBar(BarState.Off)
.width("100%")
.height("100%")
// 滚动事件回调, 返回竖直方向偏移量,单位vp
.onScroll((xOffset: number, yOffset: number) => {
this.currentYOffset = this.scrollerForScroll.currentOffset().yOffset;
console.info('this.currentYOffset'+this.currentYOffset)
// 非(页面在顶部或页面在底部),则页面在中间
if (!((this.scrollPosition == ScrollPosition.start && yOffset < 0)
|| (this.scrollPosition == ScrollPosition.end && yOffset > 0))) {
this.scrollPosition = ScrollPosition.center
}
})
// 当组件滚动到边缘时触发
.onScrollEdge((side: Edge) => {
if (side == Edge.Top) {
// 页面在顶部
this.scrollPosition = ScrollPosition.start
} else if (side == Edge.Bottom) {
// 页面在底部
this.scrollPosition = ScrollPosition.end
}
})
.onScrollFrameBegin(offset => {
if (this.scrollPosition == ScrollPosition.end) {
return { offsetRemain: 0 };
} else {
return { offsetRemain: offset };
}
})
// 顶部导航输入框
Row() {
TextInput({ text: '', placeholder: 'input your word...', controller: this.controller }).fontSize(24)
}
.justifyContent(FlexAlign.Center)
.backgroundColor('#00ffffff')
.width('100%')
.height('8%')
}
}.tabBar(this.tabBuilder(0, '首页'))
...
}
...
}.width('100%')
}
}
方案二:
通过原生属性nestedScroll,结合calc计算高度实现上述效果
核心代码
Tabs({ barPosition: BarPosition.Start, controller: this.subsController }) {
TabContent() {
List({ space: 10, scroller: this.scrollerForList }) {
...
}
.nestedScroll({
scrollForward: NestedScrollMode.PARENT_FIRST,
scrollBackward: NestedScrollMode.SELF_FIRST
})
场景二:tabbar样式自定义
方案
由于tabs本身是有组件进行封装的,如果需要自定义样式,可以使用swiper自定义实现,Swiper在能力演进上会比Tabs能力强,比如使用swiper自定义的tabs组件可以实现数据懒加载功能
通过swiper实现tabs以下功能点:
1、下划线跟手动画:通过swiper的onGestureSwipe在页面跟手滑动过程中的回调,返回index以及extraInfo动画相关信息来判断当前index、页签距离左边margin,以及当前页签的宽度信息等,再利用动画开始以及动画结束回调结合animateTo实现下划线的动效。
2、tabbar 选中文字颜色变化:判断是否为currentIndex设置为不一样的文字颜色。
3、tabbar 选中页签位置居中:用scroll+row自定义页签栏,通过scroll实现页签停留位置居中效果。
4、使用图像效果blendMode,将当前控件的内容与下方画布已有内容进行混合,给自定义tabbar的组件row设置.blendMode,给row的父组件设置linearGradient以及blendMode来实现文字尾端渐变效果。
关于blendMode枚举说明,s表示源像素,d表示目标像素,sa表示原像素透明度,da表示目标像素透明度,r表示混合后像素,ra表示混合后像素透明度。
BlendMode.SRC\_IN:r = s * da,只显示源像素中与目标像素重叠的部分。
BlendMode.SRC\_OVER:r = s * (1 - da),只显示源像素中与目标像素不重叠的部分。
BlendApplyType.OFFSCREEN:将此组件和子组件内容绘制到离屏画布上,然后整体进行混合
核心代码
第一步:通过scroll组件+row组件实现自定义可滑动的tabbar
Row(){
Column() {
Scroll(this.scroller) {
Row() {
ForEach(this.arr, (item: string, index: number) => {
Column() {
Text(item)
.fontSize(16)
.borderRadius(5)
//字体颜色粗细变化
.fontColor(this.indicatorIndex === index ? Color.Red : Color.Black)
.fontWeight(this.indicatorIndex === index ? FontWeight.Bold : FontWeight.Normal)
.margin({ left: this.initialTabMargin, right: this.initialTabMargin })
.id(index.toString())
.onAreaChange((oldValue: Area, newValue: Area) => {
if (this.indicatorIndex === index && (this.indicatorMarginLeft === 0 || this.indicatorWidth === 0)) {
if (newValue.globalPosition.x != undefined) {
let positionX = Number.parseFloat(newValue.globalPosition.x.toString());
this.indicatorMarginLeft = Number.isNaN(positionX) ? 0 : positionX;
}
let width = Number.parseFloat(newValue.width.toString());
this.indicatorWidth = Number.isNaN(width) ? 0 : width;
}
})
.onClick(() => {
this.indicatorIndex = index;
this.underlineScrollAuto(this.animationDuration, index);
this.scrollIntoView(index);
// swiper进行联动
this.swiperIndex = index;
})
}
.width(this.textLength[index] * 28)
}, (item: string) => item)
}
.height(32)
}
.width('100%')
.scrollable(ScrollDirection.Horizontal)
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
.onScroll((xOffset: number, yOffset: number) => {
console.info(xOffset + ' ' + yOffset)
this.indicatorMarginLeft -= xOffset;
})
.onScrollStop(() => {
console.info('Scroll Stop')
this.underlineScrollAuto(0, this.indicatorIndex);
})
//下划线
Column()
.width(this.indicatorWidth)
.height(2)
.borderRadius(2)
.backgroundColor(Color.Red)
.alignSelf(ItemAlign.Start)
.margin({ left: this.indicatorMarginLeft, top: 5 })
}
.width('92%')
.margin({ top: 15, bottom: 10})
Text('更多')
.width(36)
.height(50)
.backgroundColor(Color.Pink)
.fontSize(16)
.borderRadius(5)
}
第二步:通过swiper组件来写tabContent对应的区域,主要用swiper的属性index(this.swiperIndex)来联动上面的自定义tabbar,swiper里面可以使用LazyForEach来实现数据懒加载功能
Swiper(this.swiperController) {
LazyForEach(this.data, (item: number) => {
Column() {
Text(item.toString())
...
}
.onAreaChange((oldValue: Area, newValue: Area) => {
let width = Number.parseFloat(newValue.width.toString());
this.swiperWidth = Number.isNaN(width) ? 0 : width;
})
}, (item: string) => item)
}
.onChange((index: number)=>{
this.swiperIndex = index;
})
.cachedCount(2)
.index(this.swiperIndex)
.indicator(false)
.curve(this.animationCurve)
.loop(false)
第三步:1、通过swiper的onGestureSwipe,实现跟手过程中是左滑还是右滑,计算当前以及下一个目标页面的索引值,当前距离左边的距离,以及当前tabbar的宽度2、通过用componentUtils.getRectangleById,获取指定id的组件大小、位置、平移缩放旋转及仿射矩阵属性信息,得到当前距离左边的距离以及对应tabbar的宽度,用onAnimationStart在切换动画开始触发的时候,下划线跟踪页面一起滑动,同时宽度渐变,3、当滑动结束时通过onAnimationEnd以及自定义tabbar的scrollTo等回调实现tabbar在滚动结束之后再中间位置
.onAnimationStart((index: number, targetIndex: number, event: SwiperAnimationEvent) => {
// 切换动画开始时触发该回调。下划线跟着页面一起滑动,同时宽度渐变。
this.indicatorIndex = targetIndex;
this.underlineScrollAuto(this.animationDuration, targetIndex);
})
.onAnimationEnd((index: number, event: SwiperAnimationEvent) => {
// 切换动画结束时触发该回调。下划线动画停止。
let currentIndicatorInfo = this.getCurrentIndicatorInfo(index, event);
this.startAnimateTo(0, currentIndicatorInfo.left, currentIndicatorInfo.width);
this.scrollIntoView(index);
})
.onGestureSwipe((index: number, event: SwiperAnimationEvent) => {
// 在页面跟手滑动过程中,逐帧触发该回调。
let currentIndicatorInfo = this.getCurrentIndicatorInfo(index, event);
this.indicatorIndex = currentIndicatorInfo.index;//当前页签index
this.indicatorMarginLeft = currentIndicatorInfo.left;//当前页签距离左边margin
this.indicatorWidth = currentIndicatorInfo.width;//当前页签宽度
})
// 获取屏幕宽度,单位vp
private getDisplayWidth(): number {
return this.displayInfo != null ? px2vp(this.displayInfo.width) : 0;
}
// 获取组件大小、位置、平移缩放旋转及仿射矩阵属性信息。
private getTextInfo(index: number): Record<string, number> {
let modePosition :componentUtils.ComponentInfo = componentUtils.getRectangleById(index.toString());
try {
return { 'left': px2vp(modePosition.windowOffset.x), 'width': px2vp(modePosition.size.width) }
} catch (error) {
return { 'left': 0, 'width': 0 }
}
}
// 当前下划线动画
private getCurrentIndicatorInfo(index: number, event: SwiperAnimationEvent): Record<string, number> {
let nextIndex = index;
// 滑动范围限制,Swiper不可循环,Scroll保持不可循环
if (index > 0 && event.currentOffset > 0) {
nextIndex--; // 左滑
} else if (index < this.data.totalCount() - 1 && event.currentOffset < 0) {
nextIndex++; // 右滑
}
this.nextIndicatorIndex = nextIndex;
// 获取当前tabbar的属性信息
let indexInfo = this.getTextInfo(index);
// 获取目标tabbar的属性信息
let nextIndexInfo = this.getTextInfo(nextIndex);
// 滑动页面超过一半时页面切换
this.swipeRatio = Math.abs(event.currentOffset / this.swiperWidth);
let currentIndex = this.swipeRatio > 0.5 ? nextIndex : index; // 页面滑动超过一半,tabBar切换到下一页。
let currentLeft = indexInfo.left + (nextIndexInfo.left - indexInfo.left) * this.swipeRatio;
let currentWidth = indexInfo.width + (nextIndexInfo.width - indexInfo.width) * this.swipeRatio;
this.indicatorIndex = currentIndex;
return { 'index': currentIndex, 'left': currentLeft, 'width': currentWidth };
}
private scrollIntoView(currentIndex: number): void {
const indexInfo = this.getTextInfo(currentIndex);
let tabPositionLeft = indexInfo.left;
let tabWidth = indexInfo.width;
// 获取屏幕宽度,单位vp
const screenWidth = this.getDisplayWidth();
const currentOffsetX: number = this.scroller.currentOffset().xOffset;//当前滚动的偏移量
this.scroller.scrollTo({
// 将tabbar可滑动时候定位在正中间
xOffset: currentOffsetX + tabPositionLeft - screenWidth / 2 + tabWidth / 2,
yOffset: 0,
animation: {
duration: this.animationDuration,
curve: this.animationCurve, // 动画曲线
}
});
this.underlineScrollAuto(this.animationDuration, currentIndex);
}
private startAnimateTo(duration: number, marginLeft: number, width: number): void {
animateTo({
duration: duration, // 动画时长
curve: this.animationCurve, // 动画曲线
onFinish: () => {
console.info('play end')
}
}, () => {
this.indicatorMarginLeft = marginLeft;
this.indicatorWidth = width;
})
}
// 下划线动画
private underlineScrollAuto(duration: number, index: number): void {
let indexInfo = this.getTextInfo(index);
this.startAnimateTo(duration, indexInfo.left, indexInfo.width);
}
第四步:使用图像效果blendMode以及颜色渐变linearGradient实现文字尾端有渐变的效果
Scroll(this.scroller) {
Row() {
ForEach(this.arr, (item: string, index: number) => {
...
}, (item: string) => item)
}
.blendMode(BlendMode.SRC_IN, BlendApplyType.OFFSCREEN)
.backgroundColor(Color.Transparent)
.height(32)
}
// 设置tabbar文字尾端显隐
.linearGradient({
angle: 90,
colors: [['rgba(0, 0, 0, 0)', 0], ['rgba(0, 0, 0, 1)', 0], ['rgba(0, 0, 0, 1)', 0.9], ['rgba(0, 0, 0, 0)', 1]]
})
.blendMode(BlendMode.SRC_OVER, BlendApplyType.OFFSCREEN)
场景三:tabContent切换动画
方案
通过customContentTransition实现了自定义Tabs页面的切换动画,index0-1,2-3是缩放,其他页面切换时显隐from:动画开始时,当前页面的index值。to:动画开始时,目标页面的index值。使用customContentTransition注意事项:1、当使用自定义切换动画时,Tabs组件自带的默认切换动画会被禁用,同时,页面也无法跟手滑动。2、当设置为undefined时,表示不使用自定义切换动画,仍然使用组件自带的默认切换动画。3、当前自定义切换动画不支持打断。4、目前自定义切换动画只支持两种场景触发:点击页签和调用TabsController.changeIndex()接口。
核心代码
// 自定义tabContent切换效果
// customContentTransition 控制是否为undefined
@State useCustomAnimation: boolean = true
// tabContent对应内容区域缩放值
@State tabContent0Scale: number = 1.0
@State tabContent1Scale: number = 1.0
@State tabContent2Scale: number = 1.0
@State tabContent3Scale: number = 1.0
// tabContent对应内容区域显隐值
@State tabContent0Opacity: number = 1.0
@State tabContent1Opacity: number = 1.0
@State tabContent2Opacity: number = 1.0
@State tabContent3Opacity: number = 1.0
private firstTimeout: number = 1000
private secondTimeout: number = 1000
private first2secondDuration: number = 2000
private second2thirdDuration: number = 2000
private first2thirdDuration: number = 2000
// - from:动画开始时,当前页面的index值。
// - to:动画开始时,目标页面的index值。
private baseCustomAnimation: (from: number, to: number) => TabContentAnimatedTransition = (from: number, to: number) => {
if ((from === 0 && to === 1) || (from === 1 && to === 0)|| (from === 2 && to === 3)||(from ===3 && to === 2)) {
// 缩放动画
let firstCustomTransition = {
timeout: this.firstTimeout,
transition: (proxy: TabContentTransitionProxy) => {
if (proxy.from === 0 && proxy.to === 1) {
this.tabContent0Scale = 1.0
this.tabContent1Scale = 0.5
} else {
this.tabContent0Scale = 0.5
this.tabContent1Scale = 1.0
}
if (proxy.from === 2 && proxy.to === 3) {
this.tabContent2Scale = 1.0
this.tabContent3Scale = 0.5
this.tabContent3Opacity = 1.0
} else {
this.tabContent2Scale = 0.5
this.tabContent3Scale = 1.0
this.tabContent2Opacity = 1.0 //透明度
}
animateTo({
duration: this.first2secondDuration,
onFinish: () => {
proxy.finishTransition()
}
}, () => {
if (proxy.from === 0 && proxy.to === 1) {
this.tabContent0Scale = 0.5
this.tabContent1Scale = 1.0
} else {
this.tabContent0Scale = 1.0
this.tabContent1Scale = 0.5
}
if (proxy.from === 2 && proxy.to === 3) {
this.tabContent2Scale = 0.5
this.tabContent3Scale = 1.0
this.tabContent2Opacity = 1.0 //透明度
} else {
this.tabContent2Scale = 1.0
this.tabContent3Scale = 0.5
}
})
}
} as TabContentAnimatedTransition;
return firstCustomTransition;
} else {
// 透明度动画
let secondCustomTransition = {
timeout: this.secondTimeout,
transition: (proxy: TabContentTransitionProxy) => {
if ((proxy.from === 1 && proxy.to === 2) || (proxy.from === 2 && proxy.to === 1)) {
if (proxy.from === 1 && proxy.to === 2) {
this.tabContent1Opacity = 1.0
this.tabContent2Opacity = 0.5
} else {
this.tabContent1Opacity = 0.5
this.tabContent2Opacity = 1.0
this.tabContent1Scale = 1.0
}
animateTo({
duration: this.second2thirdDuration,
onFinish: () => {
proxy.finishTransition()
}
}, () => {
if (proxy.from === 1 && proxy.to === 2) {
this.tabContent1Opacity = 0.5
this.tabContent2Opacity = 1.0
this.tabContent2Scale = 1.0
} else {
this.tabContent1Opacity = 1.0
this.tabContent2Opacity = 0.5
}
})
} else if ((proxy.from === 0 && proxy.to === 2) || (proxy.from === 2 && proxy.to === 0) || (proxy.from === 0 && proxy.to === 3) || (proxy.from === 3 && proxy.to === 0) ) {
if (proxy.from === 0 && proxy.to === 2) {
this.tabContent0Opacity = 1.0
this.tabContent2Opacity = 0.5
} else {
this.tabContent0Opacity = 0.5
this.tabContent2Opacity = 1.0
}
if (proxy.from === 0 && proxy.to === 3) {
this.tabContent0Opacity = 1.0
this.tabContent3Opacity = 0.5
} else {
this.tabContent0Opacity = 0.5
this.tabContent3Opacity = 1.0
}
animateTo({
duration: this.first2thirdDuration,
onFinish: () => {
proxy.finishTransition()
}
}, () => {
if (proxy.from === 0 && proxy.to === 2) {
this.tabContent0Opacity = 0.5
this.tabContent2Opacity = 1.0
} else {
this.tabContent0Opacity = 1.0
this.tabContent2Opacity = 0.5
}
if (proxy.from === 0 && proxy.to === 3) {
this.tabContent0Opacity = 0.5
this.tabContent3Opacity = 1.0
} else {
this.tabContent0Opacity = 1.0
this.tabContent3Opacity = 0.5
}
})
}
}
} as TabContentAnimatedTransition;
return secondCustomTransition;
}
}
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。