Выбор цветов HSL в WinRT
145Разработка под Windows 8/10 --- Выбор цветов HSL
Ранее приводилось уже немало средств выбора цвета с ползунками, предназначенными для выбора красной, зеленой и синей составляющей. Это простая и удобная схема выбора цвета, потому что именно так цвет определяется для мониторов и в Windows Runtime.
Однако этот способ нельзя назвать естественным. Похоже, люди предпочитают схемы, использующие такие параметры, как оттенок, насыщенность и освещенность. Оттенок (hue) фактически соответствует цвету радуги, которыми, как считал Исаак Ньютон, были красный, оранжевый, желтый, зеленый, голубой, синий и фиолетовый. В «компьютерных» цветовых схемах диапазон оттенка проходит от красного к желтому, зеленому, светло-голубому, голубому, малиновому и снова возвращается к красному.
Оттенок объединяется со значением насыщенности (saturation). При максимальной насыщенности цвет получается наиболее ярким и живым, а при минимальной он становится серым. Далее учитывается показатель освещенности (lightness). Увеличение освещенности приводит к размывке цвета и его постепенному переходу в белый цвет при максимальном значении. С уменьшением освещенности от среднего значения цвет темнеет.
В частности, схема выбора цвета HSL (Hue-Saturation-Lightness) применяется в Windows Paint и Microsoft Word: на двумерной панели (аналог XYSlider) задается комбинация оттенка и насыщенности, а для задания освещенности используется обычный ползунок.
Чтобы имитировать эту схему выбора цвета, я создал структуру HSL для представления цветовых значений в формате HSL. Структура реализует функции преобразования между RGB и HSL:
using System;
using Windows.UI;
namespace WinRTTestApp
{
public struct HSL
{
public HSL(byte hue, byte saturation, byte lightness) : this(hue * 360 / 255.0, saturation / 255.0, lightness / 255.0)
{ }
// Оттенок от 0 до 360, насыщенность и освещенность от 0 до 1
public HSL(double hue, double saturation, double lightness) : this()
{
this.Hue = hue;
this.Saturation = saturation;
this.Lightness = lightness;
double chroma = saturation * (1 - Math.Abs(2 * lightness - 1));
double h = hue / 60;
double x = chroma * (1 - Math.Abs(h % 2 - 1));
double r = 0, g = 0, b = 0;
if (h < 1)
{
r = chroma;
g = x;
}
else if (h < 2)
{
r = x;
g = chroma;
}
else if (h < 3)
{
g = chroma;
b = x;
}
else if (h < 4)
{
g = x;
b = chroma;
}
else if (h < 5)
{
r = x;
b = chroma;
}
else
{
r = chroma;
b = x;
}
double m = lightness - chroma / 2;
this.Color = Color.FromArgb(255, (byte)(255 * (r + m)),
(byte)(255 * (g + m)),
(byte)(255 * (b + m)));
}
public HSL(Color color) : this()
{
this.Color = color;
double r = color.R / 255.0;
double g = color.G / 255.0;
double b = color.B / 255.0;
double max = Math.Max(r, Math.Max(g, b));
double min = Math.Min(r, Math.Min(g, b));
double chroma = max - min;
this.Lightness = (max + min) / 2;
if (chroma != 0)
{
if (r == max)
this.Hue = 60 * (g - b) / chroma;
else if (g == max)
this.Hue = 120 + 60 * (b - r) / chroma;
else
this.Hue = 240 + 60 * (r - g) / chroma;
this.Hue = (this.Hue + 360) % 360;
if (this.Lightness < 0.5)
this.Saturation = chroma / (2 * this.Lightness);
else
this.Saturation = chroma / (2 - 2 * this.Lightness);
}
}
public double Hue { private set; get; }
public double Lightness { private set; get; }
public double Saturation { private set; get; }
public Color Color { private set; get; }
}
}
Обратите внимание на два разных конструктора: с аргументами byte и double. Для конкретного вызова конструктора HSL компилятор C# должен выбрать используемую версию. Первый вариант выбирается только в том случае, если все аргументы являются значениями byte. У третьего конструктора, преобразующего значение Color в HSL, такой неоднозначности не существует.
Представляя элемент управления XYSlider, я указал, что он был бы более полезным, если бы вместо событий Manipulation в нем использовались события Pointer. Здесь приводится обновленная версия. Из-за необходимости работы с событиями Pointer ей приходится отслеживать несколько пальцев, но она просто усредняет их для создания сводной позиции. В остальном элемент управления почти не изменился:
using System;
using System.Collections.Generic;
using Windows.Foundation;
using Windows.UI.Xaml;
using Windows.UI.Xaml.Controls;
using Windows.UI.Xaml.Input;
namespace WinRTTestApp
{
public class XYSlider : ContentControl
{
Dictionary<uint, Point> pointerDic = new Dictionary<uint, Point>();
FrameworkElement crossHairPart;
ContentPresenter contentPresenter;
static readonly DependencyProperty valueProperty =
DependencyProperty.Register("Value",
typeof(Point), typeof(XYSlider),
new PropertyMetadata(new Point(), OnValueChanged));
public event EventHandler<Point> ValueChanged;
public XYSlider()
{
this.DefaultStyleKey = typeof(XYSlider);
}
public static DependencyProperty ValueProperty
{
get { return valueProperty; }
}
public Point Value
{
set { SetValue(ValueProperty, value); }
get { return (Point)GetValue(ValueProperty); }
}
protected override void OnApplyTemplate()
{
// Отмена обработчиков событий
if (contentPresenter != null)
{
contentPresenter.PointerPressed -= OnContentPresenterPointerPressed;
contentPresenter.PointerMoved -= OnContentPresenterPointerMoved;
contentPresenter.PointerReleased -= OnContentPresenterPointerReleased;
contentPresenter.PointerCaptureLost -= OnContentPresenterPointerReleased;
contentPresenter.SizeChanged -= OnContentPresenterSizeChanged;
}
// Получение новых частей
crossHairPart = GetTemplateChild("CrossHairPart") as FrameworkElement;
contentPresenter = GetTemplateChild("ContentPresenterPart") as ContentPresenter;
// Назначение обработчиков событий
if (contentPresenter != null)
{
contentPresenter.PointerPressed += OnContentPresenterPointerPressed;
contentPresenter.PointerMoved += OnContentPresenterPointerMoved;
contentPresenter.PointerReleased += OnContentPresenterPointerReleased;
contentPresenter.PointerCaptureLost += OnContentPresenterPointerReleased;
contentPresenter.SizeChanged += OnContentPresenterSizeChanged;
}
// Перекрестие должно быть прозрачным для касаний
if (crossHairPart != null)
{
crossHairPart.IsHitTestVisible = false;
}
base.OnApplyTemplate();
}
private void OnContentPresenterPointerPressed(object sender, PointerRoutedEventArgs e)
{
uint id = e.Pointer.PointerId;
Point point = e.GetCurrentPoint(contentPresenter).Position;
pointerDic.Add(id, point);
contentPresenter.CapturePointer(e.Pointer);
RecalculateValue();
e.Handled = true;
}
private void OnContentPresenterPointerMoved(object sender, PointerRoutedEventArgs e)
{
uint id = e.Pointer.PointerId;
Point point = e.GetCurrentPoint(contentPresenter).Position;
if (pointerDic.ContainsKey(id))
{
pointerDic[id] = point;
RecalculateValue();
e.Handled = true;
}
}
private void OnContentPresenterPointerReleased(object sender, PointerRoutedEventArgs e)
{
uint id = e.Pointer.PointerId;
if (pointerDic.ContainsKey(id))
{
pointerDic.Remove(id);
RecalculateValue();
e.Handled = true;
}
}
private void OnContentPresenterSizeChanged(object sender, SizeChangedEventArgs e)
{
SetCrossHair();
}
private void RecalculateValue()
{
if (pointerDic.Values.Count > 0)
{
Point accumPoint = new Point();
// Усреднение всех текущих точек касания
foreach (Point point in pointerDic.Values)
{
accumPoint.X += point.X;
accumPoint.Y += point.Y;
}
accumPoint.X /= pointerDic.Values.Count;
accumPoint.Y /= pointerDic.Values.Count;
RecalculateValue(accumPoint);
}
}
private void RecalculateValue(Point absolutePoint)
{
double x = Math.Max(0, Math.Min(1, absolutePoint.X / contentPresenter.ActualWidth));
double y = Math.Max(0, Math.Min(1, absolutePoint.Y / contentPresenter.ActualHeight));
this.Value = new Point(x, y);
}
private void SetCrossHair()
{
if (contentPresenter != null && crossHairPart != null)
{
Canvas.SetLeft(crossHairPart, this.Value.X * contentPresenter.ActualWidth);
Canvas.SetTop(crossHairPart, this.Value.Y * contentPresenter.ActualHeight);
}
}
static void OnValueChanged(DependencyObject obj, DependencyPropertyChangedEventArgs e)
{
((XYSlider)obj).SetCrossHair();
((XYSlider)obj).OnValueChanged((Point)e.NewValue);
}
protected void OnValueChanged(Point value)
{
if (ValueChanged != null)
ValueChanged(this, value);
}
}
}
Следующий шаг — построение элемента управления HslColorSelector. Этот класс, производный от UserControl, создает экземпляры XYSlider, Slider и TextBlock в файле XAML. Секция Resources определяет объекты ControlTemplate для XYSlider и Slider. Шаблон XYSlider существенно упрощен по сравнению с соответствующим элементом управления, созданным ранее, потому что я точно знал, какое визуальное оформление мне нужно, и не добавлял ничего лишнего:
<UserControl ...>
<UserControl.Resources>
<ControlTemplate x:Key="xySliderTemplate" TargetType="local:XYSlider">
<Border>
<Grid>
<ContentPresenter Name="ContentPresenterPart"
Content="{TemplateBinding Content}" />
<Canvas>
<Path Name="CrossHairPart"
Fill="{TemplateBinding Foreground}"
Data="M 0 6 L -3 24 3 24 Z
M 0 -6 L -3 -24 3 -24 Z
M 6 0 L 24 -3 24 3 Z
M -6 0 L -24 -2 -24 3 Z" />
</Canvas>
</Grid>
</Border>
</ControlTemplate>
<ControlTemplate x:Key="sliderTemplate" TargetType="Slider">
<Grid>
<Grid Name="HorizontalTemplate"
Background="Transparent"
Height="48">
<Grid.ColumnDefinitions>
<ColumnDefinition />
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<Rectangle Name="HorizontalTrackRect"
Grid.Column="0"
Grid.ColumnSpan="3"
Fill="{TemplateBinding Background}"
Height="12"
VerticalAlignment="Top" />
<Thumb Name="HorizontalThumb"
Grid.Column="1"
DataContext="{TemplateBinding Value}">
<Thumb.Template>
<ControlTemplate TargetType="Thumb">
<Path Fill="{TemplateBinding Foreground}"
Data="M 0 24 L -3 48 3 48 Z" />
</ControlTemplate>
</Thumb.Template>
</Thumb>
<Rectangle Name="HorizontalDecreaseRect"
Grid.Column="2"
Fill="Transparent" />
</Grid>
<Grid Name="VerticalTemplate"
Background="Transparent"
Width="48">
<Grid.RowDefinitions>
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Rectangle Name="VerticalTrackRect"
Grid.Row="0"
Grid.RowSpan="3"
Fill="{TemplateBinding Background}"
Width="12"
HorizontalAlignment="Left" />
<Thumb Name="VerticalThumb"
Grid.Row="1"
DataContext="{TemplateBinding Value}">
<Thumb.Template>
<ControlTemplate TargetType="Thumb">
<Path Fill="{TemplateBinding Foreground}"
Data="M 24 0 L 48 -3 48 3 Z" />
</ControlTemplate>
</Thumb.Template>
</Thumb>
<Rectangle Name="VerticalDecreaseRect"
Grid.Row="2"
Fill="Transparent" />
</Grid>
</Grid>
</ControlTemplate>
</UserControl.Resources>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<local:XYSlider x:Name="xySlider"
Grid.Row="0"
Template="{StaticResource xySliderTemplate}"
ValueChanged="OnXYSliderValueChanged">
<Image Name="hsImage"
Stretch="None" />
</local:XYSlider>
<Slider Name="slider"
Grid.Row="1"
Orientation="Horizontal"
Template="{StaticResource sliderTemplate}"
Width="256"
Margin="0 12"
ValueChanged="OnSliderValueChanged">
<Slider.Background>
<LinearGradientBrush StartPoint="0 0" EndPoint="1 0">
<GradientStop Offset="0" Color="Black" />
<GradientStop x:Name="sliderGradientStop" Offset="0.5" />
<GradientStop Offset="1" Color="White" />
</LinearGradientBrush>
</Slider.Background>
</Slider>
<TextBlock Name="txtblk"
Grid.Row="2"
HorizontalAlignment="Center"
TextAlignment="Center"
FontSize="24" />
</Grid>
</UserControl>
Следует отметить, что шаблон ControlTemplate для Slider окрашивает элемент, используя его свойство Background. Свойство Background самого элемента управления Slider определяется в конце файла XAML. Его значение представляет собой градиентную кисть LinearGradientBrush с переходом от черного к белому Центральный цвет задается в файле фонового кода и определяется комбинацией оттенка и насыщенности, выбранной пользователем в элементе управления XYSlider.
Файл фонового кода определяет свойство зависимости с именем Color типа Color. Конечно, для открытого свойства, которое должно использоваться в привязках, Color выглядит намного логичнее, чем открытое свойство типа HSL. Обработчик Loaded отвечает за создание растрового изображения для основной панели выбора оттенка/насыщенности. Он использует структуру HSL для преобразования значений HSL (со средним значением освещенности) в данные RGB для пикселов изображения:
using System;
using System.IO;
using System.Runtime.InteropServices.WindowsRuntime;
using Windows.Foundation;
using Windows.UI;
using Windows.UI.Xaml;
using Windows.UI.Xaml.Controls;
using Windows.UI.Xaml.Controls.Primitives;
using Windows.UI.Xaml.Media.Imaging;
namespace WinRTTestApp
{
public sealed partial class HslColorSelector : UserControl
{
bool doNotSetSliders = false;
static readonly DependencyProperty colorProperty =
DependencyProperty.Register("Color",
typeof(Color),
typeof(HslColorSelector),
new PropertyMetadata(new Color(), OnColorChanged));
public event EventHandler<Color> ColorChanged;
public HslColorSelector()
{
this.InitializeComponent();
Loaded += OnLoaded;
}
private async void OnLoaded(object sender, RoutedEventArgs e)
{
// Построение растрового изображения для панели оттенка/насыщенности
WriteableBitmap bitmap = new WriteableBitmap(256, 256);
byte[] pixels = new byte[4 * 256 * 256];
int index = 0;
for (int y = 0; y < 256; y++)
for (int x = 0; x < 256; x++)
{
HSL hsl = new HSL((byte)x, (byte)(255 - y), (byte)128);
Color clr = hsl.Color;
pixels[index++] = clr.B;
pixels[index++] = clr.G;
pixels[index++] = clr.R;
pixels[index++] = clr.A;
}
using (Stream pixelStream = bitmap.PixelBuffer.AsStream())
{
await pixelStream.WriteAsync(pixels, 0, pixels.Length);
}
bitmap.Invalidate();
hsImage.Source = bitmap;
}
public static DependencyProperty ColorProperty
{
get { return colorProperty; }
}
public Color Color
{
set { SetValue(ColorProperty, value); }
get { return (Color)GetValue(ColorProperty); }
}
// Обработчики событий для Slider
private void OnXYSliderValueChanged(object sender, Point point)
{
HSL hsl = new HSL(360 * point.X, 1 - point.Y, 0.5);
sliderGradientStop.Color = hsl.Color;
SetColorFromSliders();
}
private void OnSliderValueChanged(object sender, RangeBaseValueChangedEventArgs e)
{
SetColorFromSliders();
}
private void SetColorFromSliders()
{
Point point = xySlider.Value;
double value = slider.Value;
HSL hsl = new HSL(360 * point.X, 1 - point.Y, value / 100);
doNotSetSliders = true;
this.Color = hsl.Color;
doNotSetSliders = false;
}
// Обработчики изменений свойств
static void OnColorChanged(DependencyObject obj, DependencyPropertyChangedEventArgs e)
{
(obj as HslColorSelector).OnColorChanged((Color)e.NewValue);
}
protected void OnColorChanged(Color color)
{
HSL hsl = new HSL(color);
if (!doNotSetSliders)
{
xySlider.Value = new Point(hsl.Hue / 360, 1 - hsl.Saturation);
slider.Value = 100 * hsl.Lightness;
}
txtblk.Text = String.Format("RGB = ({0}, {1}, {2})",
this.Color.R, this.Color.G, this.Color.B);
if (ColorChanged != null)
ColorChanged(this, color);
}
}
}
Когда новое свойство Color задается извне, обработчик OnColorChanged реагирует заданием значений XYSlider и Slider, а также выводом цветового значения RGB в элементе TextBlock. Когда пользователь изменяет значения XYSlider и Slider, задается новое значение свойства Color и вызывается метод OnColorChanged. Обычно рекурсивные вызовы обработчиков изменения свойств не создают проблем, но только не в данном случае, потому что циклическое преобразование — RGB в HSL и снова в RGB — не приводит в точности к исходному значению. Именно для этой цели и необходимо логическое поле doNotSetSliders при изменении свойства Color из пользовательского ввода.
Наконец, экземпляр HslColorSelector включается в ColorSettingDialog:
<UserControl ...>
<Grid>
<Border BorderBrush="Black"
Background="White"
BorderThickness="3"
Padding="32">
<StackPanel>
<Path Data="M 0 50 C 80 0 160 100 256 0"
StrokeEndLineCap="Round"
StrokeStartLineCap="Round"
StrokeThickness="{Binding Thickness}"
Margin="0 12">
<Path.Stroke>
<SolidColorBrush Color="{Binding Color}" />
</Path.Stroke>
</Path>
<local:HslColorSelector x:Name="hslColorSelector"
Foreground="Black"
Color="{Binding Path=Color, Mode=TwoWay}" />
</StackPanel>
</Border>
</Grid>
</UserControl>
Не забудьте раскомментировать вызов диалогового окна с настройками цвета в файле MainPage.xaml.cs проекта FingerPaint:
// ...
private void colorbtn_Click(object sender, Windows.UI.Xaml.RoutedEventArgs e)
{
DisplayDialog(sender, new ColorSettingDialog());
}
// ...
Чтобы пользователь мог представить результат, элемент управления рисует образец линии с текущей толщиной и цветом:
Работая с программой FingerPaint, вы, вероятно, заметите, что рисунок получается менее гладким, чем в предыдущих программах FingerPaint (это будет незаметно разве что на экранах с очень высоким разрешением). Для этого есть очень веская причина: при выводе графических объектов с применением Line, Polyline и Path выполняется сглаживание (antialiasing). Граница содержит цвета заливки и фона, и субъективно линии кажутся более плавными. Однако в библиотеке VectorDrawing сглаживание не применяется — пиксел либо окрашивается, либо нет.