在翻 Dopamine 的 issue 的时候,发现 Dopamine 并没有提供一个非常易用的 Color Picker 来帮助用户创建 Accent Color,而这种控件在使用了 Material Design 的 Android App 中则非常常见,而且其中多数也非常有漂亮而易用,但是找了一圈并没有发现具有类似设计的 WPF 控件。本文中需要一点点的初中数学内容,这其中可能有一些错误,或者是算法并非最优,碍于本人水平所限,请见谅。
如果接触了很多 Win32 或者 WPF 程序,例如画图、SAI、Photoshop,会发现其中的拾色器或许很专业,很稳定,但是相对于下图中的控件,则并没有那么好看。微软在 UWP SDK 1709 中,也加入了这种非常漂亮的拾色器 Color picker ,然而,WPF 似乎被忘记了。
那么如果我需要一个如下图一样漂亮的拾色器该怎么办呢?那凉了呀,只能自己画一个出来了。
那么如何去画这么一个控件呢?最明显的问题在于如何实现那个色轮,从色轮中可以很明显的看出它是由 RGB 组成的,但好像仅仅用 RGB 去描述它却有一种说不清的障碍,三个 [0,255] 的值为什么可以组成一个 360° 的圆?因为使用 RGB 分量的描述并不符合我们的感官直觉(尽管视锥细胞感受到的依然是 RGB),因此,这里需要将 RGB 空间转换为 HSV 空间 来描述同样一个颜色。
那么,我们现在已经有了数学模型,可是如何将其显示在一个 Ellipse 上呢?应该遍历填充一个 Brush 吗?不不不,这太不优雅了,当然是要使用 Shader 把色轮交给 GPU 来渲染,这样一来你不仅可以轻易得到 60 帧的 UI,而且甚至可以吹一吹硬件加速(逃。有关 Shader 的前置知识请移步在 WPF 中使用 Pixel Shader 为控件添加特效 。
编写 Shader
这部分代码使用了 JohanLarsson 所写的 Gu.Wpf.Geometry ,根据 MIT 协议发布
接下来便要开始编写一个 Shader 来实现色轮,显然我们需要先用几个常量寄存器来存放一些外部参数,顺带写上几个常量:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 static float PI = 3.14159274 f; static float PI2 = 6.28318548 f; static float2 cp = float2(0.5 , 0.5 ); float InnerRadius : register(C0);float InnerSaturation : register(C1);float Value : register(C2);float StartAngle : register(C3);
虽然说我们是用了 Shader 来渲染,但实际上的算法还是逃不出用遍历改变每像素颜色填充 Brush 的套路(笑。那么整个 Shader 的逻辑可以分为两部分,区域内的像素 -> 着色,区域外的像素 -> 透明,而这个区域则是一个圆环(默认宽度为 0):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 float3 HUEtoRGB(in float H) { float R = abs (H * 6 - 3 ) - 1 ; float G = 2 - abs (H * 6 - 2 ); float B = 2 - abs (H * 6 - 4 ); return saturate(float3(R, G, B)); } float3 HSVtoRGB(in float3 HSV) { float3 RGB = HUEtoRGB(HSV.x); return ((RGB - 1 ) * HSV.y + 1 ) * HSV.z; } float4 main(float2 uv : TEXCOORD) : COLOR { float2 rv = uv - cp; float r = length (rv); float ir = InnerRadius / 2 ; if (r >= ir && r <= 0.5 ) { return float4(HSVtoRGB(float3(h, s, v)), 1 ); } return float4(0 , 0 , 0 , 0 ); }
那么接下来只需要计算出当前像素应具有的 (h, s, v) 三元组即可。其中 h 的值均匀分布在 θ = [0, 2π] 的区间内,s 的值均匀分布在 r = [InnerRadius, OutterRadius - InnerRadius] 的区间内,而 v 直接使用寄存器的值即可。
不过!为方便描述,以上的区间都是极坐标,在实际渲染的时候需要将极坐标再转换为归一化笛卡尔坐标以供 GPU 处理。那么实际的逻辑应表达为:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 float interpolate(float min , float max , float value){ if (min == max ) { return 0.5 ; } if (min < max ) { return clamp ((value - min ) / (max - min ), 0 , 1 ); } return clamp ((value - max ) / (min - max ), 0 , 1 ); } float clamp_angle_positive(float a){ if (a < 0 ) { return a + PI2; } return a; } float clamp_angle_negative(float a){ if (a > 0 ) { return a - PI2; } return a; } float angle_from_start(float2 uv, float2 center_point, float start_angle, float central_angle){ float2 v = uv - center_point; return central_angle > 0 ? clamp_angle_positive(clamp_angle_positive(atan2(v.x, -v.y)) - clamp_angle_positive(start_angle)) : abs (clamp_angle_negative(clamp_angle_negative(atan2(v.x, -v.y)) - clamp_angle_negative(start_angle))); } float4 main(float2 uv : TEXCOORD) : COLOR { if (r >= ir && r <= 0.5 ) { float sa = radians (StartAngle); float h = interpolate(0 , PI2, angle_from_start(uv, cp, sa, PI2)); float s = lerp(InnerSaturation, 1 , interpolate(ir, 0.5 , r)); float v = Value; } return float4(0 , 0 , 0 , 0 ); }
对应的 C# 包装器则没有什么好特别说明的,注意几个寄存器的初始值即可,代码请移步:HsvWheelEffect.cs
编写布局 从设计图中可以看出,整个控件应分为三部分,也就是色轮和预览框、滑动条、数值框,每部分都可以用 StackPanel 包裹起来然后将三个 StackPanel 放到最外层的 StackPanel 中并垂直排列,而色轮和预览这部分可以用 Canvas 容器直接进行绝对布局,并给色轮加一个上文中完成的 ShaderEffect,数值框则需要用 Grid 容器并划分为四行两列。同时你会注意到,色轮上还有一个黑色的 Ellipse 作为拾色器位置指示器,这是实际上一个 Thumb 控件,使用 Canvas 的另一个好处是易于定位 Thumb。
因此,抛开其他不是那么重要的样式,布局的问题基本就是一个大概的这种样子,各个控件的属性再绑定到逻辑代码上即可。最终的代码请移步:ColorPicker.xaml
不过需要注意 HEX 和颜色分量的 TextBox 需要对输入值作判断,也就是在绑定里添加一个 Binding.ValidationRules
集合,将 RGB 限制在 [0, 255] 内,HSV 限制在 [0, 360] 和 [0, 100] 内,HEX 限制在 “^#[0-9a-fA-F]{6}” 的正则条件内。这需要增加两个数据验证器,以 HEX 的验证为例:
1 2 3 4 5 6 7 8 9 10 11 <TextBox Width ="70" > <TextBox.Text > <Binding Path ="Hex" UpdateSourceTrigger ="PropertyChanged" RelativeSource ="{RelativeSource TemplatedParent}" > <Binding.ValidationRules > <validations:RegexValidation Pattern ="^#[0-9a-fA-F]{6}" /> </Binding.ValidationRules > </Binding > </TextBox.Text > </TextBox >
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 using System.Globalization;using System.Text.RegularExpressions;using System.Windows.Controls;namespace Digimezzo.WPFControls.ValidationRules { public class RegexValidation : ValidationRule { public string Pattern { get => _pattern; set { _pattern = value ; _isRegexChanged = true ; } } private string _pattern; private bool _isRegexChanged = false ; private Regex _regex; public override ValidationResult Validate (object value , CultureInfo cultureInfo ) { if (_isRegexChanged) { _regex = new Regex(_pattern); _isRegexChanged = false ; } return new ValidationResult(_regex.IsMatch((string ) value ), value ); } } }
编写逻辑 逻辑部分抛开依赖属性与一些控件字段,主要的复杂度在于协调各个控件之间的绑定属性。因为一个作为一个拾色器,其几乎所有的控件都最终绑定到了同一个 Color 属性上,而修改该属性会触发 PropertyChanged 事件,同时在修改任意一个控件的属性值时,我们希望该控件的属性值不变而其他的控件属性值会自动变化,这就陷入了一个循环调用的境地中。因此,我们需要剥离出最终改变属性的逻辑,并将该逻辑认定为一个原子操作。
于是,将所有依赖属性的回调方法设置为同一个方法,在该方法中,当一个请求被处理时,将屏蔽所有其他请求(虽然在我的姿势范围内应该这样写,但是我觉得这样效率并不高,也不优雅,希望能指出更优雅的方案)。
但是这还有一个问题,所有依赖属性及其相关的字段、方法都需要是静态的,但是对于每一个实例的内部状态是不一样的,需要强行调用一下实例方法。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 private bool withinColorChange = false ;public static readonly DependencyProperty HueProperty = DependencyProperty.Register(nameof (Hue), typeof (double ), typeof (ColorPicker), new PropertyMetadata(0 d, ComponentChangedCallback));private static void ComponentChangedCallback (DependencyObject d, DependencyPropertyChangedEventArgs e ) => ((ColorPicker)d).OnComponentChanged(e);private void OnComponentChanged (DependencyPropertyChangedEventArgs e ){ if (withinColorChange) return ; withinColorChange = true ; if (e.Property == HexProperty) { var rgb = HexToRgb((string )e.NewValue); UpdateHsv(rgb); } else if (e.Property == RedProperty) { var rgb = Color.FromArgb(255 , Convert.ToByte(e.NewValue), SelectedColor.G, SelectedColor.B); UpdateHsv(rgb); } else if (e.Property == GreenProperty) { var rgb = Color.FromArgb(255 , SelectedColor.R, Convert.ToByte(e.NewValue), SelectedColor.B); UpdateHsv(rgb); } else if (e.Property == BlueProperty) { var rgb = Color.FromArgb(255 , SelectedColor.R, SelectedColor.G, Convert.ToByte(e.NewValue)); UpdateHsv(rgb); } else if (e.Property == SelectedColorProperty) { UpdateAllComponents(); } else { var hsv = new Hsv(Hue, Saturation, Value); UpdateRgb(hsv); if (e.Property != ValueProperty) UpdateValueSlider(hsv); } ResetPickerThumbPosition(); withinColorChange = false ; } private void UpdateAllComponents (){ var hsv = RgbToHsv(SelectedColor); Hue = hsv.Hue; Saturation = hsv.Saturation; Value = hsv.Value; Red = SelectedColor.R; Green = SelectedColor.G; Blue = SelectedColor.B; Hex = RgbToHex(SelectedColor); UpdateValueSlider(hsv); } private void UpdateHsv (Color rgb ){ var hsv = RgbToHsv(rgb); Hue = hsv.Hue; Saturation = hsv.Saturation; Value = hsv.Value; SelectedColor = rgb; Hex = RgbToHex(rgb); UpdateValueSlider(hsv); } private void UpdateRgb (Hsv hsv ){ var rgb = HsvToRgb(hsv); Red = SelectedColor.R; Green = SelectedColor.G; Blue = SelectedColor.B; SelectedColor = rgb; Hex = RgbToHex(rgb); } private void UpdateValueSlider (Hsv hsv ){ hsv.Value = 1.0 ; var rgb = HsvToRgb(hsv); SelectedFullValueColor = rgb; }
而仅仅更新了所有属性的值功能并没有完成,色轮上方的 Thumb 控件还应该既能跟随鼠标移动位置,还能在颜色分量变化时移动到色轮的正确位置处。 而 Thumb 位置坐标的计算则需要根据 HSV 值来完成,也就是将 Shader 中的极坐标 -> 笛卡尔坐标用 C# 重新实现一个,不过在逻辑代码中需要考虑的条件更多,因为其空间不是归一化的,所有的距离都需要再乘以一个半径的比率,如下:
1 2 3 4 5 6 7 private void ResetPickerThumbPosition (){ var radian = AngleToRadian(Hue * 360 d); double nX = Math.Cos(radian) * Saturation * Radius, nY = Math.Sin(radian) * Saturation * Radius; Canvas.SetLeft(pThumb, 150 - nX); Canvas.SetTop(pThumb, 150 - nY); }
当用鼠标拖动 Thumb 到一个位置时,需要将其在 Canvas 容器内的笛卡尔坐标转换为极坐标,根据极坐标计算出精确的 HSV 值再转换成 RGB 值,同时根据临界条件防止 Thumb 被拖出色轮的范围之外,如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 private void GetColorFromCurrentPositionAndMoveThumb (double nX, double nY ){ double diffX = nX - Radius, diffY = nY - Radius; var radian = Math.Atan(diffY / diffX); var hue = RadianToAngle(radian); if ((nX >= Radius && nY < Radius) || (nX >= Radius && nY >= Radius)) hue = hue + 180 ; if (nX < Radius && nY >= Radius) hue = hue + 360 ; Hue = hue / 360 d; var euclid = diffX * diffX + diffY * diffY; if (Radius2 - euclid >= 0 ) { Canvas.SetLeft(pThumb, nX); Canvas.SetTop(pThumb, nY); Saturation = Math.Sqrt(euclid) / 150 d; } else { Saturation = 1 d; nX = Math.Cos(radian) * Radius; nY = Math.Sin(radian) * Radius; if (diffX < 0 ) { Canvas.SetLeft(pThumb, Radius - nX); Canvas.SetTop(pThumb, Radius - nY); } else { Canvas.SetLeft(pThumb, Radius + nX); Canvas.SetTop(pThumb, Radius + nY); } } var hsv = new Hsv(Hue, Saturation, Value); UpdateRgb(hsv); UpdateValueSlider(hsv); }
关于上文中用到的 HSV 与 RGB 相互转换的算法:
这部分代码使用了 objorke 所写的 PropertyTools ,根据 MIT 协议发布