HarmonyOS ArkTS加载指示器实战:从基础到多选删除场景
2026/9/11 9:47:29 网站建设 项目流程

做鸿蒙应用开发这段时间,我花在加载指示器(Indicator)上的时间比想象中多。HarmonyOS 6 下的 ArkTS 声明式 UI 把加载状态组件推到了交互设计的前台——列表加载要转圈、删除操作要提示、刷新按钮要反馈,任何一个没有状态反馈的点击都会让用户觉得“卡了”。这篇内容就围绕 Indicator 展开,先聊基础用法,再讲一个真实的多选列表删除场景,最后把我踩过的坑和调试方法一并交代清楚。适合刚接触 ArkTS,或者已经在做鸿蒙应用想优化交互细节的同学参考。

1. 整体设计与思路拆解

1.1 为什么 Indicator 在 ArkTS 里不只是一个“小部件”

很多刚上手 ArkTS 的朋友会把 Indicator 理解成“一个会转的小圈圈”,真正写业务的时候才发现完全不是这么回事。在鸿蒙的声明式 UI 体系里,Indicator 并不是简单拿来就用的静态控件,它背后牵涉的是页面状态、异步任务、用户操作反馈这三者的协同。

我见过不少项目在列表页把 LoadingProgress 组件直接写在 build 里,结果永远都在转;也见过有人在删除按钮的点击回调里写死了一行this.isLoading = true,但忘了在删除完成之后把状态复位,导致整个界面卡死在加载遮罩后面。这些问题看起来是“忘了写一行代码”,本质上是没有把 Indicator 当成“状态的可视化映射”来设计。

在 ArkTS 里,界面会随着 @State 变量的变化自动刷新。所以正确的姿势是:先定义@State isLoading: boolean = false,然后用if (this.isLoading)控制 Indicator 的出现和消失。这样你不是在“控制组件”,而是在“控制状态”。状态到位了,UI 自然正确。

1.2 为什么我选择用 ArkTS 的声明式方式处理 Indicator

HarmonyOS 6 的 ArkUI 框架下,我优先用 ArkTS 实现 Indicator,而不是去依赖自定义绘制的旧方案,核心原因是代码流和状态流能保持同一条线。

以前用命令式 UI 的时候,加载状态需要手动 add/remove 组件,还要担心组件层级、内存释放、重复添加等问题。ArkTS 里由于数据驱动 UI,Indicator 的显示和隐藏可以完全跟随业务变量。比如进入页面拉数据时,isLoading=true,数据回来后isLoading=false,页面上的 LoadingProgress 就自动跟着切换,不用再手动去查节点、删节点、控制透明度。

另外 ArkTS 的组件复用和状态管理机制也比较适合做复杂交互。比如多选列表删除这个场景,选中数量变化、删除按钮的可用性、遮罩层的显示,这些都是状态之间的关系。用声明式描述这些关系,逻辑会清楚很多。你只需要关系状态本身,剩下的交给框架去刷新。

提示:Indicator 不是只能转圈,它本质上是一个“异步反馈通道”。任何耗时超过 200ms 的操作,都应该让用户知道当前发生了什么。这是交互设计的基础,也是写代码时必须有的意识。

2. 基础用法与关键属性实战

2.1 系统自带 LoadingProgress:上手最快的 Indicator

ArkUI 里最直接的 Indicator 实现就是系统自带的 LoadingProgress 组件。它会显示一个循环旋转的加载动画,用来表示任务进行中。实际项目里,我一般会优先用它,因为不需要任何额外资源,性能也稳定。

最基础的使用方式如下:

@Entry @Component struct BasicIndicatorPage { @State isLoading: boolean = true build() { Column({ space: 16 }) { Text('数据加载中') .fontSize(16) .fontColor('#333333') if (this.isLoading) { LoadingProgress() .width(48) .height(48) .color('#1E88E5') .enableLoading(true) } } .width('100%') .height('100%') .justifyContent(FlexAlign.Center) } }

这里有几个属性值得单独说一下。

color控制加载指示器的颜色,别只会用默认的灰色,视觉上要和页面主色一致才有整体感。widthheight控制大小,我建议至少 36vp,太小在手机上看着费力。enableLoading参数是个容易被忽略的点,它的作用是控制是否执行加载动画,如果你只想提前渲染静态样式,可以先用false停住动画。

如果你需要更精确的进度反馈,比如上传文件的具体百分比,那不能只用循环动画。LoadingProgress 支持value参数,取值范围 0 到 100。当你传入具体数值时,它更像一个进度条,能直观反映出任务完成度。注意一点:如果是持续性刷新,不要每次都重新设置整个组件,只要通过数据绑定更新value即可,这样动画不会中断。

2.2 自定义 Indicator:不满足于系统组件时怎么办

系统 LoadingProgress 的样式虽然简洁,但总有人觉得它“太普通”。我在实际项目里被产品经理提过好几次:能不能在加载转圈旁边加文字?能不能用品牌色做背景?加载结束后能不能带勾选动画?

这时候就需要自定义 Indicator。在 ArkTS 里实现自定义加载指示器,我最常用的是 Stack 加基础组件的组合。一个典型方案是自定义一个带文字的加载遮罩层:

@Component struct CustomIndicator { @Prop label: string = '加载中...' @Prop maskOpacity: number = 0.3 @State rotateAngle: number = 0 aboutToAppear() { this.startRotate() } startRotate() { // 通过循环动画驱动旋转角度 animateTo({ duration: 800, iterations: -1, curve: Curve.Linear }, () => { this.rotateAngle = 360 }) } build() { Stack() { // 半透明遮罩 Column() .width('100%') .height('100%') .backgroundColor(`rgba(0, 0, 0, ${this.maskOpacity})`) .position({ x: 0, y: 0 }) // 加载卡片 Row({ space: 12 }) { Row() .width(6) .height(6) .margin(8) .borderRadius(3) .backgroundColor('#FFFFFF') .rotate({ angle: this.rotateAngle }) .animation({ duration: 800, iterations: -1, curve: Curve.Linear }) Text(this.label) .fontSize(14) .fontColor('#FFFFFF') } .padding({ left: 20, right: 20, top: 14, bottom: 14 }) .backgroundColor('#CC1E232B') .borderRadius(12) } .width('100%') .height('100%') } }

这段代码里,animateTo.rotate配合实现旋转动画。我做了个小细节:用一个小圆点代替大转圈,配合白色文字,在深色遮罩上看起来比较精致。Stack负责让遮罩层铺满整个页面,这样不管下面的 List 有多长,都能被稳稳盖住。

自定义 Indicator 的好处是你能完全控制样式和动画节奏,但它也要接受状态管理。@Prop传入的文字、遮罩透明度,都是可以由父组件动态修改的,这就比系统组件灵活很多。

注意:自定义 Indicator 里的动画,在页面销毁时一定要能停掉。如果aboutToDisappear里不做清理,长时间运行页面跳转后,动画可能还在后台跑,白白浪费线程资源。

2.3 Indicator 的显示与隐藏:用状态控制而不是节点控制

很多新人容易搞混一点:不管用系统 LoadingProgress 还是自定义 Indicator,都不应该在.visibility()属性里写死常驻逻辑。你在 build 里写了组件,它就必然存在,只是显不显示的问题。更好的做法是直接用if条件渲染。

举个例子,在列表页加载数据:

build() { Stack() { if (this.itemList.length === 0 && this.isLoading) { // 首次加载,显示居中 Indicator Column() { LoadingProgress() .width(48) .height(48) .color('#1E88E5') Text('正在加载数据...') .fontSize(14) .fontColor('#999999') .margin({ top: 12 }) } } if (this.itemList.length > 0) { List({ space: 12 }) { ForEach(this.itemList, (item: ItemModel) => { ListItem() { Text(item.title) .fontSize(16) .padding(16) .backgroundColor(Color.White) .borderRadius(12) } }, (item: ItemModel) => item.id.toString()) } .padding(16) } if (this.isLoading && this.itemList.length > 0) { // 下拉刷新时,顶部或中间的遮罩 Indicator CustomIndicator({ label: '刷新中...' }) } } .width('100%') .height('100%') }

这种写法读起来很直观:第一个条件覆盖“首屏加载”,第二个条件覆盖“已有内容”,第三个条件覆盖“刷新中”。每一个状态分支都是打印在代码里的一段“可视菜单”,排查问题的时候扫一眼就知道哪个状态把页面卡住了。

同时要提醒一个实践上的细节:如果条件渲染的组件结构复杂,比如 List 套多个子组件,频繁切换时会有性能开销。所以对于 Indicator 这类轻量组件,直接用if没问题;如果是高频切换的复杂区域,再考虑用Visibility属性控制。不要看到哪都用if,也不要不分场景都用Visibility,根据具体场景做选择。

3. 基于 Indicator 的多选列表删除实操

3.1 场景描述与页面结构设计

热搜里提到了“多选列表删除”,这实际上是我在做文件管理类应用时非常常见的一个需求。用户长按某个条目进入多选模式,勾选若干项,点底部删除按钮,系统弹出确认框,确认后显示加载指示器,删除完成再刷新列表、退出多选模式。

这个场景里值得留意的是:删除操作可能涉及大量数据,也可能要调用后端接口,所以不能“点击即删除、瞬间完成”那么乐观。一定需要一个反馈中间态。这时候 Indicator 就派上用场了。

页面结构我习惯这样设计:

build() { Column() { // 顶部标题栏 Row() { Text(this.selectMode ? `已选择 ${this.selectedCount} 项` : '文件列表') .fontSize(18) .fontWeight(FontWeight.Bold) Blank() if (this.selectMode) { Text('全选') .fontSize(14) .fontColor('#1E88E5') .onClick(() => this.selectAll()) Text('退出') .fontSize(14) .fontColor('#999999') .margin({ left: 16 }) .onClick(() => this.exitSelectMode()) } } .width('100%') .padding({ left: 16, right: 16, top: 12, bottom: 12 }) // 列表区域 Stack() { List({ space: 8 }) { ForEach(this.itemList, (item: ItemModel) => { ListItem() { this.buildItemRow(item) } }, (item: ItemModel) => item.id.toString()) } .width('100%') .layoutWeight(1) .padding(12) // 正在删除的 Indicator if (this.isDeleting) { CustomIndicator({ label: '正在删除...', maskOpacity: 0.4 }) } } .width('100%') .layoutWeight(1) // 底部操作栏 if (this.selectMode) { Row() { Button('删除选中项') .width('90%') .backgroundColor('#E84026') .enabled(this.selectedCount > 0) .onClick(() => this.confirmDelete()) } .width('100%') .padding({ top: 12, bottom: 24 }) .backgroundColor(Color.White) } } .width('100%') .height('100%') .backgroundColor('#F2F4F7') }

这样整体的层次是:顶部标题栏提供状态切换,列表区域支持选择,底部操作栏收集行为,Indicator 遮罩负责异步反馈。各模块职责明确,后面接需求改东西也好改。

3.2 多选与删除逻辑实现

多选模式用两个变量控制:selectMode表示当前是否处于多选状态,selectedCount表示选中数量。列表数据用@State items: ItemModel[]保存。

class ItemModel { id: number title: string isSelected: boolean constructor(id: number, title: string, isSelected: boolean = false) { this.id = id this.title = title this.isSelected = isSelected } }

列表项的点击逻辑分两种状态:非多选模式,点击直接进入多选模式并默认选中当前项;多选模式下,点击切换该项的isSelected

onItemClick(item: ItemModel) { if (!this.selectMode) { this.selectMode = true item.isSelected = true } else { item.isSelected = !item.isSelected } this.refreshSelectedCount() } refreshSelectedCount() { this.selectedCount = this.itemList.filter((item: ItemModel) => item.isSelected).length }

注意filter在 ArkTS 里是可以用的,但要注意类型标注,否则编译器会报类型推导错误。我们这用的是(item: ItemModel) => item.isSelected这种写法,编译器就能明确知道过滤的是 ItemModel 数组。

删除逻辑第一步是把选中的 id 收集起来,第二步调用删除方法。删除方法里为了让效果更真实,我模拟了一个延迟操作,实际项目里会替换成远程接口调用。

async confirmDelete() { // 收集选中的 id const deleteIds: number[] = [] this.itemList.forEach((item: ItemModel) => { if (item.isSelected) { deleteIds.push(item.id) } }) if (deleteIds.length === 0) { return } // 进入删除中状态 this.isDeleting = true try { // 模拟网络或数据库删除耗时 await this.performDelete(deleteIds) // 移除已删除项 this.itemList = this.itemList.filter((item: ItemModel) => !deleteIds.includes(item.id)) // 重置选择状态 this.selectMode = false this.selectedCount = 0 } catch (error) { console.error(`delete failed, error: ${JSON.stringify(error)}`) } finally { this.isDeleting = false } }

finally里把isDeleting置回 false,这个很关键,无论删除成功还是失败,加载指示器都必须消失,否则用户会被永久卡在遮罩下面。

3.3 Indicator 接入的细节和小技巧

这个场景里,Indicator 不是简单的居中提示,而是要作为 Stack 中最高层的子组件覆盖到整个列表上方。我的做法是把 List 和 CustomIndicator 一起包在 Stack 里,并且给 CustomIndicator 设置全屏尺寸和半透明遮罩。

关键在于 CustomIndicator 的层级,默认排在 Stack 后面元素会覆盖在前面的元素上,所以你把 CustomIndicator 放在 List 后面,它就会盖住 List。如果没有遮罩,只显示一个小转圈在底部按钮附近,用户可能注意不到操作正在进行。有遮罩可以避免用户继续点击列表项,防止在删除过程中误触其他操作,体验上更安全。

还有一个技巧是,点击删除后最好禁用系统返回手势或者顶部返回按钮,避免用户在删除过程中退出页面导致状态错乱。这个可以在onBackPress里拦截一下:

onBackPress(): boolean { if (this.isDeleting) { // 删除进行中,不允许返回 return true } return false }

这类细节平时不做不会出大错,但一旦做了,用户对应用的可靠性评价会高很多。我之前在测试环境就遇到过连续快速点击返回导致页面退出去,结果删除请求返回后又把列表数据刷了回来,界面和逻辑状态对不上。

3.4 完整流程跑通后的效果

把上面几块拼起来,整个流程是这样的:

  1. 用户点击某个列表项,selectMode变为 true,进入多选状态。
  2. 用户在列表里继续点击,勾选多个项目,底部按钮显示“删除选中项”,数量和状态实时更新。
  3. 点击删除按钮,isDeleting置为 true,Stack 上立刻出现半透明遮罩和“正在删除...”提示。
  4. 等待删除期间,所有点击都被遮罩挡住,用户无法修改列表状态。
  5. 删除完成,列表数据过滤掉已删除的项,selectMode 退出,按钮消失,isDeleting置为 false,Indicator 自动关闭。

这个过程中 Indicator 一共参与了两次状态切换:开场遮罩和结束关闭。整个交互链路顺下来,用户不会感觉到“卡顿”或“无响应”,因为每一步都有明确的界面反馈。

4. 常见问题与排查技巧实录

4.1 输出调试:用日志定位 Indicator 状态问题

网上搜到“ArkTS 输出调试”这个词,说明很多新手在状态排查上吃了亏。确实,Indicator 这类组件的 bug 很容易出现在状态不对上:页面一直转圈、转圈不消失、删除完成后还蒙着一层灰。要快速定位,最直接的办法是在关键路径上埋日志。

我在项目里一般会用以下几种日志方式:

// 普通调试信息 console.info(`isDeleting: ${this.isDeleting}`) console.info(`selectMode: ${this.selectMode}, selectedCount: ${this.selectedCount}`) // 警告信息 console.warn('isDeleting is still true, check finally block!') // 错误信息 console.error(`delete request failed: ${JSON.stringify(error)}`)

DevEco Studio 的 Console 面板会实时输出这些日志,你可以用关键字过滤,比如输入isDeleting就能只看到状态变化日志。我自己排查 Indicator 问题时,最常用的一种方式是专门为核心状态变化写一行日志:

setDeletingState(value: boolean) { console.info(`setDeletingState -> ${value}`) this.isDeleting = value }

这样所有对isDeleting的修改都会被记录,一旦发现状态没有按预期变化,翻日志就能看到是在哪一步漏了赋值。

4.2 常见问题速查表

现象原因解决办法
点击删除后,Indicator 没有出现isDeleting没有真正改成 true,或者被后面的逻辑立即改回 falseconfirmDelete开头打印日志确认状态变化;检查finally是否被意外触发
Indicator 一直转圈不消失finally中忘记置isDeleting=false,或者异步操作永远在 pending检查异步函数是否正常 resolve/reject;在 finally 里做兜底复位
删除完成后列表没有刷新删除逻辑操作的是临时数组,没有重新赋值给this.itemListthis.itemList = this.itemList.filter(...)的方式触发重新渲染
列表项复用时选中状态错乱ForEach 的 key 不够稳定,比如用 index 当 key改成用 item.id 作为 key,保证每个 item 有唯一标识
Indicator 盖不住整页列表Indicator 和 List 不在同一个 Stack,或者位置没有铺满用 Stack 包住整体,给 Indicator 设置 width/height 100%
动画卡顿、掉帧自定义 Indicator 中动画驱动了不相关的刷新,或者动画循环没有停止动画状态独立成一个变量,页面销毁时终止动画循环
删除途中快速点击返回,页面状态错乱返回事件没有拦截,页面销毁后异步请求又改状态onBackPress里拦截isDeleting状态,删除完成前禁止返回
自定义 Indicator 文字不更新修改的是普通变量,不是 @Prop/@State 变量把传入文字改成 @Prop,父组件通过状态变更刷新

这张表基本覆盖了我做列表删除场景时遇到的大部分问题。你会发现很多问题不是 Indicator 本身不会写,而是状态没有闭环。加载状态、选中状态、数据列表状态,这三个只要有一个不是通过 @State 统一管理,就容易出问题。

4.3 关于多选删除与 Indicator 的几个操作心得

第一个心得:Indicator 的遮罩不要全黑,透明度控制在 0.3 到 0.5 之间。全黑遮罩在视觉上太压迫,用户会以为应用崩了;太透明则盖不住干扰信息。我给的参数默认是 0.3,在深色背景页面可以适当调高一点。

第二个心得:删除成功后,可以考虑给用户一个轻提示,比如promptAction.showToast显示“已删除 3 项”。因为加载指示器消失的瞬间,列表会刷新,用户可能还没反应过来发生了什么,一个 toast 能补全反馈闭环。

第三个心得:不要把 Indicator 的显示隐藏逻辑散落在多个生命周期方法里。我见过有人在这个方法里写this.isDeleting = true,又在另一个回调里写this.isDeleting = true,最后状态混乱。统一收敛到一个入口函数里,比如beginDelete()finishDelete(),所有逻辑集中管理,可读性和维护性都会好很多。

第四个心得:ArkTS 的强类型检查比较严格,写filterforEachincludes这些方法时,一定要显式标注参数类型。我在第一次写this.itemList.filter(item => ...)时,编译器直接报类型错误,当时还觉得奇怪。后来习惯每个回调都写上(item: ItemModel) =>,问题就没了。这个习惯在 ArkTS 里很实用,能避免很多隐性 bug。

5. 一点个人体会

从系统 LoadingProgress 到自定义 Indicator,再到把它接入多选列表删除这样完整的业务场景,我最大的体会是:Indicator 不是 UI 层面的“锦上添花”,而是状态管理是否扎实的试金石。一个需要加载反馈的页面,如果你能用状态变量清晰控制它,那说明你对页面逻辑的拆解是清晰的;如果你写起来手忙脚乱,大概率是业务状态本身设计得不够干净。

我在实际项目中后期,已经很少直接写this.isLoading = true; this.isLoading = false这种零散代码了。更稳定的做法是把加载状态封装成一个独立的PageState枚举或类,比如LoadingSuccessErrorEmpty,然后通过switch去渲染对应的 UI。Indicator 只是这个状态机里的一个小分支,但它和列表、异常页、空态协同工作时,整个应用体验才会真正完整。

最后再给一个建议:如果你准备在 HarmonyOS 6 上做复杂列表交互,动手前先把状态理清。Indicator 用起来不难,难的是保证它在任何异步结果下都能正确关闭。多写几个辅助函数、多打几行调试日志,都比事后在用户反馈里找 bug 要省力得多。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询