65.9K
CodeProject 正在变化。 阅读更多。
Home

我版本的万能颜色选择器

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.91/5 (12投票s)

2009 年 11 月 3 日

CPOL

12分钟阅读

viewsIcon

36902

downloadIcon

1635

本文介绍了一个颜色拾取器,它允许独立控制七个变量,并具有动态更新的滑块背景。

Article_src

引言

我开始这个项目时,渴望构建一个满足三个要求的颜色拾取器:

  1. 一个用于显示当前颜色的巨大显示区域,
  2. 能够独立调整 HSV 和 RGB 颜色模型的每个分量,以及
  3. 使各种颜色分量之间的关系清晰直观,以便在调整一个分量时,能够提前知道其效果。

我尤其想摆脱那种非常常见的颜色“样本”设计。

背景

我一直对大多数颜色拾取器感到沮丧。当前颜色的显示区域总是太小,无法很好地了解颜色在我的应用程序中实际看起来是什么样子。那种常见的颜色样本令人沮丧,因为它难以找到正确的颜色,或在调整一个轴时会发生其他轴的漂移。此外,虽然许多拾取器提供了独立调整 RGB 分量的能力,甚至一些还能独立调整 HSV(色相、饱和度、明度)或 HSL(色相、饱和度、亮度)值(例如 Microsoft Word 2007,尽管它用于显示当前颜色的区域非常小),但值之间的关系似乎仍然不如应有的直观或清晰。我认为原因在于,它没有明确地展示改变一个分量值将如何影响最终的整体颜色,或者使用的不同颜色模型——通常是 HSV 或 HSL 以及 RGB——之间是如何关联的。我认为处理这个问题的一个好方法是为这两种颜色模型的每个分量值提供滑块,并用渐变画刷为每个滑块的背景着色,显示当滑块移动到其范围内的任何位置时将产生的颜色。这就是我眼中的通用颜色拾取器所做的。

我第一次了解到使用线性渐变画刷作为滑块背景的想法是在这篇文章中,它描述了一个 ColorSlider 控件,该控件用线性渐变画刷绘制其背景,并具有一个 SelectedColor 属性,该属性通过采样滑块背景在其 Value 属性对应的点的颜色来获得。这是一个有趣的想法,我甚至还尝试了一下,设置了三个滑块——一个滑块的 SelectedColor 定义了下一个滑块颜色渐变的末端。这是一个有趣的想法,但对我想要做的事情来说并不实用。

我通过阅读 Windows SDK 附带的颜色拾取器的代码找到了我将要采用的方法,该代码可以在这里找到。它不仅使用滑块来调整色相,其背景是用线性渐变画刷创建的,它还做了一件如此简单的事情,以至于我不敢相信我竟然没有自己想到(有时显而易见的东西很容易被忽略……)这个想法是这样的:将滑块的值直接映射到它控制的颜色分量的值。就是这样。SDK 示例使用颜色样本来调整 HSV 颜色模型的饱和度和明度分量,这是通过组合线性渐变画刷生成的,这是我不打算复制但很有启发性的。

控件

控件的界面很简单;它公开了一个名为 CurrentColor 的依赖属性和一个相应的 RoutedEventCorrentColorChanged。借助 WPF 自动转换的魔力,可以将 CurrentColor 双向绑定到 stringColor 值,这很方便。我发现使用它的唯一限制是将其作为弹出窗口的内容——因为 WPF 授予弹出窗口有限的焦点,所以当控件位于弹出窗口内时,无法直接激活文本框进行值编辑。(如果您有解决此问题的办法,我很感兴趣。)

有七个滑块:三个用于 HSV(色相、饱和度、明度)颜色模型,四个用于 ARGB(Alpha、Red、Green、Blue)。如上所述,滑块的背景是用线性渐变画刷生成的。真正巧妙的功能是,背景会与当前颜色保持同步,使得每个滑块的背景显示出如果移动该滑块将产生的颜色。这非常有趣,因为它不仅显示了两种颜色模型中值之间的关系,还显示了模型本身之间的关系。非常有启发性。最后,所有值,包括十六进制颜色值本身,都可以通过文本框直接设置。

EditableField.png

在实现了滑块背景后,我开始将该控件视为一个工具,并为此添加了一些不错的功能:

  • 从标准系统颜色(System.Windows.Media.Colors)的显示中选择颜色。
  • 将控件的背景设置为等于当前颜色,或交换背景和当前颜色,从而可以轻松地查看两种颜色组合在一起的效果,或一种颜色的文本在特定背景下的效果。
  • 支持 Ctrl-C 和 Ctrl-V 来复制和粘贴十六进制表示的当前颜色。
  • 通过上下文菜单,可以从每个滑块当前显示的颜色范围内获取一组等距的颜色列表。
ColorRange.png

代码

该控件作为 WPF 中的自定义控件实现,意味着它派生自 System.Windows.Control,并且它是一个“无外观”控件——其默认视觉外观的 100% 可以被替换,而无需更改代码。它利用主题系统来应用视觉外观。默认主题在 generic.xaml 文件中实现,该文件在 Visual Studio 中创建自定义控件项时会自动生成。(**注意**:对于此项目,我使用了 Visual Studio 2010 Beta 2。)

除了主控件外,还有一个派生自 Windows.Controls.Slider 的控件层次结构,它们定义了颜色滑块的行为。基类是抽象的 ColorSliderColorSlider 直接派生自 SliderColorSlider 负责确保 Slider 的背景画刷是 LinearGradientBrush,并提供创建画刷和设置渐变颜色的辅助方法。这些方法是虚拟的,以便只有色相滑块在其背景中有两个以上的渐变停止点,可以覆盖该行为以适应其需求。以下是创建画刷的默认实现;请注意,它会考虑滑块是水平还是垂直放置,以及其 IsDirectionReversed 属性是否为 true

/// <summary>
/// Creates the gradient brush used for the background.
/// Takes into account whether the slider is oriented
/// horizontally or vertically and whether or not its direction is reversed.
/// 
/// Note: All versions of ColorSlider, except Hue slider,
/// can use the same basic brush, only the colors differ. 
/// Hue slider overrides this method to create a brush with many more gradient stops.
/// </summary>
protected virtual LinearGradientBrush CreateGradientBrush()
{
    LinearGradientBrush brush = new LinearGradientBrush();
    brush.ColorInterpolationMode = ColorInterpolationMode.ScRgbLinearInterpolation; 
    if (this.Orientation == Orientation.Horizontal)
    {
        if (!this.IsDirectionReversed)
        {
            brush.StartPoint = new Point(0, 0.5);
            brush.EndPoint = new Point(1, 0.5);
        }
        else
        {
            brush.StartPoint = new Point(1, 0.5);
            brush.EndPoint = new Point(0, 0.5);
        }
    }
    else
    {
        if (!this.IsDirectionReversed)
        {
            // default direction for vertical slider
            // is to have Minimum (0) on bottom and Maximum (1) on top.
            // but the background brush is always orientated from top down.
            brush.StartPoint = new Point(0.5, 1);
            brush.EndPoint = new Point(0.5, 0);
        }
        else
        {
            brush.StartPoint = new Point(0.5, 0);
            brush.EndPoint = new Point(0.5, 1);
        }
    }
    // Not important what the colors are at this point.
    brush.GradientStops.Add(new GradientStop(Colors.Black, 0));
    brush.GradientStops.Add(new GradientStop(Colors.Black, 1));
    return brush;
}

ColorSlider 的另一个职责是创建上下文菜单打开时显示的颜色列表,用户可以从中选择一个值,表示希望粘贴到剪贴板的均匀分布在滑块整个范围内的离散颜色数量。这需要能够确定线性渐变画刷主轴上任意点处的颜色。基于这样一个理解:各个颜色分量的值在任何两个相邻的渐变停止点之间是线性分布的(鉴于画刷名称中包含“Linear”,这是有道理的)。

private Color GetColorAtSliderPosition(double sliderPosition)
{
    LinearGradientBrush brush = this.LinearGradientBrush;

    // Normalize position to value between 0 and 1
    double normalized = (sliderPosition - Minimum) / (Maximum - Minimum);

    GradientStop gs0 = null;
    GradientStop gs1 = null; 

    // Find the two gradient stops which bound the normalized position
    for(int i = 1; i < brush.GradientStops.Count; i++)
    {
        if (brush.GradientStops[i].Offset >= normalized)
        {
            gs0 = brush.GradientStops[i - 1];
            gs1 = brush.GradientStops[i];
            break;
        }
    }

    // Now adjust the position so that it is relative
    // to the two gradient stops alone.
    float adjusted = (float)((normalized - gs0.Offset) / 
                             (gs1.Offset - gs0.Offset));

    // The individual color component values are linearly
    // distributed along the main axis, with the minimum and maximum
    // defined by the two bounding gradient stops, and the position
    // between them defined by the variable "adjusted".
    byte A = (byte)((gs1.Color.A - gs0.Color.A) * adjusted + gs0.Color.A);
    byte R = (byte)((gs1.Color.R - gs0.Color.R) * adjusted + gs0.Color.R);
    byte G = (byte)((gs1.Color.G - gs0.Color.G) * adjusted + gs0.Color.G);
    byte B = (byte)((gs1.Color.B - gs0.Color.B) * adjusted + gs0.Color.B);

    return Color.FromArgb(A, R, G, B);
}

继续颜色滑块的类层次结构,派生自 ColorSlider 的有 RgbSliderHsvSliderRgbSlider 是四个基于 RGB 的滑块的基类;它将其 MinimumMaximum 属性分别设置为 0 和 255。HsvSlider 是基于 HSV 的滑块的基类,其范围是 0 - 1。(比较特别的是色相滑块,它需要 0 到 360 的范围。)这些类中的每一个都定义了一个抽象方法 UpdateBackground。在 CurrentColor 更改时,会在所有七个滑块上调用 UpdateBackground。这两个实现的 UpdateBackground 都接受一个参数,该参数包含用作背景颜色范围基础的值。两者的区别在于颜色对象的类型。对于 RGB 版本,这是标准的 WPF Color 类型。HSV 版本的 UpdateBackground 接受一个名为 HsvColor 的类的实例,该类封装了三个 HSV 值(色相、饱和度、明度),并具有在两种颜色模型之间进行转换的方法。(转换方法直接从 Windows SDK 的颜色拾取器示例中复制。)

根据当前选定的颜色更新颜色滑块的背景很简单。由于每个滑块控制(任一)颜色模型中的三个值之一,因此只需保持滑块控制的值不变,同时改变它控制的那个值即可。

RgbSlider 控件中添加一个额外的依赖属性是必要的,即一个字符串,用于保存滑块值的十六进制表示。此属性的原因是为了绑定到文本框,允许用户直接编辑十六进制值。虽然可以使用 Binding 对象自身的 StringFormat 属性来格式化绑定值的显示,但这只是一维转换。将字符串转换为数字的默认方法不允许使用十六进制表示。

我决定继续定义针对每个单独颜色分量值的特定滑块类,尽管实际上每个滑块只有一个实例。派生自 RgbSlider 的有 AlphaSliderRedSliderGreenSliderBlueSlider,派生自 HsvSlider 的有 HueSliderSaturationSliderValueSlider。这些中的每一个都负责绘制自己的背景,除此之外(除了 HueSlider,它需要设置自己的最小值和最大值并创建自己的线性渐变画刷)。

类结构大概就是这样。以下是它们如何协同工作的快速概述:

操作始于重写的 ColorPicker.OnApplyTemplate() 方法。首先,它获取所有七个颜色滑块以及容纳标准颜色列表的 Selector 的句柄。请注意,已采取所有必要的检查,因此不需要任何对象。Selector 是 XAML 中定义的唯一一个需要特定名称才能使代码找到它的对象。由于颜色滑块都是强类型化的,因此它们可以在视觉树中轻松找到。一旦分配了滑块的句柄,就会用当前颜色更新它们的背景,设置它们的值,最后,为每个滑块的 OnValueChanged 事件添加一个单独的事件处理程序。

控件现在已准备好接收输入,输入可以来自三个来源之一:用户拖动滑块,用户在文本框中输入值,或者控件的 CurrentColor 属性由其宿主更新。就 ColorPicker 而言,滑块的值如何改变并不重要——无论是通过滑动还是通过在绑定到滑块值的文本框中输入值——所有重要的只是值发生了改变。ColorPicker 通过分配给滑块的事件处理程序得知该事件,其操作取决于改变的滑块是属于 HSV 组还是 RGB 组——新颜色从该组的滑块值中获得。在这两种情况下,都会更新 CurrentColor,更新背景(及其线性渐变画刷),并更新另一个组的滑块值。

如果 CurrentColor 是通过控件的宿主从外部更新的,过程是相同的,只是需要更新所有滑块的值,并且会给出 CurrentColor

CurrentColor 更改时出现的问题之一是潜在的无限递归。当 CurrentColor 更改时,滑块值会更改,然后在滑块事件处理程序中,CurrentColor 会被更新,这将导致滑块值再次被设置。事实证明,处理此问题的最简单、最确定的方法是,在更新滑块值之前,先将其事件处理程序移除。

XAML

遵循 Visual Studio 在创建自定义控件项目项时设定的模式,ColorPicker 的整个视觉方面都在样式内定义——更具体地说,是在样式的 Template 属性中。

<Style TargetType="{x:Type local:ColorPicker}"> 
     <Setter Property="Padding" Value="10" />           
     <Setter Property="Template">
         <Setter.Value>
             <ControlTemplate TargetType="{x:Type local:ColorPicker}">
            .
            .

添加 ColorSlider 与添加任何其他滑块没有区别。请注意上下文菜单。滑块的上下文菜单允许用户将分布在滑块背景上的均匀颜色的列表复制到剪贴板。每个菜单项代表不同数量的颜色,并显示一个 Color 对象数组。该样式的有趣之处在于,它不仅重新模板化了 ContextMenu,还为显示 Color 数组定义了一个 ItemTemplate,包括使用 Binding 对象本身的 StringFormat 属性格式化的文本。项模板包含一个列表框,该列表框本身被重新模板化,以便其内容水平排列。

<local:SaturationSlider x:Name="saturationSlider" 
                            Orientation="Vertical" 
                            IsDirectionReversed="True" 
                            IsMoveToPointEnabled="True">
    <Slider.ContextMenu>
        <ContextMenu Style="{StaticResource ColorRangeContextMenuStyle}" />
    </Slider.ContextMenu>
</local:SaturationSlider>

<Style x:Key="ColorRangeContextMenuStyle" TargetType="ContextMenu">
    <Setter Property="Template">
        <Setter.Value>
            <ControlTemplate TargetType="{x:Type ContextMenu}">
                <Border BorderThickness="2" BorderBrush="Black"
                        Background="{Binding RelativeSource={RelativeSource 
                                    Mode=FindAncestor, 
                                    AncestorType=local:ColorPicker}, 
                                    Path=Background}">
                    <DockPanel LastChildFill="True">
                        <TextBlock Margin="5" 
                            DockPanel.Dock="Top" 
                            Text="Copy Color Range" />
                        <ItemsPresenter 
                            SnapsToDevicePixels=
                              "{TemplateBinding SnapsToDevicePixels}" />

                    </DockPanel>
                </Border>
            </ControlTemplate>
        </Setter.Value>
    </Setter>
    <Setter Property="ItemTemplate">
        <Setter.Value>
            <DataTemplate>
                <StackPanel Orientation="Horizontal">
                    <ItemsControl ItemsSource="{Binding}">
                        <ItemsControl.Template>
                            <ControlTemplate>
                                <Border>
                                    <StackPanel 
                                      Orientation="Horizontal" 
                                      IsItemsHost="True" />
                                </Border>
                            </ControlTemplate>
                        </ItemsControl.Template>
                        <ItemsControl.ItemTemplate>
                            <DataTemplate>
                                <Border Width="10" Height="10">
                                    <Border.Background>
                                        <SolidColorBrush Color="{Binding}" />
                                    </Border.Background>
                                </Border>
                            </DataTemplate>
                        </ItemsControl.ItemTemplate>
                    </ItemsControl>
                    <TextBlock Margin="3,0,0,0" 
                      Text="{Binding Count, StringFormat={}({0})}" />
                </StackPanel>
            </DataTemplate>
        </Setter.Value>
    </Setter>
</Style>

Slider 的视觉外观也在样式中定义。所有 RGB 滑块共享相同的样式,而饱和度和明度(Value)的 HSV 滑块共享一个样式。HueSlider 单独定义。滑块(thumb)很有趣。您可能在图片中注意到,箭头指针一直从最小值滑到最大值,箭身的其余部分超出控件本身边界。这可以通过简单地设置 Margin 属性为负值来实现。

<Thumb Margin="0,-7,-3,-7" 
          Foreground="Black" 
          HorizontalAlignment="Right" 
          ToolTip="{TemplateBinding Value}">

最后,在 XAML 中使用 ColorPicker 本身就像这样简单:

<Grid>
    <picker:ColorPicker CurrentColor="Blue"/>
</Grid>

就是这样!希望您喜欢这篇文章,并且喜欢这个颜色拾取器。这是一个有趣的课题,它让我对 WPF 以及两种颜色模型中不同颜色变量之间的关系有了很多了解。

附录

在写这篇文章后,我发现 Paint.Net(一个很好的程序)中的颜色拾取器有所有七个颜色值的滑块,它们的背景是用渐变绘制的,并且会随着当前颜色的变化而更新等等。此外,它还包括一个颜色轮。但是,一切都太小了,你几乎看不到它的任何部分!

历史

  • 2009 年 11 月 3 日:首次发布
  • 2009 年 11 月 14 日:更新源代码(bug 修复)
  • 2009 年 11 月 29 日:更新演示
© . All rights reserved.