一、引言

配色是设计中最核心也最具挑战性的环节之一。无论你是开发 App 的独立开发者、做 PPT 的职场人士,还是设计海报的运营人员,选择一组和谐的颜色都是绕不开的课题。专业的配色工具(如 Adobe Color、Coolors)背后是色彩科学的支撑——色调在色环上的位置关系决定了配色的和谐度。

从技术角度看,配色方案生成是一个HSL 色彩空间操作问题。前 7 个游戏类 Demo 都在"逻辑规则"领域(博弈、匹配、展开、推理),而配色生成器的核心是在"连续数学空间"中进行计算——色调(Hue)是一个 0°-360° 的圆环,饱和度(Saturation)和亮度(Lightness)在 0%-100% 之间变化。三种运算共同决定了最终的颜色值。

本文用 ArkUI 从零构建一个配色方案生成器,包含基于黄金角分布的 5 色方案生成、锁定/解锁微调、饱和度滑块调节、色值点击复制和预览卡片。HSL 到 Hex 的转换使用完整的色彩空间数学——色相分段映射 + RGB 归一化 + Hex 编码。

阅读完本文,你将能够:

  • 使用 HSL 色彩空间表示和计算颜色
  • 用黄金角(137°)分布生成视觉和谐的配色方案
  • 实现 HSL → RGB → Hex 的完整色彩空间转换链
  • 用锁定/解锁机制实现定向调色(冻结满意色,重新生成其余色)

二、配色理论基础

2.1 HSL 色彩空间

在计算机图形学中,颜色有多种表示方式。前端开发者最熟悉的是 Hex(如 #667eea)和 RGB(如 rgb(102, 126, 234)),但从"生成和谐配色"的角度看,HSL 是最合适的色彩空间:

  • H(色相 Hue):0°-360°,在色环上的位置。0°=红色,120°=绿色,240°=蓝色
  • S(饱和度 Saturation):0%-100%,颜色的纯度。0%=灰色,100%=最鲜艳
  • L(亮度 Lightness):0%-100%,颜色的明暗。0%=黑色,50%=纯色,100%=白色

为什么不用 RGB?因为"和谐配色"本质上是在色环上找角度关系——互补色(180°对立)、三角色(120°均分)、类似色(30°相邻)。这些关系在 HSL 的色环上是一目了然的数学关系,但在 RGB 的三维空间中却是难以直观表达的曲面。

2.2 黄金角分布

本 Demo 使用黄金角(Golden Angle)来分布 5 个颜色的色调。黄金角约为 137.508°,来自黄金比例 φ:

黄金角 = 360° / φ² ≈ 137.5°
其中 φ = (1 + √5) / 2 ≈ 1.618

黄金角的特性在于:它是色环上"最无理"的角度。用黄金角连续旋转,永远不会回到完全相同的位置——这与向日葵种子、松果鳞片的排列原理相同。在配色上,这意味着用黄金角生成的 5 个色调在视觉上天然不重复且分布均匀,但又不像等分角(72°=360°/5)那样机械。

代码实现:

generatePalette(): void {
  const arr: PaletteColor[] = [];
  for (let i = 0; i < 5; i++) {
    if (i < this.colors.length && this.colors[i].locked) {
      arr.push(this.colors[i]);  // 保留锁定色
    } else {
      const h = (this.baseHue + i * 137) % 360;
      arr.push({ hex: this.hslToHex(h, this.saturation, 55), locked: false });
    }
  }
  this.colors = arr;
}

5 个颜色的色调分别为:baseHue, baseHue+137°, baseHue+274°, baseHue+51°, baseHue+188°(取模 360° 后)。饱和度由全局滑块控制(默认 70%),亮度固定为 55%(偏暗但不过暗,适合作为 UI 主色)。

2.3 锁定/解锁机制

配色生成器的核心交互模式是"锁定-重新生成"循环:

  1. 随机生成一组 5 色方案
  2. 你对其中 2 个颜色满意,点击 🔒 锁定
  3. 再次随机生成——锁定的 2 个颜色保持不变,另外 3 个重新生成
  4. 重复 2-3 直到整个方案满意

这种交互模式来自 Coolors 等专业配色工具,解决了一个核心矛盾:完全随机生成难以控制,而完全手动调整需要专业色彩知识。"锁定 + 重新生成"是两者之间的最优平衡。

toggleLock(idx: number): void {
  const arr: PaletteColor[] = [];
  for (let i = 0; i < this.colors.length; i++) {
    if (i === idx) {
      arr.push({ hex: this.colors[i].hex, locked: !this.colors[i].locked });
    } else {
      arr.push(this.colors[i]);
    }
  }
  this.colors = arr;
}

2.4 交互流程

  1. 初始生成:随机 baseHue + 黄金角 × 4 = 5 色方案
  2. 锁定颜色:点击色块下的 🔒 → 该色被冻结
  3. 重新生成:点击"随机生成" → 只有未锁定的颜色重新计算
  4. 偏移色相:点击"偏移色相" → 所有未锁定颜色的色相整体旋转 30°
  5. 调整饱和度:拖动滑块 → 所有未锁定颜色的饱和度同步更新
  6. 复制色值:点击色块 → Hex 值被记录,"已复制"反馈 1.5 秒
  7. 预览效果:底部预览卡片实时展示当前配色在 UI 中的表现
    在这里插入图片描述

三、HSL 到 Hex 的转换

3.1 算法原理

HSL → Hex 的转换路径是 HSL → RGB → Hex,其中 HSL → RGB 是最核心的一步。

HSL 到 RGB 的转换基于色相分段映射。给定 HSL(h, s, l),首先计算两个中间量:

c = (1 - |2l - 1|) × s        ← 色度(chroma)
x = c × (1 - |(h/60) % 2 - 1|) ← 中间值
m = l - c/2                    ← 亮度调整量

然后根据色相 h 落入的 60° 区间,计算临时 RGB(r1, g1, b1):

色相区间 r1 g1 b1 颜色过渡
0°-60° c x 0 红→黄
60°-120° x c 0 黄→绿
120°-180° 0 c x 绿→青
180°-240° 0 x c 青→蓝
240°-300° x 0 c 蓝→品红
300°-360° c 0 x 品红→红

最后将临时值加上 m 并放大到 0-255:

R = round((r1 + m) × 255)
G = round((g1 + m) × 255)
B = round((b1 + m) × 255)

3.2 ArkTS 实现

hslToHex(h: number, s: number, l: number): string {
  const sNorm = s / 100;
  const lNorm = l / 100;
  const c = (1 - Math.abs(2 * lNorm - 1)) * sNorm;
  const x = c * (1 - Math.abs(((h / 60) % 2) - 1));
  const m = lNorm - c / 2;
  let r1 = 0; let g1 = 0; let b1 = 0;
  if (h < 60) { r1 = c; g1 = x; b1 = 0; }
  else if (h < 120) { r1 = x; g1 = c; b1 = 0; }
  else if (h < 180) { r1 = 0; g1 = c; b1 = x; }
  else if (h < 240) { r1 = 0; g1 = x; b1 = c; }
  else if (h < 300) { r1 = x; g1 = 0; b1 = c; }
  else { r1 = c; g1 = 0; b1 = x; }
  const r = Math.round((r1 + m) * 255);
  const g = Math.round((g1 + m) * 255);
  const b = Math.round((b1 + m) * 255);
  return '#' + this.toHex2(r) + this.toHex2(g) + this.toHex2(b);
}

六个 if-else 分支对应色环的六个 60° 扇区。每个扇区内,三种颜色分量中有一项始终为 0,一项等于色度 c,一项随色相在 [0, c] 之间线性变化。这种"二大一小"的模式保证了色环上相邻色相的平滑过渡。

3.3 RGB → Hex 编码

三个颜色分量(0-255)分别转换为两位十六进制:

toHex2(v: number): string {
  const s = '0123456789ABCDEF';
  return s.charAt(Math.floor(v / 16)) + s.charAt(v % 16);
}

除以 16 取商和余数,在十六进制字符表中查表——比 toString(16) 更可靠(ArkTS 中数字的 toString 方法参数支持不确定)。

注意:ArkTS 不支持字符串索引访问(s[i]),必须使用 .charAt(i) 替代。这是 ArkTS 严格的类型检查带来的约束——string 类型的属性访问不被允许,只能通过 .charAt() 方法获取指定位置的字符。
在这里插入图片描述

四、饱和度调节

饱和度滑块使用 ArkUI 的 Slider 组件:

Slider({
  style: SliderStyle.InSet,
  value: this.saturation,
  min: 10,
  max: 100,
  step: 5
})
  .blockColor('#667eea')
  .trackColor('#E0E0E8')
  .selectedColor('#667eea')
  .trackThickness(6)
  .width(200)
  .onChange((v: number) => { this.updateSaturation(v); })

SliderStyle.InSet 样式(无刻度标记的连续滑块),步长 5%,最小值 10%(完全不饱和的灰色在配色中通常无用)。onChange 回调触发后:

updateSaturation(s: number): void {
  this.saturation = s;
  const arr: PaletteColor[] = [];
  for (let i = 0; i < this.colors.length; i++) {
    if (this.colors[i].locked) {
      arr.push(this.colors[i]);
    } else {
      const h = (this.baseHue + i * 137) % 360;
      arr.push({ hex: this.hslToHex(h, this.saturation, 55), locked: false });
    }
  }
  this.colors = arr;
}

同样遵循锁定/解锁规则:锁定的颜色不受饱和度变化影响,未锁定的颜色以新饱和度重新计算。这使得用户可以"锁定中意的几个颜色,调整整体的鲜艳度直到满意"。

五、预览卡片

配色方案生成器的独特价值在于:它不只是给你 5 个颜色代码,而是让你看到这些颜色在实际界面中的效果。

Column() {
  // 预览 header(使用颜色 0 作为背景)
  Row() {
    Text('标题文字')
      .fontColor(this.textColorFor(this.colors[0].hex))
  }
  .backgroundColor(this.colors[0].hex)

  // 预览 body
  Column() {
    Text('这是一段正文内容,展示配色方案在实际界面中的效果。')

    Row() {
      Button('主要按钮')          // 使用颜色 1
        .backgroundColor(this.colors[1].hex)
      Button('次要按钮')          // 使用颜色 2
        .backgroundColor(this.colors[2].hex + '22')  // 半透明
    }

    Row() {
      Text('●')                   // 使用颜色 3
        .fontColor(this.colors[3].hex)
      Text('状态标识')
    }
  }
}

5 个颜色在预览中各司其职:

  • 颜色 0:标题栏背景 → 主色
  • 颜色 1:主要按钮 → 强调色
  • 颜色 2:次要按钮文字/边框 → 辅助色
  • 颜色 3:状态标识 → 点缀色
  • 颜色 4:预留(可用于图标、链接等)

5.1 智能文字颜色

当颜色用作背景时,需要根据背景的亮度自动选择白色或深色文字:

textColorFor(bgHex: string): string {
  const r = this.hexVal(bgHex, 1, 2);
  const g = this.hexVal(bgHex, 3, 4);
  const b = this.hexVal(bgHex, 5, 6);
  const brightness = (r * 299 + g * 587 + b * 114) / 1000;
  return brightness > 150 ? '#1a1a2e' : '#FFFFFF';
}

亮度计算公式使用 BT.601 标准(人眼对绿色最敏感、红色次之、蓝色最弱)。阈值 150 将亮度区间粗略分为两半——低亮度背景用白色文字(对比度足够),高亮度背景用深色文字(避免白色淹没在浅色中)。

六、UI 设计

6.1 页面结构

┌──────────────────────────────────────────┐
│ 🎨 配色方案(深色标题栏)                  │
├──────────────────────────────────────────┤
│ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ ┌──────┐ │
│ │      │ │      │ │      │ │      │ │      │ │
│ │#6678│ │#1677│ │#52C4│ │#FF98│ │#FF4D│ │
│ │      │ │      │ │      │ │      │ │      │ │
│ │  🔒  │ │  🔓  │ │  🔓  │ │  🔓  │ │  🔓  │ │
│ └──────┘ └──────┘ └──────┘ └──────┘ └──────┘ │
│ 点击色块复制色值  |  点击 🔒 锁定颜色       │
├──────────────────────────────────────────┤
│ 饱和度  [════════○══════════]  70%        │
├──────────────────────────────────────────┤
│  🎲 随机生成         ↔ 偏移色相           │
├──────────────────────────────────────────┤
│ 预览效果                                  │
│ ┌──────────────────────────────────┐     │
│ │ 标题文字(颜色 0 背景)           │     │
│ ├──────────────────────────────────┤     │
│ │ 这是一段正文内容,展示配色方案     │     │
│ │ 在实际界面中的效果。              │     │
│ │ [主要按钮](颜色 1) [次要](颜色 2)│     │
│ │ ● 状态标识(颜色 3)              │     │
│ └──────────────────────────────────┘     │
└──────────────────────────────────────────┘

6.2 色板布局

5 个色板水平排列,使用 FlexAlign.SpaceEvenly 均匀分布:

Row() {
  ForEach(this.colors, (pc: PaletteColor, idx: number) => {
    Column() {
      Column() {
        if (this.copiedIdx === idx) {
          Text('已复制')
        } else {
          Text(pc.hex)
        }
      }
      .width(60).height(70)
      .backgroundColor(pc.hex)
      .onClick(() => { this.copyHex(pc.hex, idx); })

      Text(pc.locked ? '🔒' : '🔓')
        .onClick(() => { this.toggleLock(idx); })
    }
  })
}
.width('100%')
.justifyContent(FlexAlign.SpaceEvenly)

每个色板 60vp × 70vp,比例接近 6:7——不是正方形,略竖长,在视觉上形成"色卡"的感觉。Hex 值直接显示在色块内部,使用智能文字颜色确保在任何背景色上都可读。

点击色块时,copyHex() 通过 setInterval 在 1.5 秒后重置 copiedIdx,色块上短暂显示"已复制"文字确认操作成功。

七、完整代码结构

PalettePage (~230 行)
├── 接口定义
│   └── PaletteColor { hex, locked }
├── 状态变量
│   ├── @State colors[5] — 当前 5 色方案
│   ├── @State saturation — 饱和度(10-100)
│   ├── @State baseHue — 基础色相(0-360)
│   └── @State copiedIdx — 复制反馈索引
├── 色彩数学
│   ├── hslToHex() — HSL→RGB→Hex 完整转换链
│   ├── toHex2() — 单字节→两位十六进制
│   ├── hexVal() — Hex子串→十进制数值
│   └── textColorFor() — 背景亮度→文字颜色
├── 配色逻辑
│   ├── generatePalette() — 黄金角 × 5 色生成
│   ├── shiftHues() — 色相整体偏移 30°
│   ├── toggleLock() — 锁定/解锁切换
│   └── updateSaturation() — 饱和度调节
├── 交互
│   └── copyHex() — 色值复制 + 视觉反馈
├── 视图
│   ├── 标题栏 — 🎨 配色方案
│   ├── 5 色色板 — Row + ForEach
│   ├── 饱和度滑块 — Slider 组件
│   ├── 操作按钮 — 随机生成 + 偏移色相
│   └── 预览卡片 — 模拟 UI 组件展示
└── 生命周期
    └── aboutToAppear() — 初始随机生成

八、总结

本文从零构建了一个配色方案生成器。与前面 7 个游戏类 Demo 的"离散规则逻辑"不同,配色生成器的核心是连续色彩空间中的数学运算——HSL 色环上的角度分布、RGB 归一化和十六进制编码。从技术角度看,它是 HSL 色彩空间、黄金角分布、锁定式迭代优化和智能文字对比度的完整示例。

核心要点回顾:

  1. HSL 色彩空间:H(色相 0°-360°)确定颜色在色环上的位置,S(饱和度 0%-100%)控制鲜艳度,L(亮度 0%-100%)控制明暗。在 HSL 空间中进行配色计算比在 RGB 中更自然——和谐配色(互补、三角、黄金角)本质上是色环上的角度关系。

  2. 黄金角分布hue[i] = baseHue + i × 137° 将 5 个色调以黄金角(360°/φ²)间距分布在色环上。黄金角是"最无理"的角度,保证色调天然不重复且视觉均匀,比等分角(72°)更自然。

  3. HSL → RGB → Hex 转换链:色相落入 6 个 60° 扇区之一 → 计算 (r1, g1, b1) 临时值 → 加上亮度调整量 m → 放大到 [0, 255] → 每个分量编码为两位十六进制。完整的转换链共约 40 行代码,无任何依赖。

  4. 锁定/解锁迭代优化toggleLock() 翻转单个颜色的 locked 标志,generatePalette()shiftHues() 只更新未锁定颜色。这种"锁定满意色、重试不满意色"的模式是专业配色工具的标配交互。

  5. 智能文字对比度textColorFor() 使用 BT.601 亮度公式(0.299R + 0.587G + 0.114B)判断背景明暗,阈值 150 决定使用深色还是白色文字。确保 Hex 值在任何背景色上都清晰可读。

  6. Slider 饱和度调节Slider({ style: SliderStyle.InSet }) 实现饱和度从 10% 到 100% 的连续调节(步长 5%)。updateSaturation() 对未锁定颜色以新饱和度重新执行 HSL→Hex 转换,锁定色保持不变。

配色方案生成器是一款面向设计师和开发者的实用工具。这个 230 行的 ArkUI 实现抓住了色彩空间计算的核心乐趣:随机生成时的惊喜感、锁定满意颜色的策略感、饱和度调至最佳时的满足感、以及在预览卡片中看到完整配色效果时的成就感。它是本系列首篇设计工具类文章,也是 HSL 色彩空间计算和迭代式锁定优化的完整示例。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐