HarmonyOS开发:自定义动效设计与品牌化动效系统

举报
Jack20 发表于 2026/06/22 21:36:08 2026/06/22
【摘要】 HarmonyOS开发:自定义动效设计与品牌化动效系统📌 核心要点:从零构建一套属于你自己品牌的动效Token系统,让每一个动画细节都传递品牌个性,而不是千篇一律的默认效果。 一、背景与动机你打开两个不同的App,发现它们的按钮点击效果一模一样,页面转场一模一样,下拉刷新一模一样——就像走进了两家装修完全一样的餐厅,连菜单都一样。这能叫品牌吗?动效是品牌的"肢体语言"。就像一个人走路、挥...

HarmonyOS开发:自定义动效设计与品牌化动效系统

📌 核心要点:从零构建一套属于你自己品牌的动效Token系统,让每一个动画细节都传递品牌个性,而不是千篇一律的默认效果。


一、背景与动机

你打开两个不同的App,发现它们的按钮点击效果一模一样,页面转场一模一样,下拉刷新一模一样——就像走进了两家装修完全一样的餐厅,连菜单都一样。这能叫品牌吗?

动效是品牌的"肢体语言"。就像一个人走路、挥手、转身的姿态能让你认出他是谁,一个App的动效风格也应该让用户感受到品牌的独特气质。微信的"抖一抖"和抖音的"滑动切换"完全不同,这就是品牌化动效的力量。

然而,HarmonyOS默认提供的动画曲线和转场效果是"通用型"的——它们适合大多数场景,但不属于任何品牌。如果你想让你的App拥有独特的动效DNA,就需要建立一套品牌化动效系统:自定义曲线、自定义转场、动效Token,以及将这些Token与品牌色深度结合。

这篇文章,我们就来一步步搭建这套系统。


二、核心原理

2.1 品牌化动效设计原则

品牌化动效不是"越花哨越好",它需要遵循三个核心原则:

graph TD
    A[品牌化动效三原则]:::primary --> B[一致性<br/>Consistency]:::info
    A --> C[表达性<br/>Expressiveness]:::info
    A --> D[克制性<br/>Restraint]:::info

    B --> B1[同一品牌下所有动效<br/>遵循统一规则]:::success
    C --> C1[动效传递品牌个性<br/>活泼/沉稳/科技感]:::success
    D --> D1[动效服务于功能<br/>不喧宾夺主]:::success

    B1 --> E[示例:所有按钮使用<br/>同一条弹性曲线]:::warning
    C1 --> F[示例:金融App用沉稳曲线<br/>社交App用弹性曲线]:::warning
    D1 --> G[示例:核心操作有动效<br/>辅助操作无动效]:::warning

    classDef primary fill:#4CAF50,stroke:#388E3C,color:#fff
    classDef info fill:#2196F3,stroke:#1976D2,color:#fff
    classDef success fill:#009688,stroke:#00796B,color:#fff
    classDef warning fill:#FF9800,stroke:#F57C00,color:#fff
    classDef error fill:#F44336,stroke:#D32F2F,color:#fff

一致性:你的App里,所有按钮的点击反馈应该用同一条曲线,所有页面的转场应该用同一个时长范围,所有元素的出场顺序应该遵循同一套编排规则。用户不需要思考"这个按钮为什么反应不一样"。

表达性:动效是品牌个性的放大器。一个面向儿童的App可以用弹性十足的曲线,一个金融App则应该用沉稳克制的缓动。你的品牌是"活泼"还是"专业"?动效应该回答这个问题。

克制性:不是每个元素都需要动效。核心交互有动效,辅助操作可以没有。动效太多就像一个人说话时手舞足蹈——分散注意力,降低信息传达效率。

2.2 自定义动画曲线(CubicBezier)

动画曲线是动效的"灵魂"。HarmonyOS通过ICurve接口和Curve.cubicBezierCurve()方法支持自定义贝塞尔曲线。

一条三次贝塞尔曲线由4个控制点定义:P0(0,0)、P1(x1,y1)、P2(x2,y2)、P3(1,1)。我们只需要指定中间两个控制点P1和P2:

cubic-bezier(x1, y1, x2, y2)

其中x1、x2的值必须在[0,1]范围内,y1、y2可以超出这个范围(超出时曲线会有"回弹"效果)。

不同品牌可以定义不同的曲线特征:

品牌类型 曲线特征 示例值
科技品牌 快速响应,干净利落 (0.1, 0.0, 0.2, 1.0)
生活品牌 温和舒适,有弹性 (0.4, 0.0, 0.2, 1.4)
金融品牌 沉稳可靠,无弹性 (0.4, 0.0, 0.6, 1.0)
儿童品牌 活泼跳跃,强弹性 (0.5, 1.5, 0.5, 1.0)

2.3 动效Token系统设计

动效Token是将动效参数"设计系统化"的核心机制。就像颜色有Color Token一样,动效也应该有自己的Token体系:

Motion Token 层级结构:
├── Duration(时长)
│   ├── duration-instant: 0ms
│   ├── duration-short1: 50ms
│   ├── duration-short2: 100ms
│   ├── duration-short3: 150ms
│   ├── duration-medium1: 200ms
│   ├── duration-medium2: 300ms
│   ├── duration-long1: 400ms
│   ├── duration-long2: 500ms
│   └── duration-extra-long: 700ms
├── Easing(缓动)
│   ├── easing-standard: cubic-bezier(0.2, 0, 0, 1)
│   ├── easing-emphasized: cubic-bezier(0.2, 0, 0, 1)
│   ├── easing-brand-primary: 自定义品牌曲线1
│   └── easing-brand-secondary: 自定义品牌曲线2
├── Choreography(编排)
│   ├── stagger-short: 30ms
│   ├── stagger-medium: 50ms
│   └── stagger-long: 80ms
└── Scale(缩放系数)
    ├── scale-touch: 0.97
    ├── scale-press: 0.95
    └── scale-brand-bounce: 1.05

三、代码实战

3.1 基础用法:自定义动画曲线

先从最基础的自定义曲线开始,为品牌定义专属的缓动函数:

// 品牌化动效Token定义
export class BrandMotionTokens {
  // ====== 时长Token ======
  static readonly DURATION_INSTANT: number = 0
  static readonly DURATION_SHORT: number = 100
  static readonly DURATION_MEDIUM: number = 250
  static readonly DURATION_LONG: number = 400
  static readonly DURATION_EXTRA_LONG: number = 600

  // ====== 自定义缓动曲线 ======
  // 品牌主曲线 - 带有轻微弹性的温暖感
  static readonly EASING_BRAND_PRIMARY: ICurve = 
    Curve.cubicBezierCurve(0.4, 0.0, 0.2, 1.3)
  
  // 品牌辅助曲线 - 干净利落的科技感
  static readonly EASING_BRAND_SECONDARY: ICurve = 
    Curve.cubicBezierCurve(0.1, 0.0, 0.2, 1.0)
  
  // 品牌强调曲线 - 强弹性,用于重要反馈
  static readonly EASING_BRAND_EMPHASIS: ICurve = 
    Curve.cubicBezierCurve(0.5, 1.6, 0.4, 0.8)

  // ====== 编排Token ======
  static readonly STAGGER_SHORT: number = 30
  static readonly STAGGER_MEDIUM: number = 50
  static readonly STAGGER_LONG: number = 80

  // ====== 缩放Token ======
  static readonly SCALE_TOUCH: number = 0.97
  static readonly SCALE_PRESS: number = 0.93
  static readonly SCALE_BOUNCE: number = 1.08

  // ====== 品牌色与动效结合 ======
  static readonly BRAND_COLOR_PRIMARY: string = '#FF6B35'
  static readonly BRAND_COLOR_SECONDARY: string = '#004E89'
  static readonly BRAND_GLOW_COLOR: string = '#FF6B3540'  // 带透明度的品牌色
}

// 使用品牌曲线的按钮组件
@Component
struct BrandButton {
  @Prop text: string = ''
  @Prop type: 'primary' | 'secondary' = 'primary'
  @State isPressed: boolean = false
  private onTap?: () => void

  build() {
    Row() {
      Text(this.text)
        .fontSize(16)
        .fontWeight(FontWeight.Medium)
        .fontColor(this.type === 'primary' ? Color.White : BrandMotionTokens.BRAND_COLOR_PRIMARY)
    }
    .padding({ horizontal: 24, vertical: 12 })
    .backgroundColor(this.type === 'primary' ? BrandMotionTokens.BRAND_COLOR_PRIMARY : Color.Transparent)
    .borderWidth(this.type === 'secondary' ? 2 : 0)
    .borderColor(BrandMotionTokens.BRAND_COLOR_PRIMARY)
    .borderRadius(24)
    // 品牌化缩放效果
    .scale({
      x: this.isPressed ? BrandMotionTokens.SCALE_PRESS : 1.0,
      y: this.isPressed ? BrandMotionTokens.SCALE_PRESS : 1.0
    })
    // 品牌化阴影效果
    .shadow(this.isPressed ? null : {
      radius: this.type === 'primary' ? 12 : 0,
      color: BrandMotionTokens.BRAND_GLOW_COLOR,
      offsetY: 4
    })
    .onClick(() => {
      // 按下动画 - 使用品牌强调曲线
      animateTo({
        duration: BrandMotionTokens.DURATION_SHORT,
        curve: BrandMotionTokens.EASING_BRAND_EMPHASIS
      }, () => {
        this.isPressed = true
      })
      // 回弹动画 - 使用品牌主曲线
      setTimeout(() => {
        animateTo({
          duration: BrandMotionTokens.DURATION_MEDIUM,
          curve: BrandMotionTokens.EASING_BRAND_PRIMARY
        }, () => {
          this.isPressed = false
        })
      }, 80)
      this.onTap?.()
    })
  }
}

3.2 进阶用法:自定义转场动画与品牌色结合

品牌化转场不仅仅是换一条曲线,更是将品牌色彩融入转场过程。比如,页面切换时使用品牌色作为过渡色,或者在转场中加入品牌标志性的动效元素:

// 品牌化转场动画系统
export class BrandTransitionManager {
  // 品牌化容器转换 - 带品牌色扩散效果
  static containerTransformWithBrand(
    sharedId: string,
    brandColor: string,
    duration: number = BrandMotionTokens.DURATION_LONG
  ): void {
    animateTo({
      duration: duration,
      curve: BrandMotionTokens.EASING_BRAND_PRIMARY,
      onFinish: () => {
        console.info('品牌容器转换完成')
      }
    }, () => {
      // 状态变更由调用方处理
    })
  }

  // 品牌化共享轴转场 - 带品牌色渐变
  static sharedAxisWithBrand(
    axis: 'x' | 'y',
    direction: 'forward' | 'backward',
    brandColor: string
  ): TransitionEffect {
    let offsetX = axis === 'x' ? (direction === 'forward' ? 60 : -60) : 0
    let offsetY = axis === 'y' ? (direction === 'forward' ? 60 : -60) : 0

    return TransitionEffect.OPACITY
      .combine(TransitionEffect.translate({ x: offsetX, y: offsetY }))
      .combine(TransitionEffect.backgroundColor(brandColor))
  }

  // 品牌化淡入淡出 - 带品牌色中间帧
  static fadeThroughWithBrand(brandColor: string): TransitionEffect {
    return TransitionEffect.OPACITY
      .combine(TransitionEffect.backgroundColor(brandColor))
  }
}

// 品牌化页面转场完整示例
@Entry
@Component
struct BrandTransitionDemo {
  @State currentPage: number = 0
  @State transitionProgress: number = 0
  @State showBrandOverlay: boolean = false

  // 页面数据
  private pages: PageData[] = [
    { id: 0, title: '首页', color: '#FF6B35', icon: '🏠' },
    { id: 1, title: '探索', color: '#004E89', icon: '🔍' },
    { id: 2, title: '我的', color: '#2EC4B6', icon: '👤' }
  ]

  build() {
    Stack() {
      // 页面内容
      Column() {
        if (this.currentPage === 0) {
          this.HomePage()
        } else if (this.currentPage === 1) {
          this.ExplorePage()
        } else {
          this.ProfilePage()
        }
      }
      .width('100%')
      .height('100%')

      // 品牌色转场覆盖层
      if (this.showBrandOverlay) {
        Column() {
          // 品牌Logo或标志性元素
          Text('B')
            .fontSize(48)
            .fontWeight(FontWeight.Bold)
            .fontColor(Color.White)
        }
        .width('100%')
        .height('100%')
        .backgroundColor(BrandMotionTokens.BRAND_COLOR_PRIMARY)
        .justifyContent(FlexAlign.Center)
        .opacity(0.9)
        .transition(TransitionEffect.OPACITY)
      }

      // 底部导航
      this.BrandBottomNav()
    }
    .width('100%')
    .height('100%')
  }

  // ====== 首页 ======
  @Builder HomePage() {
    Scroll() {
      Column({ space: 16 }) {
        // 品牌标题
        Text('欢迎回来')
          .fontSize(32)
          .fontWeight(FontWeight.Bold)
          .fontColor('#212121')

        // 品牌化卡片列表
        ForEach(['今日推荐', '热门活动', '新品上线'], (item: string, index: number) => {
          this.BrandCard(item, index)
        })
      }
      .padding(20)
    }
    .layoutWeight(1)
    .width('100%')
    .transition(
      BrandTransitionManager.sharedAxisWithBrand('x', 'forward', BrandMotionTokens.BRAND_COLOR_PRIMARY)
    )
  }

  // ====== 品牌化卡片 ======
  @Builder BrandCard(title: string, index: number) {
    Row() {
      Column() {
        Text(title)
          .fontSize(18)
          .fontWeight(FontWeight.Medium)
          .fontColor('#212121')
        Text('查看详情 →')
          .fontSize(13)
          .fontColor(BrandMotionTokens.BRAND_COLOR_PRIMARY)
          .margin({ top: 8 })
      }
      .alignItems(HorizontalAlign.Start)
      .layoutWeight(1)

      // 品牌色装饰圆
      Circle()
        .width(48)
        .height(48)
        .fill(BrandMotionTokens.BRAND_GLOW_COLOR)
    }
    .width('100%')
    .padding(16)
    .backgroundColor(Color.White)
    .borderRadius(16)
    .shadow({ radius: 8, color: '#0a000000', offsetY: 2 })
    // 品牌化出场动画 - 交错编排
    .transition(
      TransitionEffect.OPACITY
        .combine(TransitionEffect.translate({ y: 30 }))
        .combine(TransitionEffect.scale({ x: 0.95, y: 0.95 }))
    )
    .onClick(() => {
      // 品牌化点击反馈
      animateTo({
        duration: BrandMotionTokens.DURATION_SHORT,
        curve: BrandMotionTokens.EASING_BRAND_EMPHASIS
      }, () => {
        // 缩放反馈
      })
    })
  }

  @Builder ExplorePage() {
    Column() {
      Text('探索页面')
        .fontSize(24)
    }
    .width('100%')
    .layoutWeight(1)
    .justifyContent(FlexAlign.Center)
    .transition(
      BrandTransitionManager.sharedAxisWithBrand('x', 'forward', BrandMotionTokens.BRAND_COLOR_SECONDARY)
    )
  }

  @Builder ProfilePage() {
    Column() {
      Text('个人中心')
        .fontSize(24)
    }
    .width('100%')
    .layoutWeight(1)
    .justifyContent(FlexAlign.Center)
    .transition(
      BrandTransitionManager.fadeThroughWithBrand(BrandMotionTokens.BRAND_COLOR_PRIMARY)
    )
  }

  // ====== 品牌化底部导航 ======
  @Builder BrandBottomNav() {
    Row() {
      ForEach(this.pages, (page: PageData, index: number) => {
        Column() {
          Text(page.icon)
            .fontSize(24)
          Text(page.title)
            .fontSize(11)
            .fontColor(this.currentPage === index ? BrandMotionTokens.BRAND_COLOR_PRIMARY : '#999999')
            .margin({ top: 2 })
        }
        .layoutWeight(1)
        .justifyContent(FlexAlign.Center)
        .height(56)
        // 品牌化选中指示器
        .borderWidth({ top: this.currentPage === index ? 3 : 0 })
        .borderColor(BrandMotionTokens.BRAND_COLOR_PRIMARY)
        .onClick(() => {
          if (this.currentPage !== index) {
            // 品牌化转场:先显示品牌色覆盖层,再切换页面
            animateTo({
              duration: BrandMotionTokens.DURATION_SHORT,
              curve: BrandMotionTokens.EASING_BRAND_SECONDARY
            }, () => {
              this.showBrandOverlay = true
            })
            // 延迟切换页面
            setTimeout(() => {
              animateTo({
                duration: BrandMotionTokens.DURATION_MEDIUM,
                curve: BrandMotionTokens.EASING_BRAND_PRIMARY
              }, () => {
                this.currentPage = index
                this.showBrandOverlay = false
              })
            }, 100)
          }
        })
      })
    }
    .width('100%')
    .height(56)
    .backgroundColor(Color.White)
  }
}

interface PageData {
  id: number
  title: string
  color: string
  icon: string
}

3.3 完整示例:品牌化组件库动效系统

下面是一个可运行的品牌化组件库动效系统,包含按钮、卡片、对话框、列表等常用组件的品牌化动效实现:

// ====== 品牌化组件库动效系统 ======

// 品牌动效编排器 - 管理交错动画
export class BrandChoreographer {
  private static instance: BrandChoreographer | null = null
  private staggerTimers: number[] = []

  static getInstance(): BrandChoreographer {
    if (!BrandChoreographer.instance) {
      BrandChoreographer.instance = new BrandChoreographer()
    }
    return BrandChoreographer.instance
  }

  // 编排列表项交错出场
  staggerItems(itemCount: number, staggerDelay: number, callback: (index: number) => void): void {
    for (let i = 0; i < itemCount; i++) {
      const timer = setTimeout(() => {
        animateTo({
          duration: BrandMotionTokens.DURATION_MEDIUM,
          curve: BrandMotionTokens.EASING_BRAND_PRIMARY
        }, () => {
          callback(i)
        })
      }, i * staggerDelay)
      this.staggerTimers.push(timer)
    }
  }

  // 取消所有编排动画
  cancelAll(): void {
    this.staggerTimers.forEach(timer => clearTimeout(timer))
    this.staggerTimers = []
  }
}

// 品牌化对话框组件
@Component
struct BrandDialog {
  @State isVisible: boolean = false
  @State scaleValue: number = 0.8
  @State opacityValue: number = 0
  @Prop title: string = ''
  @Prop message: string = ''
  @Prop confirmText: string = '确定'
  @Prop cancelText: string = '取消'
  private onConfirm?: () => void
  private onCancel?: () => void

  show(): void {
    animateTo({
      duration: BrandMotionTokens.DURATION_MEDIUM,
      curve: BrandMotionTokens.EASING_BRAND_EMPHASIS
    }, () => {
      this.isVisible = true
      this.scaleValue = 1.0
      this.opacityValue = 1.0
    })
  }

  dismiss(): void {
    animateTo({
      duration: BrandMotionTokens.DURATION_SHORT,
      curve: BrandMotionTokens.EASING_BRAND_SECONDARY
    }, () => {
      this.scaleValue = 0.8
      this.opacityValue = 0
    })
    setTimeout(() => {
      this.isVisible = false
    }, BrandMotionTokens.DURATION_SHORT)
  }

  build() {
    if (this.isVisible) {
      Stack() {
        // 遮罩层
        Column()
          .width('100%')
          .height('100%')
          .backgroundColor('#80000000')
          .opacity(this.opacityValue)
          .onClick(() => this.dismiss())

        // 对话框主体
        Column() {
          Text(this.title)
            .fontSize(20)
            .fontWeight(FontWeight.Bold)
            .fontColor('#212121')

          Text(this.message)
            .fontSize(14)
            .fontColor('#666666')
            .margin({ top: 12 })
            .textAlign(TextAlign.Start)

          // 按钮区域
          Row({ space: 12 }) {
            Button(this.cancelText)
              .fontSize(14)
              .fontColor('#666666')
              .backgroundColor('#F5F5F5')
              .borderRadius(20)
              .layoutWeight(1)
              .onClick(() => this.dismiss())

            Button(this.confirmText)
              .fontSize(14)
              .fontColor(Color.White)
              .backgroundColor(BrandMotionTokens.BRAND_COLOR_PRIMARY)
              .borderRadius(20)
              .layoutWeight(1)
              .onClick(() => {
                this.dismiss()
                this.onConfirm?.()
              })
          }
          .margin({ top: 24 })
        }
        .width('85%')
        .padding(24)
        .backgroundColor(Color.White)
        .borderRadius(24)
        .scale({ x: this.scaleValue, y: this.scaleValue })
        .opacity(this.opacityValue)
        .shadow({ radius: 24, color: '#1a000000', offsetY: 8 })
      }
      .width('100%')
      .height('100%')
    }
  }
}

// 品牌化列表项组件 - 带交错出场
@Component
struct BrandListItem {
  @Prop title: string = ''
  @Prop subtitle: string = ''
  @Prop icon: string = ''
  @State isVisible: boolean = false
  @State translateY: number = 30
  @State itemOpacity: number = 0
  @Prop index: number = 0

  aboutToAppear(): void {
    // 交错出场
    setTimeout(() => {
      animateTo({
        duration: BrandMotionTokens.DURATION_MEDIUM,
        curve: BrandMotionTokens.EASING_BRAND_PRIMARY
      }, () => {
        this.isVisible = true
        this.translateY = 0
        this.itemOpacity = 1
      })
    }, this.index * BrandMotionTokens.STAGGER_MEDIUM)
  }

  build() {
    Row() {
      // 品牌色图标
      Column() {
        Text(this.icon)
          .fontSize(20)
      }
      .width(44)
      .height(44)
      .borderRadius(12)
      .backgroundColor(BrandMotionTokens.BRAND_GLOW_COLOR)
      .justifyContent(FlexAlign.Center)

      // 文字内容
      Column() {
        Text(this.title)
          .fontSize(16)
          .fontWeight(FontWeight.Medium)
          .fontColor('#212121')
        Text(this.subtitle)
          .fontSize(13)
          .fontColor('#999999')
          .margin({ top: 4 })
      }
      .alignItems(HorizontalAlign.Start)
      .margin({ left: 12 })
      .layoutWeight(1)

      // 品牌化箭头
      Text('›')
        .fontSize(20)
        .fontColor(BrandMotionTokens.BRAND_COLOR_PRIMARY)
    }
    .width('100%')
    .padding(16)
    .backgroundColor(Color.White)
    .borderRadius(12)
    .margin({ bottom: 8 })
    .translate({ y: this.translateY })
    .opacity(this.itemOpacity)
  }
}

// ====== 完整品牌化页面 ======
@Entry
@Component
struct BrandMotionSystemDemo {
  @State listItems: ListItemData[] = []
  @State showDialog: boolean = false
  private dialogController: BrandDialog | null = null
  private choreographer: BrandChoreographer = BrandChoreographer.getInstance()

  aboutToAppear(): void {
    // 初始化列表数据
    this.listItems = [
      { title: '账户安全', subtitle: '密码、指纹、人脸识别', icon: '🔒' },
      { title: '消息通知', subtitle: '推送、短信、邮件提醒', icon: '🔔' },
      { title: '存储管理', subtitle: '缓存、下载、离线数据', icon: '💾' },
      { title: '隐私设置', subtitle: '权限、数据、追踪保护', icon: '🛡️' },
      { title: '外观主题', subtitle: '深色模式、字体大小', icon: '🎨' },
      { title: '关于应用', subtitle: '版本信息、开源许可', icon: 'ℹ️' }
    ]
  }

  build() {
    Column() {
      // 品牌化顶部栏
      this.BrandHeader()

      // 品牌化列表
      Scroll() {
        Column({ space: 8 }) {
          ForEach(this.listItems, (item: ListItemData, index: number) => {
            BrandListItem({
              title: item.title,
              subtitle: item.subtitle,
              icon: item.icon,
              index: index
            })
          })
        }
        .padding(16)
      }
      .layoutWeight(1)

      // 品牌化底部操作栏
      this.BrandFooter()
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#F8F8F8')
  }

  @Builder BrandHeader() {
    Column() {
      Text('品牌化动效系统')
        .fontSize(28)
        .fontWeight(FontWeight.Bold)
        .fontColor('#212121')

      Text('每一个动画细节都传递品牌个性')
        .fontSize(14)
        .fontColor('#999999')
        .margin({ top: 4 })
    }
    .width('100%')
    .padding({ horizontal: 20, vertical: 24 })
    .alignItems(HorizontalAlign.Start)
    .backgroundColor(Color.White)
  }

  @Builder BrandFooter() {
    Row() {
      BrandButton({ text: '重置动画', type: 'secondary' })
        .layoutWeight(1)
        .onClick(() => {
          this.choreographer.cancelAll()
        })

      BrandButton({ text: '播放动画', type: 'primary' })
        .layoutWeight(1)
        .margin({ left: 12 })
        .onClick(() => {
          // 重新触发所有列表项的出场动画
          this.choreographer.staggerItems(
            this.listItems.length,
            BrandMotionTokens.STAGGER_MEDIUM,
            (index: number) => {
              console.info(`${index}项出场`)
            }
          )
        })
    }
    .width('100%')
    .padding(16)
    .backgroundColor(Color.White)
  }
}

interface ListItemData {
  title: string
  subtitle: string
  icon: string
}

四、踩坑与注意事项

坑点1:cubicBezierCurve的y值超出范围会导致"过冲"

当你设置y1或y2大于1.0时(比如1.3、1.5),曲线会产生"过冲"效果——属性值会先超过目标值再回弹。这可以模拟弹性效果,但如果过冲幅度太大,会导致元素"飞出"可视区域。建议y值不要超过1.5。

坑点2:品牌色覆盖层不能阻挡交互

在品牌化转场中使用颜色覆盖层时,一定要在转场结束后移除覆盖层或设置hitTestBehavior(HitTestMode.None),否则覆盖层会阻挡底层页面的触摸事件。

坑点3:交错动画的延迟时间要考虑列表长度

如果你的列表有50个元素,每个延迟50ms,最后一个元素的出场时间是2.5秒——这太长了。建议对长列表使用"分组交错":每5个元素为一组,组内交错30ms,组间交错100ms。

坑点4:品牌化曲线不要和系统默认曲线混用

最忌讳的是同一个App里,按钮点击用品牌曲线,页面转场用默认曲线,列表滑动又用另一种曲线。这会让用户感觉"这个App的动效乱七八糟"。要么全部用品牌曲线,要么不用。

坑点5:对话框的弹性动画不要过度

品牌化对话框的出场动画加一点弹性是好的,但弹性幅度过大会让对话框看起来"晃来晃去",用户会觉得不稳定。对话框是"严肃"的UI元素,弹性曲线的y值建议控制在1.1以内。

坑点6:动效Token的命名要有语义

不要用curve1curve2这种命名。应该用easing-brand-primaryeasing-brand-emphasis这种语义化命名。半年后你回来看代码,curve2是什么意思?没人知道。但easing-brand-emphasis一看就知道是"品牌强调曲线"。

坑点7:品牌色与动效结合时注意色值格式

在动效中使用品牌色时,特别是做透明度渐变,一定要用8位十六进制色值(如#FF6B3540),而不是6位。6位色值不支持透明度通道,会导致动画效果和设计稿不一致。


五、HarmonyOS 6适配说明

API差异

API HarmonyOS 5.0 HarmonyOS 6.0 迁移建议
Curve.cubicBezierCurve 返回ICurve接口 返回ICurve接口,新增曲线组合 可链式组合多条曲线
animateTo 单次调用 支持嵌套animateTo 复杂编排可使用嵌套调用
TransitionEffect 静态组合 新增.animation()链式配置 使用链式配置更清晰
MotionPath 不支持 新增运动路径动画 品牌化路径动画使用此API
SpringMotion 需手动实现 新增SpringMotion内置类 弹性动画使用SpringMotion

行为变更

  • 曲线组合:6.0中可以通过Curve.compose()将多条曲线组合使用,例如先快后慢再快的效果
  • SpringMotion内置化:6.0新增SpringMotion类,支持阻尼、刚度、质量等物理参数的弹性动画,不再需要用cubicBezier模拟弹性
  • 动画中断恢复:6.0中动画被中断时,新动画会从当前值开始而不是目标值,避免了"跳帧"问题
  • TransitionEffect链式配置:6.0新增.animation()方法,可以在TransitionEffect上直接配置时长和曲线

适配代码

// HarmonyOS 6.0 品牌化弹性动画 - 使用SpringMotion
@Component
struct BrandButtonV6 {
  @Prop text: string = ''
  @State scaleValue: number = 1.0

  build() {
    Row() {
      Text(this.text)
        .fontSize(16)
        .fontColor(Color.White)
    }
    .padding({ horizontal: 24, vertical: 12 })
    .backgroundColor(BrandMotionTokens.BRAND_COLOR_PRIMARY)
    .borderRadius(24)
    .scale({ x: this.scaleValue, y: this.scaleValue })
    .onClick(() => {
      // 6.0使用SpringMotion实现品牌化弹性效果
      animateTo({
        duration: BrandMotionTokens.DURATION_MEDIUM,
        // 6.0新增:弹簧动画配置
        curve: new SpringMotion({
          stiffness: 400,    // 刚度:值越大回弹越快
          damping: 20,       // 阻尼:值越大振荡越少
          mass: 1.0          // 质量:值越大惯性越大
        })
      }, () => {
        this.scaleValue = 0.93
      })
      // 回弹
      setTimeout(() => {
        animateTo({
          duration: BrandMotionTokens.DURATION_MEDIUM,
          curve: new SpringMotion({
            stiffness: 300,
            damping: 15,
            mass: 1.0
          })
        }, () => {
          this.scaleValue = 1.0
        })
      }, 100)
    })
  }
}

// 6.0链式TransitionEffect配置
@Component
struct BrandCardV6 {
  @State isVisible: boolean = false

  build() {
    Column() {
      Text('品牌卡片')
        .fontSize(18)
    }
    .width('100%')
    .padding(16)
    .backgroundColor(Color.White)
    .borderRadius(16)
    // 6.0链式配置:直接在TransitionEffect上设置动画参数
    .transition(
      TransitionEffect.OPACITY
        .combine(TransitionEffect.translate({ y: 30 }))
        .animation({
          duration: BrandMotionTokens.DURATION_MEDIUM,
          curve: BrandMotionTokens.EASING_BRAND_PRIMARY
        })
    )
  }
}

六、总结

维度 评价
学习难度 ⭐⭐⭐⭐
使用频率 ⭐⭐⭐⭐
重要程度 ⭐⭐⭐⭐⭐

品牌化动效系统不是"锦上添花"的装饰,而是品牌识别度的核心组成部分。当用户闭上眼睛,仅凭手指的触感就能辨认出你的App时——你的品牌化动效就成功了。

建立动效Token系统是第一步,也是最关键的一步。它确保了整个App动效的一致性,也为后续的维护和迭代提供了清晰的接口。记住:好的动效系统像好的设计系统一样,是"约束"出来的,不是"自由"出来的

最后,品牌化动效的最高境界是"克制"。不是每个元素都需要动效,不是每次交互都需要品牌色。在关键节点留下品牌印记,在辅助操作保持安静——这才是成熟品牌化动效的标志。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。