часть 1: стили, кнопки и переключатели
В этой статье я продолжу разбирать нюансы разработки WPF-контролов. В прошлой части мы рассмотрели, как сделать свой стиль для кнопки и переключателя. Сейчас разберем ComboBox и DateTimePicker.
ComboBox
Функционала стандартного ComboBox хватает практически всегда. Единственное, что можно добавить, это возможность фильтрации элементов списка при вводе значения с клавиатуры. Попробуем это исправить.
Для начала напишем стиль отображения обычного ComboBox в соответствии с дизайном. Он будет состоять из 3-х частей:
стиль кнопки отображения списка элементов.
стиль элемента списка
собственно стиль ComboBox
Кнопка отображения элементов – это ToggleButton. В целом стиль для нее не сложный. Кнопка отображается в виде стрелки, при смене состояния она поворачивается на 180 градусов. Поворот осуществляется с помощью VisualStateManager, DoubleAnimation и RotateTransform как в стиле для ToggleButton из предыдущей статьи. Получился такой шаблон:
Шаблон ToggleButton для ComboBox
<ControlTemplate x:Key="ComboBoxToggleButton" TargetType="{x:Type ToggleButton}">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition/>
<ColumnDefinition Width="20"/>
</Grid.ColumnDefinitions>
<VisualStateManager.VisualStateGroups>
<VisualStateGroup x:Name="CheckStates">
<VisualState x:Name="Checked">
<Storyboard>
<DoubleAnimation Duration="0:0:0.1"
To="180"
Storyboard.TargetName="ArrowButton"
Storyboard.TargetProperty="(LayoutTransform).(RotateTransform.Angle)"/>
</Storyboard>
</VisualState>
<VisualState x:Name="Unchecked">
<Storyboard>
<DoubleAnimation Duration="0:0:0.1"
To="0"
Storyboard.TargetName="ArrowButton"
Storyboard.TargetProperty="(LayoutTransform).(RotateTransform.Angle)"/>
</Storyboard>
</VisualState>
</VisualStateGroup>
</VisualStateManager.VisualStateGroups>
<Border Name="Border"
Grid.Column="0"
Grid.ColumnSpan="2"
BorderThickness="0"
Background="Transparent"/>
<Path Name="ArrowButton"
Grid.Column="1"
Data="{StaticResource ArrowGeometry}"
HorizontalAlignment="Center"
VerticalAlignment="Center"
Fill="{TemplateBinding Foreground}"
Visibility="Visible">
<Path.LayoutTransform>
<RotateTransform Angle="0"/>
</Path.LayoutTransform>
</Path>
</Grid>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="ArrowButton" Property="Fill" Value="{DynamicResource ForegroundHover}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>Стиль элемента списка будет содержать текст и галочку, отмечающую, что данный элемент выбран. В этом поможет VisualStateManager и состояния Selected и Unselected.
Шаблон элемента списка
<ControlTemplate TargetType="{x:Type ComboBoxItem}">
<Grid Background="Transparent">
<VisualStateManager.VisualStateGroups>
<VisualStateGroup x:Name="SelectionStates">
<VisualState x:Name="Unselected"/>
<VisualState x:Name="Selected">
<Storyboard>
<ObjectAnimationUsingKeyFrames Storyboard.TargetName="SelectedIcon"
Storyboard.TargetProperty="(UIElement.Visibility)">
<DiscreteObjectKeyFrame KeyTime="0" Value="{x:Static Visibility.Visible}"/>
</ObjectAnimationUsingKeyFrames>
</Storyboard>
</VisualState>
</VisualStateGroup>
</VisualStateManager.VisualStateGroups>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<Border Grid.Column="0" x:Name="Border"
Cursor="Hand"
Padding="10"
SnapsToDevicePixels="true"
Background="Transparent">
<ContentPresenter/>
</Border>
<Path Grid.Column="1" Name="SelectedIcon"
Margin="0 -3 10 0"
Width="16"
Height="16"
Stretch="Fill"
Data="{StaticResource SelectedMarkGeometry}"
Fill="{DynamicResource ForegroundPrimary}"
HorizontalAlignment="Right"
VerticalAlignment="Center"
Visibility="Hidden"/>
</Grid>
</ControlTemplate>
Теперь можно собрать шаблон комбобокса. Он будет состоять из кнопки, области отображения выбранного элемента, текстбокса для редактирования значения и popup со списком элементов.
Шаблон ComboBox
<ControlTemplate TargetType="{x:Type ComboBox}">
<Border Background="{TemplateBinding Background}"
CornerRadius="5">
<Grid VerticalAlignment="Center"
HorizontalAlignment="Stretch">
<ToggleButton x:Name="ToggleButton"
Template="{StaticResource ComboBoxToggleButton}"
Margin="0 3 5 0"
Focusable="false"
Foreground="{TemplateBinding Foreground}"
ClickMode="Press"
IsChecked="{Binding IsDropDownOpen, Mode=TwoWay, RelativeSource={RelativeSource TemplatedParent}}"/>
<ContentPresenter x:Name="ContentSite"
IsHitTestVisible="False"
Content="{TemplateBinding SelectionBoxItem}"
ContentTemplate="{TemplateBinding SelectionBoxItemTemplate}"
ContentTemplateSelector="{TemplateBinding ItemTemplateSelector}"
Margin="10,4,30,3"
VerticalAlignment="Stretch"
HorizontalAlignment="Left">
</ContentPresenter>
<TextBox x:Name="PART_EditableTextBox"
Style="{x:Null}"
BorderThickness="0"
Foreground="{TemplateBinding Foreground}"
HorizontalAlignment="Left"
VerticalAlignment="Bottom"
Focusable="True"
Background="{TemplateBinding Background}"
Margin="8,4,30,3"
Visibility="Hidden"
IsReadOnly="{TemplateBinding IsReadOnly}"/>
<Popup x:Name="PART_Popup"
Placement="Bottom"
IsOpen="{TemplateBinding IsDropDownOpen}"
AllowsTransparency="True"
Focusable="False"
MinWidth="200"
MinHeight="40"
PopupAnimation="Fade">
<Grid x:Name="DropDown"
SnapsToDevicePixels="True"
MinWidth="{TemplateBinding ActualWidth}"
MaxHeight="{TemplateBinding MaxDropDownHeight}">
<Border x:Name="DropDownBorder" BorderThickness="1"
BorderBrush="{DynamicResource BorderPrimary}"
Background="{TemplateBinding Background}"/>
<ScrollViewer Margin="4,6,4,6" SnapsToDevicePixels="True">
<StackPanel IsItemsHost="True" KeyboardNavigation.DirectionalNavigation="Contained"/>
</ScrollViewer>
</Grid>
</Popup>
</Grid>
</Border>
<ControlTemplate.Triggers>
<Trigger Property="IsMouseOver" Value="True">
<Setter TargetName="PART_EditableTextBox" Property="Foreground" Value="{DynamicResource ForegroundHover}"/>
<Setter Property="Foreground" Value="{DynamicResource ForegroundHover}"/>
</Trigger>
<Trigger Property="IsDropDownOpen" Value="True">
<Setter TargetName="PART_EditableTextBox" Property="Foreground" Value="{DynamicResource ForegroundPrimary}"/>
<Setter Property="Foreground" Value="{DynamicResource ForegroundPrimary}"/>
</Trigger>
<Trigger Property="IsFocused" Value="True">
<Setter TargetName="PART_EditableTextBox" Property="Foreground" Value="{DynamicResource ForegroundPrimary}"/>
</Trigger>
<Trigger Property="IsEnabled" Value="False">
<Setter Property="Opacity" Value="0.56"/>
</Trigger>
<Trigger Property="HasItems" Value="false">
<Setter TargetName="DropDownBorder" Property="MinHeight" Value="95"/>
</Trigger>
<Trigger Property="IsGrouping" Value="true">
<Setter Property="ScrollViewer.CanContentScroll" Value="false"/>
</Trigger>
<Trigger SourceName="PART_Popup" Property="AllowsTransparency" Value="true">
<Setter TargetName="DropDownBorder" Property="CornerRadius" Value="8"/>
<Setter TargetName="DropDownBorder" Property="Margin" Value="0,2,0,0"/>
</Trigger>
<Trigger Property="Validation.HasError" Value="True">
<Setter Property="Foreground" Value="{DynamicResource Error}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
Шаблон ComboBox написан, осталось добавить возможность фильтровать содержимое списка. Там потребуется TextBox для ввода. К счастью, он уже есть в шаблоне, это PART_EditableTextBox. Пишем наследника от ComboBox и переопределяем метод OnApplyTemplate. В нем мы можем получить по имени контролы из шаблона с помощью метода GetTemplateChild и подписаться на необходимые события.
public override void OnApplyTemplate()
{
base.OnApplyTemplate();
_filterTextBox = (TextBox)GetTemplateChild(EDITABLE_TEXT_BOX_PART_NAME);
_contentSite = (ContentPresenter)GetTemplateChild(CONTENT_SITE_NAME);
_filterTextBox.TextChanged += FilterTextBoxKeyUpEventHandler;
DropDownOpened += DropDownOpenedEventHandler;
DropDownClosed += DropDownClosedEventHandler;
// Отключаем режим редактирования, если его по ошибке включат
IsEditable = false;
}
При вводе текста мы проходим по списку элементов и скрываем лишние.
Логика контрола
private void FilterTextBoxKeyUpEventHandler(object sender, TextChangedEventArgs e)
{
string searchString = ((TextBox)e.Source).Text.Trim();
ApplyFilter(searchString);
}
private void ApplyFilter(string searchString = "")
{
if (string.IsNullOrWhiteSpace(searchString))
{
foreach (object item in Items)
{
ComboBoxItem comboBoxItem = GetComboBoxItem(item);
if (comboBoxItem == null)
{
continue;
}
comboBoxItem.Visibility = Visibility.Visible;
}
}
else
{
searchString = searchString.ToUpper();
foreach (object item in Items)
{
ComboBoxItem comboBoxItem = GetComboBoxItem(item);
if (comboBoxItem == null)
{
continue;
}
string displayValue = GetDisplayValue(comboBoxItem);
if (displayValue.ToUpper().Contains(searchString))
{
comboBoxItem.Visibility = Visibility.Visible;
}
else
{
comboBoxItem.Visibility = Visibility.Collapsed;
}
}
}
}Осталось определить стиль контрола, так как стиль для ComboBox по умолчанию для него не применится. Но это можно поправить одной строкой:
<Style TargetType="{x:Type customWpfControls:FilteredComboBox}"
BasedOn="{StaticResource {x:Type ComboBox}}"/>
Полностью код контрола и его стиль можно посмотреть тут: стиль, контрол.
TimePicker
Теперь переходим к TimePicker. Тут уже не получится ограничиться написанием шаблона или наследованием от существующего контрола. Придется разрабатывать свой контрол. Для начала определимся, из каких частей будет состоять контрол. У меня получилось так:
TextBox для отображения часов;
TextBox для отображения минут;
Button для увеличения времени;
Button для уменьшения времени.
Опишем эти части в атрибутах.
[TemplatePart(Name = HOURS_TEXT_BOX_PART_NAME, Type = typeof(TextBox))]
[TemplatePart(Name = MINUTES_TEXT_BOX_PART_NAME, Type = typeof(TextBox))]
[TemplatePart(Name = UP_BUTTON_PART_NAME, Type = typeof(Button))]
[TemplatePart(Name = DOWN_BUTTON_PART_NAME, Type = typeof(Button))]
public class TimePicker : Control
{
public const string HOURS_TEXT_BOX_PART_NAME = "PART_HoursTextBox";
public const string MINUTES_TEXT_BOX_PART_NAME = "PART_MinutesTextBox";
public const string UP_BUTTON_PART_NAME = "PART_UpButton";
public const string DOWN_BUTTON_PART_NAME = "PART_DownButton";
Благодаря этому мы отделяем логику контрола от его разметки. При этом мы уточнили, что элементы UI с заданными именами и типами обязательно должны присутствовать в шаблоне.
Переопределив метод OnApplyTemplate, мы получаем экземпляры необходимых частей контрола, выполняем байндинг и подписываемся на события.
Переопределенный OnApplyTemplate
public override void OnApplyTemplate()
{
base.OnApplyTemplate();
if (GetTemplateChild(HOURS_TEXT_BOX_PART_NAME) is TextBox hoursTextBox)
{
hoursTextBox.PreviewKeyUp += HoursTextBoxKeyUpEventHandler;
hoursTextBox.LostFocus += TextBoxLostFocuseventHandler;
hoursTextBox.SelectionChanged += TextBoxSelectionChangedEventHandler;
Binding hoursBinding = new Binding
{
Path = new PropertyPath(nameof(Time)),
Mode = BindingMode.TwoWay,
UpdateSourceTrigger = UpdateSourceTrigger.PropertyChanged,
Converter = new TimespanToHoursStringConverter(),
RelativeSource = new RelativeSource()
{
Mode = RelativeSourceMode.FindAncestor,
AncestorType = typeof(TimePicker)
}
};
hoursTextBox.SetBinding(TextBox.TextProperty, hoursBinding);
}
if (GetTemplateChild(MINUTES_TEXT_BOX_PART_NAME) is TextBox minutesTextBox)
{
minutesTextBox.PreviewKeyUp += MinutesTextBoxKeyUpEventHandler;
minutesTextBox.LostFocus += TextBoxLostFocuseventHandler;
minutesTextBox.SelectionChanged += TextBoxSelectionChangedEventHandler;
Binding minutesBinding = new Binding
{
Path = new PropertyPath(nameof(Time)),
Mode = BindingMode.TwoWay,
UpdateSourceTrigger = UpdateSourceTrigger.PropertyChanged,
Converter = new TimespanToMinutesStringConverter(),
RelativeSource = new RelativeSource()
{
Mode = RelativeSourceMode.FindAncestor,
AncestorType = typeof(TimePicker)
}
};
minutesTextBox.SetBinding(TextBox.TextProperty, minutesBinding);
}
if (GetTemplateChild(UP_BUTTON_PART_NAME) is Button upButton)
{
upButton.Click += UpButtonClickEventHandler;
}
if (GetTemplateChild(DOWN_BUTTON_PART_NAME) is Button downButton)
{
downButton.Click += DownButtonClickEventHandler;
}
}
После этого несложно описать логику контрола. Посмотреть ее можно здесь.
При этом внешний вид контрола вынесен в стили и минимально зависит от реализации логики контрола. Единственно условие – наличие TextBox и Button с именами, описанными в коде. Посмотреть стиль можно здесь.
DateTimePicker
Следующий в очереди - DateTimePicker. Мне повезло, что в стандартной библиотеке контролов есть календарь. Не придется его разрабатывать, потребуется только написать свой стиль. Пример стиля можно посмотреть в справке на сайте Microsoft.
Так же, как и в TimePicker, описываем необходимые части контрола. Тут их будет больше:
TextBox для даты;
Popup с контролами редактирования;
Button для отображения Popup;
Calendar для ввода даты;
TimePicker для ввода времени;
Button для сохранения даты и времени;
Button для закрытия Popup без сохранения изменений.
[TemplatePart(Name = DATE_TIME_TEXT_BOX_PART_NAME, Type = typeof(TextBox))]
[TemplatePart(Name = SELECT_BUTTON_PART_NAME, Type = typeof(Button))]
[TemplatePart(Name = SELECTOR_POPUP_PART_NAME, Type = typeof(Popup))]
[TemplatePart(Name = CALENDAR_PART_NAME, Type = typeof(Calendar))]
[TemplatePart(Name = TIME_PICKER_PART_NAME, Type = typeof(TimePicker))]
[TemplatePart(Name = SAVE_BUTTON_PART_NAME, Type = typeof(Button))]
[TemplatePart(Name = CANCEL_BUTTON_PART_NAME, Type = typeof(Button))]
public class DateTimePicker : Control, IDataErrorInfo
{
public const string DATE_TIME_TEXT_BOX_PART_NAME = "PART_DateTimeTextBox";
public const string SELECT_BUTTON_PART_NAME = "PART_SelectButton";
public const string SELECTOR_POPUP_PART_NAME = "PART_SelectorPopup";
public const string CALENDAR_PART_NAME = "PART_Calendar";
public const string TIME_PICKER_PART_NAME = "PART_TimePicker";
public const string SAVE_BUTTON_PART_NAME = "PART_SaveButton";
public const string CANCEL_BUTTON_PART_NAME = "PART_CancelButton";
Так же, как и в случаи с TimePicker, переопределяем метод OnApplyTemplate, получаем необходимые контролы, подписываемся на события и выполняем байндинг.
Переопределенный OnApplyTemplate
public override void OnApplyTemplate()
{
base.OnApplyTemplate();
if (GetTemplateChild(DATE_TIME_TEXT_BOX_PART_NAME) is TextBox dateTimeTextBox)
{
MultiBinding dateTimeBinding = new MultiBinding
{
Bindings =
{
new Binding
{
Path = new PropertyPath(nameof(DateTime)),
RelativeSource = new RelativeSource()
{
Mode = RelativeSourceMode.FindAncestor,
AncestorType = typeof(DateTimePicker)
}
},
new Binding
{
Path = new PropertyPath(nameof(DateTimeFormatString)),
RelativeSource = new RelativeSource()
{
Mode = RelativeSourceMode.FindAncestor,
AncestorType = typeof(DateTimePicker)
}
}
},
Mode = BindingMode.OneWay,
UpdateSourceTrigger = UpdateSourceTrigger.PropertyChanged,
Converter = new DateTimeToStringConverter()
};
dateTimeTextBox.SetBinding(TextBox.TextProperty, dateTimeBinding);
}
if (GetTemplateChild(SELECT_BUTTON_PART_NAME) is Button selectButton)
{
selectButton.Click += SelectButtonClickEventHandler;
}
if (GetTemplateChild(SELECTOR_POPUP_PART_NAME) is Popup selectorPopup)
{
_dateTimeSelector = selectorPopup;
}
if (GetTemplateChild(CALENDAR_PART_NAME) is Calendar calendar)
{
calendar.GotMouseCapture += CalendarGotMouseCaptureEventHandler;
Binding dateBinding = new Binding
{
Path = new PropertyPath(nameof(DateForEdit)),
Mode = BindingMode.TwoWay,
UpdateSourceTrigger = UpdateSourceTrigger.PropertyChanged,
RelativeSource = new RelativeSource()
{
Mode = RelativeSourceMode.FindAncestor,
AncestorType = typeof(DateTimePicker)
}
};
calendar.SetBinding(Calendar.SelectedDateProperty, dateBinding);
}
if (GetTemplateChild(TIME_PICKER_PART_NAME) is TimePicker timePicker)
{
Binding timeBinding = new Binding
{
Path = new PropertyPath(nameof(TimeForEdit)),
Mode = BindingMode.TwoWay,
UpdateSourceTrigger = UpdateSourceTrigger.PropertyChanged,
RelativeSource = new RelativeSource()
{
Mode = RelativeSourceMode.FindAncestor,
AncestorType = typeof(DateTimePicker)
}
};
timePicker.SetBinding(TimePicker.TimeProperty, timeBinding);
}
if (GetTemplateChild(CANCEL_BUTTON_PART_NAME) is Button cancelButton)
{
cancelButton.Click += CancelButtonClickEventHandler;
}
if (GetTemplateChild(SAVE_BUTTON_PART_NAME) is Button saveButton)
{
saveButton.Click += SaveButtonClickEventHandler;
}
}Так как у нас отдельно изменяются время и дата и мы можем закрыть Popup без сохранения изменений – заводим для них приватные свойства DateForEdit и TimeForEdit. Также потребуется свойство для хранения строки форматирования даты. Код контрола лежит тут, его стиль - тут.
Ссылка на проект с примерами.
Заключение
Как видно из примеров, при разработке своего контрола не обязательно делать UserControl с разметкой и кодом. Могут возникнуть проблемы, когда вы захотите сменить его стиль. Гораздо эффективнее вынести шаблон контрола в стили, а в cs-файле оставить бизнес-логику, связав их с помощью метода OnApplyTemplate.