欢迎光临
我们一直在努力

鸿蒙原生 ArkTS 布局实战:Button + Loading 加载按钮完全指南

项目演示

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


目录

  • 一、前言:为什么加载按钮如此重要
  • 二、核心概念解析
    • 2.1 Button 组件基础
    • 2.2 LoadingProgress 组件
    • 2.3 @State 状态管理
  • 三、四种实现方案对比
  • 四、实战场景全解
  • 五、状态机设计模式
  • 六、响应式布局适配
  • 七、深色模式与主题适配
  • 八、性能优化与最佳实践
  • 九、常见问题 FAQ
  • 十、完整项目实战
  • 十一、总结与展望

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

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=true 且 isSuccess=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 核心要点回顾

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

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

    11.3 未来展望

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

  • 更多动画效果 – 支持渐入渐出、弹性动画等
  • 更丰富的状态 – 骨架屏、占位符等新形式
  • 更好的性能 – 渲染优化、内存占用降低
  • 更强的可访问性 – 无障碍支持、语义化标签
  • 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 应用!

    赞(0)
    未经允许不得转载:171主机测评 » 鸿蒙原生 ArkTS 布局实战:Button + Loading 加载按钮完全指南
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址