作为一个长期在鸿蒙生态里折腾自定义组件的开发者,我拿到 “RcSwitch” 这个标题时是有共鸣的。一个看似简单的开关组件,居然能花掉半年时间,这在不懂行的人看来可能有点夸张,但真正做过自研组件库的人会明白,难的不是把滑块从左边挪到右边,而是把“颜色系统”和“状态联动”这两件表面不起眼、实际上牵一发动全身的事做到位。
这个组件在 HarmonyOS 6 的 ArkTS 环境下重新设计后,主要解决了两类问题:一是组件配色彻底摆脱了“写死颜色”的硬编码模式,二是把禁用(disabled)和加载(loading)这两个状态从“互不相干”变成了“有序联动”。如果你正在做鸿蒙应用的自定义组件,或者业务里频繁需要带异步反馈的开关按钮,这篇文章应该能帮你省掉不少凭空踩坑的时间。
我会从设计动机、颜色系统拆解、状态机设计、核心代码实现、真机踩坑这几个维度,把 RcSwitch 这半年的沉淀一次讲透。
1. 起因:为什么一个开关组件值得花半年重做
先交代一下背景。RcSwitch 最初并不是从零新建的组件,而是在原有开关基础上做的第二次重构。第一版上线后,业务方用得倒是挺顺,但随着接入页面增多,问题开始集中暴露。
1.1 第一版组件的三个硬伤
第一版 RcSwitch 的实现方式非常直接:背景色用Color.Gray和Color.Blue,滑块用Color.White,切换动画用animateTo包一层。表面看没什么问题,但实际接入十几个业务页面后,三个硬伤开始显现。
第一个硬伤是颜色散落。业务方希望开关能适配不同页面的品牌色,比如有的页面主题色是蓝色,有的是绿色,还有的是品牌自定义色。第一版把颜色全部写死在组件内部,导致业务方只能通过新增属性去覆盖,比如加一个activeColor、inactiveColor,后来又加thumbColor、loadingColor……半年不到,组件属性膨胀得厉害,颜色逻辑也乱成一锅粥。
第二个硬伤是深浅色模式适配靠人肉补丁。鸿蒙应用的深色模式切换是系统级的,第一版组件在浅色模式下看着正常,一旦切到深色模式,灰色部分会变得浑浊,滑块和背景的对比度明显不足。业务方只能被迫在每个页面里手动判断是否深色模式,然后传不同的颜色值进来,这完全违背了组件封装的初衷。
第三个硬伤是状态管理太粗糙。第一版只有一个disabled属性,加载状态根本没有。但实际业务中有大量异步开关场景,比如控制智能设备、切换消息免打扰、开启云同步,这些操作都需要等接口返回才能确定最终状态。没有加载态,用户快速连续点击就会导致请求重复发送,体验非常糟糕。
1.2 重做时确定的设计目标
基于上面的问题,我在重做前给自己定了几个明确目标,这半年所有迭代都是围绕这几条展开的。
第一,颜色必须语义化。组件内部不允许出现具体颜色值,所有颜色都通过配置类或资源文件注入,业务方只需要传一个主题色,组件自动推导出配套的背景色、前景色、加载色。
第二,状态必须独立且有序。disabled 和 loading 不能是互不相关的两个布尔值,而应该是一个统一状态机里的不同节点。组件内部要明确知道“当前是禁用还是加载中”“加载中能不能被禁用打断”“禁用时加载动画还要不要转”这些边界问题。
第三,动画必须可中断、可恢复。开关切换过程中的动画不能因为状态突变而卡死,加载态转圈和滑块位移不能互相打架。
这三个目标听起来不复杂,但真正落地时牵扯到的细节非常多。下面我从颜色系统和状态系统两个角度分别展开。
2. 颜色系统设计:从硬编码到语义化令牌
颜色系统是 RcSwitch 重构中改动最大、也是收益最明显的一部分。这部分的核心理念是“分层”,把颜色从“具体值”变成“语义引用”。
2.1 颜色分层的三级结构
我最终把颜色分为三层:基础色板、语义色、组件属性色。
基础色板是最底层的颜色 token,比如品牌主色、成功色、警告色、危险色、中性灰阶。这些颜色通常由设计侧统一维护,在整个设计系统内共享。以 RcSwitch 为例,基础色板大致长这样:
// theme/color-token.ets export class ColorToken { static readonly brandPrimary: ResourceColor = $r('app.color.brand_primary'); static readonly brandPrimaryDisabled: ResourceColor = $r('app.color.brand_primary_disabled'); static readonly neutralGray: ResourceColor = $r('app.color.neutral_gray'); static readonly neutralGrayLight: ResourceColor = $r('app.color.neutral_gray_light'); static readonly textInverse: ResourceColor = $r('app.color.text_inverse'); }语义色是第二层,它以基础色板为原料,表达“这个颜色是干嘛用的”。比如“开关激活态背景色”“开关非激活态背景色”“滑块颜色”“加载指示器颜色”。语义色不关心具体色值,只关心业务语义。
组件属性色是第三层,它是 RcSwitch 内部渲染时真正引用的颜色。这层的作用是把前两层的抽象和组件实现解耦:即使将来基础色板完全换一套,组件代码也不需要改。
这里有一个很关键的设计决策:基础色板优先使用资源文件(Resource),而不是直接写 Color 常量。原因是 HarmonyOS 的资源限定符天然支持深色模式切换,$r('app.color.xxx')在深浅色模式下会自动解析为不同值。如果直接在代码里写Color.Blue,那深色模式就得人工判断再替换,又回到了第一版的老路。
2.2 深浅色模式的自动适配实现
有了资源文件以后,深浅色适配就变得非常省心。我在 resource 目录下维护了两份颜色配置,一份在base/element/color.json,一份在dark/element/color.json。
以开关的未激活轨道色为例,浅色模式下用浅灰,深色模式下用深灰:
// base/element/color.json { "color": [ { "name": "switch_track_off", "value": "#E5E5E9" } ] }// dark/element/color.json { "color": [ { "name": "switch_track_off", "value": "#3A3A3E" } ] }组件内部引用时,不需要关心当前是深色还是浅色,直接写$r('app.color.switch_track_off')即可。系统在页面渲染时会根据当前的系统深浅色模式自动解析出正确值。
但这里有一个容易被忽略的坑:组件库如果作为独立 HAR 包或 HSP 包发布,资源文件的引用路径要特别注意。早期我直接把资源放在组件库模块内,结果 App 工程引用后解析不到颜色,排查了很久才发现是资源目录没合并对。后来统一把资源放到组件库的src/main/resources下,通过$r引用时才稳定。
另外,如果业务方需要支持“应用内手动切换深色模式”(比如 App 内置夜间模式开关,而不跟随系统),仅靠 Resource 就不够了。这时需要配合媒体查询mediaquery监听颜色模式变化,或者在配置类里手动注入一套深浅色值。我在 RcSwitch 里提供了theme配置入口,允许外部传入一套完整的SwitchTheme对象,优先级高于内置资源。
2.3 自定义主题色的推导与对比度控制
支持自定义主题色是 RcSwitch 的一个核心卖点。业务方只需要传入一个品牌色,组件需要自动推导出与之配套的激活轨道色、滑块颜色、加载颜色。
推导逻辑并不复杂,核心是对品牌色做亮度调整。以品牌色brandColor为例:
- 激活轨道色:品牌色本身(或略微降低亮度)
- 激活轨道禁用色:品牌色加灰、降透明度
- 滑块颜色:根据品牌色亮度自动判断白色或深灰色,保证对比度
其中“滑块颜色”的选取有一个相对实用的公式。先计算品牌色的相对亮度(参考 WCAG 的相对亮度公式),然后判断与白色对比度高还是与黑色对比度高,选对比度更高的那个:
function getContrastTextColor(bgColor: string): string { // 将 #RRGGBB 转为 RGB 分量 const r = parseInt(bgColor.substr(1, 2), 16) / 255; const g = parseInt(bgColor.substr(3, 2), 16) / 255; const b = parseInt(bgColor.substr(5, 2), 16) / 255; // 计算相对亮度(线性化处理) const linearize = (c: number) => c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); const L = 0.2126 * linearize(r) + 0.7152 * linearize(g) + 0.0722 * linearize(b); // 与白色和黑色分别计算对比度,取值大者 const contrastWithWhite = 1.05 / (L + 0.05); const contrastWithBlack = (L + 0.05) / 0.05; return contrastWithWhite >= contrastWithBlack ? '#FFFFFF' : '#1A1A1A'; }这个“自动判断前景色”的逻辑在深色模式下尤其重要。如果品牌色本身是深色,滑块再用深色就会糊成一片;如果品牌色很亮,滑块用白色又会看不清楚。公式算一遍,比设计师逐个场景手动调要可靠得多。
2.4 禁用态颜色不能只靠透明度
禁用态的颜色处理是我踩过比较深的坑。第一版组件做禁用态时,直接在正常颜色上叠了 0.4 的透明度,看起来“变淡了”就觉得大功告成。但实际上,透明的灰色叠在深色背景上会出现一种混浊感,尤其在深色模式下,原本就偏深的轨道色再叠透明度,几乎和背景融为一体。
后来我换了一个思路:禁用态使用专门的禁用色,而不是在正常色上叠加透明度。也就是说,为每一个语义色都预先定义好对应的 disabled 版本。这样虽然颜色定义的工作量变多了,但视觉可控性大幅提升。
比如轨道激活色的禁用版,不是品牌色的半透明,而是在品牌色基础上降饱和、降对比度的实体色。这个转换不一定要在代码里实时计算,我建议直接在资源文件里配置好,因为禁用态的视觉效果属于设计决策,设计侧定了值,开发侧直接用就好,不要试图用公式去猜设计师的意图。
3. 禁用与加载状态:比想象中复杂的两个布尔值
很多人觉得禁用和加载不就是一个disabled加一个loading嘛,这有什么好讲的?但真正把交互细节盘清楚后你会发现,这两个值之间的关系远不是“非此即彼”那么简单。
3.1 禁用态的三个维度
禁用态至少要兼顾三个维度:交互、视觉、语义。
交互维度上,禁用后不能响应点击、不能触发动画、不能产生回调。这是最基本的。但“不能响应点击”也要分清楚是“完全无反应”还是“有震动反馈但无动作”,我建议在组件设计时把这两种模式都暴露出来,比如disabledFeedback属性,默认无反应,需要时由业务方决定是否给触感反馈。
视觉维度上,禁用态是 RcSwitch 颜色系统里比较典型的应用场景。轨道背景、滑块、loading 指示器都要切换到对应的 disabled 色。注意,这里不是所有元素都要变灰,比如滑块在禁用态保持白色或浅灰色,轨道变成低饱和色,两者之间依然要保持最基本的可辨识度。
语义维度上,禁用态应该让用户“一看就懂”。如果用户不知道为什么这个开关不能点,那禁用态在体验上就是失败的。我在 RcSwitch 里保留了disabledReason属性,用于在辅助功能场景下播报具体原因。比如“当前没有权限修改免打扰设置”,而不是干巴巴地读“不可用”。
3.2 加载态的交互锁与超时保护
加载态的核心目的是:在异步操作完成前,锁住用户重复交互。RcSwitch 在加载态下会做三件事。
第一,拦截点击。加载中点击组件,直接忽略,不触发任何回调。这个逻辑要在onClick的最前面判断,越早拦截越好。
第二,视觉反馈。加载指示器在滑块上显示,告诉用户“你的操作正在执行”。RcSwitch 的加载指示器是一个顺时针转动的圆弧,直径比滑块略小,位置覆盖在滑块中央。
第三,超时保护。接口如果一直没有返回,组件不能永远转圈。我默认设置了 5 秒超时,超时后自动退出加载态并回调一个onTimeout事件,由业务方决定是重新请求还是恢复原状态。这个机制在弱网环境下非常有用,防止组件“卡死”。
3.3 状态组合的优先级与边界规则
这是 RcSwitch 状态系统里最有价值的部分。disabled 和 loading 同时为 true 时,应该表现成什么样?我针对这个问题做了详细的优先级梳理。
先定义状态枚举:
export enum RcSwitchState { Off = 'off', // 关闭 On = 'on', // 开启 TurningOn = 'turning_on', // 正在开启(加载) TurningOff = 'turning_off', // 正在关闭(加载) Disabled = 'disabled' // 禁用 }把状态从“两个布尔值的排列组合”收敛成一个枚举,最大的好处是渲染逻辑只需要对一种状态做判断,不会出现互相覆盖的问题。
优先级规则如下:
Disabled是最高优先级。只要 disabled 为 true,无论 loading 是否在进行,交互都必须完全禁止。- 但 loading 的“视觉”可以继续存在。也就是说,如果组件先进入
TurningOn,然后外部又把它设为 disabled,此时交互锁住,但加载指示器继续转,直到外部把 loading 置为 false。 - 反过来,如果组件先处于
Disabled,然后外部又让它 loading,视觉上我会强制显示为Disabled,不展示加载动画。理由很简单:用户已经明确知道这个操作不可执行,再转圈反而会产生困惑。
这套规则的实践意义在于:交互层面看 disabled,视觉层面优先尊重“正在发生的状态”。把一个规则说得更直白一点:用户在等待结果时,不要用禁用态把它“静音”;用户没有操作权限时,不要用加载动画制造虚假希望。
3.4 受控模式与非受控模式的选择
在状态管理上,RcSwitch 选择了受控模式。也就是说,checked和loading都不是组件内部自己维护的@State,而是由父组件通过属性传入,组件只负责展示和回调。
@Component export struct RcSwitch { @Prop checked: boolean = false; @Prop enabled: boolean = true; @Prop loading: boolean = false; @Prop loadingTimeout: number = 5000; onChange: (value: boolean) => void = () => {}; onTimeout: () => void = () => {}; }为什么选受控而不是非受控?因为开关在真实业务里几乎都是异步联动场景。用户拨动开关后,组件需要先把“视觉位置”切换到目标位置,同时进入 loading 态,等接口返回成功后才确定最终状态。如果 checked 是组件内部自行维护的,外部就无法在接口失败时把状态“回滚”到原来的位置。
受控模式唯一的问题是写起来麻烦,父组件要维护一堆状态。但它换来的是极大概率的状态可控性,这个取舍我认为是值得的。
4. 核心实现解析:属性定义、状态机与动画时序
前面两章讲的是设计思路,这一章进入实操层面,把 RcSwitch 的核心实现拆开看。
4.1 组件整体结构与属性定义
RcSwitch 的整体结构是一个Stack容器,底层是轨道(圆角矩形),上层是滑块(圆形),加载指示器则叠加在滑块之上。这种布局的优点是绘制逻辑简单,轨道和滑块的动画可以分别控制。
组件的对外属性除了前面提到的checked、enabled、loading、loadingTimeout之外,还有一组控制颜色和尺寸的属性:
@property theme: SwitchTheme = defaultSwitchTheme; @property width: Length = 52; @property height: Length = 32; @property thumbDiameter: Length = 28;SwitchTheme是一个普通类,包含所有语义色:
export class SwitchTheme { trackOnColor: ResourceColor = $r('app.color.switch_track_on'); trackOffColor: ResourceColor = $r('app.color.switch_track_off'); trackOnDisabledColor: ResourceColor = $r('app.color.switch_track_on_disabled'); trackOffDisabledColor: ResourceColor = $r('app.color.switch_track_off_disabled'); thumbColor: ResourceColor = Color.White; thumbDisabledColor: ResourceColor = $r('app.color.switch_thumb_disabled'); loadingColor: ResourceColor = $r('app.color.switch_loading'); loadingDisabledColor: ResourceColor = $r('app.color.switch_loading_disabled'); }4.2 动画时序:先滑后变还是边滑边变
开关动画最核心的视觉点是滑块从一侧滑到另一侧的过程中,轨道颜色如何变化。这里有两种做法:一种是滑块滑动到位后再变轨道颜色;另一种是边滑动边变轨道颜色。RcSwitch 采用的做法是后者,但时间节奏做了区分。
我实际用的动画参数是:滑块位移 200ms,轨道颜色渐变 150ms。滑块先开始动,轨道颜色稍微滞后一点但基本同时进行。这样视觉上更顺滑,不会有“滑块到了,颜色才跟上”的割裂感。
加载态转圈动画是另一个独立的动画,我用的是Canvas绘制圆弧,通过属性动画改变圆弧的起始角度和结束角度:
@State private loadingAngleStart: number = 0; @State private loadingAngleEnd: number = 60; // 每帧更新角度,实现转圈效果 private startLoadingAnimation() { this.loadingAnimation = setInterval(() => { this.loadingAngleStart = (this.loadingAngleStart + 8) % 360; this.loadingAngleEnd = (this.loadingAngleEnd + 8) % 360; }, 16); }这里要注意一点:加载指示器不要用透明度闪烁的方式做“伪 loading”,视觉上看起来很廉价,而且用户感知不到“正在请求”的真实进度感。旋转圆弧的指向性更强,符合“操作进行中”的语义。
4.3 状态切换的防抖与打断策略
动画做多了就会遇到“动画被打断”的问题。最典型的一个场景是:滑块正在从 On 滑向 Off 的过程中,由于接口快速返回,外部立刻把 checked 状态又切回 On。这时候如果不做处理,滑块在被中断的动画上继续执行新的动画,位置可能卡在一个中间值上。
我的处理方式是在每次状态切换前,先调用一次animateTo的关闭方法,把上一次动画强制结束,然后再发起新的动画。在 ArkTS 里可以通过animateTo的finishCallback来感知上一次动画的结束时机,确保动画不叠加:
private animateToState(targetState: RcSwitchState) { // 先取消上一次动画回调 if (this.pendingAnimationFinish) { this.pendingAnimationFinish(); this.pendingAnimationFinish = null; } animateTo({ duration: 200, curve: Curve.EaseOut, finishCallback: () => { this.pendingAnimationFinish = null; }}, () => { this.currentState = targetState; }); }4.4 为什么不用系统 Toggle 而是自定义绘制
可能会有人问:ArkUI 自带Toggle组件,直接设置SwitchType不就行了?为什么还要自绘?
我的回答是:系统 Toggle 在基础场景够用,但它对自定义颜色、加载态、禁用组合态的扩展成本太高。比如 Toggle 中无法直接在滑块上叠加一个 loading 圆弧,也无法精细控制“切换过程中轨道颜色的中间态”。如果你只是内部工具 App 用,完全不考虑视觉定制,用系统 Toggle 完全没问题。但如果你在做一个需要面向多个业务方的组件库或设计系统,自定义绘制几乎是唯一可靠的选择。
自定义绘制的代价是代码量增加,但 RcSwitch 的整体代码量也就 400 行左右,换来的是完全可控的视觉和交互细节,这个投入产出比是很划算的。
5. 半年踩坑实录:真机测试中遇到的典型问题
这半年踩过的坑,我挑几个有代表性的拿出来说说,这些问题在文档里基本找不到现成答案,遇到了只能靠真机排查。
5.1 深色模式下的颜色“发脏”问题
这个问题在 1.1 节提到过,这里说下排查过程。最初切深色模式后,开关的灰显区域看起来有一层“脏”的颜色,像蒙了灰一样。后来逐个颜色排查,发现是禁用态用透明度叠加导致的,透明灰叠在深色轨道上,色值产生了不可预期的混合。
解决方式是前面讲到的统一换用禁用专用色。排查时有一个小技巧:在真机上开启开发者选项里的“显示点按响应”和“布局边界”,能直观看到组件实际渲染区域的色块,有助于快速定位颜色异常是组件问题还是外部容器背景影响。
5.2 加载态转圈时出现掉帧
早期版本加载圆弧用的是每秒更新角度值的setInterval方案,在低端机上肉眼可见的掉帧。后来我把角度更新从setInterval改为使用 ArkUI 的显式动画驱动,让框架接管每一帧的刷新,掉帧问题大幅缓解。
具体做法是:不再手动设置loadingAngleStart和loadingAngleEnd,而是通过animateTo让圆弧的结束角度 360 度循环增长,每次增长时重新触发动画。这样帧率交由系统统一调度,不会受 JS 线程定时器精度影响。
5.3 连续点击导致的请求风暴
这个问题的根源是早期版本没有 loading 状态的交互锁。用户快速拨动开关三下,三份网络请求同时发出,后端收到三份互相矛盾的指令,最终状态被最后返回的那份请求覆盖,界面和实际设备状态不一致。
加交互锁是最直接的解决方案:组件一旦进入TurningOn或TurningOff,就拒绝一切新的点击事件,直到状态机切回稳态或者超时。这个锁不需要额外变量,因为在状态机枚举里已经天然包含了。
5.4 无障碍模式下开关语义不清晰
辅助功能(TalkBack)开启后,开关如果只是读“开关”,用户完全不知道它控制的是什么功能。RcSwitch 增加了accessibilityLabel属性,比如“消息免打扰开关”,同时accessibilityState会如实反映当前是否选中、是否禁用、是否忙碌。这些属性不仅在无障碍场景有用,在 UI 自动化测试里也能提升元素定位的准确性。
5.5 常见问题速查表
| 问题 | 根因 | 解决方案 |
|---|---|---|
| 深色模式颜色浑浊 | 禁用态用透明度叠加 | 改用专用禁用色 |
| 连续点击重复请求 | 无加载态交互锁 | 状态机中增加 Turning 状态 |
| 动画打断后滑块错位 | 旧动画未取消 | 动画前强制结束上一次动画 |
| 加载转圈掉帧 | 定时器驱动动画 | 改为动画框架驱动 |
| 资源颜色解析失败 | 组件库资源未正确合并 | 统一放置资源并核对路径 |
| 深浅色切换不生效 | 硬编码 Color 值 | 改用资源引用 |
6. 验收清单与后续扩展方向
最后分享一套 RcSwitch 的验收清单,每次改动后我都会照着跑一遍,半年下来这套清单帮我避免了至少三次线上事故。
6.1 真机必测场景
- 浅色模式和深色模式下分别测试开启、关闭、禁用、加载四种基础形态
- 快速连续点击开关,确认不会出现重复回调
- 在弱网环境下测试加载态超时自动恢复
- 切换过程中快速改变 checked 值,确认动画不会卡在中间态
- 开启无障碍模式,确认 TalkBack 读屏语义正确
- 自定义主题色后,检查激活轨道色和滑块颜色的对比度
6.2 后续可扩展的方向
目前 RcSwitch 的颜色系统已经支持基础的主题色推导,后续我准备扩展的方向有三个。
第一是支持多主题切换。目前的 SwitchTheme 是一次性注入的,后续可以配合全局主题管理,在运行时动态替换整套色板。
第二是增加尺寸规范化。开关在不同场景下的尺寸规范应该从设计侧统一维护,组件内部只做有限个档位,而不是允许任意缩放。
第三是提供状态变化的事件流。目前 onChange 只回调 boolean 值,后续想增加onStateChange回调,把完整的状态机变化暴露给外部,便于埋点统计和调试。
开关组件看起来是 UI 组件里最简单的那一类,但真正把它做到“让人放心”的程度,需要的不是炫技,而是对每一个交互细节的较真。这半年磨下来的最大收获不是 RcSwitch 本身,而是养成了“不放过任何一个状态组合”的习惯。日常开发中,用户感知到的“好用”,往往就是这些细节堆出来的。