Предпосылки: понимая, что контейнеры компоновки в 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 и любой другой.

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

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


  1. Ellinist
    05.09.2022 14:21

    Все это очень здорово, тем более, что таких решений с Rectangle в интернете много.

    А как вы решите задачу отрисовки на канве, если не знаете, какой объект будет отрисовываться? Я имею в виду рисование линий и иной фигни - которая может проявиться только в ViewModel - в зависимости от условий (тех или иных) - ну, например, сложная логика.

    Будете отягощать XAML на все возможные случаи жизни? Или, может, ну его нафиг этот MVVM - пробросить в VM саму канву, да и рисовать на ней...?

    Отделение VM от View было придумано для того, чтобы т.н. дизайнер ничего не знал о логике приложения - но это ведь не работает - ни разу не видел ни одной программы, чтобы там в самом XAML не было Binding к тем или иным свойствам - а это уже логика. И что? Дизайнер должен в этом ковыряться?


    1. a-tk Автор
      05.09.2022 14:30

      Для начала можно DataTemplate для каждого типа из VM. По мере усложнения можно развивать код в разные случаи.

      VM от View, по моему скромному мнению, разделяется принципу ЧТО ЛОГИЧЕСКИ должно рисоваться (VM) и как оно должно быть ФИЗИЧЕСКИ ОТРИСОВАНО (View).

      А вообще XAML и работа дизайнера - это длинная философская дискуссия примерно того же плана, должен ли дизайнер знать CSS и HTML в мире Веба. Вместе с JS фреймворками.


      1. Ellinist
        05.09.2022 14:34

        Об этом и речь - в попытке разделить зоны ответственности для программиста и дизайнера был создан монстр, в котором не то, что дизайнер, а и программист уже зачастую путается. В особенности вот эти вот DataTemplate - явно ведь совсем неочевидная вещь для дизайнера.


        1. a-tk Автор
          05.09.2022 14:55

          Посмотрите на то, как развивалась история с дизайнерами, верстальщиками и прочими программистами в вебе.

          ЗЫ. Надеюсь, претензия на сложность XAML для дизайнера не ко мне.


      1. Deosis
        06.09.2022 07:02

        Можно использовать DataTemplateSelector если не хватает логики разделения по типу.


  1. imlex
    06.09.2022 08:02

    В варианте WPF чуть правильнее использовать свойство ItemsControl.ItemContainerStyle, а не просто складывать стиль в ресурсы.


    1. a-tk Автор
      06.09.2022 21:11

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


  1. Egor92
    06.09.2022 08:02

    Есть свойство ItemsControl.ItemContainerStyle