如何实现一个颜色选择器

6,978 阅读11分钟

在开发公司UI组件库的过程中,颜色组件ColorPicker由于时间关系没有去深入研究,看着花花绿绿的色谱图,以为实现起来会很复杂,就直接将一个开源的颜色选择器封装了一下。这大概是半年以前的事了,这篇文章也在我的博客中以草稿形式(只有标题没有内容,当时是做了一个记录,想着后来有时间了去研究,目前这种草稿还有很多😂)存放了半年了。前段时间请教了公司UI同事一些颜色相关的概念,又去搜索了下这方面的知识,收获还是蛮大的,尤其是知乎色彩空间中的 HSL、HSV、HSB 有什么区别?这篇提问中,@Forrest近乎大白话的回答,超赞👍。


在了解了相关概念之后,再去审视这个组件的话,就不会有那种陌生行业的抵触感了。最终也实现了这个这个组件,DEMO,并将实现过程记录如下:

【解析Photoshop调色器】小章节是基于维基百科和知乎文章对一些颜色概念的总结。开发视角总结的可能并不太专业,了解即可。

解析Photoshop调色器

PhotoShop拾色器

HSL、HSV、HSB的区别

作为前端,我们了解HEX,了解RGB,但对于HSL、HSV、HSB这几个颜色概念往往是模糊不清的。如上图Photoshop的调色器,其中下部分别有HSBRGB等几个输入框,RGB分别代表红绿蓝三原色的颜色通道,通过对这三个颜色通道的变化以及相互之间的叠加可以得到各种不同的颜色,这是我们所熟知的。那么HSB又是什么呢?

事实上,HSBRGB一样,也是一种颜色模式,其中H(hue)表示色相(什么颜色),S(saturation)表示饱和度(颜色的纯度),B(brightness)表示明度亮度(颜色的明亮程度)。相较于RGB模式,HSB颜色模式更加人性化,它定义了颜色“是什么颜色?颜色艳不艳?颜色亮不亮?”。

什么是色相

色相就是在不同的光照下,人眼所感觉不同的颜色。 ”赤橙黄绿青蓝紫,谁持彩练当空舞?“,伟人的诗词早就描绘过了。 在HSV/HSL色彩模式下, 色相是以红色为0度(360度),黄色为60度,绿色为120度,青色为180度,蓝色为240度,品红色为300度的表现,其值范围为0 - 360度,如下图:

色相

什么是饱和度

饱和度是指颜色的强度或纯度。饱和度表示色相中彩色成分所占的比例,用从 0 (透明) ~ 100% (完全饱和)的百分比来度量。纯度是色彩感觉强弱的标志,纯度高的颜色人眼看上去显得比较鲜艳。

什么是明度

明度是颜色的相对明暗程度。通常是从 0 (黑) ~ 100% (白)的百分比来度量的。


同样的,HSLHSVHSB一样,都是基于色相、饱和度、明度三方面对色彩的解释。其中HSB 和 HSV 是同一个东西,只是叫法上不同。后面我们统一使用HSV来表示。

对三种颜色模式的区别,知乎上(文末附链接)有个很浅显易懂的回答:

在原理和表现上,HSL 和 HSB 中的 H(色相) 完全一致,但二者的 S(饱和度)不一样, L 和 B (明度 )也不一样:

  • HSB 中的 S 控制纯色中混入白色的量,值越大,白色越少,颜色越纯;
  • HSB 中的 B 控制纯色中混入黑色的量,值越大,黑色越少,明度越高
  • HSL 中的 S 和黑白没有关系,饱和度不控制颜色中混入黑白的多寡;
  • HSL 中的 L 控制纯色中的混入的黑白两种颜色。

HSV的三个值表达范围分别为: H [0-360] float S [0-1] float V [0-1] float

上述概念,可能并不太专业,了解即可。 用程序思维来解释上述概念的话,就是说我们在一个颜色中混入不同程度的黑和白就能变为另外一种颜色值,如下图:

PhotoShop拾色器
也就是说,抛开RGB三个颜色通道之外,还可以通过色相H、饱和度S、明度V另外三个通道来表达颜色。

实现流程

color-picker效果图
如上效果图,就是最终要实现的颜色选择器的效果。整个布局由饱和度/明度面板、色相面板、透明度面板三个元件组成,三个面板都是滑块模型,其中饱和度/明度面板上的滑块可以在其面板上自由滑动,而色相和透明度面板上的滑块仅允许左右滑动。滑块滑动最终产生的坐标值将是用来计算HSV值的依据。 “夫君在手,天下我有”。基于上述概念和思路,我们只需要通过坐标计算出色相H、饱和度S、明度V三个值,就可以将其转换为指定格式的颜色。
流程图

  • 饱和度/明度面板由饱和度和明度两层组成,滑块坐标值的Left值用来计算饱和度,Top值用来计算明度;
  • 色相面板的滑块坐标值的Left值用来计算色相值;
  • 透明度面板的滑块坐标值的Left值用来计算透明度,透明度作为颜色的一种补充,和颜色本身没有任何关系;
  • 最终产生的HSVA四个值将通过不同算法转换成不同的颜色格式;

饱和度/亮度面板的实现

通俗点来说,饱和度就是在一个纯色中“混入”了百分之多少的白色,明度就是在一个纯色中“混入”了百分之多少的黑色;这些量都用百分比来表示,其值范围在0% ~ 100%

饱和度是从左往右,混入的白色越少,表示颜色的纯度越高。也就是在面板最左边混入的白色达到纯白的峰值,而最右边混入的白色是接近于无的透明,这就是一条从左往右,由白色到透明的径向渐变;同样的道理可以得出,明度可以由一条从下往上,由纯黑到完全透明的径向渐变表示。由于这两条渐变大部分区域是透明的,因此,当在这两层的下方设置赤橙黄绿青蓝紫等不同色相时,整个区域就会形成一个色谱。如下图所示:

饱和度/亮度面板的实现原理
为了更方便理解,我录制了一个小视频,通过调整面板元素的背景色,可以更清晰的表现出这两条径向渐变覆盖在一个纯色背景色上产生的效果。 在清楚了色谱的形成过程之后,饱和度/亮度面板的实现就变得容易多了,为了简化结构,直接使用::before/::after伪元素来实现饱和度层和明度层。

布局
<div class="mo-color-sat-val">
  <!-- 饱和度渐变元素 -->
  ::before
  <!-- 滑块 -->
  <div class="mo-color-thumb" role="slider" tabindex="0">
    <span></span>
  </div>
  <!-- 明度渐变元素 -->
  ::after
</div>
.mo-color-sat-val {
  position: relative;
  // 色相将直接作用于面板的背景色上
  background: transparent;

  &::before,
  &::after {
    content: '';
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
    height: 100%;
  }

  // 饱和度 一条从左往右,由纯白到透明的径向渐变
  &::before {
    background: linear-gradient(to right, white, transparent);
  }

  // 明度 一条从下往上,由纯黑到透明的径向渐变
  &::after {
    background: linear-gradient(to top, black, transparent);
  }
}
通过坐标计算S值和V值

当滑块在面板上自由滑动时,滑块所在的位置就是计算S值和V值所需要的坐标值。如何滑动和计算坐标不是本文的重点,可以直接参考源码,我已将其封装为一个Draggable类,该类会在滑动过程中产生一个坐标,并且通过一系列钩子函数返回实例。

// 初始化$sat面板的Draggable类
states.satDragIns = new Draggable(states.$sat, {
  drag: satDrag,
  end: satDrag
})

function satDrag (coordinate: Coordinate) {
  // satWidth 面板的宽度
  // satHeight 面板的高度
  // 饱和度和明度的范围都是从 0 - 100 的一个百分比值,为了便于计算,这里直接保存为小数, 坐标原点是左下角
  const saturation = Math.round(coordinate.left / satWidth * 100) / 100
  const value = Math.round((1 - coordinate.top / satHeight) * 100) / 100
}

色相改变时重置面板背景色

饱和度/亮度面板的最后一环就是当色相改变时,要去设置面板的背景色,以便形成最终的色谱(如上面视频效果)。只需要将色相改变后生成的H值(参考色相的实现)生成一个hsl格式的颜色即可:

 states.$sat.style.background = `hsl(${h}, 100%, 50%)`

色相的实现

色相
可以看出,HSV色彩空间下,色相条由红色、黄色、绿色、青色、蓝色、品红色六中颜色渐变过渡形成,最终再由平红色过渡到红色闭环。将其还原成环状可能更容易理解(黑色小点表示角度):
色相环
那么,只要计算出每个颜色的角度就可以实现这个渐变条,渐变从红色开始(0度),从红色闭环(360度);中间5个色值依次平分角度:

0 deg: 红色
1/6 deg: 黄色
2/6 deg: 绿色
...
5/6 deg: 品红色
360 deg: 红色
布局

转换成最终的CSS代码如下:

// 色相
.mo-color-hue>.mo-color-rail {
  background: linear-gradient(to right, #f00 0%, #ff0 16.66%, #0f0 33.33%, #0ff 50%, #00f 66.66%, #f0f 83.33%, #f00 100%);
}
<div class="mo-color-hue">
  <!-- 渐变条 -->
  <div class="mo-color-rail"></div>
  <!-- 滑块 -->
  <div class="mo-color-thumb" role="slider" tabindex="0">
    <span></span>
  </div>
</div>
通过坐标计算H值
// 通过left坐标和hue元素宽度计算出比例值,然后乘以360度计算出当前位置所处的度数
hue = Math.round((left / hueWidth) * 360 * 100) / 100

透明度的实现

透明度面板的实现是最简单的,格子背景同样使用CSS渐变来生成,具体可以参考CSS大神张鑫旭的这篇文章《CSS届的绘图板CSS Paint API简介》。

布局
<!-- 格子背景直接作用在容器上 -->
<div class="mo-color-alpha">
  <!-- 颜色背景层 -->
  <div class="mo-color-rail"></div>
  <div class="mo-color-thumb" role="slider" tabindex="0">
    <span></span>
  </div>
</div>
.mo-color-alpha {
  background-color: white;
  background-image: linear-gradient(45deg, #c5c5c5 25%, transparent 0, transparent 75%, #c5c5c5 0, #c5c5c5), linear-gradient(45deg, #c5c5c5 25%, transparent 0, transparent 75%, #c5c5c5 0, #c5c5c5);
  background-size: 10px 10px;
  background-position: 0 0, 5px 5px;
}
通过坐标计算透明度值
alpha = Math.round(left / alpWidth * 100) / 100
色相改变时更改颜色层的背景色
const hsl = hsv2hsl(h, s, v)
states.$alpRail.style.background = `linear-gradient(to right, transparent, hsl(${hsl.h}, ${hsl.s * 100}%, ${hsl.l * 100}%))`

如上,我们得到了生成一个颜色所必需的HSV三个重要的参数。有了这三个参数,就可以通过颜色转换算法,如hsv2rgb等,将hsv三个参数转换为不同格式的颜色值,至于转换算法,本文就不再展开,这个网站详细的罗列了各种色彩的转换算法,可以参考。

小技巧:如何验证一个颜色是否有效

不可忽略的是,颜色选择器可能会接收来自用户的传入值,并且ColorPicker组件本身也内置了setValue方法,我们无法保证用户传入的是否是一个有效的颜色值,那么如何去验证这个颜色是否有效呢?起初我考虑用正则表达式去校验,但由于又会涉及到如传入rgb(260, 260, 260)这种某个通道超出范围的值,校验起来比较困难。然后我在Chrome浏览器中给某个元素设置了一个不规范的颜色,发现Chrome将这个颜色自动降级为白色了,那么,如果将一个错误颜色赋值给一个DOM的color样式,然后再获取这个color,再去验证获取到的color和传入的color是否一致是否可以做为校验的依据呢?Chrome浏览器通过了这个小测验,其他浏览器没有测试。

测试用例较少,该技巧可能存在问题。

/**
 * 校验颜色是否合法
 * 测试用例较少,该技巧可能存在问题
 * 
 * 原理,如果颜色不合法,将会被转换为rgb(255,255,255)
 * 只需验证设置后的颜色是否等于传入的颜色
 * @param color 
 */
function checkColor(color: string) {
  // todo
  const style = new Option().style
  style.color = color
  return style.color === color 
}

// 只负责将传入的值转换为 h, s, v 三个通道值,而不去校验值是否超出范围或有效
function parseColor(color) {
  // hex2hsv
  if (HEX_REG.test(color)) {}
  // rgb2hsv
  if (RGB_REG.test(color)) {}
  // ...

  return {
    h,
    s,
    v,
    a
  }
}

setValue (color) {
  const { h, s, v, a } = parseColor(color)
  const {r, g, b} = hsv2rgb(h, s, v,)
  if (checkColor(`rgb(${r},${g},${b})`)) {
    // todo
  }
}

源码

本文首发于我的博客

参考文献