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

Комментарии (0)