Administrator
发布于 2026-08-30 / 67 阅读
0
0

鸿蒙自定义组件与动画:打造企业级可复用 UI 组件库

系列文章《鸿蒙开发从入门到专家》第 7 篇(中级篇)

前言

网络层封装好了,现在能拉数据、能展示列表。但如果每个页面都手写一套按钮样式、弹窗逻辑、加载动画,代码会迅速膨胀成一锅粥。企业级应用的核心竞争力之一,就是有一套设计统一、行为一致的组件库。

ArkUI 提供了强大的自定义组件能力:@Builder、@BuilderParam、@Styles、@Extend,加上属性动画和转场动画,足以支撑一套完整的组件体系。本文从实战出发,搭建一个包含按钮、卡片、弹窗、加载骨架屏的微型组件库。

一、自定义组件的四种武器

ArkUI 里有四个容易混淆的概念,先厘清它们的区别:

机制

定义位置

能否接收参数

能否有状态

使用场景

@Component

独立文件

能

能

完整组件(如一个弹窗)

@Builder

组件内部或全局

能

否

UI 片段复用(如列表项)

@Styles

组件内部或全局

否

否

样式集合(如统一的 padding)

@Extend

全局

能

否

扩展现有组件的链式方法

1.1 @Component:完整的独立组件

// components/LoadingButton.ets
@Component
export struct LoadingButton {
  @Prop label: string = '确认'
  @Prop loading: boolean = false
  @Prop disabled: boolean = false
  @Prop type: 'primary' | 'secondary' | 'danger' = 'primary'
  private onClickHandler?: () => void

  private getBackgroundColor(): ResourceColor {
    if (this.disabled || this.loading) return '#CCCCCC'
    switch (this.type) {
      case 'primary': return '#317AF7'
      case 'secondary': return '#F0F0F0'
      case 'danger': return '#E84026'
      default: return '#317AF7'
    }
  }

  private getTextColor(): ResourceColor {
    if (this.disabled || this.loading) return '#999999'
    return this.type === 'secondary' ? '#333333' : Color.White
  }

  build() {
    Button() {
      Row({ space: 8 }) {
        if (this.loading) {
          LoadingProgress()
            .width(18)
            .height(18)
            .color(Color.White)
        }
        Text(this.label)
          .fontSize(16)
          .fontColor(this.getTextColor())
      }
    }
    .width('100%')
    .height(48)
    .backgroundColor(this.getBackgroundColor())
    .enabled(!this.loading && !this.disabled)
    .onClick(() => {
      if (this.onClickHandler) {
        this.onClickHandler()
      }
    })
  }
}

// 使用
LoadingButton({
  label: '提交订单',
  loading: this.isSubmitting,
  disabled: this.formInvalid,
  type: 'primary',
  onClickHandler: () => this.handleSubmit()
})

1.2 @Builder:轻量级 UI 片段

@Builder 适合不需要独立状态的 UI 复用场景,比如列表项、卡片模板:

// builders/CardBuilders.ets
@Builder
export function InfoCard(title: string, content: string, icon?: Resource) {
  Column({ space: 8 }) {
    Row({ space: 8 }) {
      if (icon) {
        Image(icon).width(20).height(20)
      }
      Text(title)
        .fontSize(16)
        .fontWeight(FontWeight.Medium)
    }
    .width('100%')

    Text(content)
      .fontSize(14)
      .fontColor('#666666')
      .maxLines(3)
      .textOverflow({ overflow: TextOverflow.Ellipsis })
  }
  .padding(16)
  .backgroundColor(Color.White)
  .borderRadius(12)
  .width('100%')
}

// 使用
InfoCard('订单状态', '您的订单已发货,预计明天送达', $r('app.media.icon_truck'))

1.3 @BuilderParam:组件插槽

Vue 和 React 开发者熟悉的"插槽"概念,在 ArkUI 里用 @BuilderParam 实现:

// components/Dialog.ets
@Component
export struct CustomDialog {
  @Prop title: string = '提示'
  @Prop visible: boolean = false
  @BuilderParam contentBuilder?: () => void
  @BuilderParam footerBuilder?: () => void
  private onClose?: () => void

  build() {
    if (this.visible) {
      Stack({ alignContent: Alignment.Center }) {
        // 遮罩层
        Column()
          .width('100%')
          .height('100%')
          .backgroundColor('rgba(0, 0, 0, 0.5)')
          .onClick(() => {
            if (this.onClose) this.onClose()
          })

        // 弹窗内容
        Column({ space: 16 }) {
          Text(this.title)
            .fontSize(18)
            .fontWeight(FontWeight.Bold)
            .width('100%')

          if (this.contentBuilder) {
            this.contentBuilder()
          }

          if (this.footerBuilder) {
            this.footerBuilder()
          } else {
            // 默认底部按钮
            Row({ space: 12 }) {
              Button('取消')
                .width(120)
                .height(40)
                .backgroundColor('#F0F0F0')
                .fontColor('#666666')
                .onClick(() => {
                  if (this.onClose) this.onClose()
                })
              Button('确认')
                .width(120)
                .height(40)
                .backgroundColor('#317AF7')
                .onClick(() => {
                  if (this.onClose) this.onClose()
                })
            }
          }
        }
        .padding(24)
        .backgroundColor(Color.White)
        .borderRadius(16)
        .width('80%')
      }
      .width('100%')
      .height('100%')
      .position({ x: 0, y: 0 })
    }
  }
}

// 使用:自定义内容插槽
CustomDialog({
  title: '确认删除',
  visible: this.showDeleteDialog,
  contentBuilder: () => {
    Text('删除后无法恢复,是否继续?')
      .fontSize(14)
      .fontColor('#999999')
  },
  footerBuilder: () => {
    Row({ space: 12 }) {
      Button('再想想')
        .width(120)
        .height(40)
        .backgroundColor('#F0F0F0')
        .onClick(() => this.showDeleteDialog = false)
      Button('果断删除')
        .width(120)
        .height(40)
        .backgroundColor('#E84026')
        .onClick(() => this.confirmDelete())
    }
  },
  onClose: () => this.showDeleteDialog = false
})

1.4 @Styles 与 @Extend:样式复用

// 全局样式函数
@Styles
function cardStyle() {
  .padding(16)
  .backgroundColor(Color.White)
  .borderRadius(12)
  .shadow({ radius: 8, color: 'rgba(0,0,0,0.08)', offsetY: 4 })
}

// 扩展 Text 组件
@Extend(Text)
function secondaryText() {
  .fontSize(14)
  .fontColor('#999999')
  .maxLines(2)
  .textOverflow({ overflow: TextOverflow.Ellipsis })
}

// 使用
Column() {
  Text('标题').fontSize(18).fontWeight(FontWeight.Bold)
  Text('这是一段描述文本').secondaryText()
}
.cardStyle()
.width('100%')

二、实战:骨架屏组件

骨架屏是提升用户体验的神器,在数据加载时展示一个"灰色占位块"的预览:

// components/Skeleton.ets
@Component
export struct Skeleton {
  @Prop width: string | number = '100%'
  @Prop height: string | number = 16
  @Prop borderRadius: number = 4

  @State shimmerOffset: number = -100

  aboutToAppear() {
    // 启动 shimmer 动画
    animateTo({
      duration: 1500,
      iterations: -1,  // 无限循环
      curve: Curve.Linear
    }, () => {
      this.shimmerOffset = 200
    })
  }

  build() {
    Stack() {
      // 底色
      Column()
        .width(this.width)
        .height(this.height)
        .backgroundColor('#E8E8E8')
        .borderRadius(this.borderRadius)

      // shimmer 高光
      Column()
        .width(60)
        .height(this.height)
        .backgroundColor('linear-gradient(90deg, rgba(255,255,255,0) 0%, rgba(255,255,255,0.5) 50%, rgba(255,255,255,0) 100%)')
        .position({ x: this.shimmerOffset })
    }
    .clip(true)
    .borderRadius(this.borderRadius)
  }
}

// 骨架屏列表组件
@Component
export struct SkeletonList {
  @Prop itemCount: number = 5

  @Builder
  SkeletonItem() {
    Row({ space: 12 }) {
      Skeleton({ width: 80, height: 80, borderRadius: 8 })
      Column({ space: 8 }) {
        Skeleton({ width: '60%', height: 16 })
        Skeleton({ width: '80%', height: 14 })
        Skeleton({ width: '40%', height: 12 })
      }
      .layoutWeight(1)
      .alignItems(HorizontalAlign.Start)
    }
    .width('100%')
    .padding(16)
  }

  build() {
    Column({ space: 8 }) {
      ForEach(Array.from({ length: this.itemCount }), () => {
        this.SkeletonItem()
      })
    }
    .width('100%')
    .backgroundColor(Color.White)
  }
}

三、动画系统:属性动画与转场动画

3.1 属性动画

属性动画是最常用的动画类型,改变组件的某个属性值并自动插值:

@Entry
@Component
struct AnimationDemo {
  @State scaleValue: number = 1
  @State opacityValue: number = 1
  @State rotateValue: number = 0

  build() {
    Column({ space: 30 }) {
      Text('动画目标')
        .width(120)
        .height(120)
        .backgroundColor('#317AF7')
        .fontColor(Color.White)
        .textAlign(TextAlign.Center)
        .scale({ x: this.scaleValue, y: this.scaleValue })
        .opacity(this.opacityValue)
        .rotate({ angle: this.rotateValue })
        .animation({
          duration: 500,
          curve: Curve.EaseInOut,
          iterations: 1
        })

      Row({ space: 16 }) {
        Button('缩放')
          .onClick(() => {
            this.scaleValue = this.scaleValue === 1 ? 1.5 : 1
          })
        Button('淡入淡出')
          .onClick(() => {
            this.opacityValue = this.opacityValue === 1 ? 0.3 : 1
          })
        Button('旋转')
          .onClick(() => {
            this.rotateValue += 90
          })
      }
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
  }
}

.animation() 方法定义动画参数,当绑定的状态变量变化时,ArkUI 自动执行过渡动画。

3.2 animateTo:显式控制动画

animateTo 适合同时改变多个属性,或者需要精确控制动画时机的场景:

Button('展开详情')
  .onClick(() => {
    animateTo({
      duration: 300,
      curve: Curve.EaseOut,
      onFinish: () => {
        console.info('动画结束')
      }
    }, () => {
      this.isExpanded = !this.isExpanded
      this.containerHeight = this.isExpanded ? 300 : 100
      this.arrowRotation = this.isExpanded ? 180 : 0
    })
  })

3.3 转场动画:页面与组件的过渡

组件出现/消失时的过渡效果:

if (this.showPanel) {
  Column() {
    Text('弹出的面板')
  }
  .width('100%')
  .height(300)
  .backgroundColor(Color.White)
  .transition(TransitionEffect.asymmetric(
    // 入场:从底部滑入 + 淡入
    TransitionEffect.move(TransitionEdge.BOTTOM)
      .combine(TransitionEffect.opacity(0)),
    // 出场:向底部滑出 + 淡出
    TransitionEffect.move(TransitionEdge.BOTTOM)
      .combine(TransitionEffect.opacity(0))
  ))
}

3.4 列表项滑动删除

ListItem() {
  // 列表项内容
  Row() {
    Text('左滑删除我')
  }
  .width('100%')
  .height(60)
  .padding({ left: 16, right: 16 })
  .backgroundColor(Color.White)
}
.swipeAction({
  end: {
    builder: () => {
      Row() {
        Text('删除')
          .fontColor(Color.White)
          .onClick(() => {
            this.deleteItem(item.id)
          })
      }
      .width(80)
      .height('100%')
      .backgroundColor('#E84026')
      .justifyContent(FlexAlign.Center)
    }
  }
})

四、组件库目录结构建议

components/
├── base/                   # 基础组件
│   ├── LoadingButton.ets
│   ├── Skeleton.ets
│   └── EmptyState.ets      # 空状态页
├── form/                   # 表单组件
│   ├── FormInput.ets
│   ├── FormPicker.ets
│   └── FormValidator.ets
├── feedback/               # 反馈组件
│   ├── CustomDialog.ets
│   ├── Toast.ets
│   └── ActionSheet.ets
├── display/                # 展示组件
│   ├── ImageViewer.ets
│   ├── Badge.ets
│   └── Tag.ets
└── builders/
    ├── CardBuilders.ets
    └── ListBuilders.ets

小结与下篇预告

本文搭建了一套可复用的组件体系:

  1. @Component 做完整组件,@Builder 做 UI 片段,@BuilderParam 做插槽

  2. 骨架屏用属性动画实现 shimmer 效果,提升加载体验

  3. 属性动画用 .animation() 或 animateTo,转场用 TransitionEffect

  4. 列表滑动操作用 swipeAction 实现删除等交互

下一篇(第 8 篇)进入高级内容:性能优化。我们会从启动速度、列表渲染和内存占用三个维度,给出可落地的优化方案和测量方法。

参考阅读


评论