案例集合Tabs:自定义tabs凹陷球体 滑动动画
·
🎯 案例集合Tabs:自定义tabs凹陷球体 滑动动画
🌍 案例集合Tabs
🚪 最近开启了学员班级点我欢迎加入(学习相关知识可获得定制礼盒)
🏷️ 效果图

📖 参考
🧩 拆解
import { AnimatorResult } from '@ohos.animator';
import { display, window } from '@kit.ArkUI';
import { common } from '@kit.AbilityKit';
/**
* 安全区高度
*/
interface AvoidArea {
topRectHeight: number
bottomRectHeight: number
}
/**
* 安全类型边距枚举
* statusBarType 状态栏
* navBarType 导航栏
*/
const statusBarType = window.AvoidAreaType.TYPE_SYSTEM
const navBarType = window.AvoidAreaType.TYPE_NAVIGATION_INDICATOR
/**
* tabs数据
*/
const mockData: string[] = ['购物', '体育', '服装', '军事']
@Component
export struct TabsConcaveBallCase {
/**
* UI上下文
*/
private context = this.getUIContext()?.getHostContext() as common.UIAbilityContext
/**
* 窗口对象
*/
private windowClass = this.context.windowStage.getMainWindowSync()
/**
* 当前选中的Tabs下标(还未切换动画前获取)
*/
@State animationStartIdx: number = 0
/**
* 安全区高度对象(状态栏、导航栏)
*/
@State avoidArea: AvoidArea = { topRectHeight: 0, bottomRectHeight: 0 }
/**
* tabs构造器
*/
private tabsController: TabsController = new TabsController()
/**
* 背景色 文字 | 占位
*/
private tabsBgColor: string = '#ff818080'
/**
* 动画持续时间 文字 | 凹陷 过渡
*/
private animationDuration: number = 300
/**
* 凸出球x轴位置
*/
@State ballCenterX: number = 0
/**
* 配置CanvasRenderingContext2D对象的参数,包括是否开启抗锯齿。
*/
private canvasContext: CanvasRenderingContext2D = new CanvasRenderingContext2D(new RenderingContextSettings(true))
/**
* 画布动画
*/
private canvasAnimator: AnimatorResult | undefined = undefined
/**
* 当前凹槽位置
*/
@State canvasConcaveAreaX: number = 0
aboutToAppear(): void {
this.windowClass.setWindowLayoutFullScreen(true)
this.windowClass.on('avoidAreaChange', this.onAvoidAreaChange)
this.setAvoidArea()
}
aboutToDisappear(): void {
this.windowClass.setWindowLayoutFullScreen(false)
this.windowClass.off('avoidAreaChange', this.onAvoidAreaChange)
}
/**
* 设置状态栏和导航栏避让区域 && 监听不同设备避让区域的变化
*/
setAvoidArea() {
const statusBarArea = this.windowClass.getWindowAvoidArea(statusBarType)
this.avoidArea.topRectHeight = statusBarArea.topRect.height
const navBarArea = this.windowClass.getWindowAvoidArea(navBarType)
this.avoidArea.bottomRectHeight = navBarArea.bottomRect.height
}
onAvoidAreaChange = (data: window.AvoidAreaOptions) => {
if (data.type === statusBarType) {
this.avoidArea.topRectHeight = data.area.topRect.height
} else if (data.type === navBarType) {
this.avoidArea.bottomRectHeight = data.area.bottomRect.height
}
}
/**
* tab
* @param label
* @param idx
*/
@Builder
tabBuilder(label: string, idx: number) {
Column({ space: 5 }) {
Text(label)
.fontSize(16)
.fontColor(this.animationStartIdx === idx ? Color.White : Color.Black)
.fontWeight(this.animationStartIdx === idx ? FontWeight.Medium : FontWeight.Normal)
.offset({
y: this.animationStartIdx === idx ? -15 : 0
})
.animation({ duration: this.animationDuration, curve: Curve.Smooth })
Image($r('app.media.startIcon'))
.syncLoad(true)
.draggable(false)
.width(20)
.height(20)
}
.height(60)
.onClick(() => {
this.tabsController.changeIndex(idx)
this.createAnimation()
})
.justifyContent(FlexAlign.Center)
.layoutWeight(1)
}
/**
* 应用于占位
*/
@Builder
slotBuilder() {
Text()
.width('100%')
.height(this.getUIContext().px2vp(this.avoidArea.bottomRectHeight))
.backgroundColor(this.tabsBgColor)
}
/**
* 获取菜单选项的中间点
*/
private getMenuCenterX(): number {
const itemWidth = this.getUIContext().px2vp(display.getDefaultDisplaySync().width) / mockData.length
// 获取当前下标项的中心位置
return (itemWidth * this.animationStartIdx) + (itemWidth / 2)
}
/**
* 获取兼容性宽度
* @param { number } width
* @param { number } height
* @param { number } menuLength
* @returns { number } 适配当菜单数量,取菜单的宽度和tabs高度,把小数值返回作为后续使用
*/
private getMinWidth(width: number, height: number, menuLength: number = 0): number {
return Math.min(width / menuLength, height)
}
/**
*
* 创建移动动画
* 用于移动圆球和重绘canvas中凹陷部分
* @param duration
*/
private createAnimation(duration?: number) {
this.canvasAnimator = this.getUIContext().createAnimator({
duration: duration ?? this.animationDuration,
easing: "ease",
delay: 0, // 动画等待时长
fill: "forwards",
direction: "normal",
iterations: 1, // 动画执行次数
begin: this.canvasConcaveAreaX,
end: this.getMenuCenterX()
})
this.canvasAnimator.onFrame = (value: number) => {
this.canvasConcaveAreaX = value
// 减去凸出球体的一半 (居中)
this.ballCenterX = value - 25
this.createCanvas()
}
this.canvasAnimator.play()
}
/**
* 创建canvas 背景 和 凹槽
*/
private createCanvas() {
this.canvasContext.reset()
this.canvasCreateRectangle()
this.canvasClipGroove()
}
/**
* 绘制 Canvas 大小和填充颜色
*/
private canvasCreateRectangle() {
// CanvasRenderingContext2D
const ctx = this.canvasContext
// canvas 宽度
const cW = ctx.width
// canvas 高度
const cH = ctx.height
ctx.clearRect(0, 0, cW, cH)
ctx.beginPath()
ctx.moveTo(0, 0)
ctx.lineTo(cW, 0)
ctx.lineTo(cW, cH)
ctx.lineTo(0, cH)
ctx.closePath()
ctx.fillStyle = this.tabsBgColor // 设置填充颜色
ctx.fill()
ctx.closePath()
}
/**
* 给 Canvas 切割悬浮槽
* TODO: 知识点:通过 clip 来实现切割后保留的圆弧实现平滑过渡
*/
private canvasClipGroove() {
// CanvasRenderingContext2D
const ctx = this.canvasContext
// canvas 宽度
const cW = ctx.width
// canvas 高度
const cH = ctx.height
// 半径
const radius = this.getMinWidth(cW, cH, mockData.length) / 2
// 中间圆的中心点
const circleCenter = this.canvasConcaveAreaX || cW / 2
// 计算左右俩倒角的x轴距离圆心的距离
const aroundCenter = Math.sqrt(Math.pow(radius * 2, 2) - Math.pow(radius, 2))
/**
* canvas 绘制的度数 1°
* 这里是 1° 如果需要多少度 直接 * 具体数字即可
*/
const chamferDegrees1 = Math.PI / 180
// 330°
const chamferDegrees330 = chamferDegrees1 * 330
// 270°
const chamferDegrees270 = chamferDegrees1 * 270
// 210°
const chamferDegrees210 = chamferDegrees1 * 210
// 150°
const chamferDegrees150 = chamferDegrees1 * 150
// 30°
const chamferDegrees30 = chamferDegrees1 * 30
ctx.beginPath()
// 左边圆弧线
ctx.arc(circleCenter - aroundCenter, radius, radius, chamferDegrees270, chamferDegrees330)
// 中间圆弧线
ctx.arc(circleCenter, 0, radius, chamferDegrees30, chamferDegrees150)
// 右边圆弧线
ctx.arc(circleCenter + aroundCenter, radius, radius, chamferDegrees210, chamferDegrees270)
ctx.closePath()
// 应用剪裁路径
ctx.clip()
// 清除剪裁区域内的部分
ctx.clearRect(0, 0, cW, cH)
}
build() {
Stack({ alignContent: Alignment.BottomStart }) {
Tabs({ controller: this.tabsController }) {
ForEach(mockData, (item: string) => {
TabContent() {
Column() {
Text(item)
.fontColor(Color.White)
.fontSize(20)
.fontWeight(FontWeight.Bold)
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
.backgroundColor(Color.Brown)
}
})
}
.barHeight(0)
// 关键:切换动画开始时触发该回调:解决切换tabs延迟的问题
.onAnimationStart((idx: number, targetIndex: number) => {
if (idx === targetIndex) {
return
}
this.animationStartIdx = targetIndex
this.createAnimation()
})
Column() {
Stack() {
// 背景 - 凹槽
Canvas(this.canvasContext)
.width('100%')
.height('100%')
// duration 第一次创建的时候不给动画
.onReady(() => this.createAnimation(0))
// 凸出球体
Text()
.width(50)
.aspectRatio(1)
.backgroundColor(this.tabsBgColor)
.borderRadius(25)
.position({
y: -25,
x: this.ballCenterX
})
// 自定义tabs
Row() {
ForEach(mockData, (item: string, idx: number) => {
this.tabBuilder(item, idx)
})
}
.width('100%')
}
.height(60)
.width('100%')
// 安全边距占位-未在最大容器设置背景规避凹陷区域被覆盖
this.slotBuilder()
}
}
.width('100%')
.height('100%')
.padding({ top: this.avoidArea.topRectHeight + 'px' })
}
}
🌸🌼🌺
更多推荐




所有评论(0)