Ознакомившись со средствами расширения и изменения существующих элементов управления в предыдущих частях, мы подходим к наиболее интересной теме данного цикла статей – создание новых элементов управления.

Часть 3. Создание новых элементов управления


Посредством присоединенных свойств (Attached Properties) и поведений (Behaviors) мы имеем возможность расширять существующие элементы управления без вмешательства в их внутренее устройство. Располагая же разметкой их шаблонов, мы также можем изменить их внешний вид и работу визуальных состояний (VisualState). Однако, если требуется изменить или расширить логику существующего элемента управления, или и вовсе создать новый элемент управления, то нам необходимо опуститься на уровень кода (ControlName.cs).


Создание нового элемента управления на базе существующего

Данный пункт является плавным переходом от темы модификации шаблонов элементов управления, подробно рассмотренной в предыдущей части, к теме создания своих собственных элементов управления. В рамках него мы воспользуемся раннее рассмотренным материалом а также расширим логику работы существующего элемента управления.

Расширение логики достигается за счет создания нового элемента управления, базирующегося на уже существующем. Это позволяет полностью перенять уже реализованную логику работы со всеми свойствами зависимостей, что избавит нас от необходимости воссоздавать имеющийся в данном элементе управления функционал.

Предположим, что нам требуется разработать поле ввода регистрации с функцией валидации ввода. Для решения данной задачи нам отлично подойдет элемент управления TextBox в качестве основы.

Расширим его следующим образом:

  1. Функционалом валадиции по заданому шаблону Regex
  2. Визуальным индикатором сообщающим статус валидации
  3. Визуальным индикатором сообщающим является ли поле ввода обязательным к заполнению

В результате получим следующий элемент управления


Элемент управления ExtendedTextBox

Итак, приступим. Создадим новый элемент управления. Для удобства предлагается размещать их в проекте по следуещему пути “.../Controls/ControlName/ControlName.cs”.


Размещение нового элемента управления в проекте

Добавляем новый элемент проекта как Templated Control. Не смотря на то, что данный шаблон не особо сильно отличается от нового пустого класса, главное, что он автоматически создает пустую заглушку шаблона разметки в файле Generiс.xaml для нового элемента управления. А в случае отсутствия данного файла создает его. Именно в этом файле по умолчанию осуществляется поиск шаблона разметки пользовательских элементов управления.


Создание нового элемента проекта по шаблону Templated Control

<ResourceDictionary
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
    <Style xmlns:local2="using:ArticleApp.Controls.ExtendedTextBox" TargetType="local2:ExtendedTextBox">
        <Setter Property="Template">
            <Setter.Value>
                <ControlTemplate TargetType="local2:ExtendedTextBox">
                    <Border
                        Background="{TemplateBinding Background}"
                        BorderBrush="{TemplateBinding BorderBrush}"
                        BorderThickness="{TemplateBinding BorderThickness}">
                    </Border>
                </ControlTemplate>
            </Setter.Value>
        </Setter>
    </Style>
</ResourceDictionary>

Шаблон разметки нового элемента управления по умолчанию

При этом namespace у всех новых элементов управления рекомендуется указывать общий, для упрощения работы с ними из XAML. А также изменить базовый класс на тот который мы собираемся взять за основу. В данном случае TextBox.

После чего внести некоторые правки в шаблон разметки по умолчанию.


Изменение namespace и базового класса

<ResourceDictionary
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:controls="using:ArticleApp.Controls">
    <Style TargetType="controls:ExtendedTextBox">
        <Setter Property="Template">
            <Setter.Value>
                <ControlTemplate TargetType="controls:ExtendedTextBox">
                    <Border
                        Background="{TemplateBinding Background}"
                        BorderBrush="{TemplateBinding BorderBrush}"
                        BorderThickness="{TemplateBinding BorderThickness}">
                    </Border>
                </ControlTemplate>
            </Setter.Value>
        </Setter>
    </Style>
</ResourceDictionary>

Исправленный шаблон разметки элемента управления

После чего новый элемент управления можно использовать. На данный момент он полностью копирует функционал элемента управления TextBox и обладает разметкой состоящей из элемента Border.

<Page x:Class="ArticleApp.MainPage"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:controls="using:ArticleApp.Controls">
    <Grid Background="{ThemeResource ApplicationPageBackgroundThemeBrush}">
        <controls:ExtendedTextBox HorizontalAlignment="Center" VerticalAlignment="Center" 
                                  Width="200" Height="40" 
                                  BorderBrush="Red" BorderThickness="2"/>
    </Grid>
</Page>

Использование нового элемента управления


Внешний вид нового элемента управления

Следующим шагом является переиспользование шаблона разметки элемента управления TextBox (об этом мы подробно писали в предыдущей части).
Получив его, заменяем им шаблон нового элемента управления ExtendedTextBox. Не забываем поменять в нужных местах значения атрибутов TargetType c TextBox на ExtendedTextBox.


Исправленная разметка нового элемента управления

Теперь элемент управления ExtendedTextBox является точной копией элемента управления TeBox, повторяя как его внешний вид, так и функционал.


Новый элемент управления повторяет внешний вид TextBox

Приступим к расширению функционала.

1. Добавим визуальный индикатор, сообщающий, является ли поле ввода обязательным к заполнению.

Находим в шаблоне разметки часть ответствунную за Header и изменяем её следующим образом:

<StackPanel Grid.Row="0" Grid.ColumnSpan="2" Orientation="Horizontal" Margin="0,0,0,8">
    <ContentPresenter x:Name="HeaderContentPresenter"
      x:DeferLoadStrategy="Lazy"
      Visibility="Collapsed"
      Foreground="{ThemeResource SystemControlForegroundBaseHighBrush}"
      Content="{TemplateBinding Header}"
      ContentTemplate="{TemplateBinding HeaderTemplate}"
      FontWeight="Normal" />
    <TextBlock x:Name="NecessityIndicatorTextBlock" Text="*" FontSize="{TemplateBinding FontSize}" Foreground="Red"
               Visibility="{TemplateBinding IsNecessarily}"/>
</StackPanel>

Внедрение в разметку индикатора “*”

Параллельно с этим в файле ExtendedTextBox.cs вносим следующие изменения

public sealed class ExtendedTextBox : TextBox
{
    private TextBlock _necessityIndicatorTextBlock;

    public ExtendedTextBox()
    {
        this.DefaultStyleKey = typeof(ExtendedTextBox);
    }

    protected override void OnApplyTemplate()
    {
        base.OnApplyTemplate();

        _necessityIndicatorTextBlock = GetTemplateChild("NecessityIndicatorTextBlock") as TextBlock;

        UpdateControl();
    }

    public bool IsNecessarily
        {
            get => (bool)GetValue(IsNecessarilyProperty);
            set => SetValue(IsNecessarilyProperty, value);
        }

    public static readonly DependencyProperty IsNecessarilyProperty =
        DependencyProperty.Register("IsNecessarily", typeof(bool), typeof(ExtendedTextBox), new PropertyMetadata(false, IsNecessarilyPropertyChanged));

    private static void IsNecessarilyPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
    {
        var textbox = d as ExtendedTextBox;
        if (textbox == null || !(e.NewValue is bool))
        {
            return;
        }

        textbox.UpdateNecessityIndicator();
    }

    private void UpdateControl()
    {
        UpdateNecessityIndicator();
    }

    private void UpdateNecessityIndicator()
    {
        if (_necessityIndicatorTextBlock != null)
        {
            _necessityIndicatorTextBlock.Visibility = IsNecessarily ? Visibility.Visible : Visibility.Collapsed;
        }
    }
}

Состояние класса ExtendedTextBox

Здесь обратим внимание на следующее:

  • Переопределяем метод OnApplyTemplate() и находим ссылки на элементы разметки по заданным x:Name для дальнейшей работы с ними
  • Определяем свойство зависимости IsNecessarily, определяющее является ли данное поле ввода обязательным к заполнению, и его PropertyChangedCallback IsNecessarilyPropertyChanged в теле которого обновляем видимость данного индикатора.

Важно. Стоит обратить внимание на то, что при инициализации элемента управления PropertyChangedCallback-функции могут выполняться раньше, чем вызовется метод OnApplyTemplate(). Из-за чего мы получаем ситуацию, когда в момент выполнения данных функций дерево элемента ещё не загруженно и целевые объекты не найдены. По этой причине необходимо в конце метода OnApplyTemplate() привести состояние элемента управления в корректное. Здесь это выполняет метод UpdateControl().
Теперь если задать значение свойства IsNecessarily как true мы получим следующий результат.


Индикатор обязательности ExtendedTextBox

2. Займемся логикой валидации введенных данных по заданому шаблону Regex.

Определим следующее:

  • Свойство зависимости RegexPattern и его CallBack-функцию RegexPatternPropertyChanged
  • Свойство зависимости IsValid c приватным set-аксессором.
  • Метод ValidateTextBox() вызываемый при изменении свойств RegexPattern или Text.

И внесем ещё несколько изменений в код класса в результате чего он станет выглядеть следующим образом

public sealed class ExtendedTextBox : TextBox
{
    private TextBlock _necessityIndicatorTextBlock;

    public ExtendedTextBox() ...

    protected override void OnApplyTemplate()
        {
            base.OnApplyTemplate();
            this.TextChanged -= ExtendedTextBoxTextChanged;
            _necessityIndicatorTextBlock = GetTemplateChild("NecessityIndicatorTextBlock") as TextBlock;
            this.TextChanged += ExtendedTextBoxTextChanged;
            UpdateControl();
        }

    private void ExtendedTextBoxTextChanged(object sender, TextChangedEventArgs e)
    {
        ValidateTextBox();
    }

    //public bool IsNecessarily ...
    //public static readonly DependencyProperty IsNecessarilyProperty = ...
    //private static void IsNecessarilyPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) ...

    public string RegexPattern
        {
            get { return (string)GetValue(RegexPatternProperty); }
            set { SetValue(RegexPatternProperty, value); }
        }

    public static readonly DependencyProperty RegexPatternProperty =
        DependencyProperty.Register("RegexPattern", typeof(string), typeof(ExtendedTextBox), new PropertyMetadata(string.Empty, RegexPatternPropertyChanged));

    private static void RegexPatternPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
    {
        var textbox = d as ExtendedTextBox;
        if (textbox == null || !(e.NewValue is string))
        {
            return;
        }

        textbox.ValidateTextBox();
    }

    private void ValidateTextBox()
    {
        IsValid = Regex.IsMatch(Text, RegexPattern);

        if (this.Text.Length == 0 || !this.IsValid.HasValue)
        {
            VisualStateManager.GoToState(this, "Indeterminate", true);
            return;
        }

        VisualStateManager.GoToState(this, this.IsValid.Value ? "Valid" : "Invalid", true);
    }

    public bool? IsValid
        {
            get { return (bool?)GetValue(IsValidProperty); }
            private set { SetValue(IsValidProperty, value); }
        }

    public static readonly DependencyProperty IsValidProperty =
        DependencyProperty.Register("IsValid", typeof(bool?), typeof(ExtendedTextBox), new PropertyMetadata(default(bool?)));

    private void UpdateControl()
    {
        UpdateNecessityIndicator();
        ValidateTextBox();
    }

    //private void UpdateNecessityIndicator() ...
}

Состояние класса ExtendedTextBox после добавления логики валидации

Важно. Ввиду того, что изменение шаблона элемента управления возможно во время исполнения необходимо с осторожностью подходить к подпискам на события в методе OnApplyTemplate(). Так, одной из практик устранения лишних прослушиваний изменений свойств при изменении шаблонов является отписка от событий с последующей переподпиской в теле данного метода.

Также обратим внимание на тело метода ValidateTextBox() который в зависимости от состояния валидности вызывает метод класса VisualStateManager переводящий визуальное состояние элемента управления в одно из трех состояний на которые мы ссылаемся, но ещё не определили в разметке шаблона.

Первым делом несколько расширим разметку строения элемента управления

<Grid>
    <Grid.ColumnDefinitions>
        <ColumnDefinition Width="*" />
        <ColumnDefinition Width="Auto" />
        <ColumnDefinition Width="5" />
        <ColumnDefinition Width="16" />
    </Grid.ColumnDefinitions>
    <Grid.RowDefinitions>
        <RowDefinition Height="Auto" />
        <RowDefinition Height="*" />
    </Grid.RowDefinitions>
    <Border x:Name="BackgroundElement"/>
    <Border x:Name="BorderElement"/>
    <StackPanel Grid.Row="0" Grid.ColumnSpan="2" Orientation="Horizontal" Margin="0,0,0,8">
        <ContentPresenter x:Name="HeaderContentPresenter"/>
        <TextBlock x:Name="NecessityIndicatorTextBlock"/>
    </StackPanel>
    <ScrollViewer x:Name="ContentElement"/>
    <ContentControl x:Name="PlaceholderTextContentPresenter"/>
    <Button x:Name="DeleteButton"/>
    <Image x:Name="ValidationStatusImage" Grid.Row="1" Grid.Column="3"/>
</Grid>

Расширение Grid и добавление картинки-индикатора валидности

И добавим нужные визуальные состояния с описанием их логики.

<VisualStateGroup x:Name="ValidStates">
    <VisualState x:Name="Indeterminate"/>
    <VisualState x:Name="Valid">
        <Storyboard>
            <ObjectAnimationUsingKeyFrames Storyboard.TargetName="ValidationStatusImage"
                                           Storyboard.TargetProperty="Source">
                <DiscreteObjectKeyFrame KeyTime="0" Value="Assets/Icons/validState.png" />
            </ObjectAnimationUsingKeyFrames>
        </Storyboard>
    </VisualState>
    <VisualState x:Name="Invalid">
        <Storyboard>
            <ObjectAnimationUsingKeyFrames Storyboard.TargetName="ValidationStatusImage"
                                           Storyboard.TargetProperty="Source">
                <DiscreteObjectKeyFrame KeyTime="0" Value="Assets/Icons/invalidState.png" />
            </ObjectAnimationUsingKeyFrames>
        </Storyboard>
    </VisualState>
</VisualStateGroup>

Новая визуальная группа ValidStates

Теперь мы можем создать следующую страницу ввода данных


Страница ввода данных

В заключение данного примера обратим внимание, что объем кода в файла ExtendedTextBox.cs хоть и не очень большой, но в перспективе, в случае дальнешего расширения логики элемента управления, может доставлять определенные неудобства в ориентации по файлу.

Чтобы свести такие неудоства к минимуму предлагаем воспользоваться частичными (partial) классами и создать по собственному усмотрению дополнительные файлы с декларациями строения класса.

Например, следующим образом:


Строение проекта при разделении деклараций элементов управления на частичные


Вид файлов содержащих частичные декларации элемента управления

На этом закончим работу с данным элементом управления. Аналогичным образом мы можем расширять логику, внешний вид, добавить дополнительные свойства зависимости для лучшей параметризации (например, свойствами ValidImageSource и InvalidImageSource)

Создание нового элемента управления

В данном пункте мы рассмотрим процесс создания нового элемента управления с нуля. Принципиально этот процесс ничем не отличается от того, что мы делали в предыдущем пункте.
В качестве примера рассмотрим полезный но не вошедший в поставку UWP элемент управления Expander.

public partial class Expander
{
    public static readonly DependencyProperty HeaderProperty =
        DependencyProperty.Register(nameof(Header), typeof(string), typeof(Expander), new PropertyMetadata(null));

    public static readonly DependencyProperty IsExpandedProperty =
        DependencyProperty.Register(nameof(IsExpanded), typeof(bool), typeof(Expander), new PropertyMetadata(false, OnIsExpandedPropertyChanged));

    public string Header
    {
        get { return (string)GetValue(HeaderProperty); }
        set { SetValue(HeaderProperty, value); }
    }

    public bool IsExpanded
    {
        get { return (bool)GetValue(IsExpandedProperty); }
        set { SetValue(IsExpandedProperty, value); }
    }
}

Свойства зависимости элемента управления Expander

public sealed partial class Expander : ContentControl
{
    public Expander()
    {
        this.DefaultStyleKey = typeof(Expander);
    }

    protected override void OnApplyTemplate()
    {
        base.OnApplyTemplate();

        if (IsExpanded)
        {
            VisualStateManager.GoToState(this, "Expanded", true);
        }
    }

    private void ExpandControl()
    {
        VisualStateManager.GoToState(this, "Expanded", true);
    }

    private void CollapseControl()
    {
        VisualStateManager.GoToState(this, "Collapsed", true);
    }

    private static void OnIsExpandedPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
    {
        var expander = d as Expander;

        bool isExpanded = (bool)e.NewValue;
        if (isExpanded)
        {
            expander.ExpandControl();
        }
        else
        {
            expander.CollapseControl();
        }
    }
}

Основная часть элемента управления Expander

Здесь обратим внимание на то, что у Expander предполагается наличие контента который может быть чем угодно. Ввиду этого имеет смысл наследовать его не от класса Control а от расширяющего его класса ContentControl чтобы сразу получить функционал работы со свойством Content.

<Style TargetType="controls:Expander">
    <Setter Property="Template">
        <Setter.Value>
            <ControlTemplate TargetType="controls:Expander">
                <Grid>
                    <VisualStateManager.VisualStateGroups>
                        <VisualStateGroup x:Name="ExpandedStates">
                            <VisualState x:Name="Expanded">
                                <VisualState.Setters>
                                    <Setter Target="MainContent.Visibility" Value="Visible" />
                                </VisualState.Setters>
                            </VisualState>
                            <VisualState x:Name="Collapsed" />
                        </VisualStateGroup>
                    </VisualStateManager.VisualStateGroups>
                    <Grid>
                        <Grid.RowDefinitions>
                            <RowDefinition Height="Auto" />
                            <RowDefinition Height="Auto" />
                        </Grid.RowDefinitions>
                        <ToggleButton x:Name="ExpanderToggleButton" Height="40"
                                      HorizontalContentAlignment="Left"
                                      HorizontalAlignment="Stretch"
                                      Foreground="{TemplateBinding Foreground}"
                                      Content="{TemplateBinding Header}"
                                      IsChecked="{Binding IsExpanded, Mode=TwoWay, RelativeSource={RelativeSource TemplatedParent}}" />
                        <ContentPresenter Grid.Row="1" x:Name="MainContent"
                                          Background="{TemplateBinding Background}"
                                          HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"
                                          HorizontalContentAlignment="Stretch"
                                          VerticalAlignment="{TemplateBinding VerticalContentAlignment}"
                                          Visibility="Collapsed" />
                    </Grid>
                </Grid>
            </ControlTemplate>
        </Setter.Value>
    </Setter>
</Style>

Шаблон разметки элемента управления Expander

Интересным местом в данной разметке является свойство IsChecked элемента управления ToggleButton а именно то, каким образом мы его привязываем к свойству IsExpanded родительского элемента. Такой способ привязки обусловлен тем, что TemplateBinding не предоставляет свойство Mode которое будучи установленным в значение TwoWay должно влиять на состояние Expander.

В данных статьях мы подробно рассмотрели средства расширения, изменения и создания элементов управления на платформе UWP. Надеемся, что материалы показались вам интересными и полезными.

В следующих статьях мы рассмотрим вопросы оптимизации процесса разработки на UWP.

Если что-то было по вашему мнению освещено недостаточно подробно — с удовольствием ответим на все вопросы в комментариях!

Ян Мороз, старший .NET разработчик

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