Как подружить 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.

Скриншот сделан с помощью Avalonia DevTools.
Скриншот сделан с помощью Avalonia DevTools.

Собственно, выходим на решение:

<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 и любой другой.

На этом у меня всё, надеюсь, информация оказалась полезной.


ссылка на оригинал статьи https://habr.com/ru/post/686438/