ToB企服应用市场:ToB评测及商务社交产业平台
标题:
鸿蒙HarmonyOS (开发进阶)自定义Tab效果实践案例
[打印本页]
作者:
道家人
时间:
2024-11-21 17:26
标题:
鸿蒙HarmonyOS (开发进阶)自定义Tab效果实践案例
鸿蒙NEXT开发实战往期必看文章:
一分钟了解”纯血版!鸿蒙HarmonyOS Next应用开发!
“非常详细的” 鸿蒙HarmonyOS Next应用开发学习路线!(从零基础入门到精通)
HarmonyOS NEXT应用开发案例实践总结合(连续更新......)
HarmonyOS NEXT应用开发性能优化实践总结(连续更新......)
介绍
本示例介绍利用List、Text等组件,以及animateTo等接口实现自定义Tab效果
效果预览图
利用说明
1.选中页签,字体放大加粗且背面有配景条,起到强调作用。
2.手势触摸tab内容滑动,配景条跟随手势一起滑动。抬手时,当tab内容滑动间隔不足一半时,会自动回弹,而当tab内容滑动间隔大于一半时,配景条则会移动到下一个页签。当配景条滑动到一定间隔后开始滑动页签条,使配景条始终能够保持在可视范围内。
3.点击页签,可以进行页签切换。
4.滑动页签条,配景条也会随之一起滑动,然后滑动tab内容,页签条会滑动到原处,使配景条处于可视范围内,之后配景条开始跟随手势滑动。
5.动画承接,配景条滑动过程中,触摸屏幕,配景条动画制止,松开手势,配景条继续滑动
下载安装
ohpm install @ohos-cases/custom_animation_tab
复制代码
快速利用
1.数据准备(以tab单个页面为例)
首先构建一个TabInfo数组,然后向此中传入对应的内容。以base页面为例,首先创建一个@Builder函数,在该函数中填入struct组件,在struct组件的build的函数中编写对应页面。然后,实现对应的tabBar界面,此中传入了一个TabBarItemInterface匿名类,包括了一些必要属性,可以自定义动态属性修改。
// tab数据
tabsInfo: TabInfo[] = [];
this.tabsInfo = [
new TabInfo(CustomAnimationTabConfigure.DEFAULT_BASE_TAB, wrapBuilder(baseBuilder), wrapBuilder(tabBar)),
new TabInfo(CustomAnimationTabConfigure.DEFAULT_UI_TAB, wrapBuilder(uiBuilder), wrapBuilder(tabBar)),
new TabInfo(CustomAnimationTabConfigure.DEFAULT_DYEFFECT_TAB, wrapBuilder(dyEffectBuilder), wrapBuilder(tabBar)),
new TabInfo(CustomAnimationTabConfigure.DEFAULT_THIRTYPARTY_TAB, wrapBuilder(thirdPartyBuilder), wrapBuilder(tabBar)),
new TabInfo(CustomAnimationTabConfigure.DEFAULT_NATIVE_TAB, wrapBuilder(nativeBuilder), wrapBuilder(tabBar)),
new TabInfo(CustomAnimationTabConfigure.DEFAULT_OTHER_TAB, wrapBuilder(otherBuilder), wrapBuilder(tabBar))
]
// baseBuilder页面
import LazyDataSource from './LazyDataSource';
import { SkeletonLayout } from './SkeletonLayout';
@Builder
export function baseBuilder() {
BasePage();
}
@Component
struct BasePage {
@State data: LazyDataSource<string> = new LazyDataSource<string>();
aboutToAppear(): void {
for (let i = 0; i < 100; i++) {
this.data.pushData(`${i}`);
}
}
build() {
Column() {
List() {
LazyForEach(this.data, (data: string) => {
ListItem() {
SkeletonLayout({isMine: false})
}
})
}
.cachedCount(1)
.width("100%")
.height("100%")
}
.width("100%")
.height("100%")
}
}
// tabBar样式
@Builder
function tabBar($$: TabBarItemInterface) {
Text($$.title)
.fontSize($$.curIndex === $$.index ? $r("app.float.custom_animation_tab_list_select_font_size") : $r("app.float.custom_animation_tab_list_unselect_font_size"))
.fontColor($r("app.color.custom_animation_tab_list_font_color"))
.fontWeight($$.curIndex === $$.index ? FontWeight.Bold : FontWeight.Medium)
.textAlign(TextAlign.Center)
.height($r("app.string.custom_animation_tab_one_hundred_percent"))
.width($r("app.string.custom_animation_tab_one_hundred_percent"))
}
复制代码
2.动效属性准备
动效属性父类为AnimationAttribute,此中提供了tab动效必备的属性,开发者后续可以通过继承方式添加自定义动效。(留意class对象属性级更新的正确利用)
import { AnimationAttribute } from '@ohos/customanimationtab';
// 定义自定义动效属性
export class MyAnimationAttribute extends AnimationAttribute {
indicatorBarColor: ResourceColor;
constructor(indicatorBarWidth: number, indicatorBarColor: ResourceColor) {
super(indicatorBarWidth);
this.indicatorBarColor = indicatorBarColor;
}
}
复制代码
@Builder
indicatorBar($$: BaseInterface) {
Column()
.height($r("app.float.custom_animation_tab_indicator_height"))
.width($r("app.string.custom_animation_tab_one_hundred_percent"))
.backgroundColor(this.animationAttribute.indicatorBarColor)
.borderRadius($r("app.float.custom_animation_tab_indicator_border_radius"))
}
// 更新动效变量
Column() {
Button($r("app.string.custom_animation_tab_button_text"))
.height($r("app.string.custom_animation_tab_ninety_percent"))
.type(ButtonType.Capsule)
.onClick(() => {
if ((this.animationAttribute.indicatorBarColor as Resource).id ===
$r("app.color.custom_animation_tab_indicator_color").id) {
this.animationAttribute.indicatorBarColor = Color.Yellow;
} else if (this.animationAttribute.indicatorBarColor === Color.Yellow) {
this.animationAttribute.indicatorBarColor = $r("app.color.custom_animation_tab_indicator_color");
}
})
}
.justifyContent(FlexAlign.Center)
.height($r("app.string.custom_animation_tab_ten_percent"))
.width($r("app.string.custom_animation_tab_one_hundred_percent"))
复制代码
3.配景条配置
配景条可以自行new IndicatorBarAttribute配置,也可以利用已有的配景条配置(现在支持两种: IndicatorBarAttribute.BACKGROUNDBAR和IndicatorBarAttribute.THINSTRIP)
indicatorBarAttribute: IndicatorBarAttribute = new IndicatorBarAttribute(this.indicatorBar);
复制代码
4.页签条配置
tabBarAttribute: TabBarAttribute = new TabBarAttribute(CustomAnimationTabConfigure.LIST_ITEM_WIDTH)
复制代码
5.tab及页签条控制器配置
// tabController
tabController: CustomAnimationTabController = new CustomAnimationTabController();
// scroller
scroller: Scroller = new Scroller();
复制代码
6.构建Tab
/**
* 构建自定义动效Tab
* tabsInfo: tab基本信息
* indicatorBarAttribute: 背景条属性
* tabBarAttribute: 页签条属性
* animationAttribute: 动效属性
* tabController: 自定义动效tab控制器
* scroller: 页签条滚动控制器
*/
CustomAnimationTab({
animationAttribute: this.animationAttribute,
tabsInfo: this.tabsInfo,
indicatorBarAttribute: this.indicatorBarAttribute,
tabBarAttribute: this.tabBarAttribute,
tabController: this.tabController,
scroller: this.scroller
})
复制代码
属性(接口)说明
CustomAnimationTab组件属性
属性类型释义默认值animationAttributeAnimationAttribute动效属性(必须)undefinedtabsInfoTabInfo[]tab信息-indicatorBarAttributeIndicatorBarAttribute配景条属性-tabBarAttributeAnimationAttribute页签条属性-tabControllerCustomAnimationControllertab控制器-scrollerScroller页签条控制器-animationDurationnumber页签切换时长240msstartIndexnumber起始页签索引0gestureAnimation(index: number, targetIndex: number, elementsInfo: [number, number][], ratio: number) => void手势滑动动效-autoAnimation(index: number, targetIndex: number, elementsInfo: [number, number][], ratio: number) => void自动滑动动效-clickAnimation(index: number, targetIndex: number, indexInfo: Record<string, number>, targetIndexInfo: Record<string, number>, elementsInfo: [number, number][]) => void点击页签动效-getScrollInfo(center: number, width: number) => [number, number]获取页签对应的配景条位置以及页签条偏移- AnimationAttribute类属性
属性类型释义默认值indicatorBarWidthnumber配景条宽度- TabInfo类属性
属性类型释义默认值titlestringtab项标题-contentbuilderWrappedBuilder<[]>tab项内容-barBuilderWrappedBuilder<[TabBarItemInterface]>tabBar(页签样式)undefined IndicatorBarAttribute类属性
属性类型释义默认值indicatorBar(index: BaseInterface) => void自定义配景条-maxIndicatorBarLeftnumber配景条最大偏移(<0: 无上限, >=0: innerMaxIndicatorBarLeft)-1barAlignVerticalAlign配景条垂直结构VerticalAlign.Center TabBarAttribute类属性
属性类型释义默认值barItemWidthLength页签宽度70barEdgeEffectEdgeEffect页签条边缘滑动效果EdgeEffect.SpringbarHeightLength页签条高度40scrollableboolean是否可以滚动页签条(平分全部页签宽度,barItemWidth失效)truebarVerticalBarPosition页签条位置BarPosition.StartbarBackgroundColorResourceColor页签条配景颜色Color.Transparent
实现思绪
本案例的功能实现主要可以分为两个部分:第一个是点击页签的切换,第二个是滑动tab的切换。在后续两小节将对以上两个部分进行详细介绍。然后,我们展示了一些紧张的变量名及其含义。
maxListOffset:页签条最大偏移间隔
maxIndicatorBarLeft: 配景条最大偏移间隔
AnimationAttribute.left:配景条位置
1.核心函数getScrollInfo
由于我们将页签动画效果分为两种不同类型的滑动,因此必要实现一个函数以分别获取每个页签对应的配景条位置以及页签条滑动偏移。
1.1 配景条最大滑动间隔以及页签条最大滑动间隔
首先,我们必要了解两个概念:配景条最大滑动间隔以及页签条最大滑动间隔,如下图所示。
(1)配景条最大偏移间隔:配景条滑动到该处时不再向后滑动,此时页签条接受滑动。
(2)页签条最大偏移间隔:当页签条接受滑动以后,当滑动到末尾时,无法向后滑动,此时配景条再次接受滑动。
1.2 三个阶段
从上面的两个概念,我们可以看出滑动主要可以切分为三个阶段:1)配景条初始滑动阶段;2)页签条滑动阶段;3)配景条再次滑动阶段。
1.3 代码实现
通过以上两个小节的介绍,我们可以实验实当代码。具体代码如下所示:
/**
* 获取页签对应的背景条位置以及页签条偏移
* @param center - 页签中心点
* @param width - 页签条宽度
* @returns: [背景条左端位置, 页签条偏移]
*/
@Param getScrollInfo: (center: number, width: number) => [number, number] =
(center: number, width: number): [number, number] => {
// 获取背景条位置
let indicatorLeft: number = center - this.indicatorBarWith / 2;
// TODO: 知识点: 当背景条位置大于默认的背景条最大位置时,选取背景条最大位置作为背景条实际位置
let finalIndicatorLeft: number = this.maxIndicatorBarLeft >= 0 ? Math.min(indicatorLeft, this.maxIndicatorBarLeft) : indicatorLeft;
// TODO: 知识点: 背景条产生的多余距离作为页签条滑动距离
let listOffset: number = indicatorLeft - finalIndicatorLeft;
// TODO: 知识点: 当页签条偏移大于页签条可偏移量,选取页签条可偏移量作为页签条实际偏移
let finalListOffset: number = Math.min(listOffset, this.maxListOffset);
// TODO: 知识点: 页签条多余的偏移作为背景条后续的滑动距离
finalIndicatorLeft += listOffset - finalListOffset;
return [finalIndicatorLeft, finalListOffset];
};
复制代码
具体思绪:首先我们可以根据页签的位置信息获取对应配景条的初始位置。1)第一阶段:配景条初始位置就是配景条的实际位置;2)第二阶段:当配景条偏移大于配景条最大偏移间隔时,进入第二阶段。这时候后续多余的配景条偏移必要作为页签条偏移,以实现页签条移动;3)第三阶段:当页签条偏移大于页签条最大偏移量时,进入第三阶段。此时多余的页签条偏移会作为配景条的偏移,使配景条继续向后滑动。
2.点击页签的切换
首先在onChange回调中实现对应的动画效果,当事件为点击事件并且必要进行页签切换时才进入到对应的动画效果实现,此中首先通过获取index页签的中心位置计算配景条位置,以实现配景条移动到当前页签位置。然后,通过elementsInfo数组获取index页签对应的页签条偏移,从而对页签条进行滑动。而配景条的滑动则通过页签条的滑动回调函数onDidScroll来进行。
// tab
Swiper(this.swiperController) {
// 布局实现
}
.onChange((index: number) => {
// 点击事件且发生页签切换
if (this.listTouchState === 1 && index !== this.curIndex) {
let indexInfo: Record<string, number> = this.getElementInfo(this.curIndex);
let targetIndexInfo: Record<string, number> = this.getElementInfo(index);
this.clickAnimation(this.curIndex, index, indexInfo, targetIndexInfo, this.elementsInfo);
}
this.curIndex = index;
console.log(`curIndex: ${this.curIndex}`)
})
@Param clickAnimation: (index: number, targetIndex: number, indexInfo: Record<string, number>,
targetIndexInfo: Record<string, number>, elementsInfo: [number, number][]) => void =
(index: number, targetIndex: number, indexInfo: Record<string, number>,
targetIndexInfo: Record<string, number>, elementsInfo: [number, number][]): void => {
// 根据targetIndex页签当前位置获取对应的背景条位置
this.animationAttribute.left = targetIndexInfo.center - this.indicatorBarWith / 2;
this.scroller!.scrollTo({xOffset: elementsInfo[targetIndex][1], yOffset: 0, animation: {duration: this.animationDuration, curve: Curve.Linear}});
};
复制代码
在页签点击事件中触发页签切换事件,后续就会触发tab的onChange事件实现切换动画。
// 页签点击事件
ListItem() {
// 布局实现
}
.onClick(() => {
this.listTouchState = 1;
this.tabController.changeIndex(index);
})
复制代码
2.滑动Tab的切换
滑动页签切换主要分为两个部分:一个是配景条的滑动,一个是页签条的滑动。
2.1 手势跟踪
Swiper(this.swiperController) {
// 布局实现
}
.onGestureSwipe((index: number, event: TabsAnimationEvent) => {
this.listTouchState = 0;
let curOffset: number = event.currentOffset;
let targetIndex: number = index;
this.isReachBorder = false;
// tab组件到达边界使背景条和页签条跳转到终点位置
// TODO: 知识点: 这里不能判断到边界直接退出,因为onGestureSwipe每一帧触发回调,当手势滑动较快,上一帧背景条没有到达边界
// TODO(接上): 知识点: 下一帧content超出边界,这时候背景条没有更新,退出将导致背景条停滞在上一帧位置无法更新。
if ((index === 0 && curOffset > 0) ||
(index === this.innerBarData.length - 1 && curOffset < 0)) {
this.isReachBorder = true;
curOffset = 0;
}
let ratio: number = Math.abs(curOffset / this.tabsWidth); // tab滑动比例
if (curOffset < 0) { // tab右滑
targetIndex = index + 1;
} else if (curOffset > 0) { // tab左滑
targetIndex = index - 1;
}
// 获取背景条位置及页签条偏移
// 获取背景条位置及页签条偏移
this.gestureAnimation(index, targetIndex, this.elementsInfo, ratio);
})
/**
* 手势滑动动效
* @param index - 起始页签索引
* @param targetIndex - 目标页签索引
* @param elementsInfo - 页签信息[背景条左端位置, 页签条偏移]
* @param ratio - 当前手势滑动比例
* @returns
*/
@Param gestureAnimation: (index: number, targetIndex: number, elementsInfo: [number, number][], ratio: number) => void =
(index: number, targetIndex: number, elementsInfo: [number, number][], ratio: number): void => {
this.animationAttribute.left = elementsInfo[index][0] + (elementsInfo[targetIndex][0] - elementsInfo[index][0]) * ratio;
this.scroller!.scrollTo({xOffset: elementsInfo[index][1] + (elementsInfo[targetIndex][1] - elementsInfo[index][1]) * ratio, yOffset: 0});
this.animationAttribute.indicatorBarWidth = this.getIndicatorWidth(ratio);
};
复制代码
具体思绪: 手势跟踪滑动主要存在两种情况:1)配景条到达边界;2)配景条未到达边界。首先判定tab是否滑动到边界,若滑动到边界,则目标页签等于当前页签。否则,则根据当前的偏移情况来判定目标页签相对于当前页签的位置。然后,分别获取当前页签以及目标页签对应的配景条位置以及页签条偏移作为配景条和页签条的起始状态和最终状态。之后,可以通过计算tab滑动比例,获取当前配景条位置以及页签条偏移,公式如下所示:
2.2 动画效果
Swiper(this.swiperController) {
// 布局实现
}
.onContentDidScroll((selectedIndex: number, index: number, position: number, mainAxisLength: number) => {
// 动画启动,选取当前index索引页签的属性来执行背景条和页签条滑动
if (this.isAnimationStart && index === this.innerCurrnetIndex) {
// 使用选中页签相对于Swiper主轴起始位置的移动比例判断滑动的目标页签targetIndex的位置
let targetIndex: number = position < 0 ? index + 1 : index - 1;
if (targetIndex >= this.innerBarData.length || targetIndex < 0) {
console.warn(`Error: targetIndex exceeds the limit range:
selectedIndex: ${selectedIndex}, curIndex: ${this.innerCurrnetIndex}, index: ${index},
targetIndex: ${targetIndex}, position: ${position}, mainAxisLength: ${mainAxisLength}`);
return;
}
let ratio: number = Math.abs(position);
// 通过页签比例计算当前页签条和背景条的位置
this.autoAnimation(index, targetIndex, this.elementsInfo, ratio);
}
})
.onAnimationStart((index: number, targetIndex: number, event: TabsAnimationEvent) => {
if (this.isReachBorder) { // 若tab到达边界,则不继续执行动画
return;
}
this.isAnimationStart = true;
this.listTouchState = 0;
})
.onAnimationEnd(() => {
this.isAnimationStart = false;
})
/**
* 自动滑动动效
* @param index - 起始页签索引
* @param targetIndex - 目标页签索引
* @param elementsInfo - 页签动效信息[背景条左端位置, 页签条偏移]
* @param ratio - 当前tab滑动比例
* @returns
*/
@Param autoAnimation: (index: number, targetIndex: number, elementsInfo: [number, number][], ratio: number) => void =
(index: number, targetIndex: number, elementsInfo: [number, number][], ratio: number): void => {
this.animationAttribute.left = elementsInfo[index][0] + (elementsInfo[targetIndex][0] - elementsInfo[index][0]) * ratio;
this.scroller!.scrollTo({
xOffset: elementsInfo[index][1] + (elementsInfo[targetIndex][1] - elementsInfo[index][1]) * ratio,
yOffset: 0
});
this.animationAttribute.indicatorBarWidth = this.getIndicatorWidth(ratio);
};
复制代码
具体思绪:首先在动画开始时,我们在onAnimationStart回调中只进行动画开始状态的改变(i.e. this.isAnimationStart = true)。然后,在onContentDidScroll回调中进行绘制动画。具体来说,在每一次回调onContentDidScroll接口时通过起始页签index、目标页签targetIndex以及滑动比例来判定当前配景条位置以及页签条的偏移,如公式(1)所示。 因此,动画函数中最紧张的就是判定index、targetIndex以及滑动比例。由于页签条的滑动等价于配景条滑动,因此我们只必要判定配景条的滑动情况就可以覆盖全部情况。如下图所示,这里主要存在以下三种情况的判定:1)配景条未回弹且滑动比例小于0.5;2)配景条未回弹且滑动比例大于等于0.5;3)配景条回弹。
配景条未回弹且滑动比例小于0.5。这时候起始页签index应该等于curIndex,而目标页签targetIndex则可以根据滑动比例正负判定index+1(index-1)。当tab不断向左(右)滑动时,index页签滑动比例不断增加,配景条也不断向右(左)滑动。
配景条回弹。这时候起始页签应该等于curIndex,而目标页签targetIndex则可以根据滑动比例正负判定index+1(index-1)。当tab回弹时,index页签滑动比例不断减少,配景条也不断向左(右)滑动,直至回弹到原位置。
配景条未回弹且滑动比例大于等于0.5。这时候目标页签应该等于curIndex,起始页签index应该则可以根据滑动比例正负判定targetIndex+1(targetIndex-1)。但是,仔细观察我们可以发现,其实这种情况与配景条回弹情况根本一致。可以将其看作是黄色页签开始向左滑动,也可以将其看作是绿色页签开始进行回弹。因此,我们可以将其转化为绿色页签回弹,如后续第二张图所示。这时候起始页签应该等于curIndex,而目标页签targetIndex则可以根据滑动比例正负判定index+1(index-1)。当index页签内容回弹时,tab滑动比例不断减少,配景条也不断向右(左)滑动,直至回弹到原位置。
高性能知识点
本示例利用了LazyForEach进行数据懒加载,LazyForEach懒加载可以通过设置cachedCount属性来指定缓存数量,同时搭配组件复用能力以达到性能最优效果。
工程结构&模块类型
customAnimationTabs // har类型
|---common
| |---CommonConstants.ets // 内置常量定义
|---model
| |---AnimationAttribute.ets // 动效属性
| |---BaseInterface.test // 基础信息接口
| |---ComponentFactory.ets // 组件工厂
| |---CustomAniamtionTabController.ets // 自定义tab控制器
| |---IndicatorBarAttribute.ets // 背景条属性
| |---TabBarAttribute.ets // 页签条属性
| |---TabBarItemInterface.ets // 页签信息接口
| |---TabInfo.ets // tab项信息
|---utils
| |---CustomAnimationTab.ets // customAnimationTab组件
|---view
| |---BasePage.ets // tab页面内容及页签
| |---CustomAnimationTabConfigure.ets // 用户配置
| |---CustomAnimationTabView.ets // 样例页面
| |---DyEffectPage.ets // tab页面内容及页签
| |---LazyDataSource.ets // 懒加载数据
| |---NativePage.ets // tab页面内容及页签
| |---OtherPage.ets // tab页面内容及页签
| |---SkeletonLayout.ets // 骨架页面
| |---ThirdPartyPage.ets // tab页面内容及页签
| |---UIPage.ets // tab页面内容及页签
|---FeatureComponent.ets // AppRouter入口文件
复制代码
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!更多信息从访问主页:qidao123.com:ToB企服之家,中国第一个企服评测及商务社交产业平台。
欢迎光临 ToB企服应用市场:ToB评测及商务社交产业平台 (https://dis.qidao123.com/)
Powered by Discuz! X3.4