Пользовательские панели в WinRT

50

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

Допустим, вам потребовалось написать класс, производный от Panel, который размещает своих потомков нестандартным способом (скажем, по кругу). Проще всего это сделать, если все потомки помещаются на экране. Если без прокрутки не обойтись, макет должен быть адаптирован к возможностям ScrollViewer. В противном случае придется заменить Scrollviewer самостоятельно реализованным механизмом прокрутки.

Классы, производные от Panel, могут определять свойства зависимости и вложенные свойства. Например, и Grid, и Canvas определяют вложенные свойства. Однако в общем случае класс, производный от Panel и имеющий вложенные свойства, не может использоваться в ItemsPanelTemplate, потому что обычно не существует нормального механизма задания этих вложенных свойств из DataTemplate.

Классы, производные от Panel, всегда переопределяют два защищенных метода: MeasureOverride() и ArrangeOverride(). Эти методы соответствуют двум проходам формирования макета. В методе MeasureOverride() класс, производный от Panel, вызывает метод Measure() для всех своих потомков и вычисляет свой требуемый размер. В методе ArrangeOverride() класс, производный от Panel, вызывает метод Arrange() для всех своих потомков, что приводит к заданию размеров и позиции каждого потомка относительно контейнера.

Имена методов MeasureOverride() и ArrangeOverrid()e на первый взгляд выглядят немного странно. Они появились в WPF, а их выбор обусловлен различиями между WPF-версиями классов UIElement и FrameworkElement. UIElement реализует относительно простую систему формирования макета с участием методов Measure() и Arrange(). Однако класс FrameworkElement дополнил WPF-версию UIElement свойствами HorizontalAlignment, VerticalAlignment и Margin, существенно усложняющими макет. При этом FrameworkElement также определяет методы MeasureOverride() и ArrangeOverride(), заменяющие Measure() и Arrange(), хотя Measure() и Arrange() продолжают играть свою рать в формировании макета.

Короче говоря, Panel переопределяет MeasureOverride() и ArrangeOverride(), а в этих методах вызывает Measure() и Arrange() для всех своих потомков. Во внутренней реализации эти методы Measure() и Arrange() вызывают методы MeasureOverride() и ArrangeOverride() потомков. Затем потомок вызывает Measure() и Arrange() для всех своих потомков, и процесс продолжается далее по дереву.

Вы можете переопределить MeasureOverride() и ArrangeOverride() в любом классе, производном от FrameworkElement, но в программах, написанных для Windows Runtime, эта возможность обычно используется только в классах, производных от Panel. Класс, производный от Panel, может игнорировать любые из следующих свойств, заданных для него или его потомков:

Width, MinWidth и MaxWidth;
Height, MinHeight и MaxHeight;
HorizontalAlignment и VerticalAlignment;
Margin;
Visibility;
Opacity (не влияет на макет);
RenderTransform (не влияет на макет).

Все эти свойства обрабатываются автоматически. В классе, производном от Panel, метод MeasureOverride() выглядит следующим образом:

protected override Size MeasureOverride(Size availableSize)
{
    // ...

    return desiredSize;
}

Аргумент availableSize относится к типу Size, который (как известно) содержит два свойства Width и Height типа double. Иногда аргумент availableSize очень прост: например, если панель является содержимым Page, то availableSize обозначает размер страницы, который обычно совпадает с размером окна приложения. Если панель заполняет ячейку Grid с заданными конкретными размерами в пикселах, то availableSize соответствует размеру ячейки.

Однако есть немало распространенных случаев, в которых размеры Width и/или Height могут оказаться бесконечными. В методе MeasureOverride() для проверки Width и Height на бесконечность следует использовать статический метод Double.IsPositiveInfinity.

Бесконечное свойство Width или Height в availableSize означает, что родитель панели предоставляет ей столько пространства по горизонтали или вертикали, сколько потребуется. Если панель является потомком вертикального элемента StackPanel, свойство Height будет бесконечным; для потомков горизонтального элемента StackPanel бесконечным будет свойство Width. Если панель является потомком Canvas, то бесконечными будут и Width, и Height. Если панель находится в ячейке Grid, у которой ширина и высота определяются в режиме Auto, то свойства Width и Height объекта availableSize будут бесконечными.

Метод MeasureOverride() должен корректно обрабатывать все эти случаи. Значение desiredSize, возвращаемое методом, не должно иметь бесконечные значения свойств Width или Height. Иначе говоря, MeasureOverride() не может просто вернуть availableSize - такое решение не работает.

Метод MeasureOverride() должен вызвать Measure() для каждого из своих потомков; в противном случае потомки останутся невидимыми. При возвращении из каждого вызова Measure() свойство DesiredSize каждого потомка будет содержать действительное значение, а панель может использовать желательные размеры всех своих потомков для вычисления собственного желательного размера.

Когда метод MeasureOverride() вызывает Measure() для потомка, он передает ему доступный размер для этого потомка. Одно или оба свойства доступного размера могут быть бесконечными. Например, вертикальная панель StackPanel вызывает Measure() для всех своих потомков с доступной шириной (Width), равной ее собственной доступной ширине, и бесконечной доступной высотой (Height):

protected override Size MeasureOverride(Size availableSize)
{
    double maxWidth = 0, height = 0;

    foreach (UIElement el in Children)
    {
        el.Measure(new Size(availableSize.Width, Double.PositiveInfinity));
        maxWidth = Math.Max(maxWidth, el.DesiredSize.Width);
        height += el.DesiredSize.Height;
    }

    return new Size(maxWidth, height);
}

MeasureOverride для вертикальной панели накапливает максимальную ширину и общую высоту всех своих потомков. Полученные значения определяют желательные размеры.

Один из классов, производных от Panel, который мне кажется полезным при программировании для WPF, называется UniformGrid. Он похож на обычный класс Grid, но все ячейки имеют одинаковые размеры. Потомки просто распределяются по одному на ячейку, так что вложенные свойства не нужны. Хотя размер потомков может изменяться, UniformGrid работает со всеми потомкам так, словно они имеют одинаковые размеры, определяемые максимальным размером потомка или пространством, доступным для UniformGrid.

Моя версия UniformGrid определяет два свойства типа int с именами Rows и Columns; значения по умолчанию этих свойств равны -1, что указывает на отсутствие заданных значений. Если ни одно из свойств не задано, UniformGrid пытается определить оптимальное количество строк и столбцов; в противном случае, если задано одно из двух свойств, другое вычисляется по количеству потомков. Задавать оба свойства не рекомендуется: если произведение Rows и Columns меньше количества потомков, некоторые потомки могут не поместиться на панели.

В большинстве случаев применения UniformGrid свойствам Rows и Columns оставляются значения по умолчанию -1, или одно из этих свойств задается равным 1. Если свойство Rows или Columns равно 1, то UniformGrid ведет себя как Grid с одним столбцом или строкой или как панель StackPanel, у которой все потомки имеют одинаковые размеры.

Если свойства Width и Height аргумента availableSize имеют конечные значения, UniformGrid пытается уместить всех потомков в отведенное пространство. В противном случае максимальный размер потомка используется для их размещения. Единственная ситуация, с которой UniformGrid не может справиться - когда Rows и Columns равны 1, а значения доступной ширины и высоты Width и Height бесконечны. В этом случае происходит исключение.

UniformGrid, как и StackPanel, определяет свойство Orientation. Ниже приведены определения свойств и обработчика изменения свойства, общего для них:

using System;
using Windows.Foundation;
using Windows.UI.Xaml;
using Windows.UI.Xaml.Controls;

namespace WinRTTestApp
{
    public class UniformGrid : Panel
    {
        // Задаются в MeasureOverride(), используются в ArrangeOverride
        protected int rows, cols;

        static UniformGrid()
        {
            ColumnsProperty = DependencyProperty.Register("Columns",
                typeof(int), typeof(UniformGrid),
                new PropertyMetadata(-1, OnPropertyChanged));

            RowsProperty = DependencyProperty.Register("Rows",
                typeof(int), typeof(UniformGrid),
                new PropertyMetadata(-1, OnPropertyChanged));

            OrientationProperty = DependencyProperty.Register("Orientation",
                typeof(Orientation), typeof(UniformGrid),
                new PropertyMetadata(Orientation.Vertical, OnPropertyChanged));
        }

        public static DependencyProperty ColumnsProperty { private set; get; }

        public static DependencyProperty RowsProperty { private set; get; }

        public static DependencyProperty OrientationProperty { private set; get; }

        public int Columns
        {
            set { SetValue(ColumnsProperty, value); }
            get { return (int)GetValue(ColumnsProperty); }
        }

        public int Rows
        {
            set { SetValue(RowsProperty, value); }
            get { return (int)GetValue(RowsProperty); }
        }

        public Orientation Orientation
        {
            set { SetValue(OrientationProperty, value); }
            get { return (Orientation)GetValue(OrientationProperty); }
        }

        protected override Size MeasureOverride(Size availableSize)
        {
            // ...
        }

        protected override Size ArrangeOverride(Size finalSize)
        {
            // ...
        }

        static void OnPropertyChanged(DependencyObject obj, DependencyPropertyChangedEventArgs args)
        {
            if (args.Property == UniformGrid.OrientationProperty)
            {
                (obj as UniformGrid).InvalidateArrange();
            }
            else
            {
                (obj as UniformGrid).InvalidateMeasure();
            }
        }
    }
}

В обработчике изменения свойства вызовы InvalidateMeasure() и InvalidateArrange() сигнализируют системе формирования макета о том, что она должна построить новый макет. Вызов InvalidateMeasure() инициирует оба прохода (Measure и Arrange); вызов InvalidateArrange инициирует только проход Arrange, а проход Measure пропускается. В этом случае все размеры сохраняются неизменными, но потомки могут быть перемещены в другие места.

Конечно, это не единственные ситуации, при которых макет может стать недействительным. Например, любое изменение в количестве потомков панели инициирует пересчет нового макета. Метод MeasureOverrid()e сначала выполняет пару проверок действительности значений, а затем вычисляет поля rows и cols по свойствам Rows и Columns и количеству потомков:

protected override Size MeasureOverride(Size availableSize)
{
     // Продолжить только при наличии потомков
     if (this.Children.Count == 0)
         return new Size();

    // Если свойства недействительны, выдаются исключения
    if (this.Rows != -1 && this.Rows < 1)
        throw new ArgumentOutOfRangeException("Свойство UniformGrid.Rows должно быть больше нуля");

    if (this.Columns != -1 && this.Columns < 1)
        throw new ArgumentOutOfRangeException("Свойство UniformGrid.Columns должно быть больше нуля");

    // Вычисление фактического количества строк и столбцов
    // ----------------------------------------
    // Этот вариант нежелателен
    if (this.Rows != -1 && this.Columns != -1)
    {
        rows = this.Rows;
        cols = this.Columns;
    }
    // В этих двух вариантах часто используется значение 1
    else if (this.Rows != -1)
    {
        rows = this.Rows;
        cols = (int)Math.Ceiling((double)this.Children.Count / rows);
    }
    else if (this.Columns != -1)
    {
        cols = this.Columns;
        rows = (int)Math.Ceiling((double)this.Children.Count / cols);
    }
    // Если оба значения Rows и Columns равны -1, необходимо
    // проверить availableSize на бесконечность
    else if (Double.IsInfinity(availableSize.Width) &&
             Double.IsInfinity(availableSize.Height))
    {
        throw new NotSupportedException(
            "UniformGrid требует установки свойства Rows или Columns");
    }

    // ...
}

Обработка MeasureOverride() продолжается вычислением максимального размера потомком. Этот код перебирает коллекцию Children и вызывает метод Measure() для каждого потомка. Без вызова Measure() потомок имеет нулевой размер. После вызова Measure() свойство DesiredSize потомка имеет действительное значение:

protected override Size MeasureOverride(Size availableSize)
{
     // ...

    // Определение максимального размера по всем потомкам
    // ------------------------------------------
    Size maximumSize = new Size();
    Size infiniteSize = new Size(Double.PositiveInfinity,
                 Double.PositiveInfinity);

    // Перебор всех потомков с проверкой
    foreach (UIElement child in this.Children)
    {
        child.Measure(infiniteSize);
        Size childSize = child.DesiredSize;
        maximumSize.Width = Math.Max(maximumSize.Width, childSize.Width);
        maximumSize.Height = Math.Max(maximumSize.Height, childSize.Height);
    }

     // ...
}

Эти вычисления выполняются во многих классах, производных от Panel. Впрочем, метод Measure() не всегда вызывается с бесконечной высотой. В этом конкретном случае UniformGrid хочет определить «естественный размер» каждого элемента, а для этого нужно действовать именно так.

Ранее я упоминал, что класс, производный от Panel, не должен учитывать свойства Margin, заданные для него самого или его потомков. Доступный размер, передаваемый в аргументе MeasureOverride(), уже исключает значение Margin, заданное для элемента. Однако когда панель вызывает Measure() для своих потомков, этот размер неявно включает значение Margin потомка. Метод Measure() потомка уменьшает доступный размер на величину Margin потомка. (Конечно, если размер бесконечен, как в нашем случае, результат не изменится.) Затем значение с вычетом Margin передается методу MeasureOverride() потомка, потомок вычисляет свой размер и возвращает его из MeasureOverride(). Метод Measure() потомка прибавляет величину Margin потомка к размеру, возвращенному MeasureOverride(), и задает свойству DesiredSize потомка этот увеличенный размер.

Таким образом, значение Margin учитывается при построении макета несмотря на то, что MeasureOverride() его игнорирует.

Теперь, после вычислении максимального размера потомка, можно переходить к определению желательного размера Panel. Однако оно может потребовать довольно продолжительных вычислений: если обоим свойствам Rows и Columns были оставлены значении но умолчанию, сам объект Panel должен вычислить оптимальное количество строк и столбцов на основании доступного размера и максимального размера потомка:

protected override Size MeasureOverride(Size availableSize)
{
    // ...
    
    // Вычислить rows и cols, если оба свойства Rows и Colunms равны -1
     if (this.Rows == -1 && this.Columns == -1)
     {
         if (this.Children.Count == 1)
         {
             rows = 1;
             cols = 1;
         }
         else if (Double.IsInfinity(availableSize.Width))
         {
             rows = (int)Math.Max(1, availableSize.Height / maximumSize.Height);
             cols = (int)Math.Ceiling((double)this.Children.Count / rows);
         }
         else if (Double.IsInfinity(availableSize.Height))
         {
             cols = (int)Math.Max(1, availableSize.Width / maximumSize.Width);
             rows = (int)Math.Ceiling((double)this.Children.Count / cols);
         }
         // Ни одна размерность не бесконечна - сложный случай
         else
         {
             double aspectRatio = maximumSize.Width / maximumSize.Height;
             double bestHeight = 0;
             double bestWidth = 0;

             for (int tryRows = 1; tryRows <= this.Children.Count; tryRows++)
             {
                 int tryCols = (int)Math.Ceiling((double)this.Children.Count / tryRows);
                 double childHeight = availableSize.Height / tryRows;
                 double childWidth = availableSize.Width / tryCols;

                 // Регулировка пропорций
                 if (childWidth > aspectRatio * childHeight)
                     childWidth = aspectRatio * childHeight;
                 else
                     childHeight = childWidth / aspectRatio;

                 // Сравнить с размером других потомков
                 if (childHeight > bestHeight)
                 {
                     bestHeight = childHeight;
                     bestWidth = childWidth;
                     rows = tryRows;
                     cols = tryCols;
                 }
             }
         }
     }

     // Вернуть желательный размер
     Size desiredSize = new Size(Math.Min(cols * maximumSize.Width, availableSize.Width),
                   Math.Min(rows * maximumSize.Height, availableSize.Height));

     return desiredSize;
}

Обычно желательный размер панели определяется исключительно по размеру потомков с учетом всех необходимых дополнительных полей. Этот размер может превышать availableSize. ScrollViewer необходим для прокрутки потомков. Однако при конечном значении availableSize для UniformGrid я предпочитаю ограничивать панель этим размером.

Метод ArrangeOverride() обычно бывает проще метода MeasureOverride(). Аргумент finalSize содержит конечный размер, выделяемый для панели. Единственное требование к методу ArrangeOverride() заключается в том, что он должен вызвать для каждого потомка метод Arrange() и передавать ему объект Rect с информацией о положении потомка относительно панели и его размере. Очень часто размер соответствует свойству DesiredSize потомка, но в данном случае общий размер панели должен быть равномерно распределен между строками и столбцами:

protected override Size ArrangeOverride(Size size)
{
    int index = 0;
    double cellWidth = size.Width / cols;
    double cellHeight = size.Height / rows;

    if (Orientation == Orientation.Vertical)
    {
        for (int row = 0; row < rows; row++)
        {
            double y = row * cellHeight;

            for (int col = 0; col < cols; col++)
            {
                double x = col * cellWidth;

                if (index < this.Children.Count)
                    Children[index].Arrange(new Rect(x, y, cellWidth, cellHeight));

                index++;
            }
        }
    }
    else
    {
        for (int col = 0; col < cols; col++)
        {
            double x = col * cellWidth;

            for (int row = 0; row < rows; row++)
            {
                double y = row * cellHeight;

                if (index < Children.Count)
                    Children[index].Arrange(new Rect(x, y, cellWidth, cellHeight));

                index++;
            }
        }
    }

    return base.ArrangeOverride(size);
}

Это единственное место в коде UniformGrid, в котором играет роль свойство Orientation: оно управляет тем, как должны потомки размещаться в первую очередь: слева направо или сверху вниз. Метод ArrangeOverride() почти всегда возвращает finalSize, возвращаемое базовым методом.

Давайте опробуем это решение в ситуации, в которой availableSize имеет конечные значения свойств Width и Height - например, когда ItemsControl не находится в ScrollViewer:

<Page ...>

    <Grid Background="#FF1D1D1D">
        <Grid.Resources>
            <local:NamedColor x:Key="namedcolors" />
            <local:ColorToContrastColorConverter x:Key="colorsConverter" />
        </Grid.Resources>

        <ItemsControl ItemsSource="{Binding Source={StaticResource namedcolors},
                                            Path=All}">
            <ItemsControl.ItemTemplate>
                <DataTemplate>
                    <Border BorderBrush="{Binding Path=Foreground,
                                                  RelativeSource={RelativeSource TemplatedParent}}"
                            BorderThickness="3" Margin="3">
                        <Border.Background>
                            <SolidColorBrush Color="{Binding Color}" />
                        </Border.Background>

                        <Viewbox>
                            <TextBlock VerticalAlignment="Center"
                                       HorizontalAlignment="Center"
                                       Text="{Binding Name}">
                                <TextBlock.Foreground>
                                    <SolidColorBrush Color="{Binding Color,
                                                Converter={StaticResource colorsConverter}}" />
                                </TextBlock.Foreground>
                            </TextBlock>
                        </Viewbox>
                    </Border>
                </DataTemplate>
            </ItemsControl.ItemTemplate>

            <ItemsControl.ItemsPanel>
                <ItemsPanelTemplate>
                    <local:UniformGrid />
                </ItemsPanelTemplate>
            </ItemsControl.ItemsPanel>
        </ItemsControl>
    </Grid>
</Page>

Обратите внимание на использование UniformGrid как значения ItemsPanel элемента управления (в конце разметки).

Я сделал шаблон варианта несколько проще, чем в предыдущих примерах. Теперь он состоит из элемента Border, у которого свойство Background строится посредством привязки к свойству Color объекта NamedColor, и потомка TextBlock для вывода имени цвета. Учтите, что элемент TextBlock находится внутри Viewbox, поэтому размер текста должен адаптироваться к доступному размеру потомка. Также следует заметить, что свойство Foreground элемента TextBlock объединено привязкой со свойством Color, но с прохождением через преобразователь ColorToContrastColorConverter. Преобразователь вычисляет оттенок серого, соответствующий входному цвету, и затем возвращает контрастное значение Colors.Black или Colors.White:

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

namespace WinRTTestApp
{
    public class ColorToContrastColorConverter : IValueConverter
    {
        public object Convert(object value, Type targetType, object par, string lang)
        {
            Color color = (Color)value;
            double grayShade = 0.30 * color.R + 0.59 * color.G + 0.11 * color.B;
            return grayShade > 128 ? Colors.Black : Colors.White;
        }
        public object ConvertBack(object value, Type targetType, object parameter, string language)
        {
            return value;
        }
    }
}

Решение хорошо работает для любого значения, кроме Transparent:

Применение элемента управления UniformGrid

Весь список из 141 цвета помещается в окне, чего я и добивался. Когда программа находится в режиме Snap View, ячейки и текст уменьшаются:

Элемент UniformGrid в режиме Snap View

Впрочем, если ячейки становятся слишком мелкими, визуальное оформление программы нарушается.

Теперь давайте опробуем UniformGrid в ListBox. Я сохранил упрощенный шаблон данных, но задал конкретные размеры Border и TextBlock:

<Page ...>

    <Grid Background="#FF1D1D1D">
        <Grid.Resources>
            <local:NamedColor x:Key="namedcolors" />
            <local:ColorToContrastColorConverter x:Key="colorsConverter" />
        </Grid.Resources>
        
        <ListBox ItemsSource="{Binding Source={StaticResource namedcolors},
                                            Path=All}">
            <ItemsControl.ItemTemplate>
                <DataTemplate>
                    <Border BorderBrush="{Binding Path=Foreground,
                                                  RelativeSource={RelativeSource TemplatedParent}}"
                            BorderThickness="3" Margin="3"
                            Width="300" Height="80">
                        <Border.Background>
                            <SolidColorBrush Color="{Binding Color}" />
                        </Border.Background>

                        <TextBlock VerticalAlignment="Center"
                                   HorizontalAlignment="Center"
                                   Text="{Binding Name}"
                                   FontSize="32">
                            <TextBlock.Foreground>
                                <SolidColorBrush Color="{Binding Color,
                                                Converter={StaticResource colorsConverter}}" />
                            </TextBlock.Foreground>
                        </TextBlock>
                    </Border>
                </DataTemplate>
            </ItemsControl.ItemTemplate>

            <ItemsControl.ItemsPanel>
                <ItemsPanelTemplate>
                    <local:UniformGrid />
                </ItemsPanelTemplate>
            </ItemsControl.ItemsPanel>
        </ListBox>
    </Grid>
</Page>

В данном случае аргумент availableSize метода MeasureOverride() в UniformGrid имеет бесконечное свойство Height для вертикальной прокрутки. UniformGrid вычисляет количество столбцов на основании доступной ширины и максимальной ширины потомка. По этому результату вычисляется количество строк. Панель UniformGrid имеет желательный размер, вычисляемый на основании ее общей высоты, а панель поддерживает вертикальную прокрутку.

UniformGrid как шаблон для элемента ListBox

Переключиться на горизонтальную прокрутку относительно несложно. Просто задайте вложенные свойства ScrollViewer, как было показано ранее в проекте HorizontalListBox, а затем задайте свойству Orientation объекта UniformGrid значение Horizontal:

<Page
    x:Class="WinRTTestApp.MainPage"
    xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
    xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
    xmlns:local="using:WinRTTestApp">

    <Grid Background="#FF1D1D1D">
        <Grid.Resources>
            <local:NamedColor x:Key="namedcolors" />
            <local:ColorToContrastColorConverter x:Key="colorsConverter" />
        </Grid.Resources>
        
        <ListBox ItemsSource="{Binding Source={StaticResource namedcolors},
                                            Path=All}"
                 ScrollViewer.HorizontalScrollBarVisibility="Auto"
                 ScrollViewer.HorizontalScrollMode="Enabled"
                 ScrollViewer.VerticalScrollBarVisibility="Disabled"
                 ScrollViewer.VerticalScrollMode="Disabled">
            <ItemsControl.ItemTemplate>
                <DataTemplate>
                    ...
                </DataTemplate>
            </ItemsControl.ItemTemplate>

            <ItemsControl.ItemsPanel>
                <ItemsPanelTemplate>
                    <local:UniformGrid Orientation="Horizontal" />
                </ItemsPanelTemplate>
            </ItemsControl.ItemsPanel>
        </ListBox>
    </Grid>
</Page>

Значение Horizontal свойства Orientation не является строго необходимым, поэтому оно приводит к другой последовательности упорядочения потомков - сначала сверху вниз, а затем слева направо:

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