часть 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.
