Как подружить Canvas и ItemsSource в WPF и AvaloniaUI
Предпосылки: понимая, что контейнеры компоновки в WPF не позволяют сделать привязки (Binding) к своим дочерним элементам, решил поэкспериментировать, а как же всё-таки подсунуть данные из View Model для формирования содержимого в эти самые контейнеры компоновки. Позже аналогичное решение было сделано для AvaloniaUI.
Кроме того, я стал регулярно обращать внимание на то, что подобные вопросы появлялись в телеграме в чатах pro.net и AvaloniaUI (RU), поэтому своё решение опубликовал на гитхабе. Но вопросы продолжают появляться регулярно, что и сподвигло меня написать статью на Хабре с пошаговым разбором, что делать.
Итак, если Вас эта тема заинтересовала, добро пожаловать под кат.
Базовое решение на самом деле достаточно простое: достаточно посмотреть, в какой момент возникает свойство ItemsSource
: это ItemsControl. Этот ItemsControl
предлагает также свойство ItemsPanel - указывает панель (то есть контейнер компоновки), который должен будет использоваться для размещения элементов, притом значением по умолчанию является StackPanel
.
Давайте поставим задачу следующим образом: делаем максимально простую View Model. Пускай это будет набор квадратов разного цвета и текстом, которые мы хотим спозиционировать по Canvas
-у. Для самого Canvas
-а при этом вычисляется размер исходя из размеров элементов. Пока без динамики, чтобы не засорять код.
internal class ViewModel
{
public List<Item> Items { get; } = new List<Item>()
{
new Item {X = 100, Y = 200, Size=100, Color = Colors.Cyan, Text = "First"},
new Item {X = 500, Y = 300, Size=200, Color = Colors.Yellow, Text = "Second"},
new Item {X = 300, Y = 500, Size=150, Color = Colors.Red, Text = "Third"},
};
public int Width => Items.Max(x => x.X + x.Size);
public int Height => Items.Max(x => x.Y + x.Size);
}
internal class Item
{
public int X { get; init; }
public int Y { get; init; }
public int Size { get; init; }
public Color Color { get; init; }
public string Text { get; init; }
}
Создадим представление:
<Window ...>
<Window.DataContext>
<local:ViewModel />
</Window.DataContext>
<Viewbox Stretch="Uniform">
<ItemsControl ItemsSource="{Binding Items}" Width="{Binding Width}" Height="{Binding Height}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<Canvas Background="Silver" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
<DataTemplate DataType="{x:Type local:Item}">
<Rectangle Width="{Binding Size}" Height="{Binding Size}">
<Rectangle.Fill>
<SolidColorBrush Color="{Binding Color}" />
</Rectangle.Fill>
</Rectangle>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</Viewbox>
</Window>
Всё по классике: создали ItemsControl
, привязали свойства, заменили ItemsPanel
, сделали DataTemplate
для элемента коллекции. Однако как сделать так, чтобы элементы позиционировались в Canvas
'е? И вот тут начались приключения.
По идее надо всего-то задать прикреплённые свойства Canvas.Left
и Canvas.Top
. Но для какого элемента это нужно сделать? Если задать для Rectangle
в DataTemplate
, то работать не будет, пробовал.
Подсказка к решению этой проблемы скрывается в том, как трансформируется дерево элементов, когда есть привязка списка.
А если конкретнее, то каждый элемент списка оборачивается в элемент ContentPresenter
и его содержимое уже связывается с соответствующим элементом данных. Собственно, в этот момент и становится ясно, что нужно сделать: через стили сконфигурировать этот самый ContentPresenter
.
Собственно, выходим на решение:
<Window ...>
<Window.DataContext>
<local:ViewModel />
</Window.DataContext>
<Viewbox Stretch="Uniform">
<ItemsControl ItemsSource="{Binding Items}" Width="{Binding Width}" Height="{Binding Height}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<Canvas Background="Silver" />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.Resources>
<Style TargetType="ContentPresenter">
<Setter Property="Canvas.Left" Value="{Binding X}" />
<Setter Property="Canvas.Top" Value="{Binding Y}" />
</Style>
<DataTemplate DataType="{x:Type local:Item}">
<Rectangle Width="{Binding Size}" Height="{Binding Size}">
<Rectangle.Fill>
<SolidColorBrush Color="{Binding Color}" />
</Rectangle.Fill>
</Rectangle>
</DataTemplate>
</ItemsControl.Resources>
</ItemsControl>
</Viewbox>
</Window>
С Авалонией всё примерно то же самое с точностью до имён некоторых свойств (найдите десять отличий, ага):
<Window ... >
<Design.DataContext>
<vm:MainWindowViewModel/>
</Design.DataContext>
<ItemsControl Items="{Binding Items}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<Canvas />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.Styles>
<Style Selector="ItemsControl ContentPresenter">
<Setter Property="Canvas.Left" Value="{Binding X}" />
<Setter Property="Canvas.Top" Value="{Binding Y}" />
</Style>
</ItemsControl.Styles>
<ItemsControl.DataTemplates>
<DataTemplate DataType="{x:Type vm:Item}">
<Rectangle Width="{Binding Size}" Height="{Binding Size}">
<Rectangle.Fill>
<SolidColorBrush Color="{Binding Color}" />
</Rectangle.Fill>
</Rectangle>
</DataTemplate>
</ItemsControl.DataTemplates>
</ItemsControl>
</Window>
Собственно, всё. Точно так же можно подсовывать данные в любой контейнер компоновки, элементы которого требуют конфигурирования через присоединённые свойства: Grid
, DockPanel
и любой другой.
На этом у меня всё, надеюсь, информация оказалась полезной.