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





目录
- 一、前言:为什么加载按钮如此重要
- 二、核心概念解析
- 三、四种实现方案对比
- 四、实战场景全解
- 五、状态机设计模式
- 六、响应式布局适配
- 七、深色模式与主题适配
- 八、性能优化与最佳实践
- 九、常见问题 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)