[鸿蒙从零到一] ArkUI 基础组件实战:Text、Image、Button 与 TextInput
前言
页面布局解决的是组件如何排列,基础组件解决的则是用户看到什么、输入什么以及如何触发操作。在 ArkUI 中,Text、Image、Button 和 TextInput 几乎会出现在每一个业务页面里。它们的基础用法并不复杂,但要写出稳定、易维护且体验完整的界面,还需要理解资源引用、状态驱动、输入约束和事件处理之间的关系。
本文从组件的常用属性讲起,逐步实现一个资料编辑页。示例采用 ArkTS 和 ArkUI 声明式语法,可直接放入 Stage 模型工程的页面目录中进行调整。
一、Text:不仅是显示一段文字
Text 用于展示标题、正文、提示、状态等文本内容。最简单的写法如下:
Text('欢迎使用 HarmonyOS')
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor('#182230')
ArkUI 通过链式属性设置字号、字重、颜色和布局信息。样式写在组件之后,阅读代码时可以直接看到组件最终呈现的状态。
处理长文本
列表项、工具栏和用户信息区域通常无法容纳任意长度的文本。应主动限制行数并设置溢出策略:
Text('这是一段长度可能发生变化的用户简介')
.fontSize(14)
.fontColor('#667085')
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.lineHeight(22)
maxLines 限制最多显示的行数,TextOverflow.Ellipsis 在空间不足时显示省略号。固定高度的小区域尤其需要这组配置,否则动态内容可能挤压相邻组件。
使用资源代替硬编码文案
需要多语言适配的业务文案应放到资源文件中。例如在 resources/base/element/string.json 中声明:
{
"string": [
{
"name": "profile_title",
"value": "编辑资料"
},
{
"name": "save",
"value": "保存"
}
]
}
页面中使用 $r 引用资源:
Text($r('app.string.profile_title'))
Button($r('app.string.save'))
这样既便于统一修改,也为后续增加其他语言资源做好准备。
拼接不同样式的文本
一段文本中存在多种样式时,可以在 Text 内使用 Span:
Text() {
Span('昵称')
.fontColor('#344054')
Span(' *')
.fontColor('#D92D20')
}
.fontSize(14)
Span 适合协议链接、关键词强调和必填标记。若每一段内容都需要独立点击、复杂布局或无障碍语义,拆成多个组件会更清晰。
二、Image:正确加载并裁剪图片
Image 可以加载应用资源、像素图以及网络地址。应用内图标优先使用资源引用:
Image($r('app.media.avatar_default'))
.width(88)
.height(88)
.borderRadius(44)
.objectFit(ImageFit.Cover)
当容器宽高比例与原图不一致时,objectFit 决定图片如何适配:
ImageFit.Cover保持比例并填满容器,边缘可能被裁剪,适合头像和封面。ImageFit.Contain保持比例并完整显示,容器中可能留白,适合商品图和示意图。ImageFit.Fill直接拉伸到容器尺寸,原图比例可能改变,通常不适合照片。
网络图片与权限
加载网络图片时传入 HTTPS 地址:
Image('https://example.com/images/avatar.png')
.width(88)
.height(88)
.objectFit(ImageFit.Cover)
.borderRadius(44)
同时需要在模块的 module.json5 中声明网络权限:
{
"module": {
"requestPermissions": [
{
"name": "ohos.permission.INTERNET"
}
]
}
}
生产环境还应准备占位图和加载失败状态,避免图片请求失败后留下突兀的空白。头像等固定格式媒体需要明确设置宽高,防止资源加载完成前后引发布局跳动。
三、Button:把点击事件变成明确的业务动作
文字按钮适合提交、确认等清晰命令:
Button('保存资料')
.width('100%')
.height(44)
.fontSize(16)
.backgroundColor('#0A7A5A')
.onClick(() => {
console.info('save profile')
})
按钮样式应与动作的重要程度一致。一个页面通常只有一个主要操作,次要操作可以使用普通文本按钮或浅色背景,避免所有按钮都争夺注意力。
防止提交状态下重复点击
保存操作可能需要访问网络。在请求完成前禁用按钮,并给出正在处理的反馈:
@State isSaving: boolean = false
Button(this.isSaving ? '保存中...' : '保存资料')
.enabled(!this.isSaving)
.opacity(this.isSaving ? 0.6 : 1)
.onClick(async () => {
if (this.isSaving) {
return
}
this.isSaving = true
try {
await this.saveProfile()
} finally {
this.isSaving = false
}
})
这里的 enabled 不只是视觉变化,它还阻止组件继续响应点击。finally 能确保成功或失败后都恢复状态。
自定义按钮内容
按钮需要同时展示图标与文字时,可以使用构建器形式:
Button({ type: ButtonType.Normal }) {
Row({ space: 8 }) {
Image($r('app.media.ic_camera'))
.width(18)
.height(18)
Text('更换头像')
.fontSize(14)
}
}
.height(40)
.borderRadius(6)
.backgroundColor('#EEF2F6')
.fontColor('#344054')
这种形式适合需要组合内容的按钮,但点击事件仍应绑定在 Button 上,保证整个区域都可点击。
四、TextInput:让输入值进入状态系统
TextInput 用于单行输入。它通常通过 text 接收当前值,并在 onChange 中更新状态:
@State nickname: string = ''
TextInput({
text: this.nickname,
placeholder: '请输入昵称'
})
.width('100%')
.height(44)
.fontSize(16)
.padding({ left: 12, right: 12 })
.backgroundColor(Color.White)
.border({ width: 1, color: '#D0D5DD' })
.borderRadius(6)
.onChange((value: string) => {
this.nickname = value
})
@State 发生变化后,依赖它的界面会自动刷新。这就是状态驱动 UI 的基本闭环:组件展示状态,用户事件修改状态,框架根据新状态更新组件。
输入类型与长度限制
不同字段应使用匹配的输入类型和限制:
TextInput({ placeholder: '请输入手机号' })
.type(InputType.PhoneNumber)
.maxLength(11)
TextInput({ placeholder: '请输入邮箱' })
.type(InputType.Email)
.maxLength(64)
合适的输入类型会让系统提供更匹配的软键盘,但它不能代替业务校验。提交前仍需检查格式、空值和长度,并把错误信息展示在对应字段附近。
密码输入与清除按钮
密码字段可以使用 Password 类型,普通输入框则可按需显示清除按钮:
TextInput({ placeholder: '请输入密码' })
.type(InputType.Password)
.showPasswordIcon(true)
TextInput({ text: this.nickname, placeholder: '请输入昵称' })
.showCancelButton({ style: CancelButtonStyle.CONSTANT })
.onChange((value: string) => {
this.nickname = value
})
是否持续显示清除按钮要结合页面密度决定。表单较紧凑时,可以使用随输入状态出现的默认策略。
五、组合实现资料编辑页
下面把四类组件组合成完整页面。页面支持编辑昵称和邮箱、显示剩余字数、进行基础校验,并在保存过程中禁用按钮。
@Entry
@Component
struct ProfileEditPage {
@State nickname: string = 'Lulu'
@State email: string = ''
@State errorMessage: string = ''
@State isSaving: boolean = false
private validate(): boolean {
const name: string = this.nickname.trim()
const mail: string = this.email.trim()
if (name.length === 0) {
this.errorMessage = '昵称不能为空'
return false
}
if (mail.length > 0 && (!mail.includes('@') || !mail.includes('.'))) {
this.errorMessage = '请输入有效的邮箱地址'
return false
}
this.errorMessage = ''
return true
}
private async saveProfile(): Promise<void> {
// 实际项目中在这里调用仓储层或网络服务。
await new Promise<void>((resolve: () => void) => {
setTimeout(resolve, 600)
})
}
build() {
Scroll() {
Column({ space: 20 }) {
Text('编辑资料')
.width('100%')
.fontSize(24)
.fontWeight(FontWeight.Bold)
.fontColor('#182230')
Column({ space: 10 }) {
Image($r('app.media.avatar_default'))
.width(88)
.height(88)
.borderRadius(44)
.objectFit(ImageFit.Cover)
Button('更换头像')
.height(36)
.fontSize(14)
.backgroundColor('#EEF2F6')
.fontColor('#344054')
}
.width('100%')
Column({ space: 8 }) {
Row() {
Text('昵称 *')
.fontSize(14)
.fontColor('#344054')
Blank()
Text(`${this.nickname.length}/20`)
.fontSize(12)
.fontColor('#667085')
}
.width('100%')
TextInput({ text: this.nickname, placeholder: '请输入昵称' })
.width('100%')
.height(44)
.maxLength(20)
.showCancelButton({ style: CancelButtonStyle.CONSTANT })
.border({ width: 1, color: '#D0D5DD' })
.borderRadius(6)
.backgroundColor(Color.White)
.onChange((value: string) => {
this.nickname = value
this.errorMessage = ''
})
}
.alignItems(HorizontalAlign.Start)
Column({ space: 8 }) {
Text('邮箱')
.fontSize(14)
.fontColor('#344054')
TextInput({ text: this.email, placeholder: '用于接收账户通知' })
.width('100%')
.height(44)
.type(InputType.Email)
.maxLength(64)
.border({ width: 1, color: '#D0D5DD' })
.borderRadius(6)
.backgroundColor(Color.White)
.onChange((value: string) => {
this.email = value
this.errorMessage = ''
})
}
.alignItems(HorizontalAlign.Start)
if (this.errorMessage.length > 0) {
Text(this.errorMessage)
.width('100%')
.fontSize(13)
.fontColor('#D92D20')
}
Button(this.isSaving ? '保存中...' : '保存资料')
.width('100%')
.height(44)
.enabled(!this.isSaving)
.opacity(this.isSaving ? 0.6 : 1)
.backgroundColor('#0A7A5A')
.onClick(async () => {
if (!this.validate() || this.isSaving) {
return
}
this.isSaving = true
try {
await this.saveProfile()
} finally {
this.isSaving = false
}
})
}
.width('100%')
.padding(20)
.alignItems(HorizontalAlign.Start)
}
.width('100%')
.height('100%')
.backgroundColor('#F7F8FA')
}
}
示例中的输入值、错误信息和保存状态全部由 @State 管理。界面没有直接查找或手动修改某个控件,而是根据状态自然呈现,这也是 ArkUI 声明式开发的核心方式。
六、常见问题与改进建议
把所有文案都写死在页面中
硬编码适合快速验证,但正式项目应尽早使用字符串资源。除了多语言需求,资源化还能统一产品用词,减少跨页面修改成本。
只限制输入,不校验提交数据
maxLength 和 InputType 改善的是输入过程,不能保证数据一定有效。校验逻辑应靠近表单状态或业务层,并明确告诉用户哪个字段存在问题。
图片没有稳定尺寸
网络图片未设置宽高时,加载过程可能影响布局。头像、封面、缩略图都应根据设计比例设置稳定尺寸,并提供占位和失败兜底。
异步操作仍允许重复点击
用户可能在网络响应前多次点击提交按钮。使用独立的加载状态控制 enabled,同时在事件入口再次判断,可以减少重复请求和数据冲突。
视觉变化代替了真实交互状态
仅把按钮颜色变灰并不会自动阻止点击。不可用状态应通过 enabled(false) 表达,透明度或颜色只作为视觉反馈。
总结
Text、Image、Button 和 TextInput 构成了 ArkUI 页面最常见的内容与交互基础。掌握它们不仅是记住属性,更重要的是建立几个稳定习惯:文本资源化、媒体尺寸明确、输入状态可追踪、提交操作可防重、错误反馈贴近字段。
当组件展示由状态决定、用户事件只负责更新状态时,页面逻辑会更容易理解和测试。后续无论构建登录、搜索、资料编辑还是业务表单,都可以沿用同一套状态驱动思路。