Шаблоны в WinRT

172

Шаблоном (template) обычно называется схема или форма, используемая для создания идентичных или похожих объектов. В Windows Runtime этот термин обозначает фрагмент XAML, используемый Windows для создания визуального дерева элементов. На первый взгляд в этой концепции нет ничего удивительного - мы ранее видели, как Windows преобразует XAML в визуальные деревья. Однако шаблоны почти всегда содержат привязки данных, поэтому один шаблон может породить много визуальных деревьев разного внешнего вида в зависимости от источника привязки. По этой причине шаблоны очень часто определяются как ресурсы, рассчитанные на многократное использование.

Ранее мы кратко упоминали термин три шаблона. Речь идет о трех классах, производных от FrameworkTemplate:

Object
    DependencyObject
        FrameworkTemplate (без создания экземпляров)
            ControlTemplate
            DataTemplate
            ItemsPanelTemplate

Шаблоны не могут определяться в программном коде - необходимо использовать XAML. И не рассчитывайте получить сокровенные знания об этих классах из документации Windows Runtime. DataTemplate определяет всего один открытый метод, ControlTemplate - всего одно открытое свойство, a ItemsPanelTemplate вообще ничего не определяет. Практически все, что связано с непосредственной механикой классов шаблонов, относится к внутренней реализации Windows Runtime.

Класс DataTemplate используется для визуального оформления объектов данных, которые не обязательно имеют собственное визуальное оформление. Сначала я продемонстрирую использование DataTemplate в сочетании с элементами управления, производными от ContentControl; возможно, вам покажется, что возможности применения этого класса ограничены. Но класс DataTemplate играет исключительно важную роль при выводе отдельных элементов коллекций, в котором задействованы элементы управления, производные от ItemsControl.

Класс ControlTemplate используется для переопределения внешнего вида стандартных элементов управления; это чрезвычайно мощный инструмент для настройки визуального оформления приложения.

Класс ItemsPanelTemplate намного проще двух других. Он используется только в классах, производных от ItemsControl.

Как и следует ожидать от столь универсального инструмента, шаблоны, определяемые как часть DataTemplate или ControlTemplate, могут быть достаточно сложными. Многие программисты оценили ту поддержку, которую оказывает Expression Blend при проектировании их шаблонов. Как обычно, я покажу, как создавать шаблоны «вручную». Даже если в итоге вы перейдете на использование Expression Blend, вам будет проще разобраться в генерируемой разметке XAML.

Данные в кнопке

Некоторые распространенные элементы и элементы управления Windows Runtime могут иметь визуальных потомков. Самый очевидный пример - Panel - поддерживает включение множественных потомков через свойство Children типа UIElementCollection. У элемента Border может быть только один потомок - его свойство Child относится к типу UIElement. Создавая пользовательский элемент управления, производный от UserControl, вы задаете визуальное дерево его свойству Content, которое также относится к типу UIElement.

Класс Button тоже содержит свойство Content, но это свойство относится к типу Object. Почему? Простой ответ: потому что Button наследует от ContentControl, а класс ContentControl определяет свойство Content типа Object:

Object
    DependencyObject
        UIElement
            FrameworkElement
                Control
                    ContentControl
                        ButtonBase
                            Button

Но на самом деле это не лучший ответ. В большинстве случаев свойству Content экземпляра Button задается не объект, а текст. Вероятно, вы (правильно) предполагаете, что где-то «за кулисами» при этом создается объект TextBlock для вывода заданного текста.

Для более изощренных кнопок свойству Content можно задать любой объект, производный от UIElement. Пример кнопки с панелью, содержащей растровое изображение и отформатированный текст:

<Grid Background="#FF1D1D1D">
     <Button VerticalAlignment="Center" HorizontalAlignment="Center">
            <StackPanel>
                <Image Source="http://professorweb.ru/my/windows8/rt/level1/files/win8logo.png"
                       Height="100" Width="200" />
                <TextBlock HorizontalAlignment="Center">
                    <Italic>Hello, </Italic> Windows 8!
                </TextBlock>
            </StackPanel>
     </Button>
</Grid>

Результат:

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

Но если свойство Content класса Button действительно относится к типу Object, то ему можно задать экземпляр класса, не наследующего от UIElement. Как вы думаете, что произойдет в этом случае? Попробуйте, например, задать в качестве содержимого кнопки объект LinearGradientBrush:

<Button VerticalAlignment="Center" HorizontalAlignment="Center">
       <LinearGradientBrush>
             <GradientStop Offset="1" Color="LimeGreen" />
             <GradientStop Offset="0" Color="DarkOrange" />
       </LinearGradientBrush>
</Button>

Такая конструкция абсолютно допустима, хотя не совсем понятно, что вы пытаетесь сделать. Кисти часто задаются различным свойствам элементов (например, Background или Foreground класса Button), но собственного визуального представления кисть не имеет. По этой причине на кнопке выводится ToString-представление кисти. Для некоторых классов метод ToString выводит осмысленную информацию, но реализация по умолчанию просто возвращает полное имя класса:

Полное имя класса в кнопке

Вряд ли это то, что нужно.

Однако проблему можно решить! Класс ContentControl определяет (а класс Button наследует) не только свойство Content, но и свойство с именем ContentTemplate. Этому свойству задается объект типа DataTemplate, в котором определяется визуальное дерево с привязками, ссылающимися на объект из свойства Content.

Начнем с добавления тегов элементов свойств для свойства ContentTemplate класса Button, которому будет задан объект DataTemplate:

<Button VerticalAlignment="Center" HorizontalAlignment="Center">
    <LinearGradientBrush>
        <GradientStop Offset="1" Color="LimeGreen" />
        <GradientStop Offset="0" Color="DarkOrange" />
    </LinearGradientBrush>
    
    <Button.ContentTemplate>
        <DataTemplate></DataTemplate>
    </Button.ContentTemplate>
</Button>

В тегах DataTemplate можно определить визуальное дерево, которое каким-либо образом использует содержимое кнопки. Попробуем включить тег Ellipse:

<Button VerticalAlignment="Center" HorizontalAlignment="Center">
    <LinearGradientBrush>
        <GradientStop Offset="1" Color="LimeGreen" />
        <GradientStop Offset="0" Color="DarkOrange" />
    </LinearGradientBrush>
    
    <Button.ContentTemplate>
        <DataTemplate>
            <Ellipse Fill="{Binding}" Width="90" Height="100"></Ellipse>
        </DataTemplate>
    </Button.ContentTemplate>
</Button>
Применение шаблона DataTemplate

Обратите внимание на расширение разметки Binding в свойстве Fill тега Ellipse. Конечно, это очень простая привязка. Источник (Source) ей не нужен, потому что свойству DataContext шаблона было присвоено содержимое кнопки. У привязки нет определения Path, потому что мы хотим, чтобы свойству Fill задавалось непосредственное содержимое кнопки. С шаблоном содержимое кнопки становится видимым. Внешне все выглядит так, как если бы элемент Ellipse был задан как содержимое кнопки, а кисть LinearGradientBrush была задана прямо в свойстве Fill:

<Button VerticalAlignment="Center" HorizontalAlignment="Center">
    <Ellipse Width="90" Height="100">
        <Ellipse.Fill>
            <LinearGradientBrush>
                <GradientStop Offset="1" Color="LimeGreen" />
                <GradientStop Offset="0" Color="DarkOrange" />
            </LinearGradientBrush>
        </Ellipse.Fill>
    </Ellipse>
</Button>

Однако шаблон может быть частью стиля, совместно используемого многими кнопками, так что решение с шаблоном определенно получается более гибким и универсальным.

Привязка данных в DataTemplate не обязана быть такой простой, как в рассмотренном примере. Ниже приведен более полный шаблон, который обращается к свойству Color второго объекта GradientStop в содержимом кнопки и задает по нему цвет кисти SolidColorBrush, используемой для прорисовки контура эллипса:

<Button VerticalAlignment="Center" HorizontalAlignment="Center">
    <LinearGradientBrush>
        <GradientStop Offset="1" Color="LimeGreen" />
        <GradientStop Offset="0" Color="DarkOrange" />
    </LinearGradientBrush>
    
    <Button.ContentTemplate>
        <DataTemplate>
            <Ellipse Fill="{Binding}" Width="90" Height="100"
             StrokeThickness="5">
                <Ellipse.Stroke>
                    <SolidColorBrush Color="{Binding Path=GradientStops[0].Color}" />
                </Ellipse.Stroke>
            </Ellipse>
        </DataTemplate>
    </Button.ContentTemplate>
</Button>

Тег Binding для свойства Color объекта SolidColorBrush использует атрибут Path для обращения к свойству GradientStops объекта LinearGradientBrush, индекс для получения конкретного объекта GradientStop, а затем Color для обращения к свойству этого объекта:

<SolidColorBrush Color="{Binding Path=GradientStops[0].Color}" />

Атрибут Binding в теге DataTemplate обычно не содержит определений ElementName или Source, потому что источник предоставляется как часть контекста данных. Так как Path является первым (и единственным) компонентом Binding, часть Path можно удалить:

<SolidColorBrush Color="{Binding GradientStops[0].Color}" />

Привязки в шаблонах данных почти всегда определяются именно так. Вот как выглядит результат:

Использование свойства ContentTemplate

Конечно, шаблон рассчитан на то, что содержимое представляет собой объект LinearGradientBrush. В противном случае привязка работать не будет.

Объект DataTemplate можно определить в секции Resources страницы (или другом файле XAML):

<Page ...>
    
    <Page.Resources>
        <DataTemplate x:Key="btnTemplate">
            <Ellipse Fill="{Binding}" Width="90" Height="100"
                             StrokeThickness="5">
                <Ellipse.Stroke>
                    <SolidColorBrush Color="{Binding Path=GradientStops[0].Color}" />
                </Ellipse.Stroke>
            </Ellipse>
        </DataTemplate>
    </Page.Resources>

    ...
</Page>

Чтобы включить в кнопку ссылку на шаблон, достаточно стандартного расширения разметки StaticResource:

<Button VerticalAlignment="Center" HorizontalAlignment="Center"
        ContentTemplate="{StaticResource btnTemplate}">
    <LinearGradientBrush>
        <GradientStop Offset="1" Color="LimeGreen" />
        <GradientStop Offset="0" Color="DarkOrange" />
    </LinearGradientBrush>
</Button>

Шаблон может использоваться многими кнопками (или другими элементами управления, производными от ContentControl). Обычно совместное использование визуальных деревьев невозможно, потому что визуальный элемент не может иметь более одного родителя. Однако шаблон работает по другому принципу: по нему генерируется уникальное визуальное дерево для каждого элемента управления, который ссылается на него. Если у 100 кнопок свойству ContentTemplate задается этот шаблон то будут созданы 100 элементов Ellipse.

Очень часто шаблон определяется в определении Style, чтобы к элементу в то же время могли быть применены другие свойства. В следующем проекте мы неявно определяем стиль в секции Resources страницы:

<Page ...>
    
    <Page.Resources>
        <Style TargetType="Button">
            <Setter Property="VerticalAlignment" Value="Center" />
            <Setter Property="HorizontalAlignment" Value="Center" />
            <Setter Property="ContentTemplate">
                <Setter.Value>
                    <DataTemplate>
                        <Ellipse Fill="{Binding}" Width="90" Height="100" />
                    </DataTemplate>
                </Setter.Value>
            </Setter>
        </Style>
    </Page.Resources>

    <Grid Background="#FF1D1D1D">
        <Grid.ColumnDefinitions>
            <ColumnDefinition />
            <ColumnDefinition />
            <ColumnDefinition />
        </Grid.ColumnDefinitions>
        
        <Button>
            <SolidColorBrush Color="DarkOrange" />
        </Button>
        
        <Button Grid.Column="1">
            <LinearGradientBrush>
                <GradientStop Offset="1" Color="LimeGreen" />
                <GradientStop Offset="0" Color="DarkOrange" />
            </LinearGradientBrush>
        </Button>
        
        <Button Grid.Column="2">
            <ImageBrush 
                ImageSource="http://professorweb.ru/my/windows8/rt/level1/files/win8logo.png" />
        </Button>
    </Grid>
</Page>

Неявное определение стиля автоматически задает свойства каждого объекта Button, включая свойство ContentTemplate. Остается лишь определить в качестве содержимого отдельных кнопок экземпляр класса, производного от Brush.

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

Шаблон обращается к объектам через обычные привязки данных, поэтому если объект-источник реализует механизм оповещений (скорее всего, INotifyPropertyChanged), визуальное оформление будет обновляться динамически. Допустим, вы создали класс Clock, который использует событие CompositionTarget.Rendering для получения текущего времени и задания нескольких свойств, каждое из которых инициирует событие PropertyChanged:

using System;
using System.ComponentModel;
using System.Runtime.CompilerServices;
using Windows.UI.Xaml.Media;

namespace WinRTTestApp
{
    public class Clock : INotifyPropertyChanged
    {
        int hour, minute, second, hourAngle, minuteAngle, secondAngle;
        bool enabled;

        public event PropertyChangedEventHandler PropertyChanged;

        public bool IsEnabled
        {
            set
            {
                if (SetProperty<bool>(ref enabled, value, "IsEnabled"))
                {
                    if (enabled)
                        CompositionTarget.Rendering += OnCompositionTargetRendering;
                    else
                        CompositionTarget.Rendering -= OnCompositionTargetRendering;
                }
            }
            get
            {
                return enabled;
            }
        }

        public int Second
        {
            set { SetProperty<int>(ref second, value); }
            get { return second; }
        }

        public int Minute
        {
            set { SetProperty<int>(ref minute, value); }
            get { return minute; }
        }

        public int Hour
        {
            set { SetProperty<int>(ref hour, value); }
            get { return hour; }
        }

        public int SecondAngle
        {
            set { SetProperty<int>(ref secondAngle, value); }
            get { return secondAngle; }
        }

        public int MinuteAngle
        {
            set { SetProperty<int>(ref minuteAngle, value); }
            get { return minuteAngle; }
        }

        public int HourAngle
        {
            set { SetProperty<int>(ref hourAngle, value); }
            get { return hourAngle; }
        }

        private void OnCompositionTargetRendering(object sender, object args)
        {
            DateTime dateTime = DateTime.Now;
            Hour = dateTime.Hour;
            Minute = dateTime.Minute;
            Second = dateTime.Second;

            HourAngle = 30 * dateTime.Hour + dateTime.Minute / 2;
            MinuteAngle = 6 * dateTime.Minute + dateTime.Second / 10;
            SecondAngle = 6 * dateTime.Second + dateTime.Millisecond / 166;
        }

        protected bool SetProperty<T>(ref T storage, T value,
                                      [CallerMemberName] string propertyName = null)
        {
            if (object.Equals(storage, value))
                return false;

            storage = value;
            OnPropertyChanged(propertyName);
            return true;
        }

        protected virtual void OnPropertyChanged(string propertyName)
        {
            if (PropertyChanged != null)
                PropertyChanged(this, new PropertyChangedEventArgs(propertyName));
        }
    }
}

Вы можете задать экземпляр этого класса как содержимое кнопки и использовать шаблон DataTemplate для определения способа его прорисовки:

<Page ...>

    <Grid Background="#FF1D1D1D">
        <Button VerticalAlignment="Center" HorizontalAlignment="Center">

            <local:Clock IsEnabled="True" />

            <Button.ContentTemplate>
                <DataTemplate>
                    <Grid Width="288" Height="288">
                        <Grid.Resources>
                            <Style TargetType="Polyline">
                                <Setter Property="Stroke" Value="#DEFFFFFF" />
                            </Style>
                        </Grid.Resources>

                        <Polyline Points="144 160, 144 48" StrokeThickness="8">
                            <Polyline.RenderTransform>
                                <RotateTransform Angle="{Binding HourAngle}"
                                                 CenterX="144" CenterY="144" />
                            </Polyline.RenderTransform>
                        </Polyline>

                        <Polyline Points="144 172, 144 24" StrokeThickness="4">
                            <Polyline.RenderTransform>
                                <RotateTransform Angle="{Binding MinuteAngle}"
                                                 CenterX="144" CenterY="144" />
                            </Polyline.RenderTransform>
                        </Polyline>

                        <Polyline Points="144 172, 144 12" StrokeThickness="2">
                            <Polyline.RenderTransform>
                                <RotateTransform Angle="{Binding SecondAngle}"
                                                 CenterX="144" CenterY="144" />
                            </Polyline.RenderTransform>
                        </Polyline>
                    </Grid>
                </DataTemplate>
            </Button.ContentTemplate>
        </Button>
    </Grid>
</Page>

Обратите внимание: я определил неявный стиль для Polyline в визуальном дереве DataTemplate. Он применяется ко всем элементам Polyline в этом визуальном дереве. У этих элементов Polyline свойствам RenderTransform задается объект RotateTransform, у которого свойство Angle привязывается к различным свойствам класса Clock. В сочетании эти три элемента Polyline образуют примитивные часы, которые показывают время, одновременно функционируя как часть полностью работоспособной кнопки:

Применение сложных шаблонов для создания стрелочных часов

Помните, что шаблон DataTemplate, заданный свойству ContentTemplate объекта Button, определяет только внешний вид содержимого кнопки, но не «хром» (chrome) самой кнопки. Например, кнопка будет по-прежнему иметь прямоугольную границу, будет слегка затемняться (в темной теме оформления) при прохождении над ней указателя мыши и будет выделяться белым фоном при щелчке. Изменение этих аспектов внешнего вида кнопки потребует работы с объектом ControlTemplate, заданного свойству Template кнопки.

Принятие решений

XAML не является полноценным языком программирования, потому что в нем нет циклов и команд if. XAML не может принимать решения, поэтому он не может содержать блоки разметки, выполнение которых зависит от проверки условия. Но попробовать можно всегда.

Давайте доработаем класс Clock из предыдущего проекта так, чтобы в нем различалась половина суток. Для этого мы создадим новый класс TwelveHourClock, производный от Clock, с новым свойством Hour12 в диапазоне от 1 до 12. Также новый класс будет дополнен парой логических свойств с именами IsAm и IsPm в надежде, что нам удастся использовать эти свойства для изменения отображаемой информации в зависимости от их значений:

namespace WinRTTestApp
{
    public class TwelveHourClock : WinRTTestApp.Clock
    {
        // Инициализация свойства Hour значением 0
        int hour12 = 12;
        bool isAm = true;
        bool isPm = false;

        public bool IsAm
        {
            set { SetProperty<bool>(ref isAm, value); }
            get { return isAm; }
        }

        public bool IsPm
        {
            set { SetProperty<bool>(ref isPm, value); }
            get { return isPm; }
        }

        public int Hour12
        {
            set { SetProperty<int>(ref hour12, value); }
            get { return hour12; }
        }

        protected override void OnPropertyChanged(string propertyName)
        {
            if (propertyName == "Hour")
            {
                Hour12 = (Hour - 1) % 12 + 1;
                IsAm = Hour < 12;
                IsPm = !IsAm;
            }

            base.OnPropertyChanged(propertyName);
        }
    }
}

К счастью, метод OnPropertyChanged в Clock определен как виртуальный, поэтому новый класс может переопределить этот метод и проверить, равен ли аргумент propertyName строке «Hour». Если условие выполняется, то задаются все три новых свойства, а эти свойства также вызывают SetProperty (а следовательно, и OnPropertyChanged) для инициирования собственных событий PropertyChanged.

Допустим, на кнопке должен выводиться текст вида «Сейчас примерно 9 часов дня!» или «Сейчас примерно 5 часов утра!». Класс TwelveHourClock располагает всей необходимой информацией, и вы можете взяться за определение кнопки следующим образом:

<Button VerticalAlignment="Center" HorizontalAlignment="Center">

    <local:TwelveHourClock />

    <Button.ContentTemplate>
        <DataTemplate>
            <StackPanel Orientation="Horizontal">
                <TextBlock Text="Сейчас примерно  &#x00A0;"/>
                <TextBlock Text="{Binding Hour12}" />
                <TextBlock Text=" часов" />
                <TextBlock Text=" утра!" />
                <TextBlock Text=" дня!" />
            </StackPanel>
        </DataTemplate>
    </Button.ContentTemplate>
</Button>

Однако один из двух завершающих элементов TextBlock необходимо подавить. Первая пара должна отображаться только в том случае, если истинно свойство IsAm, а вторая - если истинно свойство IsPm. Вспомните, что у элементов имеется свойство Visibility, которому могут задаваться члены перечисления Visibility - Visible или Collapsed. Если логические свойства TwelveHourClock удастся как-то преобразовать в члены перечисления Visibility, все будет замечательно.

Я представлял преобразователи привязки ранее. Оказывается, один из самых частых преобразователей привязки становится BooleanToVisibilityConverter. Если вы создадите проект типа Grid App или Split App в Visual Studio, то такой преобразователь будет автоматически создан в папке Common, но написать его самому тоже несложно:

using Windows.UI.Xaml.Data;
using Windows.UI.Xaml;
using System;

namespace WinRTTestApp
{
    public sealed class BooleanToVisibilityConverter : IValueConverter
    {
        public object Convert(object value, Type targetType, object parameter, string language)
        {
            return (bool)value ? Visibility.Visible : Visibility.Collapsed;
        }

        public object ConvertBack(object value, Type targetType, object parameter, string language)
        {
            return (Visibility)value == Visibility.Visible;
        }
    }
}

Версия, генерируемая Visual Studio, лишь немного сложнее этой: она проверяет, что значения аргументов действительно относятся к тем типам, к которым они преобразуются. Но если использование преобразователя ограничивается несколькими конкретными фрагментами разметки, проверку типов можно опустить. Конечно, в нашей программе имеет место именно такой случай - экземпляр преобразователя создается не в секции Resources тега Page, а в секции Resources шаблона:

<Page ...>

    <Grid Background="#FF1D1D1D">
        <Button FontSize="32"
            VerticalAlignment="Center" HorizontalAlignment="Center">
            
            <Button.Resources>
                <local:BooleanToVisibilityConverter x:Key="boolConverter" />
            </Button.Resources>

            <local:TwelveHourClock IsEnabled="True"  />

            <Button.ContentTemplate>
                <DataTemplate>
                    <StackPanel Orientation="Horizontal">
                        <TextBlock Text="Сейчас примерно &#x00A0;"/>
                        <TextBlock Text="{Binding Hour12}" />
                        <TextBlock Text=" часов" />
                        <TextBlock Text=" утра!"
                                   Visibility="{Binding IsAm,
                                                Converter={StaticResource boolConverter}}"/>
                        <TextBlock Text=" дня!"
                                   Visibility="{Binding IsPm,
                                                Converter={StaticResource boolConverter}}"/>
                    </StackPanel>
                </DataTemplate>
            </Button.ContentTemplate>
        </Button>
    </Grid>
</Page>

Свойства Visibility двух последних элементов TextBlock теперь привязаны к свойствам IsAm и IsPm объекта TwelveHourClock, а объект BooleanToVisibilityConverter определяет, какой из них видим на экране.

Использование логического ветвления в шаблоне
Пройди тесты
Лучший чат для C# программистов