项目演示

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述


目录


一、前言:为什么加载按钮如此重要

1.1 交互反馈的黄金法则

在移动应用开发中,加载状态(Loading State) 是最基本也最重要的用户反馈机制之一。当用户点击一个按钮后,应用需要执行耗时操作(网络请求、数据处理、文件上传等),如果没有任何视觉反馈,用户会产生焦虑:

“我点了吗?”
“是不是没反应?”
“要不要再点一次?”

据 UX 研究显示,超过 60% 的用户在没有加载反馈时会重复点击按钮,这不仅浪费资源,还可能导致数据错误(如重复下单、重复提交表单)。

1.2 HarmonyOS NEXT 的设计理念

HarmonyOS NEXT 以"一次开发,多端部署"为核心理念,其 ArkUI 声明式 UI 框架在加载状态处理上采用了独特的设计思路:

特性 描述
声明式编程 只需描述"状态如何",框架负责"如何渲染"
组件化 Button 与 LoadingProgress 分离,灵活组合
状态驱动 @State 变化自动触发 UI 更新
多端适配 同一套代码适配手机、平板、穿戴设备

1.3 你可能踩过的坑

如果你从其他框架(如 Flutter、React Native)转向 HarmonyOS,可能会犯一个常见错误:

// ❌ 错误写法:以为 Button 有 loading 属性
Button('提交')
  .loading(true)    // 这个属性不存在!

重要提示:HarmonyOS NEXT 的 Button 组件没有内置 loading 属性。正确做法是使用 Button 的子组件功能,内部放置 LoadingProgress。


二、核心概念解析

2.1 Button 组件基础

Button 是 ArkUI 中最基础的交互组件之一,从 API 7 开始支持。

基本构造函数
// 方式一:带文字参数
Button('确定')

// 方式二:空构造 + 子组件(用于自定义内容)
Button() {
  // 在这里放置任意子组件
}

// 方式三:带配置选项
Button('确定', {
  type: ButtonType.Capsule,
  stateEffect: true
})
ButtonType 枚举
枚举值 说明 默认圆角
ButtonType.Normal 普通矩形按钮 无圆角
ButtonType.Capsule 胶囊形按钮 高度的一半
ButtonType.Circle 圆形按钮 宽高较小值的一半
ButtonType.ROUNDED_RECTANGLE 圆角矩形 20vp (API 15+)
常用属性
Button('确定')
  .width(200)
  .height(48)
  .backgroundColor('#007DFF')
  .fontColor(Color.White)
  .fontSize(16)
  .fontWeight(FontWeight.Medium)
  .borderRadius(8)
  .enabled(true)
  .stateEffect(true)
  .onClick(() => {
    console.log('按钮被点击')
  })

2.2 LoadingProgress 组件

LoadingProgress 是专门用于显示加载动画的组件,从 API 8 开始支持。

基本用法
LoadingProgress()
  .width(24)
  .height(24)
  .color('#FFFFFF')
在 Button 中使用
Button() {
  Row({ space: 8 }) {
    LoadingProgress()
      .width(18)
      .height(18)
      .color(Color.White)
    Text('加载中...')
      .fontSize(14)
      .fontColor(Color.White)
  }
}

2.3 @State 状态管理

@State 是 ArkUI 最基础的状态管理装饰器,用于声明组件内部的响应式变量。

基本语法
@State isLoading: boolean = false
@State buttonText: string = '提交'
@State progress: number = 0
类型约束
// ✅ 正确:明确类型
@State isLoading: boolean = false

// ❌ 错误:使用 any/unknown
@State data: any = null

// ❌ 错误:未指定类型
@State value = 123

三、四种实现方案对比

3.1 方案一:条件渲染法

最直观、最常用的方式,通过 if 条件判断切换状态。

@Entry
@Component
struct ConditionalLoadingButton {
  @State isLoading: boolean = false
  
  handleClick() {
    if (this.isLoading) {
      return
    }
    this.isLoading = true
    setTimeout(() => {
      this.isLoading = false
    }, 2000)
  }
  
  build() {
    Column() {
      Button() {
        if (this.isLoading) {
          Row({ space: 8 }) {
            LoadingProgress()
              .width(20)
              .height(20)
              .color(Color.White)
            Text('提交中...')
              .fontSize(16)
              .fontWeight(FontWeight.Medium)
              .fontColor(Color.White)
          }
        } else {
          Text('提交')
            .fontSize(16)
            .fontWeight(FontWeight.Medium)
            .fontColor(Color.White)
        }
      }
      .width('100%')
      .height(48)
      .backgroundColor(this.isLoading ? '#999999' : '#007DFF')
      .borderRadius(8)
      .enabled(!this.isLoading)
      .onClick(() => {
        this.handleClick()
      })
    }
    .width('100%')
    .padding(24)
  }
}
优点 缺点
代码直观易读 两个分支的内容需保持一致
灵活度高 存在一定的代码重复
性能好 -

3.2 方案二:contentModifier 法

contentModifier 是 API 12 引入的高级特性。

class LoadingButtonModifier implements ContentModifier<ButtonConfiguration> {
  isLoading: boolean = false
  customText: string = ''
  loadingText: string = ''
  
  constructor(isLoading: boolean, customText: string, loadingText: string) {
    this.isLoading = isLoading
    this.customText = customText
    this.loadingText = loadingText
  }
  
  applyContent(config: ButtonConfiguration): void {
    config.label = this.isLoading ? this.loadingText : this.customText
    config.fontSize = 16
    config.fontWeight = FontWeight.Medium
    config.fontColor = Color.White
  }
}

@Entry
@Component
struct ModifierLoadingButton {
  @State isLoading: boolean = false
  
  build() {
    Column() {
      Button()
        .width('100%')
        .height(48)
        .backgroundColor(this.isLoading ? '#999999' : '#007DFF')
        .borderRadius(8)
        .contentModifier(new LoadingButtonModifier(this.isLoading, '提交', '提交中...'))
        .enabled(!this.isLoading)
        .onClick(() => {
          this.isLoading = true
          setTimeout(() => {
            this.isLoading = false
          }, 2000)
        })
    }
    .padding(24)
  }
}
优点 缺点
代码结构清晰 API 较新,文档不完善
支持复杂逻辑 自定义能力有限
类型安全 学习成本较高

3.3 方案三:自定义组件封装法

将加载按钮封装为独立组件,通过 @Prop 接收状态。

@Component
struct LoadingButton {
  @Prop isLoading: boolean = false
  @Prop buttonText: string = '提交'
  @Prop loadingText: string = '加载中...'
  @Prop buttonColor: string = '#007DFF'
  @Prop loadingColor: string = '#999999'
  onClickCallback: () => void = () => {}
  
  build() {
    Button() {
      Row({ space: 8 }) {
        if (this.isLoading) {
          LoadingProgress()
            .width(20)
            .height(20)
            .color(Color.White)
        }
        Text(this.isLoading ? this.loadingText : this.buttonText)
          .fontSize(16)
          .fontWeight(FontWeight.Medium)
          .fontColor(Color.White)
      }
    }
    .width('100%')
    .height(48)
    .backgroundColor(this.isLoading ? this.loadingColor : this.buttonColor)
    .borderRadius(8)
    .enabled(!this.isLoading)
    .onClick(() => {
      if (!this.isLoading) {
        this.onClickCallback()
      }
    })
  }
}

@Entry
@Component
struct CustomComponentDemo {
  @State isLoading: boolean = false
  
  handleSubmit() {
    this.isLoading = true
    setTimeout(() => {
      this.isLoading = false
    }, 2000)
  }
  
  build() {
    Column() {
      LoadingButton({
        isLoading: this.isLoading,
        buttonText: '提交表单',
        loadingText: '正在提交...',
        buttonColor: '#007DFF',
        loadingColor: '#CCCCCC',
        onClickCallback: () => this.handleSubmit()
      })
    }
    .padding(24)
  }
}
优点 缺点
高度可复用 需要管理 Props 传递
职责单一 跨组件通信稍复杂
易于维护 -

3.4 方案四:@Builder 复用法

@Builder 用于定义可复用的 UI 片段。

@Entry
@Component
struct BuilderLoadingButton {
  @State isLoading: boolean = false
  
  @Builder
  LoadingButtonBuilder(
    text: string,
    loadingText: string,
    isLoading: boolean,
    onTap: () => void
  ) {
    Button() {
      Row({ space: 8 }) {
        if (isLoading) {
          LoadingProgress()
            .width(20)
            .height(20)
            .color(Color.White)
        }
        Text(isLoading ? loadingText : text)
          .fontSize(16)
          .fontWeight(FontWeight.Medium)
          .fontColor(Color.White)
      }
    }
    .width('100%')
    .height(48)
    .backgroundColor(isLoading ? '#CCCCCC' : '#007DFF')
    .borderRadius(8)
    .enabled(!isLoading)
    .onClick(() => {
      if (!isLoading) {
        onTap()
      }
    })
  }
  
  build() {
    Column() {
      this.LoadingButtonBuilder(
        '提交',
        '提交中...',
        this.isLoading,
        () => {
          this.isLoading = true
          setTimeout(() => {
            this.isLoading = false
          }, 2000)
        }
      )
    }
    .padding(24)
  }
}

四种方案对比总结

方案 代码复杂度 可复用性 推荐指数
条件渲染法 ⭐⭐⭐⭐⭐
contentModifier ⭐⭐⭐ ⭐⭐⭐⭐ ⭐⭐⭐
自定义组件 ⭐⭐⭐ ⭐⭐⭐⭐⭐ ⭐⭐⭐⭐
@Builder ⭐⭐ ⭐⭐ ⭐⭐⭐

推荐选择

  • 新手入门 → 条件渲染法
  • 中等规模项目 → 自定义组件法
  • 大型项目/组件库 → contentModifier + 自定义组件

四、实战场景全解

4.1 登录按钮

登录是最常见的加载按钮场景,通常涉及验证、网络请求、结果处理三个阶段。

状态枚举设计
enum LoginState {
  IDLE = 'idle',           // 空闲
  VALIDATING = 'validating', // 验证中
  LOGGING = 'logging',      // 登录中
  SUCCESS = 'success',      // 成功
  ERROR = 'error'           // 失败
}
完整实现
@Entry
@Component
struct LoginPage {
  @State loginState: LoginState = LoginState.IDLE
  @State username: string = ''
  @State password: string = ''
  @State errorMessage: string = ''
  
  async handleLogin() {
    // 第一步:验证输入
    if (!this.username || !this.password) {
      this.errorMessage = '请输入用户名和密码'
      return
    }
    
    if (this.password.length < 6) {
      this.errorMessage = '密码至少6位'
      return
    }
    
    this.errorMessage = ''
    this.loginState = LoginState.LOGGING
    
    try {
      // 第二步:模拟登录请求
      await this.simulateLoginRequest()
      
      // 第三步:登录成功
      this.loginState = LoginState.SUCCESS
      
      // 延迟后跳转或重置
      setTimeout(() => {
        this.loginState = LoginState.IDLE
      }, 2000)
      
    } catch (error) {
      // 登录失败
      this.loginState = LoginState.ERROR
      this.errorMessage = '用户名或密码错误'
      
      setTimeout(() => {
        this.loginState = LoginState.IDLE
      }, 1500)
    }
  }
  
  simulateLoginRequest(): Promise<void> {
    return new Promise((resolve, reject) => {
      setTimeout(() => {
        if (Math.random() > 0.1) {
          resolve()
        } else {
          reject(new Error('登录失败'))
        }
      }, 1500)
    })
  }
  
  build() {
    Column({ space: 24 }) {
      // Logo 和标题
      Column({ space: 8 }) {
        Text('用户登录')
          .fontSize(28)
          .fontWeight(FontWeight.Bold)
          .fontColor($r('app.color.text_primary'))
        Text('欢迎回来')
          .fontSize(14)
          .fontColor($r('app.color.text_secondary'))
      }
      .margin({ bottom: 48 })
      
      // 表单区域
      Column({ space: 16 }) {
        TextInput({ placeholder: '用户名' })
          .width('100%')
          .height(48)
          .onChange((value: string) => {
            this.username = value
            if (this.loginState === LoginState.ERROR) {
              this.loginState = LoginState.IDLE
            }
          })
          .enabled(this.loginState === LoginState.IDLE || this.loginState === LoginState.ERROR)
        
        TextInput({ placeholder: '密码' })
          .width('100%')
          .height(48)
          .type(InputType.Password)
          .onChange((value: string) => {
            this.password = value
            if (this.loginState === LoginState.ERROR) {
              this.loginState = LoginState.IDLE
            }
          })
          .enabled(this.loginState === LoginState.IDLE || this.loginState === LoginState.ERROR)
        
        if (this.errorMessage) {
          Text(this.errorMessage)
            .fontSize(12)
            .fontColor($r('app.color.error_color'))
        }
        
        // 登录按钮
        Button() {
          Row({ space: 8 }) {
            if (this.loginState === LoginState.LOGGING) {
              LoadingProgress()
                .width(20)
                .height(20)
                .color(Color.White)
            } else if (this.loginState === LoginState.SUCCESS) {
              Text('✓')
                .fontSize(20)
                .fontColor(Color.White)
            }
            Text(this.getButtonText())
              .fontSize(16)
              .fontWeight(FontWeight.Medium)
              .fontColor(Color.White)
          }
        }
        .width('100%')
        .height(48)
        .backgroundColor(this.getButtonColor())
        .borderRadius(24)
        .enabled(this.loginState === LoginState.IDLE || this.loginState === LoginState.ERROR)
        .onClick(() => {
          this.handleLogin()
        })
        
        Text('忘记密码?')
          .fontSize(14)
          .fontColor($r('app.color.primary_color'))
          .textAlign(TextAlign.End)
          .width('100%')
      }
      .width('100%')
    }
    .width('100%')
    .height('100%')
    .padding(24)
    .justifyContent(FlexAlign.Center)
  }
  
  getButtonText(): string {
    switch (this.loginState) {
      case LoginState.LOGGING: return '登录中...'
      case LoginState.SUCCESS: return '登录成功'
      case LoginState.ERROR: return '重新登录'
      default: return '登录'
    }
  }
  
  getButtonColor(): string {
    switch (this.loginState) {
      case LoginState.LOGGING: return '#CCCCCC'
      case LoginState.SUCCESS: return '#52C41A'
      case LoginState.ERROR: return '#FF4D4F'
      default: return '#007DFF'
    }
  }
}

4.2 倒计时发送验证码

倒计时是加载按钮的高级用法,常用于验证码发送场景。

完整实现
@Entry
@Component
struct CountdownButtonPage {
  @State phoneNumber: string = ''
  @State isSending: boolean = false
  @State countdown: number = 0
  @State errorMessage: string = ''
  
  private countdownTimer: number = -1
  
  async handleSendCode() {
    if (!this.isValidPhone(this.phoneNumber)) {
      this.errorMessage = '请输入有效的手机号'
      return
    }
    
    if (this.isSending || this.countdown > 0) {
      return
    }
    
    this.errorMessage = ''
    this.isSending = true
    
    try {
      await this.sendVerificationCode()
      this.startCountdown(60)
    } catch (error) {
      this.errorMessage = '发送失败,请重试'
    } finally {
      this.isSending = false
    }
  }
  
  isValidPhone(phone: string): boolean {
    const phoneRegex = /^1[3-9]\d{9}$/
    return phoneRegex.test(phone)
  }
  
  sendVerificationCode(): Promise<void> {
    return new Promise((resolve, reject) => {
      setTimeout(() => {
        if (Math.random() > 0.02) {
          resolve()
        } else {
          reject(new Error('发送失败'))
        }
      }, 1000)
    })
  }
  
  startCountdown(seconds: number) {
    this.countdown = seconds
    this.countdownTimer = setInterval(() => {
      this.countdown--
      if (this.countdown <= 0) {
        this.stopCountdown()
      }
    }, 1000) as number
  }
  
  stopCountdown() {
    if (this.countdownTimer !== -1) {
      clearInterval(this.countdownTimer)
      this.countdownTimer = -1
    }
    this.countdown = 0
  }
  
  aboutToDisappear() {
    this.stopCountdown()
  }
  
  build() {
    Column({ space: 24 }) {
      Text('手机号验证')
        .fontSize(24)
        .fontWeight(FontWeight.Bold)
        .width('100%')
      
      Column({ space: 12 }) {
        Row({ space: 12 }) {
          TextInput({ placeholder: '请输入手机号' })
            .layoutWeight(1)
            .height(48)
            .type(InputType.Number)
            .maxLength(11)
            .onChange((value: string) => {
              this.phoneNumber = value
              this.errorMessage = ''
            })
          
          // 发送验证码按钮
          Button() {
            Row({ space: 6 }) {
              if (this.isSending) {
                LoadingProgress()
                  .width(16)
                  .height(16)
                  .color(Color.White)
              }
              Text(this.getButtonText())
                .fontSize(14)
                .fontWeight(FontWeight.Medium)
                .fontColor(Color.White)
            }
          }
          .height(48)
          .padding({ left: 16, right: 16 })
          .backgroundColor(this.getButtonColor())
          .borderRadius(8)
          .enabled(!this.isSending && this.countdown === 0)
          .onClick(() => {
            this.handleSendCode()
          })
        }
        .width('100%')
        
        if (this.errorMessage) {
          Text(this.errorMessage)
            .fontSize(12)
            .fontColor($r('app.color.error_color'))
        }
        
        if (this.countdown > 0) {
          Text(`验证码已发送,${this.countdown}秒后可重发`)
            .fontSize(12)
            .fontColor($r('app.color.text_secondary'))
        }
      }
      .width('100%')
    }
    .width('100%')
    .height('100%')
    .padding(24)
    .justifyContent(FlexAlign.Start)
  }
  
  getButtonText(): string {
    if (this.isSending) return '发送中'
    if (this.countdown > 0) return `${this.countdown}s`
    return '获取验证码'
  }
  
  getButtonColor(): string {
    if (this.isSending) return '#CCCCCC'
    if (this.countdown > 0) return '#999999'
    return '#007DFF'
  }
}

4.3 上传/下载进度

上传下载场景需要展示实时进度和支持暂停/继续操作。

完整实现
enum UploadState {
  IDLE = 'idle',
  UPLOADING = 'uploading',
  SUCCESS = 'success',
  FAILED = 'failed',
  PAUSED = 'paused'
}

@Entry
@Component
struct UploadProgressPage {
  @State uploadState: UploadState = UploadState.IDLE
  @State progress: number = 0
  @State fileName: string = 'document.pdf'
  @State fileSize: string = '3.2 MB'
  
  private uploadTimer: number = -1
  
  handleMainAction() {
    switch (this.uploadState) {
      case UploadState.IDLE:
      case UploadState.FAILED:
        this.startUpload()
        break
      case UploadState.UPLOADING:
        this.pauseUpload()
        break
      case UploadState.PAUSED:
        this.resumeUpload()
        break
    }
  }
  
  startUpload() {
    this.uploadState = UploadState.UPLOADING
    this.progress = 0
    this.simulateUpload()
  }
  
  pauseUpload() {
    this.uploadState = UploadState.PAUSED
    this.stopTimer()
  }
  
  resumeUpload() {
    this.uploadState = UploadState.UPLOADING
    this.simulateUpload()
  }
  
  cancelUpload() {
    this.stopTimer()
    this.uploadState = UploadState.IDLE
    this.progress = 0
  }
  
  simulateUpload() {
    this.uploadTimer = setInterval(() => {
      const increment = Math.random() * 8 + 2
      this.progress = Math.min(this.progress + increment, 100)
      
      if (this.progress >= 100) {
        this.stopTimer()
        this.uploadState = Math.random() > 0.05 
          ? UploadState.SUCCESS 
          : UploadState.FAILED
      }
    }, 200) as number
  }
  
  stopTimer() {
    if (this.uploadTimer !== -1) {
      clearInterval(this.uploadTimer)
      this.uploadTimer = -1
    }
  }
  
  aboutToDisappear() {
    this.stopTimer()
  }
  
  build() {
    Column({ space: 20 }) {
      Text('文件上传')
        .fontSize(24)
        .fontWeight(FontWeight.Bold)
        .width('100%')
      
      // 文件信息卡片
      Column({ space: 16 }) {
        Row({ space: 12 }) {
          Column() {
            Text('📄')
              .fontSize(28)
          }
          .width(56)
          .height(56)
          .backgroundColor($r('app.color.background_card'))
          .borderRadius(8)
          .justifyContent(FlexAlign.Center)
          .alignItems(HorizontalAlign.Center)
          
          Column({ space: 4 }) {
            Text(this.fileName)
              .fontSize(16)
              .fontWeight(FontWeight.Medium)
              .width('100%')
            Text(this.fileSize)
              .fontSize(12)
              .fontColor($r('app.color.text_secondary'))
          }
          .layoutWeight(1)
          .alignItems(HorizontalAlign.Start)
        }
        .width('100%')
        
        // 进度区域
        if (this.uploadState !== UploadState.IDLE) {
          Column({ space: 8 }) {
            Progress({
              value: this.progress,
              total: 100,
              type: ProgressType.Linear
            })
              .width('100%')
              .height(6)
              .color(this.getProgressColor())
              .backgroundColor($r('app.color.divider_color'))
              .borderRadius(3)
            
            Row() {
              Text(`${Math.floor(this.progress)}%`)
                .fontSize(12)
                .fontColor($r('app.color.text_secondary'))
              
              Blank()
              
              Text(this.getStateText())
                .fontSize(12)
                .fontColor(this.getProgressColor())
            }
            .width('100%')
          }
        }
      }
      .width('100%')
      .padding(16)
      .backgroundColor($r('app.color.background_card'))
      .borderRadius(12)
      
      // 操作按钮组
      Row({ space: 12 }) {
        Button() {
          Row({ space: 8 }) {
            if (this.uploadState === UploadState.UPLOADING) {
              LoadingProgress()
                .width(18)
                .height(18)
                .color(Color.White)
            }
            Text(this.getMainButtonText())
              .fontSize(16)
              .fontWeight(FontWeight.Medium)
              .fontColor(Color.White)
          }
        }
        .layoutWeight(1)
        .height(48)
        .backgroundColor(this.getMainButtonColor())
        .borderRadius(8)
        .enabled(this.uploadState !== UploadState.SUCCESS)
        .onClick(() => {
          this.handleMainAction()
        })
        
        if (this.uploadState === UploadState.UPLOADING || 
            this.uploadState === UploadState.PAUSED) {
          Button('取消')
            .height(48)
            .padding({ left: 20, right: 20 })
            .backgroundColor($r('app.color.error_color'))
            .borderRadius(8)
            .onClick(() => {
              this.cancelUpload()
            })
        }
      }
      .width('100%')
    }
    .width('100%')
    .height('100%')
    .padding(24)
  }
  
  getMainButtonText(): string {
    switch (this.uploadState) {
      case UploadState.IDLE: return '开始上传'
      case UploadState.UPLOADING: return '暂停'
      case UploadState.PAUSED: return '继续上传'
      case UploadState.SUCCESS: return '上传完成 ✓'
      case UploadState.FAILED: return '重新上传'
    }
  }
  
  getMainButtonColor(): string {
    switch (this.uploadState) {
      case UploadState.IDLE: return '#007DFF'
      case UploadState.UPLOADING: return '#FFA940'
      case UploadState.PAUSED: return '#007DFF'
      case UploadState.SUCCESS: return '#52C41A'
      case UploadState.FAILED: return '#FF4D4F'
    }
  }
  
  getProgressColor(): string {
    switch (this.uploadState) {
      case UploadState.UPLOADING:
      case UploadState.PAUSED: return '#007DFF'
      case UploadState.SUCCESS: return '#52C41A'
      case UploadState.FAILED: return '#FF4D4F'
      default: return '#007DFF'
    }
  }
  
  getStateText(): string {
    switch (this.uploadState) {
      case UploadState.UPLOADING: return '上传中'
      case UploadState.PAUSED: return '已暂停'
      case UploadState.SUCCESS: return '上传成功'
      case UploadState.FAILED: return '上传失败'
      default: return ''
    }
  }
}

五、状态机设计模式

当按钮状态超过 3 个时,建议使用状态机模式管理状态转换。

5.1 为什么需要状态机

使用分散的 boolean 变量容易导致:

  • 状态不一致(如 isLoading=trueisSuccess=true
  • 状态遗漏(忘记处理某个状态)
  • 代码难以维护

5.2 实现方案

enum ButtonState {
  IDLE = 'idle',
  VALIDATING = 'validating',
  LOADING = 'loading',
  SUCCESS = 'success',
  FAILED = 'failed',
  DISABLED = 'disabled'
}

interface StateConfig {
  text: string
  loading: boolean
  enabled: boolean
  color: string
}

class ButtonStateMachine {
  private currentState: ButtonState = ButtonState.IDLE
  private listeners: Array<(state: ButtonState) => void> = []
  
  private stateConfigs: Map<ButtonState, StateConfig> = new Map([
    [ButtonState.IDLE, {
      text: '提交',
      loading: false,
      enabled: true,
      color: '#007DFF'
    }],
    [ButtonState.VALIDATING, {
      text: '验证中...',
      loading: true,
      enabled: false,
      color: '#999999'
    }],
    [ButtonState.LOADING, {
      text: '提交中...',
      loading: true,
      enabled: false,
      color: '#999999'
    }],
    [ButtonState.SUCCESS, {
      text: '提交成功 ✓',
      loading: false,
      enabled: false,
      color: '#52C41A'
    }],
    [ButtonState.FAILED, {
      text: '重新提交',
      loading: false,
      enabled: true,
      color: '#FF4D4F'
    }],
    [ButtonState.DISABLED, {
      text: '不可用',
      loading: false,
      enabled: false,
      color: '#CCCCCC'
    }]
  ])
  
  private validTransitions: Map<ButtonState, ButtonState[]> = new Map([
    [ButtonState.IDLE, [ButtonState.VALIDATING, ButtonState.DISABLED]],
    [ButtonState.VALIDATING, [ButtonState.LOADING, ButtonState.FAILED, ButtonState.IDLE]],
    [ButtonState.LOADING, [ButtonState.SUCCESS, ButtonState.FAILED]],
    [ButtonState.SUCCESS, [ButtonState.IDLE]],
    [ButtonState.FAILED, [ButtonState.VALIDATING, ButtonState.IDLE]],
    [ButtonState.DISABLED, [ButtonState.IDLE]]
  ])
  
  getState(): ButtonState {
    return this.currentState
  }
  
  getConfig(): StateConfig {
    return this.stateConfigs.get(this.currentState)!
  }
  
  canTransition(targetState: ButtonState): boolean {
    const allowed = this.validTransitions.get(this.currentState)
    return allowed !== undefined && allowed.includes(targetState)
  }
  
  transition(targetState: ButtonState): boolean {
    if (!this.canTransition(targetState)) {
      console.warn(`状态转换失败: ${this.currentState} -> ${targetState}`)
      return false
    }
    
    console.log(`状态转换: ${this.currentState} -> ${targetState}`)
    this.currentState = targetState
    this.listeners.forEach(listener => listener(targetState))
    return true
  }
  
  addListener(listener: (state: ButtonState) => void): void {
    this.listeners.push(listener)
  }
  
  removeListener(listener: (state: ButtonState) => void): void {
    const index = this.listeners.indexOf(listener)
    if (index !== -1) {
      this.listeners.splice(index, 1)
    }
  }
  
  reset(): void {
    this.transition(ButtonState.IDLE)
  }
}

// 使用示例
@Entry
@Component
struct StateMachineDemo {
  @State currentState: ButtonState = ButtonState.IDLE
  private stateMachine: ButtonStateMachine = new ButtonStateMachine()
  
  aboutToAppear() {
    this.stateMachine.addListener((state: ButtonState) => {
      this.currentState = state
    })
  }
  
  aboutToDisappear() {
    this.stateMachine.listeners = []
  }
  
  async handleSubmit() {
    if (!this.stateMachine.transition(ButtonState.VALIDATING)) {
      return
    }
    
    await new Promise(resolve => setTimeout(resolve, 500))
    
    if (!this.stateMachine.transition(ButtonState.LOADING)) {
      return
    }
    
    try {
      await new Promise((resolve, reject) => {
        setTimeout(() => {
          Math.random() > 0.1 ? resolve(null) : reject(new Error('失败'))
        }, 1500)
      })
      
      this.stateMachine.transition(ButtonState.SUCCESS)
      
      setTimeout(() => {
        this.stateMachine.reset()
      }, 2000)
      
    } catch (error) {
      this.stateMachine.transition(ButtonState.FAILED)
    }
  }
  
  build() {
    Column({ space: 20 }) {
      Column({ space: 8 }) {
        Text('当前状态')
          .fontSize(14)
          .fontColor($r('app.color.text_secondary'))
        Text(this.currentState.toUpperCase())
          .fontSize(20)
          .fontWeight(FontWeight.Bold)
          .fontColor(this.stateMachine.getConfig().color)
      }
      .width('100%')
      .padding(20)
      .backgroundColor($r('app.color.background_card'))
      .borderRadius(12)
      .alignItems(HorizontalAlign.Center)
      
      Button() {
        Row({ space: 8 }) {
          if (this.stateMachine.getConfig().loading) {
            LoadingProgress()
              .width(20)
              .height(20)
              .color(Color.White)
          }
          Text(this.stateMachine.getConfig().text)
            .fontSize(16)
            .fontWeight(FontWeight.Medium)
            .fontColor(Color.White)
        }
      }
      .width('100%')
      .height(48)
      .backgroundColor(this.stateMachine.getConfig().color)
      .borderRadius(8)
      .enabled(this.stateMachine.getConfig().enabled)
      .onClick(() => {
        this.handleSubmit()
      })
    }
    .width('100%')
    .height('100%')
    .padding(24)
    .justifyContent(FlexAlign.Center)
  }
}

六、响应式布局适配

6.1 使用 layoutWeight

Row({ space: 12 }) {
  TextInput({ placeholder: '请输入内容' })
    .layoutWeight(1)
    .height(48)
  
  Button('发送')
    .height(48)
    .padding({ left: 20, right: 20 })
}
.width('100%')

6.2 使用百分比

Button('自适应按钮')
  .width('80%')
  .height(48)

6.3 断点响应式

@Entry
@Component
struct ResponsiveButtonDemo {
  @State screenWidth: number = 0
  
  aboutToAppear() {
    const display = display.getDefaultDisplaySync()
    this.screenWidth = display.width
  }
  
  getButtonWidth(): string {
    if (this.screenWidth < 400) return '100%'
    if (this.screenWidth < 600) return '80%'
    return '60%'
  }
  
  build() {
    Column() {
      Button('响应式按钮')
        .width(this.getButtonWidth())
        .height(48)
        .fontSize(this.screenWidth < 400 ? 14 : 16)
        .backgroundColor('#007DFF')
        .borderRadius(8)
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center)
  }
}

七、深色模式与主题适配

7.1 资源文件定义

浅色模式 (resources/base/element/color.json):

{
  "color": [
    { "name": "button_bg_primary", "value": "#007DFF" },
    { "name": "button_bg_loading", "value": "#CCCCCC" },
    { "name": "button_bg_success", "value": "#52C41A" },
    { "name": "button_bg_error", "value": "#FF4D4F" },
    { "name": "button_text_primary", "value": "#FFFFFF" },
    { "name": "button_text_secondary", "value": "#333333" }
  ]
}

深色模式 (resources/dark/element/color.json):

{
  "color": [
    { "name": "button_bg_primary", "value": "#1E88E5" },
    { "name": "button_bg_loading", "value": "#4A4A4A" },
    { "name": "button_bg_success", "value": "#73D13D" },
    { "name": "button_bg_error", "value": "#FF7875" },
    { "name": "button_text_primary", "value": "#FFFFFF" },
    { "name": "button_text_secondary", "value": "#E0E0E0" }
  ]
}

7.2 使用资源引用

Button() {
  Row({ space: 8 }) {
    if (this.isLoading) {
      LoadingProgress()
        .width(20)
        .height(20)
        .color($r('app.color.button_text_primary'))
    }
    Text(this.isLoading ? '加载中...' : '提交')
      .fontSize(16)
      .fontColor($r('app.color.button_text_primary'))
  }
}
.width('100%')
.height(48)
.backgroundColor(this.isLoading 
  ? $r('app.color.button_bg_loading') 
  : $r('app.color.button_bg_primary'))
.borderRadius(8)
.enabled(!this.isLoading)

八、性能优化与最佳实践

8.1 避免不必要的渲染

// ❌ 避免:每次渲染都创建新对象
@State config: object = { color: '#007DFF' }

// ✅ 推荐:使用固定值或缓存
const BUTTON_COLOR = '#007DFF'

@Entry
@Component
struct OptimizedButton {
  @State isLoading: boolean = false
  
  build() {
    Button('提交')
      .backgroundColor(this.isLoading ? '#CCCCCC' : BUTTON_COLOR)
      .enabled(!this.isLoading)
  }
}

8.2 合理使用条件渲染

// ✅ 推荐:条件渲染避免同时维护两个组件
Button() {
  if (this.isLoading) {
    LoadingProgress().width(20).height(20)
  }
  Text(this.isLoading ? '加载中' : '提交')
}

8.3 及时清理定时器

@Entry
@Component
struct TimerButton {
  @State countdown: number = 0
  private timer: number = -1
  
  startCountdown() {
    this.countdown = 60
    this.timer = setInterval(() => {
      this.countdown--
      if (this.countdown <= 0) {
        this.stopTimer()
      }
    }, 1000) as number
  }
  
  stopTimer() {
    if (this.timer !== -1) {
      clearInterval(this.timer)
      this.timer = -1
    }
  }
  
  aboutToDisappear() {
    this.stopTimer()  // 页面销毁时清理
  }
}

8.4 使用 renderGroup 优化

// 对于复杂组件,使用 renderGroup 减少重绘
Button() {
  Column() {
    LoadingProgress().width(24).height(24)
    Text('加载中').fontSize(14)
  }
}
.renderGroup(true)  // 减少渲染批次

九、常见问题 FAQ

Q1: Button 为什么没有 loading 属性?

A: HarmonyOS 的 Button 设计为轻量级基础组件,加载效果通过子组件实现。这种设计更灵活,可以自定义加载动画的位置、大小、样式。

Q2: LoadingProgress 的大小怎么设置?

A: 使用 .width().height() 方法,单位默认为 vp。建议在按钮内使用 16-24vp 大小。

Q3: 如何实现按钮点击防抖?

A: 使用状态变量控制,在加载期间禁用按钮:

.enabled(!this.isLoading)

Q4: 倒计时结束后按钮不恢复?

A: 确保在页面销毁时清理定时器,并且在倒计时结束时正确重置状态:

if (this.countdown <= 0) {
  clearInterval(this.timer)
  this.countdown = 0  // 重要:重置为0
}

Q5: 深色模式下颜色不对?

A: 检查是否在 resources/dark/element/ 目录下定义了对应的资源文件。

Q6: 如何禁用按钮的点击效果?

A: 使用 stateEffect(false)

Button('无效果按钮')
  .stateEffect(false)

Q7: Button 和 LoadingProgress 之间的间距怎么调?

A: 在 Row 中使用 space 参数或给 LoadingProgress 添加 margin

Row({ space: 8 }) {
  LoadingProgress().width(20).height(20)
  Text('加载中')
}

十、完整项目实战

10.1 项目结构

Entry/src/main/
├── ets/
│   ├── pages/
│   │   └── Index.ets           # 主页面
│   └── components/
│       └── LoadingButton.ets   # 自定义加载按钮组件
└── resources/
    ├── base/
    │   └── element/
    │       ├── color.json
    │       ├── string.json
    │       └── float.json
    └── dark/
        └── element/
            └── color.json

10.2 自定义组件

LoadingButton.ets:

@Component
export struct LoadingButton {
  @Prop isLoading: boolean = false
  @Prop text: string = '确定'
  @Prop loadingText: string = '加载中...'
  @Prop backgroundColor: string = '#007DFF'
  @Prop loadingColor: string = '#CCCCCC'
  @Prop textColor: string = '#FFFFFF'
  @Prop fontSize: number = 16
  @Prop height: number = 48
  @Prop borderRadius: number = 8
  @Prop showSuccess: boolean = false
  @Prop successText: string = '成功'
  @Prop successColor: string = '#52C41A'
  
  onTap: () => void = () => {}
  
  build() {
    Button() {
      Row({ space: 8 }) {
        if (this.isLoading) {
          LoadingProgress()
            .width(20)
            .height(20)
            .color(this.textColor)
        } else if (this.showSuccess) {
          Text('✓')
            .fontSize(20)
            .fontColor(this.textColor)
        }
        Text(this.getCurrentText())
          .fontSize(this.fontSize)
          .fontWeight(FontWeight.Medium)
          .fontColor(this.textColor)
      }
    }
    .width('100%')
    .height(this.height)
    .backgroundColor(this.getCurrentColor())
    .borderRadius(this.borderRadius)
    .enabled(!this.isLoading && !this.showSuccess)
    .onClick(() => {
      if (!this.isLoading && !this.showSuccess) {
        this.onTap()
      }
    })
  }
  
  getCurrentText(): string {
    if (this.isLoading) return this.loadingText
    if (this.showSuccess) return this.successText
    return this.text
  }
  
  getCurrentColor(): string {
    if (this.isLoading) return this.loadingColor
    if (this.showSuccess) return this.successColor
    return this.backgroundColor
  }
}

10.3 主页面使用

Index.ets:

import { LoadingButton } from '../components/LoadingButton'

@Entry
@Component
struct Index {
  @State isLoginLoading: boolean = false
  @State loginSuccess: boolean = false
  
  @State isSubmitLoading: boolean = false
  @State submitSuccess: boolean = false
  
  @State countdown: number = 0
  @State isSendingCode: boolean = false
  
  private countdownTimer: number = -1
  
  handleLogin() {
    this.isLoginLoading = true
    setTimeout(() => {
      this.isLoginLoading = false
      this.loginSuccess = true
      setTimeout(() => {
        this.loginSuccess = false
      }, 2000)
    }, 2000)
  }
  
  handleSubmit() {
    this.isSubmitLoading = true
    setTimeout(() => {
      this.isSubmitLoading = false
      this.submitSuccess = true
      setTimeout(() => {
        this.submitSuccess = false
      }, 2000)
    }, 2000)
  }
  
  handleSendCode() {
    this.isSendingCode = true
    setTimeout(() => {
      this.isSendingCode = false
      this.countdown = 60
      this.countdownTimer = setInterval(() => {
        this.countdown--
        if (this.countdown <= 0) {
          clearInterval(this.countdownTimer)
          this.countdown = 0
        }
      }, 1000) as number
    }, 1000)
  }
  
  aboutToDisappear() {
    if (this.countdownTimer !== -1) {
      clearInterval(this.countdownTimer)
    }
  }
  
  build() {
    Scroll() {
      Column({ space: 24 }) {
        // 标题
        Column({ space: 8 }) {
          Text('加载按钮实战')
            .fontSize(28)
            .fontWeight(FontWeight.Bold)
            .fontColor($r('app.color.text_primary'))
          Text('HarmonyOS NEXT API 24')
            .fontSize(14)
            .fontColor($r('app.color.text_secondary'))
        }
        .width('100%')
        .alignItems(HorizontalAlign.Start)
        
        // 示例一:登录按钮
        Column({ space: 12 }) {
          Text('示例一:登录按钮')
            .fontSize(18)
            .fontWeight(FontWeight.Medium)
            .width('100%')
          
          LoadingButton({
            isLoading: this.isLoginLoading,
            text: '登录',
            loadingText: '登录中...',
            showSuccess: this.loginSuccess,
            successText: '登录成功',
            onTap: () => this.handleLogin()
          })
        }
        .width('100%')
        .padding(16)
        .backgroundColor($r('app.color.background_card'))
        .borderRadius(12)
        
        // 示例二:表单提交
        Column({ space: 12 }) {
          Text('示例二:表单提交')
            .fontSize(18)
            .fontWeight(FontWeight.Medium)
            .width('100%')
          
          LoadingButton({
            isLoading: this.isSubmitLoading,
            text: '提交表单',
            loadingText: '提交中...',
            showSuccess: this.submitSuccess,
            successText: '提交成功',
            onTap: () => this.handleSubmit()
          })
        }
        .width('100%')
        .padding(16)
        .backgroundColor($r('app.color.background_card'))
        .borderRadius(12)
        
        // 示例三:倒计时发送
        Column({ space: 12 }) {
          Text('示例三:倒计时发送')
            .fontSize(18)
            .fontWeight(FontWeight.Medium)
            .width('100%')
          
          Row({ space: 12 }) {
            TextInput({ placeholder: '手机号' })
              .layoutWeight(1)
              .height(48)
              .type(InputType.Number)
            
            LoadingButton({
              isLoading: this.isSendingCode,
              text: this.countdown > 0 ? `${this.countdown}s` : '获取验证码',
              loadingText: '发送中...',
              backgroundColor: this.countdown > 0 ? '#999999' : '#007DFF',
              enabled: this.countdown === 0
            })
            .width(140)
            .onTap(() => this.handleSendCode())
          }
          .width('100%')
        }
        .width('100%')
        .padding(16)
        .backgroundColor($r('app.color.background_card'))
        .borderRadius(12)
        
        // 示例四:状态说明
        Column({ space: 8 }) {
          Text('状态说明')
            .fontSize(14)
            .fontWeight(FontWeight.Medium)
            .width('100%')
          
          Row() {
            Text('•')
              .fontSize(14)
              .fontColor($r('app.color.primary_color'))
              .margin({ right: 8 })
            Text('正常状态:可点击')
              .fontSize(14)
              .fontColor($r('app.color.text_secondary'))
          }
          .width('100%')
          
          Row() {
            Text('•')
              .fontSize(14)
              .fontColor($r('app.color.primary_color'))
              .margin({ right: 8 })
            Text('加载状态:显示Loading动画,按钮禁用')
              .fontSize(14)
              .fontColor($r('app.color.text_secondary'))
          }
          .width('100%')
          
          Row() {
            Text('•')
              .fontSize(14)
              .fontColor($r('app.color.primary_color'))
              .margin({ right: 8 })
            Text('成功状态:显示✓,2秒后恢复')
              .fontSize(14)
              .fontColor($r('app.color.text_secondary'))
          }
          .width('100%')
        }
        .width('100%')
        .padding(16)
        .backgroundColor($r('app.color.background_card'))
        .borderRadius(12)
      }
      .width('100%')
      .padding(16)
    }
    .width('100%')
    .height('100%')
    .backgroundColor($r('app.color.start_window_background'))
  }
}

十一、总结与展望

11.1 核心要点回顾

  1. Button 没有 loading 属性 - 使用子组件 LoadingProgress 实现
  2. 四种实现方案 - 条件渲染、contentModifier、自定义组件、@Builder
  3. 状态管理 - 简单场景用 boolean,复杂场景用状态机
  4. 响应式设计 - 使用 layoutWeight、百分比、断点适配
  5. 主题支持 - 资源文件实现浅色/深色模式切换

11.2 开发建议

  • 从小处着手,先实现基础功能再逐步完善
  • 合理封装,提取公共组件提高复用性
  • 注意性能,及时清理定时器等资源
  • 测试充分,覆盖各种状态和边界条件

11.3 未来展望

随着 HarmonyOS NEXT 的持续演进,Button 和 Loading 相关能力也在不断增强:

  1. 更多动画效果 - 支持渐入渐出、弹性动画等
  2. 更丰富的状态 - 骨架屏、占位符等新形式
  3. 更好的性能 - 渲染优化、内存占用降低
  4. 更强的可访问性 - 无障碍支持、语义化标签

11.4 学习资源

  • 官方文档:https://developer.huawei.com/consumer/cn/doc/harmonyos-guides-V5/
  • ArkTS 语法参考:https://developer.huawei.com/consumer/cn/doc/harmonyos-references-V5/
  • 示例代码仓库:GitHub - harmonyos/samples

结束语

本文详细介绍了 HarmonyOS NEXT (API 24) 中 Button + Loading 加载按钮的实现方案和最佳实践。希望能帮助开发者更好地理解和应用这一常见交互模式。记住,良好的加载状态设计不仅是技术实现问题,更是用户体验问题。让我们一起打造更优秀的 HarmonyOS 应用!

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐